Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: the agent terminal page gets cancel turn, effort, state age and the expand-output setting

The per-agent page had these and the swarm terminal did not; they need no new
backend. A cancel turn button beside send, shown while the agent is in a turn,
posts to the controller's existing cancel route and says whether a turn was
stopped. The terminal's badges add the effort and how long the agent has been in
its turn state. The expand tool output setting moves to the shared package so
both pages render the one component, and the swarm menu gets it. Docs follow.
This commit is contained in:
iris 2026-10-28 20:07:42 +01:00
commit 214d7000d3
9 changed files with 119 additions and 20 deletions

View file

@ -71,7 +71,9 @@ badge for a paused agent, and the tokens in its context and billed. The row
reads "no report yet" until the first report arrives, and for an agent that's reads "no report yet" until the first report arrives, and for an agent that's
offline. The agent sends a report when its state changes and once a minute. If offline. The agent sends a report when its state changes and once a minute. If
the browser gives up on the stream, a **reconnect** badge opens it again. The the browser gives up on the stream, a **reconnect** badge opens it again. The
terminal's own tab keeps these badges, with the model, beside the connection badge. terminal's own tab keeps these badges, with the model and the effort, beside the
connection badge; the turn-state badge says how long the agent has been in that
state.
On a terminal's own page (`/agents/<name>/terminal`, or a subagent's) the 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 header has an **N running subagents** badge. It opens a list of the agent's
@ -97,6 +99,10 @@ you flip it. It also hides the **components** tab, a page for developers, which
still answers at `/components`. The browser keeps the choice for the swarm UI, apart from the still answers at `/components`. The browser keeps the choice for the swarm UI, apart from the
per-agent pages'. per-agent pages'.
The menu also has an **expand tool output** toggle. It opens tool-output rows
expanded; rows already on screen keep their state, and rows that arrive after
you flip it follow it.
A terminal's own tab takes the agent's icon as its browser-tab icon, so tabs of A terminal's own tab takes the agent's icon as its browser-tab icon, so tabs of
different agents tell apart. A subagent's tab shows its agent's icon. An agent different agents tell apart. A subagent's tab shows its agent's icon. An agent
with no icon keeps the hyperhive mark. with no icon keeps the hyperhive mark.
@ -354,7 +360,9 @@ box. **send** hands the text to the agent's harness, and the agent takes it as
an inbox message from your authelia username. **sent** means the harness was an inbox message from your authelia username. **sent** means the harness was
listening and holds the message. If the harness isn't running, or isn't listening and holds the message. If the harness isn't running, or isn't
connected to the swarm queue, the box shows an error and the agent gets nothing. connected to the swarm queue, the box shows an error and the agent gets nothing.
Subagents have no message box. While the agent is in a turn, a **cancel turn** button sits beside **send**. It
asks the agent's harness to stop the turn and says whether one was running
("turn cancelled" or "nothing was running"). Subagents have no message box.
The message doesn't go through the agent's hive, so the hive dashboard's The message doesn't go through the agent's hive, so the hive dashboard's
message history doesn't list it. The agent's answer shows in its terminal; an message history doesn't list it. The agent's answer shows in its terminal; an

View file

@ -12,7 +12,7 @@ import { useApplyMotionOverride } from "@hive/shared/motion-apply.js";
import { Header } from "./components/Header.js"; import { Header } from "./components/Header.js";
import { MetaNav } from "./components/MetaNav.js"; import { MetaNav } from "./components/MetaNav.js";
import { AgentUserMenu } from "./components/AgentUserMenu.js"; import { AgentUserMenu } from "./components/AgentUserMenu.js";
import { ExpandDetailsSetting } from "./components/ExpandDetailsSetting.js"; import { ExpandDetailsSetting } from "@hive/shared/expand-details-setting.js";
import { HideDebugSetting } from "@hive/shared/hide-debug-setting.js"; import { HideDebugSetting } from "@hive/shared/hide-debug-setting.js";
import { StatusChips } from "./components/StatusChips.js"; import { StatusChips } from "./components/StatusChips.js";
import { LiveStream, type LiveStreamHandle } from "./components/LiveStream.js"; import { LiveStream, type LiveStreamHandle } from "./components/LiveStream.js";

View file

@ -53,6 +53,7 @@
"./motion-apply.js": "./src/settings/motion-apply.ts", "./motion-apply.js": "./src/settings/motion-apply.ts",
"./settings-menu.js": "./src/settings/SettingsMenu.tsx", "./settings-menu.js": "./src/settings/SettingsMenu.tsx",
"./hide-debug-setting.js": "./src/settings/HideDebugSetting.tsx", "./hide-debug-setting.js": "./src/settings/HideDebugSetting.tsx",
"./expand-details-setting.js": "./src/settings/ExpandDetailsSetting.tsx",
"./settings-menu.css": "./src/settings/SettingsMenu.css", "./settings-menu.css": "./src/settings/SettingsMenu.css",
"./user-menu.js": "./src/user-menu/UserMenu.tsx", "./user-menu.js": "./src/user-menu/UserMenu.tsx",
"./user-menu.css": "./src/user-menu/UserMenu.css" "./user-menu.css": "./src/user-menu/UserMenu.css"

View file

@ -0,0 +1,32 @@
// Settings-menu row: "expand tool output". Rendered as a `<SettingsMenu>`
// child (`./SettingsMenu.js`) by each page that has a terminal, the per-agent
// page and the swarm UI, rather than as a prop on the menu: the caller owns its
// row, and the menu stays free of options only some pages use. Both read the
// same preference (`../prefs.js`), which `../terminal/Row.tsx` follows live.
import { useState } from "preact/hooks";
import { getExpandDetailsPref, setExpandDetailsPref } from "../prefs.js";
export function ExpandDetailsSetting() {
// Plain localStorage read, not a hook-managed override like
// theme/motion — `getExpandDetailsPref`/`setExpandDetailsPref`
// (`@hive/shared/prefs.js`) are the existing shared-storage-key pair
// the terminal itself already reads live (no round-trip needed here
// beyond re-rendering this row's own checkbox state on toggle).
const [expandDetails, setExpandDetailsState] = useState(() =>
getExpandDetailsPref(),
);
return (
<label class="settings-menu-row">
<span>expand tool output</span>
<input
type="checkbox"
checked={expandDetails}
onChange={(e) => {
const v = (e.target as HTMLInputElement).checked;
setExpandDetailsPref(v);
setExpandDetailsState(v);
}}
/>
</label>
);
}

View file

@ -8,15 +8,25 @@ import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { TextField } from "../../ui/text-field/TextField.js"; import { TextField } from "../../ui/text-field/TextField.js";
import { Button } from "../../ui/button/Button.js"; import { Button } from "../../ui/button/Button.js";
import { canCancel, cancelNote, cancelPath } from "./cancel.js";
import "./AgentInputForm.css"; import "./AgentInputForm.css";
type SubmitState = type SubmitState =
| { status: "idle" } | { status: "idle" }
| { status: "submitting" } | { status: "submitting" }
| { status: "sent" } | { status: "sent" }
| { status: "cancelled"; acted: boolean }
| { status: "error"; problem: ProblemDetails }; | { status: "error"; problem: ProblemDetails };
export function AgentInputForm({ agentName }: { agentName: string }) { export function AgentInputForm({
agentName,
turnState,
}: {
agentName: string;
// The agent's turn state when the page has it; the cancel button shows only
// while the agent is in a turn.
turnState?: string;
}) {
const [body, setBody] = useState(""); const [body, setBody] = useState("");
const [result, setResult] = useState<SubmitState>({ status: "idle" }); const [result, setResult] = useState<SubmitState>({ status: "idle" });
@ -43,6 +53,21 @@ export function AgentInputForm({ agentName }: { agentName: string }) {
} }
} }
async function cancel() {
setResult({ status: "submitting" });
try {
const r = await fetch(cancelPath(agentName), { method: "POST" });
if (!r.ok) {
setResult({ status: "error", problem: await readApiError(r) });
return;
}
const { acted } = (await r.json()) as { acted: boolean };
setResult({ status: "cancelled", acted });
} catch (err) {
setResult({ status: "error", problem: { detail: String(err) } });
}
}
return ( return (
<div class="ui-agent-input"> <div class="ui-agent-input">
<form class="ui-agent-input-form" onSubmit={submit}> <form class="ui-agent-input-form" onSubmit={submit}>
@ -60,8 +85,20 @@ export function AgentInputForm({ agentName }: { agentName: string }) {
> >
{result.status === "submitting" ? "sending…" : "send"} {result.status === "submitting" ? "sending…" : "send"}
</Button> </Button>
{canCancel(turnState) && (
<Button
type="button"
onClick={cancel}
disabled={result.status === "submitting"}
>
cancel turn
</Button>
)}
</form> </form>
{result.status === "sent" && <p class="ui-agent-input-sent">sent</p>} {result.status === "sent" && <p class="ui-agent-input-sent">sent</p>}
{result.status === "cancelled" && (
<p class="ui-agent-input-sent">{cancelNote(result.acted)}</p>
)}
{result.status === "error" && ( {result.status === "error" && (
<ApiErrorPanel context="failed to send" problem={result.problem} /> <ApiErrorPanel context="failed to send" problem={result.problem} />
)} )}

View file

@ -46,6 +46,7 @@ import {
useSwarmAgentStateStream, useSwarmAgentStateStream,
type TokenUsage, type TokenUsage,
} from "./useSwarmAgentStateStream.js"; } from "./useSwarmAgentStateStream.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import { AgentInputForm } from "./AgentInputForm.js"; import { AgentInputForm } from "./AgentInputForm.js";
import "./AgentTermPreview.css"; import "./AgentTermPreview.css";
@ -145,7 +146,13 @@ export function StateBadges({
<> <>
<Badge <Badge
tone={TURN_STATE_TONE[header.turn_state] ?? "neutral"} tone={TURN_STATE_TONE[header.turn_state] ?? "neutral"}
value={header.turn_state} value={
<>
{header.turn_state} (
<RelativeTime epochMs={Date.parse(header.turn_state_since)} />)
</>
}
title="how long the agent has been in this state"
/> />
{/* Only the non-boring case gets a badge — a healthy running {/* Only the non-boring case gets a badge — a healthy running
agent (`agent_state: "up"`) doesn't need one for the agent (`agent_state: "up"`) doesn't need one for the
@ -168,6 +175,13 @@ export function StateBadges({
} }
/> />
)} )}
{withModel && (
<Badge
label="effort"
value={header.effort}
title={`set by: ${header.effort_source}`}
/>
)}
{ctx !== null && ( {ctx !== null && (
<Badge <Badge
label="ctx" label="ctx"
@ -296,7 +310,9 @@ export function AgentTermPreview({
)} )}
</div> </div>
</div> </div>
{subagent === undefined && <AgentInputForm agentName={agentName} />} {subagent === undefined && (
<AgentInputForm agentName={agentName} turnState={header?.turn_state} />
)}
</div> </div>
); );
} }

View file

@ -0,0 +1,18 @@
// What the terminal page decides about cancelling a turn, without a DOM. The
// controller answers `POST /api/agents/{name}/cancel` with whether a turn was
// running and was stopped (`acted`), so a cancel is never silently a no-op.
/** Whether to offer the cancel button: while the agent is in a turn. */
export function canCancel(turnState: string | undefined): boolean {
return turnState === "thinking";
}
/** The path a cancel is sent to. */
export function cancelPath(agentName: string): string {
return `/api/agents/${encodeURIComponent(agentName)}/cancel`;
}
/** What to tell the operator once the agent has answered. */
export function cancelNote(acted: boolean): string {
return acted ? "turn cancelled" : "nothing was running";
}

View file

@ -34,6 +34,7 @@ import { UserMenu } from "./UserMenu.js";
import { useHideDebug } from "./useHideDebug.js"; import { useHideDebug } from "./useHideDebug.js";
import { SettingsMenu } from "@hive/shared/settings-menu.js"; import { SettingsMenu } from "@hive/shared/settings-menu.js";
import { HideDebugSetting } from "@hive/shared/hide-debug-setting.js"; import { HideDebugSetting } from "@hive/shared/hide-debug-setting.js";
import { ExpandDetailsSetting } from "@hive/shared/expand-details-setting.js";
import { useApplyThemeOverride } from "@hive/shared/theme-apply.js"; import { useApplyThemeOverride } from "@hive/shared/theme-apply.js";
import { useApplyMotionOverride } from "@hive/shared/motion-apply.js"; import { useApplyMotionOverride } from "@hive/shared/motion-apply.js";
import { isFullBleedRoute, visibleNavItems } from "./routes.js"; import { isFullBleedRoute, visibleNavItems } from "./routes.js";
@ -422,6 +423,7 @@ export function Shell({ children }: { children: ComponentChildren }) {
<div class="shell-header-actions"> <div class="shell-header-actions">
<SettingsMenu themeKey={THEME_KEY} motionKey={MOTION_KEY}> <SettingsMenu themeKey={THEME_KEY} motionKey={MOTION_KEY}>
<HideDebugSetting /> <HideDebugSetting />
<ExpandDetailsSetting />
</SettingsMenu> </SettingsMenu>
<LinksMenu /> <LinksMenu />
<UserMenu /> <UserMenu />

View file

@ -0,0 +1,25 @@
// What the terminal page decides about cancelling a turn. Run by `npm test`.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
canCancel,
cancelNote,
cancelPath,
} from "../src/agent/terminal/cancel.ts";
test("cancel is offered while the agent is in a turn, and only then", () => {
assert.equal(canCancel("thinking"), true);
assert.equal(canCancel("idle"), false);
assert.equal(canCancel("compacting"), false);
assert.equal(canCancel(undefined), false);
});
test("the cancel path names the agent, escaped", () => {
assert.equal(cancelPath("iris"), "/api/agents/iris/cancel");
assert.equal(cancelPath("a/b"), "/api/agents/a%2Fb/cancel");
});
test("the note says whether a turn was stopped", () => {
assert.equal(cancelNote(true), "turn cancelled");
assert.equal(cancelNote(false), "nothing was running");
});