feat(UI): #1404 make left side bar collapsible by hot key (#1420)

Signed-off-by: James <james@jan.ai>
Co-authored-by: James <james@jan.ai>
This commit is contained in:
NamH 2024-01-08 09:26:03 +07:00 committed by GitHub
parent 74d8c6be3d
commit 82ffcd06f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 160 additions and 119 deletions

View File

@ -1,4 +1,4 @@
import { Fragment, useState, useEffect } from 'react' import { Fragment } from 'react'
import { InferenceEngine } from '@janhq/core' import { InferenceEngine } from '@janhq/core'
import { import {
@ -11,8 +11,11 @@ import {
Badge, Badge,
} from '@janhq/uikit' } from '@janhq/uikit'
import { useAtom } from 'jotai'
import { DatabaseIcon, CpuIcon } from 'lucide-react' import { DatabaseIcon, CpuIcon } from 'lucide-react'
import { showSelectModelModalAtom } from '@/containers/Providers/KeyListener'
import { MainViewState } from '@/constants/screens' import { MainViewState } from '@/constants/screens'
import { useActiveModel } from '@/hooks/useActiveModel' import { useActiveModel } from '@/hooks/useActiveModel'
@ -23,6 +26,9 @@ export default function CommandListDownloadedModel() {
const { setMainViewState } = useMainViewState() const { setMainViewState } = useMainViewState()
const { downloadedModels } = useGetDownloadedModels() const { downloadedModels } = useGetDownloadedModels()
const { activeModel, startModel, stopModel } = useActiveModel() const { activeModel, startModel, stopModel } = useActiveModel()
const [showSelectModelModal, setShowSelectModelModal] = useAtom(
showSelectModelModalAtom
)
const onModelActionClick = (modelId: string) => { const onModelActionClick = (modelId: string) => {
if (activeModel && activeModel.id === modelId) { if (activeModel && activeModel.id === modelId) {
@ -32,44 +38,29 @@ export default function CommandListDownloadedModel() {
} }
} }
const [open, setOpen] = useState(false)
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'e' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setOpen((open) => !open)
}
}
document.addEventListener('keydown', down)
return () => document.removeEventListener('keydown', down)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const isNotDownloadedModel = downloadedModels.length === 0 const isNotDownloadedModel = downloadedModels.length === 0
if (isNotDownloadedModel) return null if (isNotDownloadedModel) return null
return ( return (
<Fragment> <Fragment>
<CommandModal open={open} onOpenChange={setOpen}> <CommandModal
open={showSelectModelModal}
onOpenChange={setShowSelectModelModal}
>
<CommandInput placeholder="Search your model..." /> <CommandInput placeholder="Search your model..." />
<CommandList> <CommandList>
<CommandEmpty>No Model found.</CommandEmpty> <CommandEmpty>No Model found.</CommandEmpty>
{!isNotDownloadedModel && ( {!isNotDownloadedModel && (
<CommandGroup heading="Your Model"> <CommandGroup heading="Your Model">
{downloadedModels {downloadedModels
.filter((model) => { .filter((model) => model.engine === InferenceEngine.nitro)
return model.engine === InferenceEngine.nitro .map((model) => (
})
.map((model, i) => {
return (
<CommandItem <CommandItem
key={i} key={model.id}
value={model.id} value={model.id}
onSelect={() => { onSelect={() => {
onModelActionClick(model.id) onModelActionClick(model.id)
setOpen(false) setShowSelectModelModal(false)
}} }}
> >
<DatabaseIcon <DatabaseIcon
@ -83,15 +74,14 @@ export default function CommandListDownloadedModel() {
)} )}
</div> </div>
</CommandItem> </CommandItem>
) ))}
})}
</CommandGroup> </CommandGroup>
)} )}
<CommandGroup heading="Find another model"> <CommandGroup heading="Find another model">
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
setMainViewState(MainViewState.Hub) setMainViewState(MainViewState.Hub)
setOpen(false) setShowSelectModelModal(false)
}} }}
> >
<CpuIcon size={16} className="mr-3 text-muted-foreground" /> <CpuIcon size={16} className="mr-3 text-muted-foreground" />

View File

@ -1,4 +1,4 @@
import { Fragment, useState, useEffect } from 'react' import { Fragment } from 'react'
import { import {
CommandModal, CommandModal,
@ -10,6 +10,7 @@ import {
CommandList, CommandList,
} from '@janhq/uikit' } from '@janhq/uikit'
import { useAtom } from 'jotai'
import { import {
MessageCircleIcon, MessageCircleIcon,
SettingsIcon, SettingsIcon,
@ -17,17 +18,14 @@ import {
MonitorIcon, MonitorIcon,
} from 'lucide-react' } from 'lucide-react'
import { showCommandSearchModalAtom } from '@/containers/Providers/KeyListener'
import ShortCut from '@/containers/Shortcut' import ShortCut from '@/containers/Shortcut'
import { MainViewState } from '@/constants/screens' import { MainViewState } from '@/constants/screens'
import { useMainViewState } from '@/hooks/useMainViewState' import { useMainViewState } from '@/hooks/useMainViewState'
export default function CommandSearch() { const menus = [
const { setMainViewState } = useMainViewState()
const [open, setOpen] = useState(false)
const menus = [
{ {
name: 'Chat', name: 'Chat',
icon: ( icon: (
@ -51,23 +49,13 @@ export default function CommandSearch() {
state: MainViewState.Settings, state: MainViewState.Settings,
shortcut: <ShortCut menu="," />, shortcut: <ShortCut menu="," />,
}, },
] ]
useEffect(() => { export default function CommandSearch() {
const down = (e: KeyboardEvent) => { const { setMainViewState } = useMainViewState()
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { const [showCommandSearchModal, setShowCommandSearchModal] = useAtom(
e.preventDefault() showCommandSearchModalAtom
setOpen((open) => !open) )
}
if (e.key === ',' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setMainViewState(MainViewState.Settings)
}
}
document.addEventListener('keydown', down)
return () => document.removeEventListener('keydown', down)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return ( return (
<Fragment> <Fragment>
@ -84,7 +72,10 @@ export default function CommandSearch() {
<ShortCut menu="K" /> <ShortCut menu="K" />
</div> </div>
</div> */} </div> */}
<CommandModal open={open} onOpenChange={setOpen}> <CommandModal
open={showCommandSearchModal}
onOpenChange={setShowCommandSearchModal}
>
<CommandInput placeholder="Type a command or search..." /> <CommandInput placeholder="Type a command or search..." />
<CommandList> <CommandList>
<CommandEmpty>No results found.</CommandEmpty> <CommandEmpty>No results found.</CommandEmpty>
@ -96,7 +87,7 @@ export default function CommandSearch() {
value={menu.name} value={menu.name}
onSelect={() => { onSelect={() => {
setMainViewState(menu.state) setMainViewState(menu.state)
setOpen(false) setShowCommandSearchModal(false)
}} }}
> >
{menu.icon} {menu.icon}

View File

@ -0,0 +1,56 @@
'use client'
import { Fragment, ReactNode, useEffect } from 'react'
import { atom, useSetAtom } from 'jotai'
import { MainViewState } from '@/constants/screens'
import { useMainViewState } from '@/hooks/useMainViewState'
type Props = {
children: ReactNode
}
export const showLeftSideBarAtom = atom<boolean>(true)
export const showSelectModelModalAtom = atom<boolean>(false)
export const showCommandSearchModalAtom = atom<boolean>(false)
export default function KeyListener({ children }: Props) {
const setShowLeftSideBar = useSetAtom(showLeftSideBarAtom)
const setShowSelectModelModal = useSetAtom(showSelectModelModalAtom)
const { setMainViewState } = useMainViewState()
const showCommandSearchModal = useSetAtom(showCommandSearchModalAtom)
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
e.preventDefault()
const prefixKey = isMac ? e.metaKey : e.ctrlKey
if (e.key === 'b' && prefixKey) {
setShowLeftSideBar((showLeftSideBar) => !showLeftSideBar)
return
}
if (e.key === 'e' && prefixKey) {
setShowSelectModelModal((show) => !show)
return
}
if (e.key === ',' && prefixKey) {
setMainViewState(MainViewState.Settings)
return
}
if (e.key === 'k' && prefixKey) {
showCommandSearchModal((show) => !show)
return
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return <Fragment>{children}</Fragment>
}

View File

@ -23,6 +23,8 @@ import {
import { instance } from '@/utils/posthog' import { instance } from '@/utils/posthog'
import KeyListener from './KeyListener'
import { extensionManager } from '@/extension' import { extensionManager } from '@/extension'
const Providers = (props: PropsWithChildren) => { const Providers = (props: PropsWithChildren) => {
@ -70,17 +72,21 @@ const Providers = (props: PropsWithChildren) => {
return ( return (
<PostHogProvider client={instance}> <PostHogProvider client={instance}>
<JotaiWrapper> <JotaiWrapper>
<KeyListener>
<ThemeWrapper> <ThemeWrapper>
{setupCore && activated && ( {setupCore && activated && (
<FeatureToggleWrapper> <FeatureToggleWrapper>
<EventListenerWrapper> <EventListenerWrapper>
<TooltipProvider delayDuration={0}>{children}</TooltipProvider> <TooltipProvider delayDuration={0}>
{children}
</TooltipProvider>
{!isMac && <GPUDriverPrompt />} {!isMac && <GPUDriverPrompt />}
</EventListenerWrapper> </EventListenerWrapper>
<Toaster position="top-right" /> <Toaster position="top-right" />
</FeatureToggleWrapper> </FeatureToggleWrapper>
)} )}
</ThemeWrapper> </ThemeWrapper>
</KeyListener>
</JotaiWrapper> </JotaiWrapper>
</PostHogProvider> </PostHogProvider>
) )

View File

@ -7,7 +7,6 @@ import { useAtom, useAtomValue } from 'jotai'
import { debounce } from 'lodash' import { debounce } from 'lodash'
import { StopCircle } from 'lucide-react' import { StopCircle } from 'lucide-react'
import { twMerge } from 'tailwind-merge'
import LogoMark from '@/containers/Brand/Logo/Mark' import LogoMark from '@/containers/Brand/Logo/Mark'
@ -15,6 +14,8 @@ import ModelReload from '@/containers/Loader/ModelReload'
import ModelStart from '@/containers/Loader/ModelStart' import ModelStart from '@/containers/Loader/ModelStart'
import { currentPromptAtom } from '@/containers/Providers/Jotai' import { currentPromptAtom } from '@/containers/Providers/Jotai'
import { showLeftSideBarAtom } from '@/containers/Providers/KeyListener'
import { MainViewState } from '@/constants/screens' import { MainViewState } from '@/constants/screens'
import { useActiveModel } from '@/hooks/useActiveModel' import { useActiveModel } from '@/hooks/useActiveModel'
@ -28,7 +29,7 @@ import ChatBody from '@/screens/Chat/ChatBody'
import ThreadList from '@/screens/Chat/ThreadList' import ThreadList from '@/screens/Chat/ThreadList'
import Sidebar, { showRightSideBarAtom } from './Sidebar' import Sidebar from './Sidebar'
import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom' import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
@ -44,6 +45,7 @@ import { activeThreadStateAtom } from '@/helpers/atoms/Thread.atom'
const ChatScreen = () => { const ChatScreen = () => {
const activeThread = useAtomValue(activeThreadAtom) const activeThread = useAtomValue(activeThreadAtom)
const { downloadedModels } = useGetDownloadedModels() const { downloadedModels } = useGetDownloadedModels()
const showLeftSideBar = useAtomValue(showLeftSideBarAtom)
const { activeModel, stateModel } = useActiveModel() const { activeModel, stateModel } = useActiveModel()
const { setMainViewState } = useMainViewState() const { setMainViewState } = useMainViewState()
@ -59,8 +61,6 @@ const ChatScreen = () => {
const activeThreadId = useAtomValue(getActiveThreadIdAtom) const activeThreadId = useAtomValue(getActiveThreadIdAtom)
const [isWaitingToSend, setIsWaitingToSend] = useAtom(waitingToSendMessage) const [isWaitingToSend, setIsWaitingToSend] = useAtom(waitingToSendMessage)
const showing = useAtomValue(showRightSideBarAtom)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const modelRef = useRef(activeModel) const modelRef = useRef(activeModel)
const engineParamsUpdate = useAtomValue(engineParamsUpdateAtom) const engineParamsUpdate = useAtomValue(engineParamsUpdateAtom)
@ -109,17 +109,14 @@ const ChatScreen = () => {
return ( return (
<div className="flex h-full w-full"> <div className="flex h-full w-full">
<div className="flex h-full w-60 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background"> {/* Left side bar */}
{showLeftSideBar ? (
<div className="flex h-full w-60 flex-shrink-0 flex-col overflow-y-auto border-r border-border">
<ThreadList /> <ThreadList />
</div> </div>
<div ) : null}
className={twMerge(
'relative flex h-full flex-col overflow-auto bg-background', <div className="relative flex h-full w-full flex-col overflow-auto bg-background">
activeThread && activeThreadId && showing
? 'w-[calc(100%-560px)]'
: 'w-full'
)}
>
<div className="flex h-full w-full flex-col justify-between"> <div className="flex h-full w-full flex-col justify-between">
{activeThread ? ( {activeThread ? (
<div className="flex h-full w-full overflow-y-auto overflow-x-hidden"> <div className="flex h-full w-full overflow-y-auto overflow-x-hidden">
@ -210,8 +207,9 @@ const ChatScreen = () => {
</div> </div>
</div> </div>
</div> </div>
{/* Sidebar */}
{activeThreadId && activeThread && <Sidebar />} {/* Right side bar */}
{activeThread && <Sidebar />}
</div> </div>
) )
} }

View File

@ -1,4 +1,3 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { Model } from '@janhq/core' import { Model } from '@janhq/core'
@ -32,6 +31,7 @@ const ModelVersionItem: React.FC<Props> = ({ model }) => {
const downloadAtom = useMemo( const downloadAtom = useMemo(
() => atom((get) => get(modelDownloadStateAtom)[model.id ?? '']), () => atom((get) => get(modelDownloadStateAtom)[model.id ?? '']),
/* eslint-disable react-hooks/exhaustive-deps */
[model.id] [model.id]
) )
const downloadState = useAtomValue(downloadAtom) const downloadState = useAtomValue(downloadAtom)