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

@@ -348,6 +348,15 @@ export function ConcludeSessionModal({
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
onKeyDown={e => {
// Enter submits, Shift+Enter inserts newline — same
// convention as the chat composer. Engineers write
// short reasons here; multi-line is rare.
if (e.key === 'Enter' && !e.shiftKey && !generating) {
e.preventDefault()
handleGenerate()
}
}}
placeholder={
outcome === 'resolved'
? 'Any additional context about the resolution...'

View File

@@ -13,6 +13,11 @@ interface RichTextInputProps {
rows?: number
className?: string
disabled?: boolean
// Enter-to-submit, matching the chat-input convention used elsewhere in
// the app: plain Enter calls onSubmit; Shift+Enter inserts a newline.
// Parents that want the legacy "Enter = newline only" behavior just
// omit this prop.
onSubmit?: () => void
}
export function RichTextInput({
@@ -24,6 +29,7 @@ export function RichTextInput({
rows = 3,
className,
disabled,
onSubmit,
}: RichTextInputProps) {
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
const [isDragOver, setIsDragOver] = useState(false)
@@ -229,6 +235,12 @@ export function RichTextInput({
onPaste={handlePaste}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && onSubmit) {
e.preventDefault()
onSubmit()
}
}}
placeholder={placeholder}
rows={rows}
disabled={disabled}

View File

@@ -1,12 +1,16 @@
import { useState, useEffect } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { AlertTriangle } from 'lucide-react'
import { AlertTriangle, ChevronDown, ChevronRight, Hash } from 'lucide-react'
import { aiSessionsApi } from '@/api/aiSessions'
import type { AISessionSummary } from '@/types/ai-session'
import { timeAgo } from '@/lib/timeAgo'
import { cn } from '@/lib/utils'
export function PendingEscalations() {
const [escalations, setEscalations] = useState<AISessionSummary[]>([])
// Single expansion at a time — keeps the dashboard compact even when
// multiple escalations are pending. Click a row again to collapse.
const [expandedId, setExpandedId] = useState<string | null>(null)
const navigate = useNavigate()
useEffect(() => {
@@ -43,35 +47,107 @@ export function PendingEscalations() {
</Link>
</div>
<div>
{escalations.slice(0, 3).map((esc, i) => (
<div
key={esc.id}
className="flex items-center gap-3 px-5 py-3"
style={{
borderBottom: i < Math.min(escalations.length, 3) - 1
? '1px solid var(--color-border-default)'
: undefined,
}}
>
<span className="h-2 w-2 shrink-0 rounded-full bg-amber-400 animate-pulse" />
<div className="flex-1 min-w-0">
<div className="text-sm text-foreground truncate">
{esc.problem_summary || 'Escalated session'}
</div>
<div className="text-[0.6875rem] text-muted-foreground">
{esc.problem_domain || 'General'}
<span className="mx-1.5 text-[var(--text-dimmed)]">&middot;</span>
<span className="font-sans text-xs">{timeAgo(esc.created_at)}</span>
</div>
</div>
<button
onClick={() => navigate(`/pilot/${esc.id}?pickup=true`)}
className="shrink-0 rounded-lg border border-amber-400/30 bg-amber-400/10 px-3 py-1 text-[0.6875rem] font-medium text-amber-400 hover:bg-amber-400/20 transition-colors"
{escalations.slice(0, 3).map((esc, i) => {
const isExpanded = expandedId === esc.id
const isLast = i >= Math.min(escalations.length, 3) - 1
return (
<div
key={esc.id}
style={{
borderBottom: !isLast
? '1px solid var(--color-border-default)'
: undefined,
}}
>
Pick up
</button>
</div>
))}
<button
type="button"
onClick={() => setExpandedId(isExpanded ? null : esc.id)}
aria-expanded={isExpanded}
className="w-full flex items-center gap-3 px-5 py-3 text-left hover:bg-elevated/30 transition-colors"
>
<span className="h-2 w-2 shrink-0 rounded-full bg-amber-400 animate-pulse" />
{isExpanded ? (
<ChevronDown size={12} className="shrink-0 text-muted-foreground" />
) : (
<ChevronRight size={12} className="shrink-0 text-muted-foreground" />
)}
<div className="flex-1 min-w-0">
<div className="text-sm text-foreground truncate">
{esc.problem_summary || 'Escalated session'}
</div>
<div className="text-[0.6875rem] text-muted-foreground">
{esc.problem_domain || 'General'}
<span className="mx-1.5 text-[var(--text-dimmed)]">&middot;</span>
<span className="font-sans text-xs">{timeAgo(esc.created_at)}</span>
{esc.psa_ticket_id && (
<>
<span className="mx-1.5 text-[var(--text-dimmed)]">&middot;</span>
<span className="inline-flex items-center gap-0.5 font-mono text-[0.625rem] text-accent-text">
<Hash size={9} />
{esc.psa_ticket_id}
</span>
</>
)}
</div>
</div>
<span
onClick={(e) => {
e.stopPropagation()
navigate(`/pilot/${esc.id}?pickup=true`)
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
navigate(`/pilot/${esc.id}?pickup=true`)
}
}}
className="shrink-0 rounded-lg border border-amber-400/30 bg-amber-400/10 px-3 py-1 text-[0.6875rem] font-medium text-amber-400 hover:bg-amber-400/20 transition-colors cursor-pointer"
>
Pick up
</span>
</button>
{isExpanded && (
<div
className={cn(
'px-5 pb-3 pl-12 space-y-2 text-xs animate-fade-in'
)}
>
{esc.escalation_reason && (
<div>
<p className="font-sans text-[0.5625rem] uppercase tracking-wider text-muted-foreground mb-0.5">
Why escalated
</p>
<p className="text-foreground whitespace-pre-wrap leading-snug">
{esc.escalation_reason}
</p>
</div>
)}
<div className="flex flex-wrap gap-x-3 gap-y-1 text-muted-foreground">
<span>
<span className="font-medium text-foreground">{esc.step_count}</span>{' '}
diagnostic {esc.step_count === 1 ? 'step' : 'steps'} on record
</span>
{esc.confidence_tier && (
<span className="font-sans uppercase tracking-wider text-[0.5625rem]">
Confidence: {esc.confidence_tier}
</span>
)}
</div>
{!esc.escalation_reason && (
<p className="italic text-muted-foreground">
No reason note from the original engineer. Pick up to see the full session
context and AI assessment.
</p>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)

View File

@@ -53,6 +53,7 @@ export function EscalateModal({ open, onClose, onEscalate, isProcessing, hasPsaT
sessionId={sessionId}
placeholder="e.g. I've exhausted all networking diagnostics and suspect this is a firewall policy issue that requires senior admin access..."
rows={4}
onSubmit={handleSubmit}
/>
<p className="mt-1 text-[0.625rem] text-text-muted">
Minimum 5 characters. This will be shown to the engineer who picks up.