From 891439133688cd0adece528fbe5bab567540d576 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Tue, 28 Apr 2026 01:26:29 -0400 Subject: [PATCH] fix(assistant-chat): kill stale task-lane flash on new-session entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two compounding bugs caused the previous session's questions/actions to render briefly when entering a new chat — visible as "the new session instantly pops with old session task-lane data" the user reported. The race - AssistantChatPage's activeQuestions / activeActions / showTaskLane useState initializers synchronously read sessionStorage's rf-tasklane-meta. They restore the persisted task-lane state if its saved chatId matches the freshly-resolved activeChatId. - On dashboard prefill flow, the page mounts on /pilot with location.state.prefill set; activeChatId initializes from sessionStorage's rf-active-chat-id (the previous session). The previous session's task-lane meta matches that chatId — so the initializer restores it. First paint shows old questions/actions. sendPrefill's resetSessionDerivedState fires later from a useEffect, but only after the flash. - Same pattern hits the senior-pickup flow: ?pickup=true means we're about to render the magic-moment screen and discard whatever chat the senior was previously on, but the underlying chat surface still initializes with their old task-lane meta. The amplifier - resetSessionDerivedState wiped the in-memory state but never removed sessionStorage's rf-tasklane-meta. Any remount or reload before the next persistence-effect write could re-hydrate the cleared state from the still-stale sessionStorage entry. Fixes - Initializer guard: when location.state.prefill is set OR ?pickup=true is in the URL, skip the sessionStorage restore entirely. Kills the first-paint flash for both entry paths. - Eager wipe: resetSessionDerivedState now also calls sessionStorage.removeItem('rf-tasklane-meta'). The persistence effect re-saves on the next state change anyway, so the only window where sessionStorage is empty is the exact window where stale-tag leakage was happening. tsc -b clean. No backend changes. Co-Authored-By: Claude Opus 4.7 --- frontend/src/pages/AssistantChatPage.tsx | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index ed39d848..58d76522 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -97,7 +97,21 @@ export default function AssistantChatPage() { const [logContent, setLogContent] = useState('') const [pendingUploads, setPendingUploads] = useState([]) const [isDragOver, setIsDragOver] = useState(false) + // Task-lane mount restoration is gated on (a) the persisted chatId + // matching whatever activeChatId resolved to, AND (b) the page not being + // entered with a prefill in location.state. The prefill case means we're + // about to create a brand-new session and discard the previous one's + // task lane anyway — restoring it just causes the previous chat's + // questions/actions to flash on the first paint before sendPrefill's + // resetSessionDerivedState clears them. Same logic for the bell-icon + // pickup flow (?pickup=true): the senior is entering an unrelated + // session and any leftover task-lane meta from their own prior chat is + // noise. Both gates collapse to "are we about to leave the previous + // chat behind?" — if yes, start clean. + const incomingPrefill = !!(location.state as { prefill?: string } | null)?.prefill + const skipTaskLaneRestore = incomingPrefill || isPickup const [activeQuestions, setActiveQuestions] = useState(() => { + if (skipTaskLaneRestore) return [] try { const saved = sessionStorage.getItem('rf-tasklane-meta') if (saved) { const d = JSON.parse(saved); if (d.chatId === activeChatId) return d.questions || [] } @@ -105,6 +119,7 @@ export default function AssistantChatPage() { return [] }) const [activeActions, setActiveActions] = useState(() => { + if (skipTaskLaneRestore) return [] try { const saved = sessionStorage.getItem('rf-tasklane-meta') if (saved) { const d = JSON.parse(saved); if (d.chatId === activeChatId) return d.actions || [] } @@ -112,6 +127,7 @@ export default function AssistantChatPage() { return [] }) const [showTaskLane, setShowTaskLane] = useState(() => { + if (skipTaskLaneRestore) return false try { const saved = sessionStorage.getItem('rf-tasklane-meta') if (saved) { const d = JSON.parse(saved); return d.show === true && d.chatId === activeChatId } @@ -479,6 +495,16 @@ export default function AssistantChatPage() { // Phase 9: tab strip reset setChatTab('chat') setScriptBuilderHasProgress(false) + // Belt-and-braces: also wipe the persisted task-lane meta. Without this, + // a remount or page reload before the next AI response can re-hydrate + // the previous session's questions/actions from sessionStorage even + // though the in-memory state has been cleared. The persistence effect + // re-saves on the next state change anyway, so the only window where + // sessionStorage is empty is between this reset and the next response — + // which is exactly the window where stale-tag leakage was happening. + try { + sessionStorage.removeItem('rf-tasklane-meta') + } catch { /* ignore */ } }, []) // Phase 2 facts — fetch + handlers. `refreshFacts` is called from selectChat