Files
boocode/apps/web/src/hooks/useChatStatus.ts
indifferentketchup 2bce4d85fa feat(mobile): v1.8 tab switcher + branch indicator + git_status tool
Mobile header is now two rows. Row 1: hamburger | project · branch
indicator (live via GET /api/projects/:id/git, 30s poll) | ModelPicker |
FolderTree. Row 2: pane-switcher pill (hand-rolled BottomSheet) +
NewPaneMenu. Chat-within-pane navigation hidden on mobile; users switch
panes via the sheet. Cross-tab status sync via chat_status frames
published from inference.ts at working/idle/error transitions; StatusDot
component renders amber-pulse/green/red/gray on each pane row and on
desktop ChatTabBar tabs. Level 1 git awareness exposes a read-only
git_status tool to the model, backed by services/git_meta.ts (execFile
+ 2s timeout + 30s cache). Workspace.tsx now receives panes/chats hooks
as props (hoisted into Session.tsx) so the header pill shares state
with the pane grid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:07:53 +00:00

72 lines
2.3 KiB
TypeScript

import { useEffect, useState } from 'react';
import { sessionEvents } from './sessionEvents';
export type RawStatus = 'working' | 'idle' | 'error';
export type DerivedStatus = 'working' | '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 === 'working') return 'working';
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));
}