feat(escalations): Escalation Mode wedge — live arrival + magic-moment pickup #155
@@ -111,14 +111,16 @@ class Settings(BaseSettings):
|
|||||||
GOOGLE_AI_API_KEY: Optional[str] = None
|
GOOGLE_AI_API_KEY: Optional[str] = None
|
||||||
AI_MODEL_GEMINI: str = "gemini-2.5-flash"
|
AI_MODEL_GEMINI: str = "gemini-2.5-flash"
|
||||||
AI_MODEL_ANTHROPIC: str = "claude-sonnet-4-6"
|
AI_MODEL_ANTHROPIC: str = "claude-sonnet-4-6"
|
||||||
# 15s is generous for the click-path; Claude usually returns a 500-token
|
# Bound for the diagnostic assessment Sonnet call. Generation runs in a
|
||||||
# diagnostic in 4-8s but tail latency on the assessment prompt has hit
|
# FastAPI BackgroundTask (commit e8ba74e), so this no longer blocks the
|
||||||
# 12-14s in the field. Going below this leaves too many escalations with
|
# senior's click — only how long we wait before publishing
|
||||||
# the "Assessment unavailable — model didn't respond in time" placeholder
|
# `handoff_assessment_ready` with has_assessment=false. 15s was hitting
|
||||||
# the senior sees on the magic-moment screen. Real fix is async generation
|
# tail latency on Sonnet (timeout 03:57:35 in field testing 2026-04-29),
|
||||||
# (kick off, persist when done, surface "still computing" with refresh) —
|
# leaving the magic-moment placeholder permanent. 45s is the right
|
||||||
# that's a follow-up; bumping the bound keeps the wedge demo coherent.
|
# ceiling: well above Sonnet p99 for a 500-token output, far enough
|
||||||
ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS: int = 15
|
# 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
|
# Model tier routing — maps action types to model tiers
|
||||||
AI_MODEL_TIERS: dict[str, str] = {
|
AI_MODEL_TIERS: dict[str, str] = {
|
||||||
|
|||||||
@@ -348,6 +348,15 @@ export function ConcludeSessionModal({
|
|||||||
<textarea
|
<textarea
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={e => setNotes(e.target.value)}
|
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={
|
placeholder={
|
||||||
outcome === 'resolved'
|
outcome === 'resolved'
|
||||||
? 'Any additional context about the resolution...'
|
? 'Any additional context about the resolution...'
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ interface RichTextInputProps {
|
|||||||
rows?: number
|
rows?: number
|
||||||
className?: string
|
className?: string
|
||||||
disabled?: boolean
|
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({
|
export function RichTextInput({
|
||||||
@@ -24,6 +29,7 @@ export function RichTextInput({
|
|||||||
rows = 3,
|
rows = 3,
|
||||||
className,
|
className,
|
||||||
disabled,
|
disabled,
|
||||||
|
onSubmit,
|
||||||
}: RichTextInputProps) {
|
}: RichTextInputProps) {
|
||||||
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
|
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
|
||||||
const [isDragOver, setIsDragOver] = useState(false)
|
const [isDragOver, setIsDragOver] = useState(false)
|
||||||
@@ -229,6 +235,12 @@ export function RichTextInput({
|
|||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={() => setIsFocused(false)}
|
onBlur={() => setIsFocused(false)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && onSubmit) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit()
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
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 { aiSessionsApi } from '@/api/aiSessions'
|
||||||
import type { AISessionSummary } from '@/types/ai-session'
|
import type { AISessionSummary } from '@/types/ai-session'
|
||||||
import { timeAgo } from '@/lib/timeAgo'
|
import { timeAgo } from '@/lib/timeAgo'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export function PendingEscalations() {
|
export function PendingEscalations() {
|
||||||
const [escalations, setEscalations] = useState<AISessionSummary[]>([])
|
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()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,17 +47,30 @@ export function PendingEscalations() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{escalations.slice(0, 3).map((esc, i) => (
|
{escalations.slice(0, 3).map((esc, i) => {
|
||||||
|
const isExpanded = expandedId === esc.id
|
||||||
|
const isLast = i >= Math.min(escalations.length, 3) - 1
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={esc.id}
|
key={esc.id}
|
||||||
className="flex items-center gap-3 px-5 py-3"
|
|
||||||
style={{
|
style={{
|
||||||
borderBottom: i < Math.min(escalations.length, 3) - 1
|
borderBottom: !isLast
|
||||||
? '1px solid var(--color-border-default)'
|
? '1px solid var(--color-border-default)'
|
||||||
: undefined,
|
: undefined,
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<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" />
|
<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="flex-1 min-w-0">
|
||||||
<div className="text-sm text-foreground truncate">
|
<div className="text-sm text-foreground truncate">
|
||||||
{esc.problem_summary || 'Escalated session'}
|
{esc.problem_summary || 'Escalated session'}
|
||||||
@@ -62,16 +79,75 @@ export function PendingEscalations() {
|
|||||||
{esc.problem_domain || 'General'}
|
{esc.problem_domain || 'General'}
|
||||||
<span className="mx-1.5 text-[var(--text-dimmed)]">·</span>
|
<span className="mx-1.5 text-[var(--text-dimmed)]">·</span>
|
||||||
<span className="font-sans text-xs">{timeAgo(esc.created_at)}</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>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<span
|
||||||
onClick={() => navigate(`/pilot/${esc.id}?pickup=true`)}
|
onClick={(e) => {
|
||||||
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"
|
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
|
Pick up
|
||||||
|
</span>
|
||||||
</button>
|
</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>
|
||||||
))}
|
)}
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function EscalateModal({ open, onClose, onEscalate, isProcessing, hasPsaT
|
|||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
placeholder="e.g. I've exhausted all networking diagnostics and suspect this is a firewall policy issue that requires senior admin access..."
|
placeholder="e.g. I've exhausted all networking diagnostics and suspect this is a firewall policy issue that requires senior admin access..."
|
||||||
rows={4}
|
rows={4}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-[0.625rem] text-text-muted">
|
<p className="mt-1 text-[0.625rem] text-text-muted">
|
||||||
Minimum 5 characters. This will be shown to the engineer who picks up.
|
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
|
// 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.
|
// calls that complete after the user switches chats don't clobber new state.
|
||||||
const currentChatRef = useRef<string | null>(activeChatId)
|
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
|
// Persist active chat ID to sessionStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -275,9 +283,17 @@ export default function AssistantChatPage() {
|
|||||||
// own the session and the regular chat surface would race against the
|
// own the session and the regular chat surface would race against the
|
||||||
// claim flow. Once magicState is 'dismissed' (post-claim, or no handoff
|
// claim flow. Once magicState is 'dismissed' (post-claim, or no handoff
|
||||||
// found at all), this effect re-fires and selectChat runs.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!urlSessionId || urlSessionId === activeChatId) return
|
if (!urlSessionId) return
|
||||||
if (magicState === 'loading' || magicState === 'visible') return
|
if (magicState === 'loading' || magicState === 'visible') return
|
||||||
|
if (loadedChatIdsRef.current.has(urlSessionId)) return
|
||||||
|
loadedChatIdsRef.current.add(urlSessionId)
|
||||||
selectChat(urlSessionId)
|
selectChat(urlSessionId)
|
||||||
}, [urlSessionId, magicState]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [urlSessionId, magicState]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
@@ -324,6 +340,14 @@ export default function AssistantChatPage() {
|
|||||||
// as chat history.
|
// as chat history.
|
||||||
setSearchParams({})
|
setSearchParams({})
|
||||||
setMagicState('dismissed')
|
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) {
|
} catch (e: unknown) {
|
||||||
// Race-condition path (locked design): the loser of the simultaneous
|
// Race-condition path (locked design): the loser of the simultaneous
|
||||||
// Pick Up gets a 409 with structured detail so we can name the
|
// Pick Up gets a 409 with structured detail so we can name the
|
||||||
|
|||||||
Reference in New Issue
Block a user