Status indicator (StatusDot): drops the flat amber pulse for a richer set of states — orbiting amber for streaming, spinning sky ring for tool_running, static violet for waiting_for_input, plus the existing idle/error. Backend chat_status frame widens from 'working|idle|error' to discriminate streaming vs tool execution vs paused for user input. Workspace pane sync: pane layout moves from per-device localStorage to server-side sessions.workspace_panes jsonb. PATCH /api/sessions/:id/workspace broadcasts session_workspace_updated on the user channel for cross-device live sync. Echo dedup via JSON comparison so the round-trip frame doesn't loop. Legacy localStorage seeds the server on first hydrate, then is deleted. Deprecated session_panes table dropped. Resilience: startup sweep marks any stale 'streaming' message older than 5 minutes as 'failed' so v1.12.0-style hung rows clear on container restart. useWorkspacePanes gains validatePanes() to prune dead chatId references from saved pane state when the chat list lands.
80 lines
2.5 KiB
TypeScript
80 lines
2.5 KiB
TypeScript
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<string, Entry>();
|
|
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<string, unknown>;
|
|
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));
|
|
}
|