From 4c4f80cd8c67c4e8383cd2fbaec65c319990d791 Mon Sep 17 00:00:00 2001 From: DEls Date: Sat, 26 Sep 2026 21:48:54 +0200 Subject: [PATCH] fix(ask): resume the active thread after leaving the page Navigating away from /dashboard/chat and back (e.g. via the sidebar nav, which drops the ?thread= query) reset the conversation to a blank composer even though the thread was still live and listed in the sidebar. Persist the actor's active thread id in localStorage and restore it when the URL has no ?thread=, so a conversation survives leaving the page as the plan specifies. "New thread" clears the stored id so it still starts blank. Co-Authored-By: Claude Opus 5.5 --- src/app/dashboard/chat/chat-view.tsx | 64 +++++++++++++++++++++------- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/src/app/dashboard/chat/chat-view.tsx b/src/app/dashboard/chat/chat-view.tsx index 37734f5..fb49b88 100644 --- a/src/app/dashboard/chat/chat-view.tsx +++ b/src/app/dashboard/chat/chat-view.tsx @@ -64,6 +64,10 @@ interface UiMemory { } const ACTOR_STORAGE_KEY = "mega-ask-user"; +/** Per-actor: the thread they were last in, so leaving the page (e.g. via + the sidebar nav) and coming back resumes it instead of starting blank. + "New thread" clears this; picking a thread or sending a message sets it. */ +const ACTIVE_THREAD_STORAGE_PREFIX = "mega-ask-active-thread:"; function loadStoredActor(): Actor | null { if (typeof window === "undefined") return null; @@ -78,6 +82,25 @@ function loadStoredActor(): Actor | null { return null; } +function loadStoredThreadId(actorId: string): string | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage.getItem(ACTIVE_THREAD_STORAGE_PREFIX + actorId); + } catch { + return null; + } +} + +function saveStoredThreadId(actorId: string, threadId: string | null) { + if (typeof window === "undefined") return; + try { + if (threadId) window.localStorage.setItem(ACTIVE_THREAD_STORAGE_PREFIX + actorId, threadId); + else window.localStorage.removeItem(ACTIVE_THREAD_STORAGE_PREFIX + actorId); + } catch { + /* ignore */ + } +} + /** Relative time for the thread list ("2h ago", "Just now"). */ function relativeTime(iso?: string): string { if (!iso) return ""; @@ -317,6 +340,7 @@ function ChatViewInner() { const loadThread = React.useCallback( async (threadId: string, forActor: Actor) => { setActiveThreadId(threadId); + saveStoredThreadId(forActor.id, threadId); setTurns([]); setError(null); try { @@ -361,20 +385,6 @@ function ChatViewInner() { [], ); - // React to the URL's ?thread= — covers first load and back/forward. - React.useEffect(() => { - if (!actor) return; - const fromUrl = searchParams.get("thread"); - if (fromUrl && fromUrl !== activeThreadIdRef.current) { - loadThread(fromUrl, actor); - } else if (!fromUrl && activeThreadIdRef.current) { - setActiveThreadId(null); - setTurns([]); - } - // Only re-run when the actor first resolves or the URL changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [actor, searchParams]); - const goToThread = React.useCallback( (threadId: string | null, mode: "push" | "replace" = "push") => { const url = threadId ? `${pathname}?thread=${threadId}` : pathname; @@ -384,13 +394,35 @@ function ChatViewInner() { [pathname, router], ); + // React to the URL's ?thread= — covers first load and back/forward. + React.useEffect(() => { + if (!actor) return; + const fromUrl = searchParams.get("thread"); + if (fromUrl && fromUrl !== activeThreadIdRef.current) { + loadThread(fromUrl, actor); + } else if (!fromUrl && activeThreadIdRef.current) { + setActiveThreadId(null); + setTurns([]); + } else if (!fromUrl && !activeThreadIdRef.current) { + // No thread in the URL and nothing loaded yet — resume the last + // thread this actor was in (e.g. after leaving via the sidebar nav + // and coming back), rather than silently starting a blank one. + // "New thread" clears the stored id, so that still lands here empty. + const stored = loadStoredThreadId(actor.id); + if (stored) goToThread(stored, "replace"); + } + // Only re-run when the actor first resolves or the URL changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actor, searchParams]); + const startNewThread = React.useCallback(() => { + if (actor) saveStoredThreadId(actor.id, null); setActiveThreadId(null); setTurns([]); setError(null); setSidebarOpen(false); goToThread(null, "push"); - }, [goToThread]); + }, [actor, goToThread]); const selectThread = React.useCallback( (t: ThreadSummary) => { @@ -614,6 +646,7 @@ function ChatViewInner() { // reflect it in the URL and sidebar. activeThreadIdRef.current = data.id; setActiveThreadId(data.id); + saveStoredThreadId(actor.id, data.id); setThreads((prev) => [ { id: data.id, title: data.title, last_message_at: new Date().toISOString() }, ...prev.filter((t) => t.id !== data.id), @@ -761,6 +794,7 @@ function ChatViewInner() { if (!activeThreadIdRef.current && data.threadId) { activeThreadIdRef.current = data.threadId; setActiveThreadId(data.threadId); + saveStoredThreadId(actor.id, data.threadId); setThreads((prev) => [ { id: data.threadId, title: data.threadTitle ?? file.name, last_message_at: new Date().toISOString() }, ...prev.filter((t) => t.id !== data.threadId),