mirror of
https://github.com/ibelick/webclaw.git
synced 2026-08-14 00:57:51 +00:00
Merge pull request #24 from ibelick/feat/add-search-sessions
feat: add command search sessions
This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user