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. After8914391made 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:
@@ -111,14 +111,16 @@ class Settings(BaseSettings):
|
||||
GOOGLE_AI_API_KEY: Optional[str] = None
|
||||
AI_MODEL_GEMINI: str = "gemini-2.5-flash"
|
||||
AI_MODEL_ANTHROPIC: str = "claude-sonnet-4-6"
|
||||
# 15s is generous for the click-path; Claude usually returns a 500-token
|
||||
# diagnostic in 4-8s but tail latency on the assessment prompt has hit
|
||||
# 12-14s in the field. Going below this leaves too many escalations with
|
||||
# the "Assessment unavailable — model didn't respond in time" placeholder
|
||||
# the senior sees on the magic-moment screen. Real fix is async generation
|
||||
# (kick off, persist when done, surface "still computing" with refresh) —
|
||||
# that's a follow-up; bumping the bound keeps the wedge demo coherent.
|
||||
ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS: int = 15
|
||||
# Bound for the diagnostic assessment Sonnet call. Generation runs in a
|
||||
# FastAPI BackgroundTask (commit e8ba74e), so this no longer blocks the
|
||||
# senior's click — only how long we wait before publishing
|
||||
# `handoff_assessment_ready` with has_assessment=false. 15s was hitting
|
||||
# tail latency on Sonnet (timeout 03:57:35 in field testing 2026-04-29),
|
||||
# leaving the magic-moment placeholder permanent. 45s is the right
|
||||
# ceiling: well above Sonnet p99 for a 500-token output, far enough
|
||||
# below "the senior gives up watching" that we still surface SOMETHING
|
||||
# on persistent slowness.
|
||||
ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS: int = 45
|
||||
|
||||
# Model tier routing — maps action types to model tiers
|
||||
AI_MODEL_TIERS: dict[str, str] = {
|
||||
|
||||
@@ -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...'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)]">·</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)]">·</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)]">·</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>
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user