From 31b7452e566a16035ad23fc496c6622aa5657812 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 21 Oct 2026 23:16:04 +0200 Subject: [PATCH] swarm-ui: let a closed stream reconnect and a failed caps read retry 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. --- docs/swarm/ui.md | 7 ++++++- .../swarm-ui/src/agent/detail/AgentLimits.tsx | 16 ++++++++++++-- .../src/agent/terminal/AgentTermPreview.tsx | 17 +++++++++++++-- .../terminal/useSwarmAgentStateStream.ts | 21 ++++++++++++------- .../src/agent/terminal/useSwarmTermStream.ts | 21 ++++++++++++------- .../src/pages/messages/MessagesPage.tsx | 20 +++++++++++++----- .../src/pages/messages/useMessageStream.ts | 21 ++++++++++++------- .../swarm-ui/src/ui/stream/connection.ts | 14 +++++++++++++ .../swarm-ui/test/stream-connection.test.mjs | 15 +++++++++++++ 9 files changed, 118 insertions(+), 34 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/ui/stream/connection.ts create mode 100644 frontend/packages/swarm-ui/test/stream-connection.test.mjs diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 14364b3e..d49e0045 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -59,6 +59,10 @@ dimmed, as **not running**. An agent's terminal opens with its last 100 rows from the past 24 hours, then follows new ones; a reconnect picks up after the last row it showed. A subagent terminal does the same with that subagent's rows, and has no turn-state badges. +The browser retries a dropped connection by itself. When the swarm answers the +stream with an error status, the browser gives up and the badge reads +**disconnected**. The **reconnect** button beside it opens the stream again, and +the stream replays the rows. The messages page works the same way. The settings menu in the header has a **hide debug output** toggle. It drops the muted debug rows (thinking, coalesced ticks, harness chatter) from every @@ -419,7 +423,8 @@ refusal's reason under the fields and leaves them as you typed them. A saved change isn't instant. The hive reads the caps about every 30 seconds, and a running agent takes a changed cap at its next spawn or rebuild, so the row shows the new value before the agent runs under it. The panel reads -`GET /api/agents/{agent}/limits`. [hivectl](../tools/hivectl.md#per-agent-resource-limits) +`GET /api/agents/{agent}/limits`; when that read fails, the panel shows the +reason and a **retry** button. [hivectl](../tools/hivectl.md#per-agent-resource-limits) sets the hive's own limits. ### Settings at creation diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx index 3860e3d9..42fe9c9c 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx @@ -117,10 +117,22 @@ export function AgentLimits({ agent }: { agent: string }) { // Shown as text, not a hover title. Caps already loaded stay under a // failed refetch, so a save that worked is not hidden by a read that did not. const errorPanel = error ? ( - + <> + + + ) : null; if (caps === null) { - return errorPanel ?? …; + return ( + errorPanel ?? ( + + loading the resource caps… + + ) + ); } return ( diff --git a/frontend/packages/swarm-ui/src/agent/terminal/AgentTermPreview.tsx b/frontend/packages/swarm-ui/src/agent/terminal/AgentTermPreview.tsx index 7994f07e..f022b3fd 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/AgentTermPreview.tsx +++ b/frontend/packages/swarm-ui/src/agent/terminal/AgentTermPreview.tsx @@ -203,8 +203,10 @@ export function AgentTermPreview({ leading?: ComponentChildren; }) { const paths = termPaths(agentName, subagent); - const { rows, connection } = useSwarmTermStream(paths.stream); - const { header } = useSwarmAgentStateStream(paths.state); + const { rows, connection, reconnect } = useSwarmTermStream(paths.stream); + const { header, reconnect: reconnectState } = useSwarmAgentStateStream( + paths.state, + ); const { open: openTab } = useDynamicTabs(); const logRef = useRef(null); const [stickToBottom, setStickToBottom] = useState(true); @@ -246,6 +248,17 @@ export function AgentTermPreview({ tone={CONNECTION_TONE[connection]} value={CONNECTION_LABEL[connection]} /> + {connection === "closed" && ( + { + reconnect(); + reconnectState(); + }} + /> + )} {header && showHeaderBadges && ( )} diff --git a/frontend/packages/swarm-ui/src/agent/terminal/useSwarmAgentStateStream.ts b/frontend/packages/swarm-ui/src/agent/terminal/useSwarmAgentStateStream.ts index c1a433ee..b7f0a931 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/useSwarmAgentStateStream.ts +++ b/frontend/packages/swarm-ui/src/agent/terminal/useSwarmAgentStateStream.ts @@ -17,7 +17,12 @@ // `status_text`/`running`). import { useEffect, useState } from "preact/hooks"; -export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed"; +import { + connectionAfterError, + type ConnectionState, +} from "../../ui/stream/connection.js"; + +export type { ConnectionState }; export interface TokenUsage { input_tokens?: number; @@ -53,6 +58,8 @@ export interface AgentStateHeader { export interface UseSwarmAgentStateStreamResult { header: AgentStateHeader | null; connection: ConnectionState; + /** Opens the stream again, for one the browser gave up on. */ + reconnect: () => void; } export function useSwarmAgentStateStream( @@ -60,6 +67,8 @@ export function useSwarmAgentStateStream( ): UseSwarmAgentStateStreamResult { const [header, setHeader] = useState(null); const [connection, setConnection] = useState("connecting"); + // Bumped by `reconnect`, which reruns the effect below. + const [attempt, setAttempt] = useState(0); useEffect(() => { setHeader(null); @@ -78,13 +87,9 @@ export function useSwarmAgentStateStream( // badge cluster over — the next valid header replaces it. } }; - es.onerror = () => { - setConnection( - es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed", - ); - }; + es.onerror = () => setConnection(connectionAfterError(es.readyState)); return () => es.close(); - }, [streamUrl]); + }, [streamUrl, attempt]); - return { header, connection }; + return { header, connection, reconnect: () => setAttempt((n) => n + 1) }; } diff --git a/frontend/packages/swarm-ui/src/agent/terminal/useSwarmTermStream.ts b/frontend/packages/swarm-ui/src/agent/terminal/useSwarmTermStream.ts index 0da6c3c4..d0bf3765 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/useSwarmTermStream.ts +++ b/frontend/packages/swarm-ui/src/agent/terminal/useSwarmTermStream.ts @@ -12,7 +12,12 @@ import { useEffect, useRef, useState } from "preact/hooks"; import type { TermMsg, TermRow } from "@hive/shared/term-msg.js"; -export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed"; +import { + connectionAfterError, + type ConnectionState, +} from "../../ui/stream/connection.js"; + +export type { ConnectionState }; function appendRow(rows: TermRow[], row: TermRow): TermRow[] { const last = rows[rows.length - 1]; @@ -25,6 +30,8 @@ function appendRow(rows: TermRow[], row: TermRow): TermRow[] { export interface UseSwarmTermStreamResult { rows: TermRow[]; connection: ConnectionState; + /** Opens the stream again, for one the browser gave up on. */ + reconnect: () => void; } export function useSwarmTermStream( @@ -33,6 +40,8 @@ export function useSwarmTermStream( const [rows, setRows] = useState([]); const [connection, setConnection] = useState("connecting"); const keySeqRef = useRef(0); + // Bumped by `reconnect`, which reruns the effect below. + const [attempt, setAttempt] = useState(0); useEffect(() => { setRows([]); @@ -59,13 +68,9 @@ export function useSwarmTermStream( } setRows((prev) => appendRow(prev, { ...msg, key, fromHistory: false })); }; - es.onerror = () => { - setConnection( - es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed", - ); - }; + es.onerror = () => setConnection(connectionAfterError(es.readyState)); return () => es.close(); - }, [streamUrl]); + }, [streamUrl, attempt]); - return { rows, connection }; + return { rows, connection, reconnect: () => setAttempt((n) => n + 1) }; } diff --git a/frontend/packages/swarm-ui/src/pages/messages/MessagesPage.tsx b/frontend/packages/swarm-ui/src/pages/messages/MessagesPage.tsx index aee5cd75..56128228 100644 --- a/frontend/packages/swarm-ui/src/pages/messages/MessagesPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/messages/MessagesPage.tsx @@ -50,7 +50,7 @@ const MessageRow = memo(function MessageRow({ m }: { m: ArrivedMessage }) { export function MessagesPage() { const [sinceMinutes, setSinceMinutes] = useState(DEFAULT_WINDOW_MINUTES); const [query, setQuery] = useState(""); - const { messages, connection } = useMessageStream(sinceMinutes); + const { messages, connection, reconnect } = useMessageStream(sinceMinutes); const shown = filterMessages(messages, query); const logRef = useRef(null); @@ -78,10 +78,20 @@ export function MessagesPage() { title="messages" icon="💬" actions={ - + <> + + {connection === "closed" ? ( + + ) : null} + } >
diff --git a/frontend/packages/swarm-ui/src/pages/messages/useMessageStream.ts b/frontend/packages/swarm-ui/src/pages/messages/useMessageStream.ts index 663935ed..44e0c3d7 100644 --- a/frontend/packages/swarm-ui/src/pages/messages/useMessageStream.ts +++ b/frontend/packages/swarm-ui/src/pages/messages/useMessageStream.ts @@ -11,7 +11,12 @@ import { useEffect, useRef, useState } from "preact/hooks"; import type { ArrivedMessage, SwarmMessage } from "./messages.js"; import { messagesUrl, newest, numbered } from "./messages.js"; -export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed"; +import { + connectionAfterError, + type ConnectionState, +} from "../../ui/stream/connection.js"; + +export type { ConnectionState }; /** The most messages the page holds; older ones drop off. */ export const MAX_MESSAGES = 2000; @@ -19,12 +24,16 @@ export const MAX_MESSAGES = 2000; export function useMessageStream(sinceMinutes: number): { messages: ArrivedMessage[]; connection: ConnectionState; + /** Opens the stream again, for one the browser gave up on. */ + reconnect: () => void; } { const [messages, setMessages] = useState([]); const [connection, setConnection] = useState("connecting"); // The next id to hand out; it never goes back, so an id is not reused after // a reconnect either. const nextId = useRef(0); + // Bumped by `reconnect`, which reruns the effect below. + const [attempt, setAttempt] = useState(0); useEffect(() => { setMessages([]); @@ -61,16 +70,12 @@ export function useMessageStream(sinceMinutes: number): { pending.push(m); if (frame === null) frame = requestAnimationFrame(flush); }; - es.onerror = () => { - setConnection( - es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed", - ); - }; + es.onerror = () => setConnection(connectionAfterError(es.readyState)); return () => { drop(); es.close(); }; - }, [sinceMinutes]); + }, [sinceMinutes, attempt]); - return { messages, connection }; + return { messages, connection, reconnect: () => setAttempt((n) => n + 1) }; } diff --git a/frontend/packages/swarm-ui/src/ui/stream/connection.ts b/frontend/packages/swarm-ui/src/ui/stream/connection.ts new file mode 100644 index 00000000..8a439b15 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/stream/connection.ts @@ -0,0 +1,14 @@ +// The state of an `EventSource` as the swarm UI's stream hooks report it. + +export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed"; + +/** `EventSource.CONNECTING`: the browser is retrying by itself. */ +const CONNECTING = 0; + +/** The state after an `error` event. While the browser is still retrying + * (`readyState` 0), the stream is "reconnecting". Any other state means the + * browser gave up, which it does when the server answers with a non-200 status + * or a wrong content type, so nothing reopens the stream unless the page does. */ +export function connectionAfterError(readyState: number): ConnectionState { + return readyState === CONNECTING ? "reconnecting" : "closed"; +} diff --git a/frontend/packages/swarm-ui/test/stream-connection.test.mjs b/frontend/packages/swarm-ui/test/stream-connection.test.mjs new file mode 100644 index 00000000..c8f34fd6 --- /dev/null +++ b/frontend/packages/swarm-ui/test/stream-connection.test.mjs @@ -0,0 +1,15 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { connectionAfterError } from "../src/ui/stream/connection.ts"; + +test("connectionAfterError: the browser still retrying is reconnecting", () => { + assert.equal(connectionAfterError(0), "reconnecting"); +}); + +test("connectionAfterError: a given-up stream is closed", () => { + assert.equal(connectionAfterError(2), "closed"); +}); + +test("connectionAfterError: an open readyState on error is also closed", () => { + assert.equal(connectionAfterError(1), "closed"); +});