fix(assistant-chat): tag task-lane state with owner chatId to kill stale flash
The previous fix (8914391) only blocked the mount-time sessionStorage
restore when the page entered with prefill or ?pickup=true. It didn't
cover any path where the page was already mounted and activeChatId
flipped without the in-memory task-lane state going through reset+
repopulate cleanly — in-place URL navigation, mid-flight pickup,
HMR re-runs, the gap between setActiveChatId(B) and the AI response
that finally populates B's questions/actions.
Root cause: activeQuestions / activeActions / showTaskLane were never
intrinsically tied to a chatId. They were treated as "the active chat's
data" by convention, with no structural enforcement. Any window where
they survived past their owning chat leaked previous-session data into
the new view. The persistence effect made it worse: it stamped the
sessionStorage chatId field with activeChatId at write time, so a
mid-transition snapshot {chatId: B, questions: [A's]} would happily
restore A's data for B on the next mount.
Fix: introduce taskLaneOwnerChatId state that records the chatId those
in-memory questions/actions/show values BELONG to. Set at every site
that populates them (sendPrefill, selectChat, handleSend, handleTaskSubmit,
handleResumeNew, refreshFacts, handleApplyFix). Cleared in
resetSessionDerivedState. The persistence effect now writes ownerChatId
as the chatId tag, not activeChatId — so the snapshot is always
self-consistent.
Render gate: taskLaneIsForActiveChat = ownerChatId === activeChatId.
ANDed into all three render conditions (toolbar Tasks button, narrow-
viewport floating drawer, main side panel). The lane is structurally
unable to display data tagged with a different chat.
The mount-time skipTaskLaneRestore guard stays — it kills the flash
between component mount and the first sendPrefill effect run, which
the owner-gate alone doesn't cover.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string | null>(() => {
|
||||
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() {
|
||||
<span className="hidden sm:inline">Paste Logs</span>
|
||||
</button>
|
||||
)}
|
||||
{!showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0) && (
|
||||
{!showTaskLane && taskLaneIsForActiveChat && (activeQuestions.length > 0 || activeActions.length > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTaskLane(true)}
|
||||
@@ -2033,6 +2080,7 @@ export default function AssistantChatPage() {
|
||||
Shows a count pill when new items are present while closed. */}
|
||||
{isNarrow
|
||||
&& !showTaskLane
|
||||
&& taskLaneIsForActiveChat
|
||||
&& (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0 || activeFix !== null) && (
|
||||
<button
|
||||
onClick={() => setShowTaskLane(true)}
|
||||
@@ -2054,7 +2102,7 @@ export default function AssistantChatPage() {
|
||||
Phase 2/3 make the lane the structural home of session diagnostic
|
||||
state, not a transient questions panel.
|
||||
Narrow viewport: the lane renders as a bottom drawer with backdrop. */}
|
||||
{showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0 || activeFix !== null) && (
|
||||
{showTaskLane && taskLaneIsForActiveChat && (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0 || activeFix !== null) && (
|
||||
isNarrow ? (
|
||||
<div className="fixed inset-0 z-50 flex flex-col" role="dialog" aria-modal="true">
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user