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
5import { Popover as _RadixPopover } from 'radix-ui'
6import {
7 ComponentPropsWithoutRef,
8 ComponentRef,
9 createRef,
10 forwardRef,
11 useEffect,
12 useRef,
13} from 'react'
14import {
15 Command,
16 CommandEmpty,
17 CommandGroup,
18 CommandInput,
19 CommandItem,
20 CommandList,
21 CommandSeparator,
22} from 'ui/src/components/shadcn/ui/command'
23import { Popover, PopoverAnchor, PopoverContent } from 'ui/src/components/shadcn/ui/popover'
24import { cn } from 'ui/src/lib/utils/cn'
25
26const 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
225export { AssistantCommandsPopover }