diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index bda1f3a7..c366a8f4 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -142,6 +142,24 @@ export default function AssistantChatPage() { } catch { /* ignore */ } return false }) + // Task-lane owner: the chatId these in-memory questions/actions/show + // values BELONG to, set every time we populate the lane. Render is gated + // on `taskLaneOwnerChatId === activeChatId` so any path that flips the + // active chat without clearing the lane state (in-place URL change, + // mid-flight pickup, etc.) cannot leak the previous chat's task data + // into the new view. The mount-time flash protection still lives in + // `skipTaskLaneRestore`; this guard handles every other transition. + const [taskLaneOwnerChatId, setTaskLaneOwnerChatId] = useState(() => { + if (skipTaskLaneRestore) return null + try { + const saved = sessionStorage.getItem('rf-tasklane-meta') + if (saved) { + const d = JSON.parse(saved) + if (typeof d.chatId === 'string' && d.chatId === activeChatId) return d.chatId + } + } catch { /* ignore */ } + return null + }) const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem('rf-chat-sidebar-collapsed') === 'true' ) @@ -495,6 +513,7 @@ export default function AssistantChatPage() { setActiveQuestions(response.questions || []) setActiveActions(response.actions || []) setShowTaskLane(true) + setTaskLaneOwnerChatId(session.session_id) } // Refetch facts + active fix — the AI may have emitted markers. refreshSessionDerived(session.session_id) @@ -509,17 +528,31 @@ export default function AssistantChatPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - // Persist task lane metadata to sessionStorage + // Render gate: the in-memory task-lane data is shown only when the chatId + // it belongs to (taskLaneOwnerChatId) matches activeChatId. Any path that + // flips activeChatId without clearing the lane state — in-place URL + // navigation, mid-flight pickup, HMR — produces a window where ownerChatId + // still tags the previous chat. The render gate keeps the lane hidden + // through that window until reset+repopulate runs for the new chat. + const taskLaneIsForActiveChat = + taskLaneOwnerChatId !== null && taskLaneOwnerChatId === activeChatId + + // Persist task lane metadata to sessionStorage. The chatId field tags + // ownership — the chatId these questions/actions belong to, NOT the + // currently-active chat. Writing activeChatId here was the original bug: + // when activeChatId flipped to B but activeQuestions still had A's data, + // the snapshot stamped {chatId: B, questions: [A's]} and a subsequent + // restore would happily render A's data for B. useEffect(() => { try { sessionStorage.setItem('rf-tasklane-meta', JSON.stringify({ show: showTaskLane, - chatId: activeChatId, + chatId: taskLaneOwnerChatId, questions: activeQuestions, actions: activeActions, })) } catch { /* ignore */ } - }, [showTaskLane, activeChatId, activeQuestions, activeActions]) + }, [showTaskLane, taskLaneOwnerChatId, activeQuestions, activeActions]) // Auto-scroll useEffect(() => { @@ -575,6 +608,7 @@ export default function AssistantChatPage() { setShowTaskLane(false) setActiveQuestions([]) setActiveActions([]) + setTaskLaneOwnerChatId(null) setFacts([]) setActiveFix(null) setPreviewKind(null) @@ -615,7 +649,12 @@ export default function AssistantChatPage() { // Auto-open the task lane when the session has facts so the engineer // can see them — without this, a session with only facts (no open // questions) would hide the lane and the facts would be invisible. - if (list.length > 0) setShowTaskLane(true) + // Tag ownership too so the lane render gate accepts it as belonging + // to the active chat (the gate is `taskLaneOwnerChatId === activeChatId`). + if (list.length > 0) { + setShowTaskLane(true) + setTaskLaneOwnerChatId(chatId) + } } catch { // Best-effort — facts are accessory state. Surfacing a toast on every // refetch failure would be noisy; the empty state explains the absence. @@ -788,7 +827,10 @@ export default function AssistantChatPage() { // TemplateMatchPanel is mounted inside TaskLane.bottomSlot, so the // lane must be visible for the panel to render. On fresh sessions // (no questions/facts) the lane defaults closed, so we open it here. + // Tag ownership to the current active chat so the lane render gate + // (taskLaneOwnerChatId === activeChatId) accepts it. setShowTaskLane(true) + if (activeChatId) setTaskLaneOwnerChatId(activeChatId) setScriptPanelOpen(true) return } @@ -1055,6 +1097,7 @@ export default function AssistantChatPage() { setActiveQuestions(q) setActiveActions(a) setShowTaskLane(true) + setTaskLaneOwnerChatId(chatId) } } } catch { @@ -1158,6 +1201,7 @@ export default function AssistantChatPage() { setActiveQuestions(response.questions || []) setActiveActions(response.actions || []) setShowTaskLane(true) + setTaskLaneOwnerChatId(sentForChatId) } // Phase 8: increment post-apply message counter for nudge logic. // Only increments when fix is still in 'proposed' (verifying) state — @@ -1238,11 +1282,13 @@ export default function AssistantChatPage() { setActiveQuestions(response.questions || []) setActiveActions(response.actions || []) setShowTaskLane(true) + setTaskLaneOwnerChatId(sentForChatId) } else { // AI sent no new tasks — clear the lane setShowTaskLane(false) setActiveQuestions([]) setActiveActions([]) + setTaskLaneOwnerChatId(null) } // Phase 8: increment post-apply message counter for nudge logic (mirrors handleSend). // Only increments in 'proposed' (verifying) state — same rationale as handleSend. @@ -1337,6 +1383,7 @@ export default function AssistantChatPage() { setActiveQuestions(response.questions || []) setActiveActions(response.actions || []) setShowTaskLane(true) + setTaskLaneOwnerChatId(session.session_id) } // Refetch facts + active fix — resume turn may emit markers. refreshSessionDerived(session.session_id) @@ -1960,7 +2007,7 @@ export default function AssistantChatPage() { Paste Logs )} - {!showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0) && ( + {!showTaskLane && taskLaneIsForActiveChat && (activeQuestions.length > 0 || activeActions.length > 0) && (