diff --git a/apps/webclaw/src/components/ui/autocomplete.tsx b/apps/webclaw/src/components/ui/autocomplete.tsx new file mode 100644 index 0000000..8db5424 --- /dev/null +++ b/apps/webclaw/src/components/ui/autocomplete.tsx @@ -0,0 +1,321 @@ +'use client' + +import { Autocomplete as AutocompletePrimitive } from '@base-ui/react/autocomplete' +import { HugeiconsIcon } from '@hugeicons/react' +import { ArrowUpDownIcon, Cancel01Icon } from '@hugeicons/core-free-icons' + +import { cn } from '@/lib/utils' +import { Input } from '@/components/ui/input' +import { + ScrollAreaRoot, + ScrollAreaScrollbar, + ScrollAreaThumb, + ScrollAreaViewport, +} from '@/components/ui/scroll-area' + +const Autocomplete = AutocompletePrimitive.Root + +function AutocompleteInput({ + className, + showTrigger = false, + showClear = false, + startAddon, + size, + ...props +}: Omit & { + showTrigger?: boolean + showClear?: boolean + startAddon?: React.ReactNode + size?: 'sm' | 'default' | 'lg' | number + ref?: React.Ref +}) { + const sizeValue = size ?? 'default' + + return ( +
+ {startAddon && ( + + )} + } + {...props} + /> + {showTrigger && ( + + + + )} + {showClear && ( + + + + )} +
+ ) +} + +function AutocompletePopup({ + className, + children, + side = 'bottom', + sideOffset = 4, + alignOffset, + align = 'start', + ...props +}: AutocompletePrimitive.Popup.Props & { + align?: AutocompletePrimitive.Positioner.Props['align'] + sideOffset?: AutocompletePrimitive.Positioner.Props['sideOffset'] + alignOffset?: AutocompletePrimitive.Positioner.Props['alignOffset'] + side?: AutocompletePrimitive.Positioner.Props['side'] +}) { + return ( + + + + + {children} + + + + + ) +} + +function AutocompleteItem({ + className, + children, + ...props +}: AutocompletePrimitive.Item.Props) { + return ( + + {children} + + ) +} + +function AutocompleteSeparator({ + className, + ...props +}: AutocompletePrimitive.Separator.Props) { + return ( + + ) +} + +function AutocompleteGroup({ + className, + ...props +}: AutocompletePrimitive.Group.Props) { + return ( + + ) +} + +function AutocompleteGroupLabel({ + className, + ...props +}: AutocompletePrimitive.GroupLabel.Props) { + return ( + + ) +} + +function AutocompleteEmpty({ + className, + ...props +}: AutocompletePrimitive.Empty.Props) { + return ( + + ) +} + +function AutocompleteRow({ + className, + ...props +}: AutocompletePrimitive.Row.Props) { + return ( + + ) +} + +function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) { + return ( + + ) +} + +function AutocompleteList({ + className, + ...props +}: AutocompletePrimitive.List.Props) { + return ( + + + + + + + + + ) +} + +function AutocompleteClear({ + className, + ...props +}: AutocompletePrimitive.Clear.Props) { + return ( + + + + ) +} + +function AutocompleteStatus({ + className, + ...props +}: AutocompletePrimitive.Status.Props) { + return ( + + ) +} + +function AutocompleteCollection({ + ...props +}: AutocompletePrimitive.Collection.Props) { + return ( + + ) +} + +function AutocompleteTrigger({ + className, + ...props +}: AutocompletePrimitive.Trigger.Props) { + return ( + + ) +} + +const useAutocompleteFilter = AutocompletePrimitive.useFilter + +export { + Autocomplete, + AutocompleteInput, + AutocompleteTrigger, + AutocompletePopup, + AutocompleteItem, + AutocompleteSeparator, + AutocompleteGroup, + AutocompleteGroupLabel, + AutocompleteEmpty, + AutocompleteValue, + AutocompleteList, + AutocompleteClear, + AutocompleteStatus, + AutocompleteRow, + AutocompleteCollection, + useAutocompleteFilter, +} diff --git a/apps/webclaw/src/components/ui/command.tsx b/apps/webclaw/src/components/ui/command.tsx new file mode 100644 index 0000000..75a4352 --- /dev/null +++ b/apps/webclaw/src/components/ui/command.tsx @@ -0,0 +1,267 @@ +'use client' + +import { Dialog as CommandDialogPrimitive } from '@base-ui/react/dialog' +import { HugeiconsIcon } from '@hugeicons/react' +import { Search01Icon } from '@hugeicons/core-free-icons' +import { + Autocomplete, + AutocompleteCollection, + AutocompleteEmpty, + AutocompleteGroup, + AutocompleteGroupLabel, + AutocompleteInput, + AutocompleteItem, + AutocompleteList, + AutocompleteSeparator, +} from './autocomplete' +import type * as React from 'react' +import { cn } from '@/lib/utils' + +const CommandDialog = CommandDialogPrimitive.Root + +const CommandDialogPortal = CommandDialogPrimitive.Portal + +const CommandCreateHandle = CommandDialogPrimitive.createHandle + +function CommandDialogTrigger(props: CommandDialogPrimitive.Trigger.Props) { + return ( + + ) +} + +function CommandDialogBackdrop({ + className, + ...props +}: CommandDialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function CommandDialogViewport({ + className, + ...props +}: CommandDialogPrimitive.Viewport.Props) { + return ( + + ) +} + +function CommandDialogPopup({ + className, + children, + ...props +}: CommandDialogPrimitive.Popup.Props) { + return ( + + + + + {children} + + + + ) +} + +function Command({ + autoHighlight = 'always', + keepHighlight = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandInput({ + className, + placeholder = undefined, + ...props +}: React.ComponentProps) { + return ( +
+ + } + {...props} + /> +
+ ) +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandPanel({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandGroupLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandCollection({ + ...props +}: React.ComponentProps) { + return +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandShortcut({ className, ...props }: React.ComponentProps<'kbd'>) { + return ( + + ) +} + +function CommandFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +export { + CommandCreateHandle, + Command, + CommandCollection, + CommandDialog, + CommandDialogPopup, + CommandDialogTrigger, + CommandEmpty, + CommandFooter, + CommandGroup, + CommandGroupLabel, + CommandInput, + CommandItem, + CommandList, + CommandPanel, + CommandSeparator, + CommandShortcut, +} diff --git a/apps/webclaw/src/components/ui/input.tsx b/apps/webclaw/src/components/ui/input.tsx new file mode 100644 index 0000000..db17939 --- /dev/null +++ b/apps/webclaw/src/components/ui/input.tsx @@ -0,0 +1,66 @@ +'use client' + +import { Input as InputPrimitive } from '@base-ui/react/input' +import type * as React from 'react' + +import { cn } from '@/lib/utils' + +type InputProps = Omit< + InputPrimitive.Props & React.RefAttributes, + 'size' +> & { + size?: 'sm' | 'default' | 'lg' | number + unstyled?: boolean + nativeInput?: boolean +} + +function Input({ + className, + size = 'default', + unstyled = false, + nativeInput = false, + ...props +}: InputProps) { + const inputClassName = cn( + 'h-8.5 w-full min-w-0 rounded-[inherit] px-[calc(--spacing(3)-1px)] leading-8.5 outline-none placeholder:text-primary-600/70 sm:h-7.5 sm:leading-7.5 [transition:background-color_5000000s_ease-in-out_0s]', + size === 'sm' && + 'h-7.5 px-[calc(--spacing(2.5)-1px)] leading-7.5 sm:h-6.5 sm:leading-6.5', + size === 'lg' && 'h-9.5 leading-9.5 sm:h-8.5 sm:leading-8.5', + props.type === 'search' && + '[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none', + props.type === 'file' && + 'text-primary-600 file:me-3 file:bg-transparent file:font-medium file:text-primary-900 file:text-sm', + ) + + return ( + + {nativeInput ? ( + + ) : ( + + )} + + ) +} + +export { Input, type InputProps } diff --git a/apps/webclaw/src/screens/chat/components/chat-sidebar.tsx b/apps/webclaw/src/screens/chat/components/chat-sidebar.tsx index 1a84cc4..58f5d7a 100644 --- a/apps/webclaw/src/screens/chat/components/chat-sidebar.tsx +++ b/apps/webclaw/src/screens/chat/components/chat-sidebar.tsx @@ -1,19 +1,22 @@ import { HugeiconsIcon } from '@hugeicons/react' import { PencilEdit02Icon, + Search01Icon, Settings01Icon, SidebarLeft01Icon, } from '@hugeicons/core-free-icons' import { AnimatePresence, motion } from 'motion/react' import { memo, useState } from 'react' -import { Link } from '@tanstack/react-router' +import { Link, useNavigate } from '@tanstack/react-router' import { useChatSettings } from '../hooks/use-chat-settings' import { useDeleteSession } from '../hooks/use-delete-session' import { useRenameSession } from '../hooks/use-rename-session' +import { useSessionShortcuts } from '../hooks/use-session-shortcuts' import { SettingsDialog } from './settings-dialog' import { SessionRenameDialog } from './sidebar/session-rename-dialog' import { SessionDeleteDialog } from './sidebar/session-delete-dialog' import { SidebarSessions } from './sidebar/sidebar-sessions' +import { CommandSessionDialog } from './command-session' import type { SessionMeta } from '../types' import { TooltipContent, @@ -72,6 +75,26 @@ function ChatSidebarComponent({ const [deleteSessionKey, setDeleteSessionKey] = useState(null) const [deleteFriendlyId, setDeleteFriendlyId] = useState(null) const [deleteSessionTitle, setDeleteSessionTitle] = useState('') + const [searchDialogOpen, setSearchDialogOpen] = useState(false) + const navigate = useNavigate() + + useSessionShortcuts({ + onNewSession: onCreateSession, + onSearchSessions: () => setSearchDialogOpen(true), + }) + + function handleSearchDialogOpenChange(nextOpen: boolean) { + setSearchDialogOpen(nextOpen) + } + + function handleSearchSelect(session: SessionMeta) { + setSearchDialogOpen(false) + void navigate({ + to: '/chat/$sessionKey', + params: { sessionKey: session.friendlyId }, + }) + onSelectSession?.() + } function handleOpenRename(session: SessionMeta) { setRenameSessionKey(session.key) @@ -173,7 +196,7 @@ function ChatSidebarComponent({ -
+
)} + {!isCollapsed ? ( + + + + O + + ) : null} + + + +
+ +
{!isCollapsed && ( diff --git a/apps/webclaw/src/screens/chat/components/command-session.tsx b/apps/webclaw/src/screens/chat/components/command-session.tsx new file mode 100644 index 0000000..4b58a08 --- /dev/null +++ b/apps/webclaw/src/screens/chat/components/command-session.tsx @@ -0,0 +1,167 @@ +'use client' + +import { Fragment, useMemo, useState } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { ArrowDown01Icon, ArrowUp01Icon } from '@hugeicons/core-free-icons' +import { + Command, + CommandCollection, + CommandDialog, + CommandDialogPopup, + CommandFooter, + CommandGroup, + CommandGroupLabel, + CommandInput, + CommandItem, + CommandList, + CommandPanel, + CommandSeparator, +} from '@/components/ui/command' +import { useAutocompleteFilter } from '@/components/ui/autocomplete' + +type CommandSession = { + key: string + friendlyId: string + label?: string + title?: string + derivedTitle?: string +} + +type CommandSessionItem = { + value: string + label: string + friendlyId: string + session: CommandSession +} + +type CommandSessionGroup = { + value: string + items: Array +} + +type CommandSessionProps = { + sessions: Array + open: boolean + onOpenChange: (open: boolean) => void + onSelect: (session: CommandSession) => void +} + +function getSessionLabel(session: CommandSession) { + return ( + session.label || session.title || session.derivedTitle || session.friendlyId + ) +} + +function CommandSessionDialog({ + sessions, + open, + onOpenChange, + onSelect, +}: CommandSessionProps) { + const [value, setValue] = useState('') + const filter = useAutocompleteFilter({ sensitivity: 'base' }) + + const groupedItems = useMemo>(() => { + return [ + { + value: 'Sessions', + items: sessions.map((session) => ({ + value: session.key, + label: getSessionLabel(session), + friendlyId: session.friendlyId, + session, + })), + }, + ] + }, [sessions]) + + const filteredGroups = useMemo(() => { + const query = value.trim() + if (!query) return groupedItems + + return groupedItems + .map((group) => ({ + ...group, + items: group.items.filter((item) => + filter.contains(item, query, (target) => target.label), + ), + })) + .filter((group) => group.items.length > 0) + }, [filter, groupedItems, value]) + + const isEmpty = filteredGroups.length === 0 + + return ( + + + + + + {isEmpty ? ( +
+ No sessions found. +
+ ) : ( + + {filteredGroups.map((group, index) => ( + + + {group.value} + + {(item) => ( + onSelect(item.session)} + className="gap-2" + > + + {item.label} + + + )} + + + {index < filteredGroups.length - 1 ? ( + + ) : null} + + ))} + + )} +
+ +
+
+ + + + + Navigate +
+
+ + Enter + + Open +
+
+
+ + Esc + + Close +
+
+
+
+
+ ) +} + +export { CommandSessionDialog } diff --git a/apps/webclaw/src/screens/chat/components/sidebar/session-item.tsx b/apps/webclaw/src/screens/chat/components/sidebar/session-item.tsx index d1da329..3ec6e85 100644 --- a/apps/webclaw/src/screens/chat/components/sidebar/session-item.tsx +++ b/apps/webclaw/src/screens/chat/components/sidebar/session-item.tsx @@ -67,7 +67,7 @@ function SessionItemComponent({ }} className={cn( 'ml-1 inline-flex size-7 items-center justify-center rounded-md text-primary-700', - 'opacity-0 transition-opacity group-hover:opacity-100 hover:bg-primary-200', + 'opacity-0 duration-0 group-hover:opacity-100 hover:bg-primary-200', 'aria-expanded:opacity-100 aria-expanded:bg-primary-200', )} > diff --git a/apps/webclaw/src/screens/chat/hooks/use-session-shortcuts.ts b/apps/webclaw/src/screens/chat/hooks/use-session-shortcuts.ts new file mode 100644 index 0000000..58730e0 --- /dev/null +++ b/apps/webclaw/src/screens/chat/hooks/use-session-shortcuts.ts @@ -0,0 +1,45 @@ +import { useEffect } from 'react' + +type SessionShortcutOptions = { + onNewSession: () => void + onSearchSessions: () => void +} + +function useSessionShortcuts({ + onNewSession, + onSearchSessions, +}: SessionShortcutOptions) { + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.defaultPrevented) return + if (event.altKey) return + if (!(event.metaKey || event.ctrlKey)) return + const target = event.target as HTMLElement | null + if (!target) return + const tag = target.tagName.toLowerCase() + if ( + tag === 'input' || + tag === 'textarea' || + tag === 'select' || + target.isContentEditable + ) { + return + } + + if (event.key.toLowerCase() === 'k' && !event.shiftKey) { + event.preventDefault() + onSearchSessions() + } + + if (event.key.toLowerCase() === 'o' && event.shiftKey) { + event.preventDefault() + onNewSession() + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [onNewSession, onSearchSessions]) +} + +export { useSessionShortcuts }