From afd76b394fb73faa207f554f74a6f94c76c8324e Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Tue, 28 Apr 2026 14:26:34 -0700 Subject: [PATCH] fix(chat): preserve thread history across page reloads Two bugs, one symptom (every reload silently spawned a blank thread, the previous conversation was orphaned in the sidebar): 1. Race in the auto-thread effect. On mount: threads = [], activeThread = null, /api/chat/threads fetch in flight. The auto-create effect re-ran the moment the workspace + auth resolved, saw threads.length === 0, and called newThread() before the fetch ever returned. When the historical threads finally landed, activeThread was already pinned to the new empty one. Gate on a `threadsLoaded` flag that flips true after the first loadThreads() resolves. Auto-create can no longer fire before history is known. 2. activeThread wasn't persisted. Even with the race fixed, refreshing the page would reset the sidebar to the top thread (most recently updated). After a deploy that's usually the brand-new empty thread we just spawned, not the conversation the user was actually in. Persist activeThread to localStorage keyed by workspace. Reload restores the same thread; switching workspaces resets cleanly. Made-with: Cursor --- components/vibn-chat/chat-panel.tsx | 38 ++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/components/vibn-chat/chat-panel.tsx b/components/vibn-chat/chat-panel.tsx index 734ecb59..2d941f03 100644 --- a/components/vibn-chat/chat-panel.tsx +++ b/components/vibn-chat/chat-panel.tsx @@ -143,6 +143,10 @@ export function ChatPanel() { return localStorage.getItem("vibn-chat-open") !== "false"; }); const [threads, setThreads] = useState([]); + // threadsLoaded flips to true after the FIRST loadThreads() resolves. + // Used to gate the auto-create effect — without it we race the fetch + // and spawn an empty thread before history loads. + const [threadsLoaded, setThreadsLoaded] = useState(false); const [activeThread, setActiveThread] = useState(null); const [messages, setMessages] = useState([]); const [toolEvents, setToolEvents] = useState([]); @@ -188,7 +192,9 @@ export function ChatPanel() { const res = await fetch(`/api/chat/threads?workspace=${encodeURIComponent(workspace)}`); const data = await res.json(); setThreads(data.threads || []); - } catch { /* silent */ } + } catch { /* silent */ } finally { + setThreadsLoaded(true); + } }, [workspace, status]); useEffect(() => { @@ -225,14 +231,34 @@ export function ChatPanel() { } catch { /* silent */ } }, []); - // Auto-create first thread + // Auto-resume previous thread (or create a fresh one if the user has + // never chatted in this workspace). We MUST wait for threadsLoaded + // before deciding — otherwise we race the fetch and spawn an empty + // thread before history arrives. Last-active thread is restored from + // localStorage so a page reload (deploy, refresh) lands the user back + // in the conversation they were in. useEffect(() => { - if (open && status === "authenticated" && workspace && threads.length === 0 && !activeThread) { + if (!open || status !== "authenticated" || !workspace) return; + if (!threadsLoaded) return; + if (activeThread) return; + + if (threads.length === 0) { newThread(); - } else if (open && threads.length > 0 && !activeThread) { - loadThread(threads[0].id); + return; } - }, [open, status, workspace, threads.length, activeThread, newThread, loadThread]); + + const savedKey = `vibn-chat-active-thread:${workspace}`; + const saved = typeof window !== "undefined" ? localStorage.getItem(savedKey) : null; + const target = saved && threads.some((t) => t.id === saved) ? saved : threads[0].id; + loadThread(target); + }, [open, status, workspace, threadsLoaded, threads, activeThread, newThread, loadThread]); + + // Persist active thread so reload re-opens the same conversation. + useEffect(() => { + if (typeof window === "undefined" || !workspace) return; + const savedKey = `vibn-chat-active-thread:${workspace}`; + if (activeThread) localStorage.setItem(savedKey, activeThread); + }, [activeThread, workspace]); useEffect(() => { scrollToBottom(); }, [messages, toolEvents, scrollToBottom]);