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. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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));
|
|
}
|