feat(escalations): Escalation Mode wedge — live arrival + magic-moment pickup #155
@@ -142,6 +142,24 @@ export default function AssistantChatPage() {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return false
|
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(() =>
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() =>
|
||||||
localStorage.getItem('rf-chat-sidebar-collapsed') === 'true'
|
localStorage.getItem('rf-chat-sidebar-collapsed') === 'true'
|
||||||
)
|
)
|
||||||
@@ -495,6 +513,7 @@ export default function AssistantChatPage() {
|
|||||||
setActiveQuestions(response.questions || [])
|
setActiveQuestions(response.questions || [])
|
||||||
setActiveActions(response.actions || [])
|
setActiveActions(response.actions || [])
|
||||||
setShowTaskLane(true)
|
setShowTaskLane(true)
|
||||||
|
setTaskLaneOwnerChatId(session.session_id)
|
||||||
}
|
}
|
||||||
// Refetch facts + active fix — the AI may have emitted markers.
|
// Refetch facts + active fix — the AI may have emitted markers.
|
||||||
refreshSessionDerived(session.session_id)
|
refreshSessionDerived(session.session_id)
|
||||||
@@ -509,17 +528,31 @@ export default function AssistantChatPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem('rf-tasklane-meta', JSON.stringify({
|
sessionStorage.setItem('rf-tasklane-meta', JSON.stringify({
|
||||||
show: showTaskLane,
|
show: showTaskLane,
|
||||||
chatId: activeChatId,
|
chatId: taskLaneOwnerChatId,
|
||||||
questions: activeQuestions,
|
questions: activeQuestions,
|
||||||
actions: activeActions,
|
actions: activeActions,
|
||||||
}))
|
}))
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}, [showTaskLane, activeChatId, activeQuestions, activeActions])
|
}, [showTaskLane, taskLaneOwnerChatId, activeQuestions, activeActions])
|
||||||
|
|
||||||
// Auto-scroll
|
// Auto-scroll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -575,6 +608,7 @@ export default function AssistantChatPage() {
|
|||||||
setShowTaskLane(false)
|
setShowTaskLane(false)
|
||||||
setActiveQuestions([])
|
setActiveQuestions([])
|
||||||
setActiveActions([])
|
setActiveActions([])
|
||||||
|
setTaskLaneOwnerChatId(null)
|
||||||
setFacts([])
|
setFacts([])
|
||||||
setActiveFix(null)
|
setActiveFix(null)
|
||||||
setPreviewKind(null)
|
setPreviewKind(null)
|
||||||
@@ -615,7 +649,12 @@ export default function AssistantChatPage() {
|
|||||||
// Auto-open the task lane when the session has facts so the engineer
|
// 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
|
// can see them — without this, a session with only facts (no open
|
||||||
// questions) would hide the lane and the facts would be invisible.
|
// 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 {
|
} catch {
|
||||||
// Best-effort — facts are accessory state. Surfacing a toast on every
|
// Best-effort — facts are accessory state. Surfacing a toast on every
|
||||||
// refetch failure would be noisy; the empty state explains the absence.
|
// 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
|
// TemplateMatchPanel is mounted inside TaskLane.bottomSlot, so the
|
||||||
// lane must be visible for the panel to render. On fresh sessions
|
// lane must be visible for the panel to render. On fresh sessions
|
||||||
// (no questions/facts) the lane defaults closed, so we open it here.
|
// (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)
|
setShowTaskLane(true)
|
||||||
|
if (activeChatId) setTaskLaneOwnerChatId(activeChatId)
|
||||||
setScriptPanelOpen(true)
|
setScriptPanelOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1055,6 +1097,7 @@ export default function AssistantChatPage() {
|
|||||||
setActiveQuestions(q)
|
setActiveQuestions(q)
|
||||||
setActiveActions(a)
|
setActiveActions(a)
|
||||||
setShowTaskLane(true)
|
setShowTaskLane(true)
|
||||||
|
setTaskLaneOwnerChatId(chatId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1158,6 +1201,7 @@ export default function AssistantChatPage() {
|
|||||||
setActiveQuestions(response.questions || [])
|
setActiveQuestions(response.questions || [])
|
||||||
setActiveActions(response.actions || [])
|
setActiveActions(response.actions || [])
|
||||||
setShowTaskLane(true)
|
setShowTaskLane(true)
|
||||||
|
setTaskLaneOwnerChatId(sentForChatId)
|
||||||
}
|
}
|
||||||
// Phase 8: increment post-apply message counter for nudge logic.
|
// Phase 8: increment post-apply message counter for nudge logic.
|
||||||
// Only increments when fix is still in 'proposed' (verifying) state —
|
// Only increments when fix is still in 'proposed' (verifying) state —
|
||||||
@@ -1238,11 +1282,13 @@ export default function AssistantChatPage() {
|
|||||||
setActiveQuestions(response.questions || [])
|
setActiveQuestions(response.questions || [])
|
||||||
setActiveActions(response.actions || [])
|
setActiveActions(response.actions || [])
|
||||||
setShowTaskLane(true)
|
setShowTaskLane(true)
|
||||||
|
setTaskLaneOwnerChatId(sentForChatId)
|
||||||
} else {
|
} else {
|
||||||
// AI sent no new tasks — clear the lane
|
// AI sent no new tasks — clear the lane
|
||||||
setShowTaskLane(false)
|
setShowTaskLane(false)
|
||||||
setActiveQuestions([])
|
setActiveQuestions([])
|
||||||
setActiveActions([])
|
setActiveActions([])
|
||||||
|
setTaskLaneOwnerChatId(null)
|
||||||
}
|
}
|
||||||
// Phase 8: increment post-apply message counter for nudge logic (mirrors handleSend).
|
// Phase 8: increment post-apply message counter for nudge logic (mirrors handleSend).
|
||||||
// Only increments in 'proposed' (verifying) state — same rationale as handleSend.
|
// Only increments in 'proposed' (verifying) state — same rationale as handleSend.
|
||||||
@@ -1337,6 +1383,7 @@ export default function AssistantChatPage() {
|
|||||||
setActiveQuestions(response.questions || [])
|
setActiveQuestions(response.questions || [])
|
||||||
setActiveActions(response.actions || [])
|
setActiveActions(response.actions || [])
|
||||||
setShowTaskLane(true)
|
setShowTaskLane(true)
|
||||||
|
setTaskLaneOwnerChatId(session.session_id)
|
||||||
}
|
}
|
||||||
// Refetch facts + active fix — resume turn may emit markers.
|
// Refetch facts + active fix — resume turn may emit markers.
|
||||||
refreshSessionDerived(session.session_id)
|
refreshSessionDerived(session.session_id)
|
||||||
@@ -1960,7 +2007,7 @@ export default function AssistantChatPage() {
|
|||||||
<span className="hidden sm:inline">Paste Logs</span>
|
<span className="hidden sm:inline">Paste Logs</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0) && (
|
{!showTaskLane && taskLaneIsForActiveChat && (activeQuestions.length > 0 || activeActions.length > 0) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowTaskLane(true)}
|
onClick={() => setShowTaskLane(true)}
|
||||||
@@ -2033,6 +2080,7 @@ export default function AssistantChatPage() {
|
|||||||
Shows a count pill when new items are present while closed. */}
|
Shows a count pill when new items are present while closed. */}
|
||||||
{isNarrow
|
{isNarrow
|
||||||
&& !showTaskLane
|
&& !showTaskLane
|
||||||
|
&& taskLaneIsForActiveChat
|
||||||
&& (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0 || activeFix !== null) && (
|
&& (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0 || activeFix !== null) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowTaskLane(true)}
|
onClick={() => setShowTaskLane(true)}
|
||||||
@@ -2054,7 +2102,7 @@ export default function AssistantChatPage() {
|
|||||||
Phase 2/3 make the lane the structural home of session diagnostic
|
Phase 2/3 make the lane the structural home of session diagnostic
|
||||||
state, not a transient questions panel.
|
state, not a transient questions panel.
|
||||||
Narrow viewport: the lane renders as a bottom drawer with backdrop. */}
|
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 ? (
|
isNarrow ? (
|
||||||
<div className="fixed inset-0 z-50 flex flex-col" role="dialog" aria-modal="true">
|
<div className="fixed inset-0 z-50 flex flex-col" role="dialog" aria-modal="true">
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user