1- import { Check , Search , Trash2 } from "lucide-react" ;
2- import { useEffect , useMemo , useRef , useState } from "react" ;
1+ import * as DialogPrimitive from "@radix-ui/react-dialog" ;
2+ import { AnimatePresence , motion } from "framer-motion" ;
3+ import { Check , Search , Trash2 , X } from "lucide-react" ;
4+ import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
35import { getRelativeTime } from "@/features/ai/lib/formatting" ;
46import type { Chat } from "@/features/ai/types/ai-chat" ;
5- import { Button } from "@/ui/button" ;
6- import { Dropdown } from "@/ui/dropdown" ;
7- import Input from "@/ui/input" ;
87import { cn } from "@/utils/cn" ;
98import { ProviderIcon } from "../icons/provider-icons" ;
109
@@ -18,8 +17,6 @@ interface ChatHistoryDropdownProps {
1817 triggerRef : React . RefObject < HTMLButtonElement | null > ;
1918}
2019
21- const DROPDOWN_WIDTH = 340 ;
22-
2320export default function ChatHistoryDropdown ( {
2421 isOpen,
2522 onClose,
@@ -29,116 +26,215 @@ export default function ChatHistoryDropdown({
2926 onDeleteChat,
3027 triggerRef,
3128} : ChatHistoryDropdownProps ) {
32- const searchRef = useRef < HTMLInputElement > ( null ) ;
29+ const inputRef = useRef < HTMLInputElement > ( null ) ;
30+ const resultsRef = useRef < HTMLDivElement > ( null ) ;
3331 const [ searchQuery , setSearchQuery ] = useState ( "" ) ;
32+ const [ selectedIndex , setSelectedIndex ] = useState ( 0 ) ;
3433
3534 const filteredChats = useMemo ( ( ) => {
36- if ( ! searchQuery . trim ( ) ) return chats ;
37- return chats . filter ( ( chat ) => chat . title . toLowerCase ( ) . includes ( searchQuery . toLowerCase ( ) ) ) ;
35+ const query = searchQuery . trim ( ) . toLowerCase ( ) ;
36+ if ( ! query ) return chats ;
37+ return chats . filter ( ( chat ) => {
38+ const titleMatch = chat . title . toLowerCase ( ) . includes ( query ) ;
39+ const providerMatch = ( chat . agentId ?? "custom" ) . toLowerCase ( ) . includes ( query ) ;
40+ return titleMatch || providerMatch ;
41+ } ) ;
3842 } , [ chats , searchQuery ] ) ;
3943
44+ const handleClose = useCallback ( ( ) => {
45+ onClose ( ) ;
46+ triggerRef . current ?. focus ( ) ;
47+ } , [ onClose , triggerRef ] ) ;
48+
4049 useEffect ( ( ) => {
41- if ( isOpen ) {
42- setSearchQuery ( "" ) ;
43- setTimeout ( ( ) => searchRef . current ?. focus ( ) , 0 ) ;
44- }
50+ if ( ! isOpen ) return ;
51+ setSearchQuery ( "" ) ;
52+ setSelectedIndex ( 0 ) ;
53+ window . setTimeout ( ( ) => inputRef . current ?. focus ( ) , 0 ) ;
4554 } , [ isOpen ] ) ;
4655
56+ useEffect ( ( ) => {
57+ if ( ! isOpen ) return ;
58+
59+ const handleKeyDown = ( event : KeyboardEvent ) => {
60+ switch ( event . key ) {
61+ case "Escape" :
62+ event . preventDefault ( ) ;
63+ handleClose ( ) ;
64+ break ;
65+ case "ArrowDown" :
66+ event . preventDefault ( ) ;
67+ setSelectedIndex ( ( prev ) => Math . min ( prev + 1 , filteredChats . length - 1 ) ) ;
68+ break ;
69+ case "ArrowUp" :
70+ event . preventDefault ( ) ;
71+ setSelectedIndex ( ( prev ) => Math . max ( prev - 1 , 0 ) ) ;
72+ break ;
73+ case "Enter" :
74+ if ( filteredChats [ selectedIndex ] ) {
75+ event . preventDefault ( ) ;
76+ onSwitchToChat ( filteredChats [ selectedIndex ] . id ) ;
77+ handleClose ( ) ;
78+ }
79+ break ;
80+ }
81+ } ;
82+
83+ document . addEventListener ( "keydown" , handleKeyDown ) ;
84+ return ( ) => document . removeEventListener ( "keydown" , handleKeyDown ) ;
85+ } , [ filteredChats , handleClose , isOpen , onSwitchToChat , selectedIndex ] ) ;
86+
87+ useEffect ( ( ) => {
88+ setSelectedIndex ( 0 ) ;
89+ } , [ searchQuery ] ) ;
90+
91+ useEffect ( ( ) => {
92+ if ( ! resultsRef . current || filteredChats . length === 0 ) return ;
93+ const selectedElement = resultsRef . current . children [ selectedIndex ] as HTMLElement | undefined ;
94+ selectedElement ?. scrollIntoView ( { block : "nearest" , behavior : "smooth" } ) ;
95+ } , [ filteredChats . length , selectedIndex ] ) ;
96+
4797 return (
48- < Dropdown
49- isOpen = { isOpen }
50- anchorRef = { triggerRef }
51- anchorAlign = "end"
52- onClose = { onClose }
53- className = "flex flex-col overflow-hidden rounded-2xl p-0"
54- style = { { width : `${ DROPDOWN_WIDTH } px` } }
55- >
56- < div className = "bg-secondary-bg px-2 py-2" >
57- < Input
58- ref = { searchRef }
59- type = "text"
60- placeholder = "Search chats..."
61- value = { searchQuery }
62- onChange = { ( e ) => setSearchQuery ( e . target . value ) }
63- leftIcon = { Search }
64- variant = "ghost"
65- className = "w-full"
66- />
67- </ div >
68-
69- < div className = "min-h-0 flex-1 overflow-y-auto p-1.5" >
70- { chats . length === 0 ? (
71- < div className = "p-3 text-center text-text-lighter text-xs italic" > No chat history</ div >
72- ) : filteredChats . length === 0 ? (
73- < div className = "p-3 text-center text-text-lighter text-xs italic" >
74- No chats match "{ searchQuery } "
75- </ div >
76- ) : (
77- < div className = "space-y-0.5" >
78- { filteredChats . map ( ( chat ) => (
79- < div
80- key = { chat . id }
81- className = { cn (
82- "group flex items-center gap-2.5 rounded-lg px-2 py-1.5 hover:bg-hover" ,
83- chat . id === currentChatId && "bg-selected" ,
84- ) }
85- >
86- < div className = "flex shrink-0 items-center" >
87- { chat . id === currentChatId ? (
88- < Check className = "text-success" />
89- ) : (
90- < ProviderIcon
91- providerId = { chat . agentId || "custom" }
92- size = { 12 }
93- className = "text-text-lighter"
94- />
95- ) }
96- </ div >
97-
98- < Button
99- type = "button"
100- variant = "ghost"
101- size = "sm"
102- onClick = { ( ) => {
103- onSwitchToChat ( chat . id ) ;
104- onClose ( ) ;
105- } }
106- className = "h-auto min-w-0 flex-1 justify-start flex-col items-start gap-0.5 px-0 py-0 text-left hover:bg-transparent"
98+ < DialogPrimitive . Root open = { isOpen } onOpenChange = { ( open ) => ! open && handleClose ( ) } >
99+ < AnimatePresence >
100+ { isOpen && (
101+ < DialogPrimitive . Portal forceMount >
102+ < div className = "fixed inset-0 z-[10030] flex items-start justify-center px-4 pt-16 sm:pt-24" >
103+ < DialogPrimitive . Overlay asChild >
104+ < motion . div
105+ initial = { { opacity : 0 } }
106+ animate = { { opacity : 1 } }
107+ exit = { { opacity : 0 } }
108+ transition = { { duration : 0.18 } }
109+ className = "fixed inset-0 bg-black/40 backdrop-blur-sm"
110+ />
111+ </ DialogPrimitive . Overlay >
112+
113+ < DialogPrimitive . Content asChild >
114+ < motion . div
115+ initial = { { opacity : 0 , scale : 0.97 , y : 10 } }
116+ animate = { { opacity : 1 , scale : 1 , y : 0 } }
117+ exit = { { opacity : 0 , scale : 0.97 , y : 10 } }
118+ transition = { { duration : 0.2 , ease : [ 0.16 , 1 , 0.3 , 1 ] } }
119+ className = "relative flex max-h-[75vh] w-full max-w-[560px] flex-col overflow-hidden rounded-2xl border border-border/40 bg-primary-bg/95 shadow-2xl focus:outline-none"
107120 >
108- < span
109- className = { cn (
110- "block w-full truncate text-left text-xs" ,
111- chat . id === currentChatId
112- ? "font-medium text-text"
113- : "text-text-lighter hover:text-text" ,
114- ) }
121+ < div className = "flex items-center gap-3 border-b border-border/30 px-5 py-4" >
122+ < Search className = "size-4 text-text-lighter" />
123+ < input
124+ ref = { inputRef }
125+ value = { searchQuery }
126+ onChange = { ( event ) => setSearchQuery ( event . target . value ) }
127+ placeholder = "Find past chats..."
128+ className = "flex-1 bg-transparent text-sm text-text placeholder-text-lighter outline-none"
129+ />
130+ < button
131+ type = "button"
132+ onClick = { handleClose }
133+ className = "rounded-full p-1 text-text-lighter transition-colors hover:bg-hover hover:text-text"
134+ aria-label = "Close chat history"
135+ >
136+ < X className = "size-4" />
137+ </ button >
138+ </ div >
139+
140+ < div
141+ ref = { resultsRef }
142+ className = "custom-scrollbar-thin flex-1 overflow-y-auto p-2"
115143 >
116- { chat . title }
117- </ span >
118- < span className = "block w-full select-none text-left text-[10px] text-text-lighter" >
119- { getRelativeTime ( chat . lastMessageAt ) }
120- </ span >
121- </ Button >
122-
123- < Button
124- type = "button"
125- variant = "ghost"
126- size = "icon-xs"
127- onClick = { ( e ) => {
128- e . stopPropagation ( ) ;
129- onDeleteChat ( chat . id , e ) ;
130- } }
131- className = "ml-auto rounded text-text-lighter opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400"
132- title = "Delete chat"
133- aria-label = "Delete chat"
134- >
135- < Trash2 />
136- </ Button >
137- </ div >
138- ) ) }
139- </ div >
144+ { chats . length === 0 ? (
145+ < div className = "py-12 text-center text-sm text-text-lighter" >
146+ No chat history yet
147+ </ div >
148+ ) : filteredChats . length === 0 ? (
149+ < div className = "py-12 text-center text-sm text-text-lighter" >
150+ No chats match "{ searchQuery } "
151+ </ div >
152+ ) : (
153+ filteredChats . map ( ( chat , index ) => {
154+ const isCurrent = chat . id === currentChatId ;
155+ const isSelected = index === selectedIndex ;
156+
157+ return (
158+ < div
159+ key = { chat . id }
160+ onClick = { ( ) => {
161+ onSwitchToChat ( chat . id ) ;
162+ handleClose ( ) ;
163+ } }
164+ onMouseEnter = { ( ) => setSelectedIndex ( index ) }
165+ className = { cn (
166+ "group relative mb-0.5 flex cursor-pointer items-start gap-3 rounded-xl px-4 py-3 transition-colors" ,
167+ isSelected ? "bg-hover/80" : "hover:bg-hover/40" ,
168+ isCurrent && "bg-accent/5 hover:bg-accent/10" ,
169+ ) }
170+ >
171+ { isCurrent && (
172+ < div className = "absolute left-0 top-1/2 h-8 w-1 -translate-y-1/2 rounded-r-full bg-accent/60" />
173+ ) }
174+
175+ < div className = "mt-0.5 flex shrink-0 items-center justify-center" >
176+ { isCurrent ? (
177+ < Check className = "size-4 text-accent" />
178+ ) : (
179+ < ProviderIcon
180+ providerId = { chat . agentId || "custom" }
181+ size = { 13 }
182+ className = "text-text-lighter"
183+ />
184+ ) }
185+ </ div >
186+
187+ < div className = "min-w-0 flex-1" >
188+ < div className = "flex items-center gap-2" >
189+ < span
190+ className = { cn (
191+ "truncate text-[13px] font-medium transition-colors" ,
192+ isCurrent ? "text-accent" : "text-text" ,
193+ ) }
194+ >
195+ { chat . title }
196+ </ span >
197+ < span className = "shrink-0 text-[10px] whitespace-nowrap text-text-lighter" >
198+ { getRelativeTime ( chat . lastMessageAt ) }
199+ </ span >
200+ </ div >
201+
202+ < div className = "mt-1 flex items-center gap-2 text-[11px] text-text-lighter" >
203+ < span className = "opacity-80" >
204+ { ( chat . agentId || "custom" ) . replace ( / - / g, " " ) }
205+ </ span >
206+ { isCurrent && (
207+ < >
208+ < span className = "opacity-30" > •</ span >
209+ < span className = "opacity-80" > Current chat</ span >
210+ </ >
211+ ) }
212+ </ div >
213+ </ div >
214+
215+ < button
216+ type = "button"
217+ onClick = { ( event ) => {
218+ event . stopPropagation ( ) ;
219+ onDeleteChat ( chat . id , event ) ;
220+ } }
221+ className = "ml-2 flex size-6 shrink-0 items-center justify-center rounded-md text-text-lighter opacity-0 transition-all hover:bg-red-500/10 hover:text-red-400 group-hover:opacity-100"
222+ aria-label = { `Delete ${ chat . title } ` }
223+ title = "Delete chat"
224+ >
225+ < Trash2 size = { 13 } />
226+ </ button >
227+ </ div >
228+ ) ;
229+ } )
230+ ) }
231+ </ div >
232+ </ motion . div >
233+ </ DialogPrimitive . Content >
234+ </ div >
235+ </ DialogPrimitive . Portal >
140236 ) }
141- </ div >
142- </ Dropdown >
237+ </ AnimatePresence >
238+ </ DialogPrimitive . Root >
143239 ) ;
144240}
0 commit comments