When the server answers an event stream with an error status the browser gives up and the badge stayed on disconnected with nothing to press. The terminal, its state header and the messages page now offer a reconnect, and the one rule for the state after an error moved into a tested helper. The resource caps panel gets a retry beside its error, and its loading state announces itself.
95 lines
3.7 KiB
TypeScript
95 lines
3.7 KiB
TypeScript
// Live SSE consumer for the swarm-relayed agent turn-state header
|
|
// (`GET /api/agents/{name}/state/stream`, swarm-controller's
|
|
// `agent_state_stream.rs`). Same transport shape as `useSwarmTermStream`
|
|
// — one core-NATS subject relayed as bare SSE `data`, no envelope, no
|
|
// replay — but the payload here is a full header that *replaces* the
|
|
// last one rather than an appendable row, so there is nothing to
|
|
// coalesce: each event is just the latest value.
|
|
//
|
|
// **No seed on attach.** `agent_state_stream.rs`'s own module doc names
|
|
// this limitation: the subject is published on transition and re-sent
|
|
// once a minute, so an agent that has sat idle since before this hook
|
|
// mounted announces nothing for up to a minute. `header` stays `null`
|
|
// until the first event lands — callers should render no badges in that
|
|
// gap rather than a stale or guessed one (there is no swarm-level
|
|
// endpoint today that can seed turn_state/model/ctx/cost synchronously —
|
|
// `GET /api/agents/status`'s snapshot carries only
|
|
// `status_text`/`running`).
|
|
import { useEffect, useState } from "preact/hooks";
|
|
|
|
import {
|
|
connectionAfterError,
|
|
type ConnectionState,
|
|
} from "../../ui/stream/connection.js";
|
|
|
|
export type { ConnectionState };
|
|
|
|
export interface TokenUsage {
|
|
input_tokens?: number;
|
|
output_tokens?: number;
|
|
cache_read_input_tokens?: number;
|
|
cache_creation_input_tokens?: number;
|
|
}
|
|
|
|
// `swarm_wire::agent_inference::Source`.
|
|
export type InferenceSource = "swarm" | "agent_nix" | "local_file" | "default";
|
|
|
|
// Mirrors `hive_agent::swarm_agent_state::AgentStateMsg` field-for-field
|
|
// — see that struct's own doc comment for why `turn_state_since` (an
|
|
// ISO 8601 string here, unlike the per-agent page's unix-seconds
|
|
// `/api/state`) and `agent_state` (the swarm `AgentState` enum, not a
|
|
// bare `paused: bool`) diverge from the per-agent page's own shape.
|
|
export interface AgentStateHeader {
|
|
turn_state: "idle" | "thinking" | "compacting";
|
|
turn_state_since: string;
|
|
agent_state: "up" | "offline" | "paused" | "destroyed";
|
|
model: string;
|
|
effort: string;
|
|
model_source: InferenceSource;
|
|
effort_source: InferenceSource;
|
|
resolved_model: string | null;
|
|
context_window_tokens: number;
|
|
ctx_usage: TokenUsage | null;
|
|
cost_usage: TokenUsage | null;
|
|
// Absent from an agent that predates the field.
|
|
needs_login?: boolean;
|
|
}
|
|
|
|
export interface UseSwarmAgentStateStreamResult {
|
|
header: AgentStateHeader | null;
|
|
connection: ConnectionState;
|
|
/** Opens the stream again, for one the browser gave up on. */
|
|
reconnect: () => void;
|
|
}
|
|
|
|
export function useSwarmAgentStateStream(
|
|
streamUrl: string | null,
|
|
): UseSwarmAgentStateStreamResult {
|
|
const [header, setHeader] = useState<AgentStateHeader | null>(null);
|
|
const [connection, setConnection] = useState<ConnectionState>("connecting");
|
|
// Bumped by `reconnect`, which reruns the effect below.
|
|
const [attempt, setAttempt] = useState(0);
|
|
|
|
useEffect(() => {
|
|
setHeader(null);
|
|
setConnection("connecting");
|
|
// `null`: a terminal with no header to follow (a subagent's).
|
|
if (streamUrl === null) return;
|
|
const es = new EventSource(streamUrl);
|
|
es.onopen = () => setConnection("open");
|
|
es.onmessage = (e) => {
|
|
try {
|
|
setHeader(JSON.parse(e.data) as AgentStateHeader);
|
|
} catch {
|
|
// Malformed payload: drop it silently. Unlike the terminal
|
|
// preview there is no row list to append a "[parse err]" line
|
|
// to here, and one bad header isn't worth degrading the whole
|
|
// badge cluster over — the next valid header replaces it.
|
|
}
|
|
};
|
|
es.onerror = () => setConnection(connectionAfterError(es.readyState));
|
|
return () => es.close();
|
|
}, [streamUrl, attempt]);
|
|
|
|
return { header, connection, reconnect: () => setAttempt((n) => n + 1) };
|
|
}
|