import { useEffect, useState } from 'react'; import { sessionEvents } from './sessionEvents'; export type RawStatus = 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'; export type DerivedStatus = | 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle_warm' | 'idle_cold' | 'error'; // Window during which an idle dot stays green; after this, it fades to gray. const WARM_WINDOW_MS = 30_000; const TICK_MS = 5_000; interface Entry { status: RawStatus; at: string; } // Module-scope shared state so every StatusDot in the app shares one map // (mirrors useSidebar's singleton pattern). The map is ephemeral — cleared on // page reload; WS reconnect repopulates as new frames arrive. const statuses = new Map(); const subscribers = new Set<() => void>(); function notify(): void { for (const s of subscribers) { try { s(); } catch { /* swallow */ } } } // Guard against duplicate listeners during Vite HMR. const G = globalThis as Record; if (!G.__boocode_chat_status_subscribed) { G.__boocode_chat_status_subscribed = true; sessionEvents.subscribe((ev) => { if (ev.type !== 'chat_status') return; statuses.set(ev.chat_id, { status: ev.status, at: ev.at }); notify(); }); // Single shared ticker: re-notify so any green dot whose 30s window just // expired re-renders as gray. We only notify if there's something warm — // avoids waking sleeping components for nothing. setInterval(() => { const now = Date.now(); for (const entry of statuses.values()) { if (entry.status === 'idle') { const age = now - new Date(entry.at).getTime(); if (age < WARM_WINDOW_MS + TICK_MS) { notify(); return; } } } }, TICK_MS); } function derive(entry: Entry | undefined): DerivedStatus { if (!entry) return 'idle_cold'; if (entry.status === 'streaming') return 'streaming'; if (entry.status === 'tool_running') return 'tool_running'; if (entry.status === 'waiting_for_input') return 'waiting_for_input'; if (entry.status === 'error') return 'error'; const age = Date.now() - new Date(entry.at).getTime(); return age < WARM_WINDOW_MS ? 'idle_warm' : 'idle_cold'; } export function useChatStatus(chatId: string | null | undefined): DerivedStatus { const [, force] = useState({}); useEffect(() => { const sub = () => force({}); subscribers.add(sub); return () => { subscribers.delete(sub); }; }, []); if (!chatId) return 'idle_cold'; return derive(statuses.get(chatId)); }