feat(escalations): Escalation Mode wedge — live arrival + magic-moment pickup #155
@@ -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