AIAssistantChatSelector.tsx215 lines · main
1import { Check, ChevronDown, Edit, Plus, Trash, X } from 'lucide-react'
2import { useState } from 'react'
3import {
4 Button,
5 cn,
6 Command,
7 CommandEmpty,
8 CommandGroup,
9 CommandInput,
10 CommandItem,
11 CommandList,
12 CommandSeparator,
13 Input,
14 Popover,
15 PopoverContent,
16 PopoverTrigger,
17 ScrollArea,
18} from 'ui'
19
20import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
21
22interface AIAssistantChatSelectorProps {
23 disabled?: boolean
24}
25
26export const AIAssistantChatSelector = ({ disabled = false }: AIAssistantChatSelectorProps) => {
27 const snap = useAiAssistantStateSnapshot()
28 const currentChat = snap.activeChat?.name
29
30 const [chatSelectorOpen, setChatSelectorOpen] = useState(false)
31 const [editingChatId, setEditingChatId] = useState<string | null>(null)
32 const [editingChatName, setEditingChatName] = useState('')
33
34 const chats = Object.entries(snap.chats)
35
36 const handleSelectChat = (id: string) => {
37 snap.selectChat(id)
38 setChatSelectorOpen(false)
39 }
40
41 const handleDeleteChat = (id: string, e?: React.MouseEvent) => {
42 if (e) {
43 e.stopPropagation()
44 }
45 snap.deleteChat(id)
46 }
47
48 const handleStartEditChat = (id: string, name: string, e?: React.MouseEvent) => {
49 if (e) {
50 e.stopPropagation()
51 }
52 setEditingChatId(id)
53 setEditingChatName(name)
54 }
55
56 const handleSaveEditChat = (e?: React.MouseEvent) => {
57 if (e) {
58 e.stopPropagation()
59 }
60 if (editingChatId && editingChatName.trim()) {
61 snap.renameChat(editingChatId, editingChatName.trim())
62 setEditingChatId(null)
63 setEditingChatName('')
64 }
65 }
66
67 const handleCancelEditChat = (e?: React.MouseEvent | React.FocusEvent) => {
68 if (e) {
69 e.stopPropagation()
70 }
71 setEditingChatId(null)
72 setEditingChatName('')
73 }
74
75 const handleInputBlur = (e: React.FocusEvent) => {
76 e.stopPropagation()
77 const relatedTarget = e.relatedTarget as HTMLElement | null
78 const isSaveOrCancelButton = relatedTarget?.closest('button')
79
80 if (!isSaveOrCancelButton && editingChatId && editingChatName.trim()) {
81 handleSaveEditChat()
82 } else if (!isSaveOrCancelButton) {
83 handleCancelEditChat(e)
84 }
85 }
86
87 return (
88 <Popover open={chatSelectorOpen} onOpenChange={setChatSelectorOpen}>
89 <PopoverTrigger asChild>
90 <Button
91 type="text"
92 size="tiny"
93 iconRight={<ChevronDown size={14} />}
94 className="max-w-64 truncate"
95 >
96 {currentChat}
97 </Button>
98 </PopoverTrigger>
99 <PopoverContent className="w-[250px] p-0" align="start">
100 <Command>
101 <CommandInput className="text-xs" placeholder="Search chats..." />
102 <CommandList>
103 <CommandEmpty>No chats found.</CommandEmpty>
104 <CommandGroup>
105 <ScrollArea className={chats.length > 4 ? 'h-40' : ''}>
106 {/* @ts-ignore */}
107 {chats.map(([id, chat]) => (
108 <CommandItem
109 key={id}
110 value={id}
111 onSelect={() => handleSelectChat(id)}
112 className="flex items-center justify-between gap-2 py-1 w-full overflow-hidden group"
113 keywords={!!chat.name ? [chat.name] : undefined}
114 disabled={disabled}
115 >
116 <div className="flex items-center w-full flex-1 min-w-0">
117 {editingChatId === id ? (
118 <div className="flex items-center gap-2 w-full">
119 <Input
120 value={editingChatName}
121 onChange={(e) => setEditingChatName(e.target.value)}
122 autoFocus
123 size="tiny"
124 className="flex-1 w-full"
125 onClick={(e) => e.stopPropagation()}
126 onBlur={handleInputBlur}
127 onKeyDown={(e) => {
128 if (e.key === 'Enter') {
129 e.preventDefault()
130 e.stopPropagation()
131 handleSaveEditChat()
132 } else if (e.key === 'Escape') {
133 e.preventDefault()
134 e.stopPropagation()
135 handleCancelEditChat()
136 }
137 }}
138 />
139 <div className="flex items-center gap-0">
140 <Button
141 type="text"
142 size="tiny"
143 icon={<Check size={14} />}
144 onClick={(e) => handleSaveEditChat(e)}
145 className="h-7 w-7"
146 />
147 <Button
148 type="text"
149 size="tiny"
150 icon={<X size={14} />}
151 onClick={(e) => handleCancelEditChat(e)}
152 className="h-7 w-7"
153 />
154 </div>
155 </div>
156 ) : (
157 <>
158 <Check
159 className={cn(
160 'mr-2 h-4 w-4 shrink-0',
161 snap.activeChatId === id ? 'opacity-100' : 'opacity-0'
162 )}
163 />
164 <span className="truncate flex-1 w-0">{chat.name}</span>
165 </>
166 )}
167 </div>
168 {editingChatId !== id && (
169 <div className="flex items-center gap-x-0 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
170 <Button
171 type="text"
172 size="tiny"
173 icon={<Edit size={14} />}
174 onClick={(e) => handleStartEditChat(id, chat.name, e)}
175 className="h-6 w-6"
176 />
177 {chats.length > 1 && (
178 <Button
179 type="text"
180 size="tiny"
181 icon={<Trash size={14} />}
182 onClick={(e) => handleDeleteChat(id, e)}
183 className="h-6 w-6"
184 />
185 )}
186 </div>
187 )}
188 </CommandItem>
189 ))}
190 </ScrollArea>
191 </CommandGroup>
192 <CommandSeparator />
193 <CommandGroup>
194 <CommandItem
195 className="cursor-pointer w-full gap-x-2"
196 onSelect={() => {
197 snap.newChat()
198 setChatSelectorOpen(false)
199 }}
200 onClick={() => {
201 snap.newChat()
202 setChatSelectorOpen(false)
203 }}
204 disabled={disabled}
205 >
206 <Plus size={14} strokeWidth={1.5} />
207 <span>Start a new chat</span>
208 </CommandItem>
209 </CommandGroup>
210 </CommandList>
211 </Command>
212 </PopoverContent>
213 </Popover>
214 )
215}