Skip to content

Commit ccf49df

Browse files
committed
fix: resolve thread dropdown flickering on open and navigation
1 parent 6770457 commit ccf49df

1 file changed

Lines changed: 36 additions & 9 deletions

File tree

src/components/ThreadDropdown.tsx

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ const t = {
2323
},
2424
}
2525

26-
2726
export default function ThreadDropdown({
2827
currentThreadId,
2928
currentTitle,
@@ -43,6 +42,8 @@ export default function ThreadDropdown({
4342
const [loading, setLoading] = useState(false)
4443
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
4544
const dropdownRef = useRef<HTMLDivElement>(null)
45+
const isNavigatingRef = useRef(false)
46+
const searchInputRef = useRef<HTMLInputElement>(null)
4647
const router = useRouter()
4748
const labels = t[locale]
4849

@@ -76,13 +77,23 @@ export default function ThreadDropdown({
7677
}
7778
}, [])
7879

80+
// Fetch on open, reset search
7981
useEffect(() => {
80-
if (isOpen) fetchThreads()
82+
if (isOpen) {
83+
setSearch("")
84+
setConfirmDeleteId(null)
85+
fetchThreads()
86+
// Focus search input after render
87+
requestAnimationFrame(() => searchInputRef.current?.focus())
88+
}
8189
}, [isOpen, fetchThreads])
8290

83-
// Debounced search
91+
// Debounced search (only when user types, not on open)
92+
const isFirstRender = useRef(true)
8493
useEffect(() => {
85-
if (!isOpen) return
94+
if (!isOpen) { isFirstRender.current = true; return }
95+
// Skip the first render (handled by the open effect above)
96+
if (isFirstRender.current) { isFirstRender.current = false; return }
8697
const timer = setTimeout(() => fetchThreads(search || undefined), 300)
8798
return () => clearTimeout(timer)
8899
}, [search, isOpen, fetchThreads])
@@ -95,12 +106,22 @@ export default function ThreadDropdown({
95106
}, [confirmDeleteId])
96107

97108
const handleSelect = (threadId: string) => {
109+
if (threadId === currentThreadId) {
110+
// Already viewing this thread, just close
111+
setIsOpen(false)
112+
return
113+
}
114+
// Close immediately without animation to prevent flash during navigation
115+
isNavigatingRef.current = true
98116
setIsOpen(false)
99117
router.push(`/chat?thread=${threadId}`)
118+
// Reset navigation flag after transition
119+
setTimeout(() => { isNavigatingRef.current = false }, 500)
100120
}
101121

102122
const handleDelete = async (e: React.MouseEvent, threadId: string) => {
103123
e.stopPropagation()
124+
e.preventDefault()
104125
if (confirmDeleteId === threadId) {
105126
try {
106127
const res = await fetch(`/api/threads/${threadId}`, { method: "DELETE" })
@@ -119,12 +140,17 @@ export default function ThreadDropdown({
119140
}
120141
}
121142

143+
const handleToggle = () => {
144+
if (isNavigatingRef.current) return
145+
setIsOpen((prev) => !prev)
146+
}
147+
122148
const displayTitle = currentTitle || "Quorum"
123149

124150
return (
125151
<div ref={dropdownRef} className="relative">
126152
<button
127-
onClick={() => setIsOpen(!isOpen)}
153+
onClick={handleToggle}
128154
aria-haspopup="listbox"
129155
aria-expanded={isOpen}
130156
className="flex items-center gap-1.5 text-sm font-semibold text-[var(--foreground)] hover:text-[var(--foreground)]/80 transition-colors max-w-[200px] sm:max-w-[300px]"
@@ -133,26 +159,27 @@ export default function ThreadDropdown({
133159
<ChevronDown className={cn("w-3.5 h-3.5 shrink-0 transition-transform", isOpen && "rotate-180")} />
134160
</button>
135161

136-
<AnimatePresence>
162+
<AnimatePresence mode="wait">
137163
{isOpen && (
138164
<motion.div
165+
key="thread-dropdown"
139166
initial={{ opacity: 0, y: -4 }}
140167
animate={{ opacity: 1, y: 0 }}
141-
exit={{ opacity: 0, y: -4 }}
142-
transition={{ duration: 0.15 }}
168+
exit={{ opacity: 0 }}
169+
transition={{ duration: 0.12 }}
143170
className="absolute top-full left-0 mt-2 w-[calc(100vw-2rem)] sm:w-80 bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-xl z-50 overflow-hidden"
144171
>
145172
{/* Search */}
146173
<div className="p-2 border-b border-[var(--border)]">
147174
<div className="relative">
148175
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--muted-foreground)]" />
149176
<input
177+
ref={searchInputRef}
150178
type="text"
151179
value={search}
152180
onChange={(e) => setSearch(e.target.value)}
153181
placeholder={labels.search}
154182
className="w-full pl-8 pr-3 py-2 sm:py-1.5 text-sm bg-transparent text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] outline-none"
155-
autoFocus
156183
/>
157184
</div>
158185
</div>

0 commit comments

Comments
 (0)