Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
8 changed files with 46 additions and 365 deletions

View file

@ -39,15 +39,6 @@ terminal in the same preview, and **expand** opens it in its own tab, as it
does for the agent. The list refreshes every 10 seconds, so a subagent the
agent spawns shows up once it writes output.
On a terminal's own page (`/agents/<name>/terminal`, or a subagent's) the
header has an **N running subagents** badge. It opens a list of the agent's
subagents, each a link to its terminal; on a subagent's page the current one
gets a marker and the list starts with a link back to the agent's own terminal. A
subagent counts as running when it published a row in the last 15 minutes. The
swarm doesn't report more than when the newest row came, so a subagent that
thinks quietly for longer drops out of the count. The list shows the others below,
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 shows rows published while it's open, and has no turn-state badges.

View file

@ -3,24 +3,57 @@
// 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 `useSubagentList` refetches it. Picking one shows its
// 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";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
import { AgentTermPreview } from "./AgentTermPreview.js";
import { STALE_S } from "./subagent-list.js";
import { useSubagentList } from "./useSubagentList.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 { subagents, error } = useSubagentList(agentName);
const [subagents, setSubagents] = useState<Subagent[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
const [selected, setSelected] = useState<string | null>(null);
async function refresh() {
const res = await fetch(
`/api/agents/${encodeURIComponent(agentName)}/subagents`,
);
if (!res.ok) {
setError(await readApiError(res));
return;
}
setSubagents(((await res.json()) as Subagent[]).sort(byRecency));
setError(null);
}
useRefreshInterval(REFRESH_MS, () => {
refresh().catch((e: unknown) => setError({ detail: String(e) }));
});
// 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.

View file

@ -1,47 +0,0 @@
/* <SubagentsMenu>: the open list under the header badge. */
.ui-subagents-menu {
display: flex;
flex-direction: column;
gap: 0.25em;
}
.ui-subagents-menu-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.ui-subagents-menu-link {
display: flex;
justify-content: space-between;
gap: 1em;
padding: 0.4em 0.5em;
border-radius: 0.35em;
color: inherit;
text-decoration: none;
}
.ui-subagents-menu-link:hover,
.ui-subagents-menu-link:focus-visible {
background: var(--bg);
}
.ui-subagents-menu-link[aria-current="page"] {
border-left: 3px solid var(--purple);
font-weight: 600;
}
.ui-subagents-menu-name {
overflow-wrap: anywhere;
}
.ui-subagents-menu-age,
.ui-subagents-menu-heading {
color: var(--muted);
font-size: 0.85em;
}
.ui-subagents-menu-heading {
margin: 0.4em 0.5em 0;
}
.ui-subagents-menu-quiet {
opacity: 0.7;
}
.ui-subagents-menu-back {
color: var(--blue);
}

View file

@ -1,145 +0,0 @@
// <SubagentsMenu> — "N running subagents" in the full-screen terminal page's
// header: a badge that opens a list of the agent's subagents, each a link to
// its own terminal page. On a subagent's page the current one is marked and the
// list starts with a way back to the agent's own terminal. The list is the
// same `GET /api/agents/{name}/subagents` as the one under the preview, and
// "running" means the same too: a row published within `STALE_S`
// (`subagent-list.ts`). Quiet subagents are listed below, dimmed, so a finished
// one can still be opened.
//
// Nothing shows for an agent with no subagents, and nothing before the first
// answer; a failed fetch shows its reason in the open list.
import { useRef, useState } from "preact/hooks";
import { Link } from "wouter-preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { Badge } from "@hive/shared/badge.js";
import { Panel } from "../../ui/panel/Panel.js";
import { Popover } from "../../ui/popover/Popover.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import { termPaths } from "./AgentTermPreview.js";
import { runningLabel, splitRunning, type Subagent } from "./subagent-list.js";
import { useSubagentList } from "./useSubagentList.js";
import "./SubagentsMenu.css";
function Entry({
agent,
sub,
current,
onPick,
}: {
agent: string;
sub: Subagent;
current: boolean;
onPick: () => void;
}) {
return (
<li>
<Link
href={termPaths(agent, sub.name).page}
class="ui-subagents-menu-link"
onClick={onPick}
aria-current={current ? "page" : undefined}
>
<span class="ui-subagents-menu-name">{sub.name}</span>
{sub.last_row_unix !== null && (
<span class="ui-subagents-menu-age">
<RelativeTime epochMs={sub.last_row_unix * 1000} />
</span>
)}
</Link>
</li>
);
}
export function SubagentsMenu({
agentName,
current,
}: {
agentName: string;
/** The subagent whose page this is, when it is a subagent's. */
current?: string;
}) {
const { subagents, error } = useSubagentList(agentName);
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLSpanElement>(null);
if (!error && (subagents === null || subagents.length === 0)) return null;
const { running, quiet } = splitRunning(subagents ?? [], Date.now() / 1000);
return (
<>
<span ref={anchorRef}>
<Badge
value={runningLabel(running.length)}
tone={running.length > 0 ? "positive" : "neutral"}
title={`${open ? "close" : "open"} ${agentName}'s subagents`}
expanded={open}
onClick={() => setOpen((o) => !o)}
/>
</span>
<Popover
open={open}
onClose={() => setOpen(false)}
anchorRef={anchorRef}
label={`${agentName}'s subagents`}
>
<Panel
title={`${agentName}'s subagents`}
icon="🧩"
onClose={() => setOpen(false)}
>
<div class="ui-subagents-menu">
{error && (
<ApiErrorPanel
context={`failed to list ${agentName}'s subagents`}
problem={error}
/>
)}
{current !== undefined && (
<Link
href={termPaths(agentName).page}
class="ui-subagents-menu-link ui-subagents-menu-back"
onClick={() => setOpen(false)}
>
← {agentName}'s own terminal
</Link>
)}
{running.length > 0 && (
<ul class="ui-subagents-menu-list" aria-label="running">
{running.map((s) => (
<Entry
key={s.name}
agent={agentName}
sub={s}
current={s.name === current}
onPick={() => setOpen(false)}
/>
))}
</ul>
)}
{quiet.length > 0 && (
<>
<p class="ui-subagents-menu-heading">not running</p>
<ul
class="ui-subagents-menu-list ui-subagents-menu-quiet"
aria-label="not running"
>
{quiet.map((s) => (
<Entry
key={s.name}
agent={agentName}
sub={s}
current={s.name === current}
onPick={() => setOpen(false)}
/>
))}
</ul>
</>
)}
</div>
</Panel>
</Popover>
</>
);
}

View file

@ -1,50 +0,0 @@
// What the subagent list means, without a DOM: the wire row, which subagents
// count as running, and the words for the count. Shared by the list under an
// agent's terminal preview and the menu on the full-screen terminal page, so
// the two agree on what "running" is.
/** One row of `GET /api/agents/{name}/subagents`. */
export interface Subagent {
name: string;
/** Unix seconds of its newest terminal row, `null` when not readable. */
last_row_unix: number | null;
}
/** A subagent quiet for longer than this is no longer counted as running. */
export const STALE_S = 15 * 60;
/**
* Whether a subagent counts as running: it published a row within
* [`STALE_S`]. The controller reports only when the newest row was published,
* so this is a guess. A subagent that is thinking quietly drops out, and one
* that finished stays in until the window passes.
*/
export function isRunning(sub: Subagent, nowS: number): boolean {
return sub.last_row_unix !== null && nowS - sub.last_row_unix <= STALE_S;
}
/** Newest row first; a subagent whose time could not be read sorts last. */
export 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);
}
/** The list split into the running subagents and the quiet ones, each newest
* first. */
export function splitRunning(
subs: Subagent[],
nowS: number,
): { running: Subagent[]; quiet: Subagent[] } {
const sorted = [...subs].sort(byRecency);
return {
running: sorted.filter((s) => isRunning(s, nowS)),
quiet: sorted.filter((s) => !isRunning(s, nowS)),
};
}
/** The badge's text: `1 running subagent`, `3 running subagents`. */
export function runningLabel(count: number): string {
return count === 1 ? "1 running subagent" : `${count} running subagents`;
}

View file

@ -1,36 +0,0 @@
// An agent's subagents, refetched on a timer: `GET /api/agents/{name}/subagents`.
// Subagents are spawned on demand, so a one-off fetch would go stale. `null`
// until the first answer; a failed fetch sets `error` and keeps the last list.
// Mount per agent name: the timer is armed once per mount.
import { useState } from "preact/hooks";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { useRefreshInterval } from "../../ui/refresh-interval/RefreshInterval.js";
import { byRecency, type Subagent } from "./subagent-list.js";
const REFRESH_MS = 10_000;
export function useSubagentList(agentName: string): {
subagents: Subagent[] | null;
error: ProblemDetails | null;
} {
const [subagents, setSubagents] = useState<Subagent[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
async function refresh() {
const res = await fetch(
`/api/agents/${encodeURIComponent(agentName)}/subagents`,
);
if (!res.ok) {
setError(await readApiError(res));
return;
}
setSubagents(((await res.json()) as Subagent[]).sort(byRecency));
setError(null);
}
useRefreshInterval(REFRESH_MS, () => {
refresh().catch((e: unknown) => setError({ detail: String(e) }));
});
return { subagents, error };
}

View file

@ -14,7 +14,6 @@ import type { RouteComponentProps } from "wouter-preact";
import { Badge } from "@hive/shared/badge.js";
import { AgentDetailPopover } from "../../agent/detail/AgentDetailPopover.js";
import { AgentIcon } from "../../agent/AgentIcon.js";
import { SubagentsMenu } from "../../agent/terminal/SubagentsMenu.js";
import {
AgentTermPreview,
termPaths,
@ -64,18 +63,15 @@ export function AgentTerminalPage({
fullHeight
showHeaderBadges
leading={
<>
<span ref={badgeRef}>
<Badge
icon={<AgentIcon name={name} small />}
value={name}
title={`${detailOpen ? "close" : "open"} ${name}'s details`}
expanded={detailOpen}
onClick={() => setDetailOpen((o) => !o)}
/>
</span>
<SubagentsMenu key={name} agentName={name} current={subagent} />
</>
<span ref={badgeRef}>
<Badge
icon={<AgentIcon name={name} small />}
value={name}
title={`${detailOpen ? "close" : "open"} ${name}'s details`}
expanded={detailOpen}
onClick={() => setDetailOpen((o) => !o)}
/>
</span>
}
/>
<AgentDetailPopover

View file

@ -1,61 +0,0 @@
// What counts as a running subagent, and the menu badge's wording. Run by
// `npm test`, which Node runs with its built-in test runner and type
// stripping, so the `.ts` module is imported as it is.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
STALE_S,
byRecency,
isRunning,
runningLabel,
splitRunning,
} from "../src/agent/terminal/subagent-list.ts";
const NOW = 1_000_000;
const sub = (name, ago) => ({
name,
last_row_unix: ago === null ? null : NOW - ago,
});
test("a subagent with a recent row is running, a quiet one is not", () => {
assert.equal(isRunning(sub("a", 5), NOW), true);
assert.equal(isRunning(sub("a", STALE_S), NOW), true);
assert.equal(isRunning(sub("a", STALE_S + 1), NOW), false);
});
test("a subagent whose time could not be read is not counted as running", () => {
assert.equal(isRunning(sub("a", null), NOW), false);
});
test("newest first, unreadable last, ties by name", () => {
const list = [sub("c", 50), sub("z", null), sub("b", 10), sub("a", 10)];
assert.deepEqual(
[...list].sort(byRecency).map((s) => s.name),
["a", "b", "c", "z"],
);
});
test("the list splits into running and quiet, each newest first", () => {
const { running, quiet } = splitRunning(
[
sub("old", STALE_S + 100),
sub("new", 1),
sub("mid", 60),
sub("none", null),
],
NOW,
);
assert.deepEqual(
running.map((s) => s.name),
["new", "mid"],
);
assert.deepEqual(
quiet.map((s) => s.name),
["old", "none"],
);
});
test("the badge counts in words", () => {
assert.equal(runningLabel(1), "1 running subagent");
assert.equal(runningLabel(4), "4 running subagents");
});