feat(escalations): Escalation Mode wedge — live arrival + magic-moment pickup #155

Merged
chihlasm merged 34 commits from feat/escalation-metric-endpoint into main 2026-04-30 21:32:16 +00:00
Showing only changes of commit 665530f812 - Show all commits

View File

@@ -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