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
This commit is contained in:
2026-04-28 14:26:34 -07:00
parent 115cf7eb28
commit afd76b394f

View File

@@ -143,6 +143,10 @@ export function ChatPanel() {
return localStorage.getItem("vibn-chat-open") !== "false";
});
const [threads, setThreads] = useState<Thread[]>([]);
// 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<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [toolEvents, setToolEvents] = useState<ToolEvent[]>([]);
@@ -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]);