AIAssistantChatSelector.tsx215 lines · main
| 1 | import { Check, ChevronDown, Edit, Plus, Trash, X } from 'lucide-react' |
| 2 | import { useState } from 'react' |
| 3 | import { |
| 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 | |
| 20 | import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' |
| 21 | |
| 22 | interface AIAssistantChatSelectorProps { |
| 23 | disabled?: boolean |
| 24 | } |
| 25 | |
| 26 | export 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 | } |