fix(assistant-chat): kill stale task-lane flash on new-session entry
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 5m4s
CI / backend (pull_request) Successful in 10m9s
CI / e2e (pull_request) Successful in 10m8s

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 01:26:29 -04:00
parent e8ba74ed6d
commit 8914391336

View File

@@ -97,7 +97,21 @@ export default function AssistantChatPage() {
const [logContent, setLogContent] = useState('')
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
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<QuestionItem[]>(() => {
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<ActionItem[]>(() => {
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