Merge pull request #24 from ibelick/feat/add-search-sessions

feat: add command search sessions
This commit is contained in:
Julien Thibeaut
2026-02-10 12:52:43 +01:00
committed by GitHub
7 changed files with 945 additions and 4 deletions
@@ -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<AutocompletePrimitive.Input.Props, 'size'> & {
showTrigger?: boolean
showClear?: boolean
startAddon?: React.ReactNode
size?: 'sm' | 'default' | 'lg' | number
ref?: React.Ref<HTMLInputElement>
}) {
const sizeValue = size ?? 'default'
return (
<div className="relative not-has-[>*.w-full]:w-fit w-full text-primary-900 has-disabled:opacity-64">
{startAddon && (
<div
aria-hidden="true"
className="[&_svg]:-mx-0.5 pointer-events-none absolute inset-y-0 start-px z-10 flex items-center ps-[calc(--spacing(3)-1px)] opacity-80 has-[+[data-size=sm]]:ps-[calc(--spacing(2.5)-1px)] [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4"
data-slot="autocomplete-start-addon"
>
{startAddon}
</div>
)}
<AutocompletePrimitive.Input
className={cn(
startAddon &&
'data-[size=sm]:*:data-[slot=autocomplete-input]:ps-[calc(--spacing(7.5)-1px)] *:data-[slot=autocomplete-input]:ps-[calc(--spacing(8.5)-1px)] sm:data-[size=sm]:*:data-[slot=autocomplete-input]:ps-[calc(--spacing(7)-1px)] sm:*:data-[slot=autocomplete-input]:ps-[calc(--spacing(8)-1px)]',
sizeValue === 'sm'
? 'has-[+[data-slot=autocomplete-trigger],+[data-slot=autocomplete-clear]]:*:data-[slot=autocomplete-input]:pe-6.5'
: 'has-[+[data-slot=autocomplete-trigger],+[data-slot=autocomplete-clear]]:*:data-[slot=autocomplete-input]:pe-7',
className,
)}
data-slot="autocomplete-input"
render={<Input nativeInput size={sizeValue} />}
{...props}
/>
{showTrigger && (
<AutocompleteTrigger
className={cn(
"-translate-y-1/2 absolute top-1/2 inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-transparent opacity-80 outline-none transition-colors pointer-coarse:after:absolute pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:opacity-100 has-[+[data-slot=autocomplete-clear]]:hidden sm:size-7 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
sizeValue === 'sm' ? 'end-0' : 'end-0.5',
)}
>
<HugeiconsIcon icon={ArrowUpDownIcon} size={20} strokeWidth={1.5} />
</AutocompleteTrigger>
)}
{showClear && (
<AutocompleteClear
className={cn(
"-translate-y-1/2 absolute top-1/2 inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-transparent opacity-80 outline-none transition-colors pointer-coarse:after:absolute pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:opacity-100 has-[+[data-slot=autocomplete-clear]]:hidden sm:size-7 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
sizeValue === 'sm' ? 'end-0' : 'end-0.5',
)}
>
<HugeiconsIcon icon={Cancel01Icon} size={20} strokeWidth={1.5} />
</AutocompleteClear>
)}
</div>
)
}
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 (
<AutocompletePrimitive.Portal>
<AutocompletePrimitive.Positioner
align={align}
alignOffset={alignOffset}
className="z-50 select-none"
data-slot="autocomplete-positioner"
side={side}
sideOffset={sideOffset}
>
<span
className={cn(
'relative flex max-h-full min-w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) rounded-lg border border-primary-200 bg-surface not-dark:bg-clip-padding shadow-lg/5 transition-[scale,opacity] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-ink/6%)] dark:before:shadow-[0_-1px_--theme(--color-surface/6%)]',
className,
)}
>
<AutocompletePrimitive.Popup
className="flex max-h-[min(var(--available-height),23rem)] flex-1 flex-col text-primary-900"
data-slot="autocomplete-popup"
{...props}
>
{children}
</AutocompletePrimitive.Popup>
</span>
</AutocompletePrimitive.Positioner>
</AutocompletePrimitive.Portal>
)
}
function AutocompleteItem({
className,
children,
...props
}: AutocompletePrimitive.Item.Props) {
return (
<AutocompletePrimitive.Item
className={cn(
'flex min-h-8 cursor-default select-none items-center rounded-sm px-2 py-1 text-base outline-none data-disabled:pointer-events-none data-highlighted:bg-primary-100 data-highlighted:text-primary-900 data-disabled:opacity-64 sm:min-h-7 sm:text-sm',
className,
)}
data-slot="autocomplete-item"
{...props}
>
{children}
</AutocompletePrimitive.Item>
)
}
function AutocompleteSeparator({
className,
...props
}: AutocompletePrimitive.Separator.Props) {
return (
<AutocompletePrimitive.Separator
className={cn('mx-2 my-1 h-px bg-border last:hidden', className)}
data-slot="autocomplete-separator"
{...props}
/>
)
}
function AutocompleteGroup({
className,
...props
}: AutocompletePrimitive.Group.Props) {
return (
<AutocompletePrimitive.Group
className={cn('[[role=group]+&]:mt-1.5', className)}
data-slot="autocomplete-group"
{...props}
/>
)
}
function AutocompleteGroupLabel({
className,
...props
}: AutocompletePrimitive.GroupLabel.Props) {
return (
<AutocompletePrimitive.GroupLabel
className={cn(
'px-2 py-1.5 font-medium text-primary-600 text-xs',
className,
)}
data-slot="autocomplete-group-label"
{...props}
/>
)
}
function AutocompleteEmpty({
className,
...props
}: AutocompletePrimitive.Empty.Props) {
return (
<AutocompletePrimitive.Empty
className={cn(
'not-empty:p-2 text-center text-base text-primary-600 sm:text-sm',
className,
)}
data-slot="autocomplete-empty"
{...props}
/>
)
}
function AutocompleteRow({
className,
...props
}: AutocompletePrimitive.Row.Props) {
return (
<AutocompletePrimitive.Row
className={className}
data-slot="autocomplete-row"
{...props}
/>
)
}
function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
return (
<AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
)
}
function AutocompleteList({
className,
...props
}: AutocompletePrimitive.List.Props) {
return (
<ScrollAreaRoot>
<ScrollAreaViewport>
<AutocompletePrimitive.List
className={cn(
'not-empty:scroll-py-1 not-empty:p-1 in-data-has-overflow-y:pe-3',
className,
)}
data-slot="autocomplete-list"
{...props}
/>
</ScrollAreaViewport>
<ScrollAreaScrollbar orientation="vertical">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
)
}
function AutocompleteClear({
className,
...props
}: AutocompletePrimitive.Clear.Props) {
return (
<AutocompletePrimitive.Clear
className={cn(
"-translate-y-1/2 absolute end-0.5 top-1/2 inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-transparent opacity-80 outline-none transition-[color,background-color,box-shadow,opacity] pointer-coarse:after:absolute pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:opacity-100 sm:size-7 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
data-slot="autocomplete-clear"
{...props}
>
<HugeiconsIcon icon={Cancel01Icon} size={20} strokeWidth={1.5} />
</AutocompletePrimitive.Clear>
)
}
function AutocompleteStatus({
className,
...props
}: AutocompletePrimitive.Status.Props) {
return (
<AutocompletePrimitive.Status
className={cn(
'px-3 py-2 font-medium text-primary-600 text-xs empty:m-0 empty:p-0',
className,
)}
data-slot="autocomplete-status"
{...props}
/>
)
}
function AutocompleteCollection({
...props
}: AutocompletePrimitive.Collection.Props) {
return (
<AutocompletePrimitive.Collection
data-slot="autocomplete-collection"
{...props}
/>
)
}
function AutocompleteTrigger({
className,
...props
}: AutocompletePrimitive.Trigger.Props) {
return (
<AutocompletePrimitive.Trigger
className={className}
data-slot="autocomplete-trigger"
{...props}
/>
)
}
const useAutocompleteFilter = AutocompletePrimitive.useFilter
export {
Autocomplete,
AutocompleteInput,
AutocompleteTrigger,
AutocompletePopup,
AutocompleteItem,
AutocompleteSeparator,
AutocompleteGroup,
AutocompleteGroupLabel,
AutocompleteEmpty,
AutocompleteValue,
AutocompleteList,
AutocompleteClear,
AutocompleteStatus,
AutocompleteRow,
AutocompleteCollection,
useAutocompleteFilter,
}
+267
View File
@@ -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 (
<CommandDialogPrimitive.Trigger
data-slot="command-dialog-trigger"
{...props}
/>
)
}
function CommandDialogBackdrop({
className,
...props
}: CommandDialogPrimitive.Backdrop.Props) {
return (
<CommandDialogPrimitive.Backdrop
className={cn(
'fixed inset-0 z-50 bg-ink/40 backdrop-blur-sm transition-all duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0',
className,
)}
data-slot="command-dialog-backdrop"
{...props}
/>
)
}
function CommandDialogViewport({
className,
...props
}: CommandDialogPrimitive.Viewport.Props) {
return (
<CommandDialogPrimitive.Viewport
className={cn(
'fixed inset-0 z-50 flex flex-col items-center justify-center px-4 py-[max(--spacing(4),4vh)]',
className,
)}
data-slot="command-dialog-viewport"
{...props}
/>
)
}
function CommandDialogPopup({
className,
children,
...props
}: CommandDialogPrimitive.Popup.Props) {
return (
<CommandDialogPortal>
<CommandDialogBackdrop />
<CommandDialogViewport>
<CommandDialogPrimitive.Popup
className={cn(
'-translate-y-[calc(1.25rem*var(--nested-dialogs))] relative row-start-2 flex max-h-105 min-h-0 w-full min-w-0 max-w-xl scale-[calc(1-0.1*var(--nested-dialogs))] flex-col rounded-2xl border border-primary-200 bg-primary-50 text-primary-900 opacity-[calc(1-0.1*var(--nested-dialogs))] shadow-lg outline-1 outline-primary-950/10 outline transition-[scale,opacity,translate] duration-200 ease-in-out will-change-transform data-nested:data-ending-style:translate-y-8 data-nested:data-starting-style:translate-y-8 data-nested-dialog-open:origin-top data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 **:data-[slot=scroll-area-viewport]:data-has-overflow-y:pe-1',
className,
)}
data-slot="command-dialog-popup"
{...props}
>
{children}
</CommandDialogPrimitive.Popup>
</CommandDialogViewport>
</CommandDialogPortal>
)
}
function Command({
autoHighlight = 'always',
keepHighlight = true,
...props
}: React.ComponentProps<typeof Autocomplete>) {
return (
<Autocomplete
autoHighlight={autoHighlight}
inline
keepHighlight={keepHighlight}
open
{...props}
/>
)
}
function CommandInput({
className,
placeholder = undefined,
...props
}: React.ComponentProps<typeof AutocompleteInput>) {
return (
<div className="px-2.5 py-1.5">
<AutocompleteInput
autoFocus
className={cn(
'border-transparent! bg-transparent! shadow-none before:hidden has-focus-visible:ring-0',
className,
)}
placeholder={placeholder}
size="lg"
startAddon={
<HugeiconsIcon icon={Search01Icon} size={20} strokeWidth={1.5} />
}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof AutocompleteList>) {
return (
<AutocompleteList
className={cn('not-empty:scroll-py-2 not-empty:p-2', className)}
data-slot="command-list"
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof AutocompleteEmpty>) {
return (
<AutocompleteEmpty
className={cn('not-empty:py-6', className)}
data-slot="command-empty"
{...props}
/>
)
}
function CommandPanel({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className="-mx-px not-has-[+[data-slot=command-footer]]:-mb-px relative min-h-0 rounded-t-xl not-has-[+[data-slot=command-footer]]:rounded-b-2xl border border-primary-200 border-b-0 bg-surface bg-clip-padding shadow-xs/5 [clip-path:inset(0_1px)] not-has-[+[data-slot=command-footer]]:[clip-path:inset(0_1px_1px_1px_round_0_0_calc(var(--radius-2xl)-1px)_calc(var(--radius-2xl)-1px))] before:pointer-events-none before:absolute before:inset-0 before:rounded-t-[calc(var(--radius-xl)-1px)] **:data-[slot=scroll-area-scrollbar]:mt-2"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof AutocompleteGroup>) {
return (
<AutocompleteGroup
className={className}
data-slot="command-group"
{...props}
/>
)
}
function CommandGroupLabel({
className,
...props
}: React.ComponentProps<typeof AutocompleteGroupLabel>) {
return (
<AutocompleteGroupLabel
className={className}
data-slot="command-group-label"
{...props}
/>
)
}
function CommandCollection({
...props
}: React.ComponentProps<typeof AutocompleteCollection>) {
return <AutocompleteCollection data-slot="command-collection" {...props} />
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof AutocompleteItem>) {
return (
<AutocompleteItem
className={cn('py-1.5', className)}
data-slot="command-item"
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof AutocompleteSeparator>) {
return (
<AutocompleteSeparator
className={cn('my-2', className)}
data-slot="command-separator"
{...props}
/>
)
}
function CommandShortcut({ className, ...props }: React.ComponentProps<'kbd'>) {
return (
<kbd
className={cn(
'ms-auto font-medium font-sans text-primary-600/80 text-xs tracking-widest',
className,
)}
data-slot="command-shortcut"
{...props}
/>
)
}
function CommandFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'flex items-center justify-between gap-2 rounded-b-[calc(var(--radius-2xl)-1px)] border-t border-primary-200 px-5 py-3 text-primary-700 text-xs',
className,
)}
data-slot="command-footer"
{...props}
/>
)
}
export {
CommandCreateHandle,
Command,
CommandCollection,
CommandDialog,
CommandDialogPopup,
CommandDialogTrigger,
CommandEmpty,
CommandFooter,
CommandGroup,
CommandGroupLabel,
CommandInput,
CommandItem,
CommandList,
CommandPanel,
CommandSeparator,
CommandShortcut,
}
+66
View File
@@ -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<HTMLInputElement>,
'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 (
<span
className={
cn(
!unstyled &&
'relative inline-flex w-full rounded-lg border border-primary-200 bg-surface not-dark:bg-clip-padding text-base text-primary-900 shadow-xs/5 ring-primary-500/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-disabled:not-has-focus-visible:not-has-aria-invalid:before:shadow-[0_1px_--theme(--color-ink/6%)] has-focus-visible:has-aria-invalid:border-destructive/64 has-focus-visible:has-aria-invalid:ring-destructive/16 has-aria-invalid:border-destructive/36 has-focus-visible:border-primary-500 has-autofill:bg-primary-100 has-disabled:opacity-64 has-[:disabled,:focus-visible,[aria-invalid]]:shadow-none has-focus-visible:ring-[3px] sm:text-sm dark:bg-primary-900/32 dark:has-autofill:bg-primary-900/30 dark:has-aria-invalid:ring-destructive/24 dark:not-has-disabled:not-has-focus-visible:not-has-aria-invalid:before:shadow-[0_-1px_--theme(--color-surface/6%)]',
className,
) || undefined
}
data-size={size}
data-slot="input-control"
>
{nativeInput ? (
<input
className={inputClassName}
data-slot="input"
size={typeof size === 'number' ? size : undefined}
{...props}
/>
) : (
<InputPrimitive
className={inputClassName}
data-slot="input"
size={typeof size === 'number' ? size : undefined}
{...props}
/>
)}
</span>
)
}
export { Input, type InputProps }
@@ -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<string | null>(null)
const [deleteFriendlyId, setDeleteFriendlyId] = useState<string | null>(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({
</TooltipProvider>
</motion.div>
<div className="px-2 mb-4">
<div className="px-2 mb-4 gap-px flex flex-col">
<motion.div
layout
transition={{ layout: transition }}
@@ -185,7 +208,7 @@ function ChatSidebarComponent({
size="sm"
onClick={onCreateSession}
onMouseUp={onSelectSession}
className="w-full pl-1.5 justify-start"
className="group w-full pl-1.5 justify-start transition-colors duration-0"
>
<HugeiconsIcon
icon={PencilEdit02Icon}
@@ -206,10 +229,62 @@ function ChatSidebarComponent({
</motion.span>
)}
</AnimatePresence>
{!isCollapsed ? (
<span className="ms-auto inline-flex items-center gap-1 text-[14px] text-primary-600 opacity-0 transition-none group-hover:opacity-100">
<kbd className="font-sans"></kbd>
<kbd className="font-sans"></kbd>
<kbd className="font-sans">O</kbd>
</span>
) : null}
</Button>
</motion.div>
<motion.div
layout
transition={{ layout: transition }}
className="w-full"
>
<Button
variant="ghost"
size="sm"
onClick={() => setSearchDialogOpen(true)}
className="group w-full pl-1.5 justify-start transition-colors duration-0"
>
<HugeiconsIcon
icon={Search01Icon}
size={20}
strokeWidth={1.5}
className="min-w-5"
/>
<AnimatePresence initial={false} mode="wait">
{!isCollapsed && (
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={transition}
className="overflow-hidden whitespace-nowrap"
>
Search sessions
</motion.span>
)}
</AnimatePresence>
{!isCollapsed ? (
<span className="ms-auto inline-flex items-center gap-1 text-[14px] text-primary-600 opacity-0 transition-none group-hover:opacity-100">
<kbd className="font-sans"></kbd>
<kbd className="font-sans">K</kbd>
</span>
) : null}
</Button>
</motion.div>
</div>
<CommandSessionDialog
sessions={sessions}
open={searchDialogOpen}
onOpenChange={handleSearchDialogOpenChange}
onSelect={handleSearchSelect}
/>
<div className="flex-1 min-h-0 overflow-hidden">
<AnimatePresence initial={false}>
{!isCollapsed && (
@@ -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<CommandSessionItem>
}
type CommandSessionProps = {
sessions: Array<CommandSession>
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<Array<CommandSessionGroup>>(() => {
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 (
<CommandDialog open={open} onOpenChange={onOpenChange}>
<CommandDialogPopup className="mx-auto self-center">
<Command
items={groupedItems}
value={value}
onValueChange={setValue}
mode="none"
>
<CommandInput placeholder="Search sessions" />
<CommandPanel className="flex min-h-0 flex-1 flex-col">
{isEmpty ? (
<div className="h-72 min-h-0 flex items-center justify-center text-sm text-primary-600">
No sessions found.
</div>
) : (
<CommandList className="h-72 min-h-0">
{filteredGroups.map((group, index) => (
<Fragment key={`${group.value}-${index}`}>
<CommandGroup items={group.items}>
<CommandGroupLabel>{group.value}</CommandGroupLabel>
<CommandCollection>
{(item) => (
<CommandItem
key={item.value}
value={item.label}
onClick={() => onSelect(item.session)}
className="gap-2"
>
<span className="text-sm font-[450] line-clamp-1">
{item.label}
</span>
</CommandItem>
)}
</CommandCollection>
</CommandGroup>
{index < filteredGroups.length - 1 ? (
<CommandSeparator />
) : null}
</Fragment>
))}
</CommandList>
)}
</CommandPanel>
<CommandFooter>
<div className="flex items-center gap-4 text-primary-700">
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-md border border-primary-200 bg-surface px-2 py-1 text-[11px] font-medium text-primary-700">
<HugeiconsIcon icon={ArrowUp01Icon} size={14} strokeWidth={1.5} />
<HugeiconsIcon icon={ArrowDown01Icon} size={14} strokeWidth={1.5} />
</span>
<span>Navigate</span>
</div>
<div className="flex items-center gap-2">
<span className="rounded-md border border-primary-200 bg-surface px-2 py-1 text-[11px] font-medium text-primary-700">
Enter
</span>
<span>Open</span>
</div>
</div>
<div className="flex items-center gap-2 text-primary-700">
<span className="rounded-md border border-primary-200 bg-surface px-2 py-1 text-[11px] font-medium text-primary-700">
Esc
</span>
<span>Close</span>
</div>
</CommandFooter>
</Command>
</CommandDialogPopup>
</CommandDialog>
)
}
export { CommandSessionDialog }
@@ -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',
)}
>
@@ -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 }