From 84c89de71c6c430b6ebafd53fdb4d12fcff8fa0a Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 4 Oct 2026 16:21:12 +0200 Subject: [PATCH] swarm-ui: show how recent each subagent is in the details panel The subagent list endpoint now returns {name, last_row_unix} per subagent, the publish time of its newest terminal row, read from the queue controller-side so agents publish nothing new. The panel sorts newest first, shows a ticking age beside each name, and dims one quiet for 15 minutes. Closes #4928 --- docs/swarm/ui.md | 3 +- .../src/pages/agents/SubagentTerms.css | 15 ++++ .../src/pages/agents/SubagentTerms.tsx | 74 +++++++++++++------ swarm-controller/src/subagent_term.rs | 62 ++++++++++++++-- 4 files changed, 124 insertions(+), 30 deletions(-) diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index e731ea35..0c7fa53c 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -66,7 +66,8 @@ The agent's subagent daemon publishes each subagent's terminal rows on into a stream named `term-sub-` that swarm-controller creates for every declared agent. The stream keeps rows for 24 hours. swarm-controller serves the list as `GET /api/agents//subagents`, the subagents named by the subjects in that -stream, and relays one subagent's rows as SSE on +stream, each with `last_row_unix`, the stream's publish time of its newest row, +and relays one subagent's rows as SSE on `GET /api/agents//subagents//term/stream`. An agent with no store identity, or no queue address, publishes no subagent terminals. diff --git a/frontend/packages/swarm-ui/src/pages/agents/SubagentTerms.css b/frontend/packages/swarm-ui/src/pages/agents/SubagentTerms.css index ec8a7a81..04280301 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/SubagentTerms.css +++ b/frontend/packages/swarm-ui/src/pages/agents/SubagentTerms.css @@ -11,6 +11,21 @@ gap: 0.5em; } +.ui-subagent-terms-item { + display: inline-flex; + align-items: center; + gap: 0.35em; +} + +.ui-subagent-terms-item-stale { + opacity: 0.6; +} + +.ui-subagent-terms-age { + color: var(--muted); + font-size: 0.85em; +} + .ui-subagent-terms-heading { color: var(--muted); } diff --git a/frontend/packages/swarm-ui/src/pages/agents/SubagentTerms.tsx b/frontend/packages/swarm-ui/src/pages/agents/SubagentTerms.tsx index a1a78cbf..7ba1d68b 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/SubagentTerms.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/SubagentTerms.tsx @@ -1,9 +1,11 @@ // — an agent's subagents, under its own terminal preview in // `AgentsPage`'s detail panel. The list is `GET /api/agents/{name}/subagents`: -// the subagents the swarm queue holds terminal rows for. Subagents are -// spawned on demand, so it is refetched every `REFRESH_MS`. Picking one -// shows its terminal in `AgentTermPreview`, expand-to-tab included. Read-only -// like the agent's own: subagents take no input. +// the subagents the swarm queue holds terminal rows for, each with the +// publish time of its newest row. Newest first, with a ticking age beside +// the name; one quiet for `STALE_S` is dimmed. Subagents are spawned on +// demand, so it is refetched every `REFRESH_MS`. Picking one shows its +// terminal in `AgentTermPreview`, expand-to-tab included. Read-only like the +// agent's own: subagents take no input. // // Mount it keyed by agent name: the refresh timer is armed once per mount. import { useState } from "preact/hooks"; @@ -12,12 +14,27 @@ import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Badge } from "@hive/shared/badge.js"; import { AgentTermPreview } from "./AgentTermPreview.js"; import { useRefreshInterval } from "../../ui/refresh-interval/RefreshInterval.js"; +import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import "./SubagentTerms.css"; const REFRESH_MS = 10_000; +const STALE_S = 15 * 60; + +interface Subagent { + name: string; + last_row_unix: number | null; +} + +// Newest row first; a subagent whose time could not be read sorts last. +function byRecency(a: Subagent, b: Subagent): number { + const ta = a.last_row_unix ?? -Infinity; + const tb = b.last_row_unix ?? -Infinity; + if (ta !== tb) return tb > ta ? 1 : -1; + return a.name.localeCompare(b.name); +} export function SubagentTerms({ agentName }: { agentName: string }) { - const [names, setNames] = useState(null); + const [subagents, setSubagents] = useState(null); const [error, setError] = useState(null); const [selected, setSelected] = useState(null); @@ -29,7 +46,7 @@ export function SubagentTerms({ agentName }: { agentName: string }) { setError(await readApiError(res)); return; } - setNames((await res.json()) as string[]); + setSubagents(((await res.json()) as Subagent[]).sort(byRecency)); setError(null); } @@ -40,26 +57,41 @@ export function SubagentTerms({ agentName }: { agentName: string }) { // An agent with no subagents shows nothing here — no heading, no // "none" line — and so does the first fetch while it is in flight. A // failed fetch still shows, so the error is not swallowed. - if (!error && (names === null || names.length === 0)) return null; + if (!error && (subagents === null || subagents.length === 0)) return null; + + const nowS = Date.now() / 1000; return (
subagents - {names?.map((name) => ( - setSelected(name === selected ? null : name)} - /> - ))} + {subagents?.map(({ name, last_row_unix }) => { + const stale = + last_row_unix !== null && nowS - last_row_unix > STALE_S; + return ( + + setSelected(name === selected ? null : name)} + /> + {last_row_unix !== null && ( + + + + )} + + ); + })}
{error && ( BTreeSet, +} + #[utoipa::path( get, path = "/api/agents/{name}/subagents", params(("name" = String, Path, description = "agent whose subagents to list")), responses( - (status = 200, description = "names of the agent's subagents with terminal rows \ - in the swarm queue, sorted; empty when the agent has published none", - body = Vec), + (status = 200, description = "the agent's subagents with terminal rows in the \ + swarm queue, sorted by name, each with the publish time of its newest row; \ + empty when the agent has published none", + body = Vec), (status = 400, description = "the agent name is not shaped like an identifier \ (problem+json)", body = String), (status = 503, description = "no swarm queue is configured on this host, or the \ @@ -104,7 +119,7 @@ async fn live_agents(wanted: &WantedWriter, hives: &[String]) -> BTreeSet, Path(agent): Path, -) -> Result>, problem_details::ProblemDetails> { +) -> Result>, problem_details::ProblemDetails> { let agent = ident(&agent).map_err(bad_request)?; let client = connected_client(&state).map_err(|e| unavailable(&e))?; let js = async_nats::jetstream::new(client); @@ -121,10 +136,41 @@ pub(crate) async fn list_subagents( .try_collect() .await .map_err(|e| read_failed(&agent, &e))?; - Ok(Json(subagent_term::subagent_names( - &agent, - subjects.iter().map(|(s, _)| s.as_str()), - ))) + let names = subagent_term::subagent_names(&agent, subjects.iter().map(|(s, _)| s.as_str())); + let mut entries = Vec::with_capacity(names.len()); + for name in names { + let last_row_unix = last_row_unix(&stream, &agent, &name).await; + entries.push(SubagentEntry { + name, + last_row_unix, + }); + } + Ok(Json(entries)) +} + +/// The publish time of a subagent's newest row. A row that cannot be read, +/// aged out between the listing and this lookup say, is a missing time and +/// not a failed list. +async fn last_row_unix( + stream: &async_nats::jetstream::stream::Stream, + agent: &str, + subagent: &str, +) -> Option { + match stream + .get_last_raw_message_by_subject(&subagent_term::subject(agent, subagent)) + .await + { + Ok(row) => Some(row.time.unix_timestamp()), + Err(e) => { + tracing::debug!( + %agent, + %subagent, + error = %swarm_queue_client::chain(&e), + "listing subagents: reading the newest row failed" + ); + None + } + } } #[utoipa::path(