Skip to content

Commit 7976e7d

Browse files
Refine session history UI (#586)
* Refine session history UI * Fix Linux cargo check for app windows * Format Linux app window fix * Fix history sidebar: exit animation, stale closure, hover selection Stabilize handleClose with useCallback and add it to the keyboard effect dependency array to prevent stale closure bugs. Move AnimatePresence inside DialogPrimitive.Root with forceMount on Portal so exit animations actually play. Add onMouseEnter to chat items so hover updates selectedIndex and Enter selects the hovered item. --------- Co-authored-by: Mehmet Özgül <mehmetozguldev@gmail.com>
1 parent 3a25af4 commit 7976e7d

1 file changed

Lines changed: 202 additions & 106 deletions

File tree

Lines changed: 202 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
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";
35
import { getRelativeTime } from "@/features/ai/lib/formatting";
46
import 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";
87
import { cn } from "@/utils/cn";
98
import { 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-
2320
export 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">&bull;</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

Comments
 (0)