import { useCallback, useState } from 'react'; export interface SessionActivity { /** Provider-supplied status line; null renders the default activity label. */ statusText: string | null; canInterrupt: boolean; /** * When this request was first marked as processing (client clock). Drives * the elapsed-time display and the stale `chat_subscribed` idle-ack guard. */ startedAt: number; } export type SessionActivityMap = ReadonlyMap; export type SessionActivitySnapshot = { sessionId: string; statusText?: string | null; canInterrupt?: boolean; startedAt?: number; }; export type MarkSessionProcessing = ( sessionId?: string | null, activity?: { statusText?: string | null; canInterrupt?: boolean }, ) => void; export type MarkSessionIdle = ( sessionId?: string | null, opts?: { ifStartedBefore?: number }, ) => void; export type SyncProcessingSessions = ( sessions: readonly SessionActivitySnapshot[], ) => void; const LOCAL_ACTIVITY_GRACE_MS = 10_000; const sessionActivityMapsMatch = ( left: ReadonlyMap, right: ReadonlyMap, ): boolean => { if (left.size !== right.size) { return false; } for (const [sessionId, leftActivity] of left) { const rightActivity = right.get(sessionId); if ( !rightActivity || leftActivity.statusText !== rightActivity.statusText || leftActivity.canInterrupt !== rightActivity.canInterrupt || leftActivity.startedAt !== rightActivity.startedAt ) { return false; } } return true; }; /** * Single source of truth for which sessions are actively processing a * request. Everything the chat UI shows (activity indicator, abort * availability, status text) is derived from this map; terminal events * (`complete`, abort, an authoritative idle subscribe ack) delete the entry * atomically. Session ids are always concrete (allocated before the first * send), so entries are keyed by real session ids only. */ export function useSessionProtection() { const [processingSessions, setProcessingSessions] = useState>( new Map(), ); const markSessionProcessing = useCallback((sessionId, activity) => { if (!sessionId) { return; } setProcessingSessions((prev) => { const existing = prev.get(sessionId); const next: SessionActivity = { statusText: activity?.statusText !== undefined ? activity.statusText : existing?.statusText ?? null, canInterrupt: activity?.canInterrupt ?? existing?.canInterrupt ?? true, startedAt: existing?.startedAt ?? Date.now(), }; if ( existing && existing.statusText === next.statusText && existing.canInterrupt === next.canInterrupt ) { return prev; } const updated = new Map(prev); updated.set(sessionId, next); return updated; }); }, []); const markSessionIdle = useCallback((sessionId, opts) => { if (!sessionId) { return; } setProcessingSessions((prev) => { const existing = prev.get(sessionId); if (!existing) { return prev; } // Guard against stale `chat_subscribed` idle acks: if a new request // started after the subscribe was sent, the idle ack describes the // older request and must not clear the newer one. if (opts?.ifStartedBefore !== undefined && existing.startedAt >= opts.ifStartedBefore) { return prev; } const updated = new Map(prev); updated.delete(sessionId); return updated; }); }, []); const syncProcessingSessions = useCallback((sessions) => { const now = Date.now(); setProcessingSessions((prev) => { const incoming = new Map(); for (const session of sessions) { if (!session.sessionId) { continue; } incoming.set(session.sessionId, session); } const updated = new Map(); for (const [sessionId, snapshot] of incoming) { const existing = prev.get(sessionId); const snapshotStartedAt = typeof snapshot.startedAt === 'number' && Number.isFinite(snapshot.startedAt) && snapshot.startedAt > 0 ? snapshot.startedAt : undefined; updated.set(sessionId, { statusText: snapshot.statusText !== undefined ? snapshot.statusText : existing?.statusText ?? null, canInterrupt: snapshot.canInterrupt ?? existing?.canInterrupt ?? true, startedAt: snapshotStartedAt ?? existing?.startedAt ?? now, }); } for (const [sessionId, activity] of prev) { if (!incoming.has(sessionId) && now - activity.startedAt < LOCAL_ACTIVITY_GRACE_MS) { updated.set(sessionId, activity); } } return sessionActivityMapsMatch(prev, updated) ? prev : updated; }); }, []); return { processingSessions, markSessionProcessing, markSessionIdle, syncProcessingSessions, }; }