AssistantCommandsPopover.tsx225 lines · main
| 1 | 'use client' |
| 2 | |
| 3 | // Required to avoid issue: |
| 4 | // The inferred type of AssistantCommandsPopover cannot be named without a reference to PopoverProps |
| 5 | import { Popover as _RadixPopover } from 'radix-ui' |
| 6 | import { |
| 7 | ComponentPropsWithoutRef, |
| 8 | ComponentRef, |
| 9 | createRef, |
| 10 | forwardRef, |
| 11 | useEffect, |
| 12 | useRef, |
| 13 | } from 'react' |
| 14 | import { |
| 15 | Command, |
| 16 | CommandEmpty, |
| 17 | CommandGroup, |
| 18 | CommandInput, |
| 19 | CommandItem, |
| 20 | CommandList, |
| 21 | CommandSeparator, |
| 22 | } from 'ui/src/components/shadcn/ui/command' |
| 23 | import { Popover, PopoverAnchor, PopoverContent } from 'ui/src/components/shadcn/ui/popover' |
| 24 | import { cn } from 'ui/src/lib/utils/cn' |
| 25 | |
| 26 | const AssistantCommandsPopover = forwardRef< |
| 27 | ComponentRef<typeof Popover>, |
| 28 | ComponentPropsWithoutRef<typeof Popover> & { |
| 29 | /* The children to render - this is where the AssistantChatForm should be placed */ |
| 30 | children: React.ReactNode |
| 31 | /* The ref for the textarea - used with the AssistantChatForm */ |
| 32 | textAreaRef: React.RefObject<HTMLTextAreaElement | null> |
| 33 | /* The function to handle the value change */ |
| 34 | setValue: (value: string) => void |
| 35 | /* The value of the textarea */ |
| 36 | value: string |
| 37 | /* Whether the popover is open */ |
| 38 | open: boolean |
| 39 | /* The function to handle the popover open state */ |
| 40 | setOpen: (value: boolean) => void |
| 41 | /* The suggestions to display in the popover */ |
| 42 | suggestions?: string[] |
| 43 | /* Whether an icon is being used in the AssistantChatForm */ |
| 44 | usingIcon?: boolean |
| 45 | /* Available commands in suggestions popover */ |
| 46 | commands?: string[] |
| 47 | } |
| 48 | >( |
| 49 | ( |
| 50 | { |
| 51 | children, |
| 52 | textAreaRef, |
| 53 | setValue, |
| 54 | value, |
| 55 | open, |
| 56 | setOpen, |
| 57 | suggestions, |
| 58 | usingIcon = false, |
| 59 | commands = ['fix', 'improve', 'explain', 'help'], |
| 60 | ...props |
| 61 | }, |
| 62 | ref |
| 63 | ) => { |
| 64 | const popoverContentRef = createRef<HTMLDivElement>() |
| 65 | const commandWidth = popoverContentRef.current?.clientWidth |
| 66 | |
| 67 | const targetInputRef = useRef<HTMLInputElement | null>(null) |
| 68 | |
| 69 | useEffect(() => { |
| 70 | // Attach the event listener when the component mounts |
| 71 | if (textAreaRef.current) { |
| 72 | textAreaRef.current.addEventListener('keydown', handleKeyPress) |
| 73 | } |
| 74 | |
| 75 | // Detach the event listener when the component unmounts |
| 76 | return () => { |
| 77 | if (textAreaRef.current) { |
| 78 | textAreaRef.current.removeEventListener('keydown', handleKeyPress) |
| 79 | } |
| 80 | // Detach the event listener when the component unmounts |
| 81 | } |
| 82 | }, []) // Empty dependency array means this effect runs once when the component mounts |
| 83 | |
| 84 | const handleKeyPress = (event: KeyboardEvent) => { |
| 85 | if (event.key === '/' && !value) { |
| 86 | // Add your action here |
| 87 | setOpen(true) |
| 88 | if (textAreaRef) textAreaRef?.current?.focus() |
| 89 | } else if (event.key === 'Escape') { |
| 90 | // Add your action here |
| 91 | setOpen(false) |
| 92 | } else if (event.key === 'Backspace') { |
| 93 | // Add your action here |
| 94 | setOpen(false) |
| 95 | } else { |
| 96 | if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Enter') { |
| 97 | // Forward the event to the target input |
| 98 | if (targetInputRef.current) { |
| 99 | const keyboardEvent = new KeyboardEvent('keydown', { |
| 100 | bubbles: true, |
| 101 | cancelable: true, |
| 102 | composed: true, |
| 103 | key: event.key, |
| 104 | code: event.code, |
| 105 | }) |
| 106 | |
| 107 | targetInputRef.current.dispatchEvent(keyboardEvent) |
| 108 | |
| 109 | // Schedule focus on the original input using requestAnimationFrame |
| 110 | requestAnimationFrame(() => { |
| 111 | if (textAreaRef.current) { |
| 112 | textAreaRef.current.focus() |
| 113 | } |
| 114 | }) |
| 115 | |
| 116 | // Prevent the default behavior for ArrowUp, ArrowDown, and Enter |
| 117 | event.preventDefault() |
| 118 | } |
| 119 | } |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | const resultArray = value.split(/(\s+)/).filter(Boolean) |
| 124 | |
| 125 | return ( |
| 126 | <> |
| 127 | <Popover |
| 128 | open={open} |
| 129 | onOpenChange={() => { |
| 130 | setOpen(!open) |
| 131 | if (textAreaRef) textAreaRef?.current?.focus() |
| 132 | }} |
| 133 | {...props} |
| 134 | > |
| 135 | <PopoverAnchor className={cn('w-full relative', usingIcon && 'bg-control')}> |
| 136 | <> |
| 137 | <div |
| 138 | style={{ |
| 139 | left: '0px', |
| 140 | top: '10px', |
| 141 | marginLeft: commandWidth |
| 142 | ? `${(usingIcon ? 48 : 12) + commandWidth + 12}px` |
| 143 | : `${usingIcon ? 48 : 12}px`, |
| 144 | }} |
| 145 | className={cn('z-0 absolute flex items-center text-sm text-transparent')} |
| 146 | > |
| 147 | {resultArray.map((item, i) => ( |
| 148 | <span |
| 149 | key={i} |
| 150 | className={cn( |
| 151 | item === '/fix' || |
| 152 | item === '/improve' || |
| 153 | item === '/explain' || |
| 154 | item === '/help' |
| 155 | ? 'bg-surface-300' |
| 156 | : '' |
| 157 | )} |
| 158 | > |
| 159 | {item === ' ' ? '\u00A0' : item} |
| 160 | </span> |
| 161 | ))} |
| 162 | </div> |
| 163 | {children} |
| 164 | </> |
| 165 | </PopoverAnchor> |
| 166 | <PopoverContent |
| 167 | ref={popoverContentRef} |
| 168 | className="w-[420px] p-0" |
| 169 | align="start" |
| 170 | onOpenAutoFocus={(event) => { |
| 171 | event.preventDefault() |
| 172 | }} |
| 173 | > |
| 174 | <Command> |
| 175 | <CommandInput |
| 176 | placeholder="Type a command or search..." |
| 177 | wrapperClassName="hidden" |
| 178 | value={value} |
| 179 | ref={targetInputRef} |
| 180 | tabIndex={-1} |
| 181 | autoFocus={false} |
| 182 | /> |
| 183 | <CommandList> |
| 184 | <CommandEmpty>No results found.</CommandEmpty> |
| 185 | <CommandGroup heading="Suggestions"> |
| 186 | {suggestions?.map((suggestion, idx) => ( |
| 187 | <CommandItem |
| 188 | key={idx} |
| 189 | value={'/ ' + suggestion} |
| 190 | className="text-sm gap-0.5" |
| 191 | onSelect={() => { |
| 192 | setValue(`${suggestion}`) |
| 193 | // closing of the popover is handled by the keydown event in AssistantChatForm |
| 194 | }} |
| 195 | > |
| 196 | <span className="text-default">{suggestion}</span> |
| 197 | </CommandItem> |
| 198 | ))} |
| 199 | </CommandGroup> |
| 200 | <CommandSeparator /> |
| 201 | <CommandGroup heading="Commands"> |
| 202 | {commands.map((command, idx) => ( |
| 203 | <CommandItem |
| 204 | key={idx} |
| 205 | className="text-sm gap-0.5" |
| 206 | onSelect={() => { |
| 207 | setValue(`/${command} `) |
| 208 | // closing of the popover is handled by the keydown event in AssistantChatForm |
| 209 | }} |
| 210 | > |
| 211 | <span className="text-brand">/</span> |
| 212 | <span className="text-default">{command}</span> |
| 213 | </CommandItem> |
| 214 | ))} |
| 215 | </CommandGroup> |
| 216 | </CommandList> |
| 217 | </Command> |
| 218 | </PopoverContent> |
| 219 | </Popover> |
| 220 | </> |
| 221 | ) |
| 222 | } |
| 223 | ) |
| 224 | |
| 225 | export { AssistantCommandsPopover } |