fix(escalations): live-test fixes from QA bash

Bundles four fixes from the live debugging session:

1. AssistantChatPage: replace urlSessionId === activeChatId gate with a
   loadedChatIdsRef. After 8914391 made activeChatId initialize from
   urlSessionId, the gate short-circuited fresh mounts and selectChat
   never fired. Symptom: senior picks up an escalation, lands on a blank
   chat surface with no conversation history and no sidebar entry. Fix
   also adds loadChats() in handleStartHere so the picked-up session
   appears in the sidebar (its escalated_to_id is null pre-claim, so
   listSessions doesn't return it until claim_session sets it).

2. config: bump ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS 15s → 45s.
   Sonnet was hitting tail latency at 15s in the field, leaving the
   magic-moment placeholder permanent. Background-task architecture
   (e8ba74e) means this no longer blocks the user; it's just the budget
   before publishing has_assessment=false. NOTE: live test still shows
   assessment not populating — see HANDOFF for the consolidation plan
   that supersedes this.

3. Enter-to-submit: chat-input convention (Enter submits, Shift+Enter
   inserts newline) on the escalate-flow forms. RichTextInput gains an
   optional onSubmit prop; EscalateModal wires it to handleSubmit;
   ConcludeSessionModal gets the same handler on its plain textarea.

4. PendingEscalations: each row is now expandable. Click row body to
   reveal the engineer's escalation reason, step count on record,
   confidence tier, and PSA ticket number. Pick Up still clicks through
   directly. Single-expand-at-a-time keeps the dashboard compact.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 00:18:40 -04:00
parent b7d7ff06d2
commit 0d1b305619
6 changed files with 162 additions and 38 deletions

View File

@@ -256,6 +256,14 @@ export default function AssistantChatPage() {
// Tracks the most recently requested active chat ID so in-flight selectChat
// calls that complete after the user switches chats don't clobber new state.
const currentChatRef = useRef<string | null>(activeChatId)
// Tracks which URL chatIds we've already loaded via selectChat in this
// page lifecycle. Replaces the old `urlSessionId === activeChatId` gate,
// which was buggy after commit 8914391 made activeChatId initialize from
// urlSessionId — they MATCH on mount, so the gate bailed and selectChat
// never fired for fresh entries (notably the bell-icon → ?pickup=true
// path: post-claim the chat surface had no messages and the senior
// landed on a blank pane).
const loadedChatIdsRef = useRef<Set<string>>(new Set())
// Persist active chat ID to sessionStorage
useEffect(() => {
@@ -275,9 +283,17 @@ export default function AssistantChatPage() {
// own the session and the regular chat surface would race against the
// claim flow. Once magicState is 'dismissed' (post-claim, or no handoff
// found at all), this effect re-fires and selectChat runs.
//
// The dedupe is on a "have we loaded this URL session yet" ref instead
// of comparing to activeChatId — activeChatId now initializes from
// urlSessionId, so the old comparison short-circuited fresh mounts and
// selectChat never fired. The ref clears nothing on its own; if you
// need to force a reload, call selectChat directly.
useEffect(() => {
if (!urlSessionId || urlSessionId === activeChatId) return
if (!urlSessionId) return
if (magicState === 'loading' || magicState === 'visible') return
if (loadedChatIdsRef.current.has(urlSessionId)) return
loadedChatIdsRef.current.add(urlSessionId)
selectChat(urlSessionId)
}, [urlSessionId, magicState]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -324,6 +340,14 @@ export default function AssistantChatPage() {
// as chat history.
setSearchParams({})
setMagicState('dismissed')
// Refresh the sidebar list. Pre-claim the session was invisible to
// listSessions because escalated_to_id was null (junior didn't
// specify a target on /escalate). Post-claim claim_session sets
// escalated_to_id = teamadmin.id, so the session is now in scope.
// Without this re-fetch the senior lands on a session with no
// sidebar entry — looks like the page navigated to a different
// session.
void loadChats()
} catch (e: unknown) {
// Race-condition path (locked design): the loser of the simultaneous
// Pick Up gets a 409 with structured detail so we can name the