Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef507b8912 | ||
|
|
664409c87f |
8 changed files with 365 additions and 46 deletions
|
|
@ -39,6 +39,15 @@ 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
|
does for the agent. The list refreshes every 10 seconds, so a subagent the
|
||||||
agent spawns shows up once it writes output.
|
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
|
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
|
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.
|
terminal shows rows published while it's open, and has no turn-state badges.
|
||||||
|
|
|
||||||
|
|
@ -3,57 +3,24 @@
|
||||||
// the subagents the swarm queue holds terminal rows for, each with the
|
// 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
|
// 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
|
// 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
|
// demand, so `useSubagentList` refetches it. Picking one shows its
|
||||||
// terminal in `AgentTermPreview`, expand-to-tab included. Read-only like the
|
// terminal in `AgentTermPreview`, expand-to-tab included. Read-only like the
|
||||||
// agent's own: subagents take no input.
|
// agent's own: subagents take no input.
|
||||||
//
|
//
|
||||||
// Mount it keyed by agent name: the refresh timer is armed once per mount.
|
// Mount it keyed by agent name: the refresh timer is armed once per mount.
|
||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
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 { Badge } from "@hive/shared/badge.js";
|
||||||
import { AgentTermPreview } from "./AgentTermPreview.js";
|
import { AgentTermPreview } from "./AgentTermPreview.js";
|
||||||
import { useRefreshInterval } from "../../ui/refresh-interval/RefreshInterval.js";
|
import { STALE_S } from "./subagent-list.js";
|
||||||
|
import { useSubagentList } from "./useSubagentList.js";
|
||||||
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
||||||
import "./SubagentTerms.css";
|
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 }) {
|
export function SubagentTerms({ agentName }: { agentName: string }) {
|
||||||
const [subagents, setSubagents] = useState<Subagent[] | null>(null);
|
const { subagents, error } = useSubagentList(agentName);
|
||||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
|
||||||
const [selected, setSelected] = useState<string | 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
|
// 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
|
// "none" line — and so does the first fetch while it is in flight. A
|
||||||
// failed fetch still shows, so the error is not swallowed.
|
// failed fetch still shows, so the error is not swallowed.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
/* <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);
|
||||||
|
}
|
||||||
145
frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.tsx
Normal file
145
frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
// <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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
// 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`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import type { RouteComponentProps } from "wouter-preact";
|
||||||
import { Badge } from "@hive/shared/badge.js";
|
import { Badge } from "@hive/shared/badge.js";
|
||||||
import { AgentDetailPopover } from "../../agent/detail/AgentDetailPopover.js";
|
import { AgentDetailPopover } from "../../agent/detail/AgentDetailPopover.js";
|
||||||
import { AgentIcon } from "../../agent/AgentIcon.js";
|
import { AgentIcon } from "../../agent/AgentIcon.js";
|
||||||
|
import { SubagentsMenu } from "../../agent/terminal/SubagentsMenu.js";
|
||||||
import {
|
import {
|
||||||
AgentTermPreview,
|
AgentTermPreview,
|
||||||
termPaths,
|
termPaths,
|
||||||
|
|
@ -63,6 +64,7 @@ export function AgentTerminalPage({
|
||||||
fullHeight
|
fullHeight
|
||||||
showHeaderBadges
|
showHeaderBadges
|
||||||
leading={
|
leading={
|
||||||
|
<>
|
||||||
<span ref={badgeRef}>
|
<span ref={badgeRef}>
|
||||||
<Badge
|
<Badge
|
||||||
icon={<AgentIcon name={name} small />}
|
icon={<AgentIcon name={name} small />}
|
||||||
|
|
@ -72,6 +74,8 @@ export function AgentTerminalPage({
|
||||||
onClick={() => setDetailOpen((o) => !o)}
|
onClick={() => setDetailOpen((o) => !o)}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
<SubagentsMenu key={name} agentName={name} current={subagent} />
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<AgentDetailPopover
|
<AgentDetailPopover
|
||||||
|
|
|
||||||
61
frontend/packages/swarm-ui/test/subagent-list.test.mjs
Normal file
61
frontend/packages/swarm-ui/test/subagent-list.test.mjs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// 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");
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue