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"); +});