Scoped half of boocode_code_review_v2 §1 #10 — publish the agent status BooCoder already observes (the config-injection notify-hook is the documented follow-on, clean-room from superset ELv2). - agent_status_updated WS frame (working|blocked|idle|error), server+web parity. - Published from the dispatcher's turn boundaries (warm-acp/opencode/sdk/pty: working at start, idle/error at end) + the permission flow (blocked/working). Best-effort, never breaks a turn. - Clean-room normalizeAgentEvent helper (superset's vendor-event -> Start/blocked /Stop collapse, event names as facts) + 25 tests — reused by the follow-on. - AgentComposerBar status dot (distinct from the WS-liveness dot), tracked per (chat,agent) by a useAgentStatus map in CoderPane. Built by 2 parallel agents vs a pinned frame contract. Server 545 + coder 294 tests passing (25 new); web tsc + builds clean; ws-frames parity green. Clears the actionable review backlog (#1/#3/#4/#6-#12). Builds on v2.7.5.
63 lines
2.1 KiB
TypeScript
63 lines
2.1 KiB
TypeScript
import { useCallback, useMemo, useState } from 'react';
|
|
|
|
// Normalized external-agent status (#10). Consumed from the
|
|
// `agent_status_updated` WS frame the coder backend publishes:
|
|
// { type: 'agent_status_updated'; chat_id; agent; status; reason?; at }
|
|
// BooCoder collapses ~30 vendor lifecycle events into these four buckets:
|
|
// working — turn in flight
|
|
// blocked — waiting on a permission / approval
|
|
// idle — clean completion
|
|
// error — crash / failure
|
|
export type AgentStatus = 'working' | 'blocked' | 'idle' | 'error';
|
|
|
|
export interface AgentStatusEntry {
|
|
status: AgentStatus;
|
|
reason?: string;
|
|
at: string;
|
|
}
|
|
|
|
const key = (chatId: string, agent: string): string => `${chatId}:${agent}`;
|
|
|
|
// Per-(chat,agent) live status map. The dot reflects the latest frame for the
|
|
// active agent in the current chat; entries are reset when the chat switches so
|
|
// a stale "working"/"blocked" from a previous chat never leaks into the next.
|
|
export function useAgentStatus() {
|
|
const [map, setMap] = useState<Record<string, AgentStatusEntry>>({});
|
|
|
|
const record = useCallback(
|
|
(chatId: string, agent: string, entry: AgentStatusEntry) => {
|
|
setMap((prev) => ({ ...prev, [key(chatId, agent)]: entry }));
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Drop every entry for a chat (called on chat switch). No-op when nothing
|
|
// matches so it's safe to call unconditionally from an effect.
|
|
const reset = useCallback((chatId: string | undefined) => {
|
|
setMap((prev) => {
|
|
if (!chatId) return prev;
|
|
const prefix = `${chatId}:`;
|
|
let changed = false;
|
|
const next: Record<string, AgentStatusEntry> = {};
|
|
for (const [k, v] of Object.entries(prev)) {
|
|
if (k.startsWith(prefix)) {
|
|
changed = true;
|
|
continue;
|
|
}
|
|
next[k] = v;
|
|
}
|
|
return changed ? next : prev;
|
|
});
|
|
}, []);
|
|
|
|
const get = useCallback(
|
|
(chatId: string | undefined, agent: string | undefined): AgentStatusEntry | null => {
|
|
if (!chatId || !agent) return null;
|
|
return map[key(chatId, agent)] ?? null;
|
|
},
|
|
[map],
|
|
);
|
|
|
|
return useMemo(() => ({ record, reset, get }), [record, reset, get]);
|
|
}
|