feat(escalations): subscribe EscalationQueue to live SSE arrivals

Adds the frontend live-arrival slice on top of the test-stabilized SSE
backend. Senior techs now see a junior's escalation slide into the
queue without refresh.

- streamEscalations(handlers, signal) in aiSessions.ts: fetch-based
  ReadableStream parser (native EventSource cannot send auth headers).
  Handles SSE frames, partial frames across chunks, : keepalive
  heartbeats. Dispatches ready and handoff_created.
- HandoffCreatedEvent + EscalationStreamHandlers types mirror the bus
  payload published by HandoffManager.dispatch_escalation_notifications.
- EscalationQueue.tsx: AbortController-managed subscription with
  exponential-backoff reconnect (1s → 30s cap, attempt counter resets
  on ready). On handoff_created, refetch and diff against previous IDs
  via sessionsRef; new arrivals prepended (newest-first) above
  established cards (oldest-first preserved). Slide-in tag held for
  800ms so the locked 200ms animation completes. Tab-title flash
  prefixes (N) while document.hidden, restores on focus / unmount.
  prefers-reduced-motion swaps slide-in for fade-in. ARIA region +
  aria-live=polite + aria-label on heading. Pick Up bumped to py-2.5
  to clear the 44px touch floor.

Verified end-to-end against the running dev stack: subscriber received
the ready frame on connect; after posting a handoff via the API, the
subscriber received the handoff_created frame with the expected
payload — wire format matches the parser. Backend regression: focused
subset still 32 passed in 18.91s. Frontend tsc -b clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 20:57:15 -04:00
parent 02d5c6c08c
commit b8627f4180
3 changed files with 303 additions and 56 deletions

View File

@@ -18,6 +18,8 @@ import type {
ChatSessionCreateResponse,
ChatMessageRequest,
ChatMessageResponse,
HandoffCreatedEvent,
EscalationStreamHandlers,
} from '@/types/ai-session'
export const aiSessionsApi = {
@@ -220,6 +222,73 @@ export const aiSessionsApi = {
return response.data
},
// Native EventSource cannot send Authorization headers, so we use fetch +
// ReadableStream and parse SSE frames manually (same pattern as
// `streamDocumentation`). The returned promise resolves on clean stream
// close (server hangs up) and rejects on network/HTTP error so the caller
// can decide whether to reconnect with backoff.
async streamEscalations(
handlers: EscalationStreamHandlers,
signal: AbortSignal,
): Promise<void> {
const token = localStorage.getItem('access_token')
const baseUrl = import.meta.env.VITE_API_URL || ''
const response = await fetch(
`${baseUrl}/api/v1/ai-sessions/escalations/stream`,
{
headers: { Authorization: `Bearer ${token}` },
signal,
},
)
if (!response.ok) {
throw new Error(`Escalation stream failed: HTTP ${response.status}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('Escalation stream: no response body')
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) return
buffer += decoder.decode(value, { stream: true })
// SSE frames are separated by blank lines. Hold the trailing partial
// frame in the buffer until the next chunk completes it.
const frames = buffer.split('\n\n')
buffer = frames.pop() ?? ''
for (const frame of frames) {
if (!frame) continue
let eventType = 'message'
let data = ''
for (const line of frame.split('\n')) {
if (line.startsWith(':')) continue // comment / keepalive
if (line.startsWith('event: ')) eventType = line.slice(7).trim()
else if (line.startsWith('data: ')) data += line.slice(6)
}
if (!data) continue
try {
const parsed = JSON.parse(data) as Record<string, unknown>
if (eventType === 'handoff_created' && parsed.type === 'handoff_created') {
handlers.onHandoffCreated?.(parsed as unknown as HandoffCreatedEvent)
} else if (eventType === 'ready') {
handlers.onReady?.()
}
} catch {
// skip malformed frame
}
}
}
},
async search(q: string, limit: number = 5): Promise<AISessionSearchResult[]> {
const response = await apiClient.get<AISessionSearchResult[]>('/ai-sessions/search', {
params: { q, limit },