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:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user