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
This commit is contained in:
parent
4356dc82a4
commit
84c89de71c
4 changed files with 124 additions and 30 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
// <SubagentTerms> — 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<string[] | null>(null);
|
||||
const [subagents, setSubagents] = useState<Subagent[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(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 (
|
||||
<section class="ui-subagent-terms" aria-label={`${agentName}'s subagents`}>
|
||||
<div class="ui-subagent-terms-list">
|
||||
<span class="ui-subagent-terms-heading">subagents</span>
|
||||
{names?.map((name) => (
|
||||
<Badge
|
||||
key={name}
|
||||
variant={name === selected ? "default" : "quiet"}
|
||||
value={name}
|
||||
expanded={name === selected}
|
||||
title={
|
||||
name === selected
|
||||
? `hide ${name}'s terminal`
|
||||
: `show ${name}'s terminal`
|
||||
}
|
||||
onClick={() => setSelected(name === selected ? null : name)}
|
||||
/>
|
||||
))}
|
||||
{subagents?.map(({ name, last_row_unix }) => {
|
||||
const stale =
|
||||
last_row_unix !== null && nowS - last_row_unix > STALE_S;
|
||||
return (
|
||||
<span
|
||||
key={name}
|
||||
class={`ui-subagent-terms-item${stale ? " ui-subagent-terms-item-stale" : ""}`}
|
||||
>
|
||||
<Badge
|
||||
variant={name === selected ? "default" : "quiet"}
|
||||
value={name}
|
||||
expanded={name === selected}
|
||||
title={
|
||||
name === selected
|
||||
? `hide ${name}'s terminal`
|
||||
: `show ${name}'s terminal`
|
||||
}
|
||||
onClick={() => setSelected(name === selected ? null : name)}
|
||||
/>
|
||||
{last_row_unix !== null && (
|
||||
<span class="ui-subagent-terms-age">
|
||||
<RelativeTime epochMs={last_row_unix * 1000} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{error && (
|
||||
<ApiErrorPanel
|
||||
|
|
|
|||
Loading…
Reference in a new issue