diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 56d1a901..e4ccf214 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -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 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 -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//terminal`, or a subagent's) the 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 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 different agents tell apart. A subagent's tab shows its agent's icon. An agent 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 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. -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 message history doesn't list it. The agent's answer shows in its terminal; an diff --git a/frontend/packages/agent/src/Root.tsx b/frontend/packages/agent/src/Root.tsx index 831e97ec..87fb0881 100644 --- a/frontend/packages/agent/src/Root.tsx +++ b/frontend/packages/agent/src/Root.tsx @@ -12,7 +12,7 @@ import { useApplyMotionOverride } from "@hive/shared/motion-apply.js"; import { Header } from "./components/Header.js"; import { MetaNav } from "./components/MetaNav.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 { StatusChips } from "./components/StatusChips.js"; import { LiveStream, type LiveStreamHandle } from "./components/LiveStream.js"; diff --git a/frontend/packages/shared/package.json b/frontend/packages/shared/package.json index 03a31dac..6a213ff1 100644 --- a/frontend/packages/shared/package.json +++ b/frontend/packages/shared/package.json @@ -53,6 +53,7 @@ "./motion-apply.js": "./src/settings/motion-apply.ts", "./settings-menu.js": "./src/settings/SettingsMenu.tsx", "./hide-debug-setting.js": "./src/settings/HideDebugSetting.tsx", + "./expand-details-setting.js": "./src/settings/ExpandDetailsSetting.tsx", "./settings-menu.css": "./src/settings/SettingsMenu.css", "./user-menu.js": "./src/user-menu/UserMenu.tsx", "./user-menu.css": "./src/user-menu/UserMenu.css" diff --git a/frontend/packages/agent/src/components/ExpandDetailsSetting.tsx b/frontend/packages/shared/src/settings/ExpandDetailsSetting.tsx similarity index 52% rename from frontend/packages/agent/src/components/ExpandDetailsSetting.tsx rename to frontend/packages/shared/src/settings/ExpandDetailsSetting.tsx index 0aed6927..859e1a10 100644 --- a/frontend/packages/agent/src/components/ExpandDetailsSetting.tsx +++ b/frontend/packages/shared/src/settings/ExpandDetailsSetting.tsx @@ -1,18 +1,10 @@ -// Agent-terminal-only settings-menu row: "expand tool output panels". -// Rendered as a `` child (`@hive/shared/settings-menu.js`) -// rather than a prop on that shared component — `SettingsMenu` is also -// used by swarm-ui, which has no terminal to apply this to, and a prior -// attempt to gate it behind a boolean prop (`showExpandDetails`) on the -// shared component drew mara's review: "you cannot just add it like -// this - if we have more and more options there in different places we -// will keep accumulating cruft in the shared component." This file owns -// its own state/storage entirely; `SettingsMenu` just renders it as a -// child, no different from any other consumer's markup. +// Settings-menu row: "expand tool output". Rendered as a `` +// 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 "@hive/shared/prefs.js"; +import { getExpandDetailsPref, setExpandDetailsPref } from "../prefs.js"; export function ExpandDetailsSetting() { // Plain localStorage read, not a hook-managed override like diff --git a/frontend/packages/swarm-ui/src/agent/terminal/AgentInputForm.tsx b/frontend/packages/swarm-ui/src/agent/terminal/AgentInputForm.tsx index 90284649..2a96fad5 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/AgentInputForm.tsx +++ b/frontend/packages/swarm-ui/src/agent/terminal/AgentInputForm.tsx @@ -8,15 +8,25 @@ import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { TextField } from "../../ui/text-field/TextField.js"; import { Button } from "../../ui/button/Button.js"; +import { canCancel, cancelNote, cancelPath } from "./cancel.js"; import "./AgentInputForm.css"; type SubmitState = | { status: "idle" } | { status: "submitting" } | { status: "sent" } + | { status: "cancelled"; acted: boolean } | { 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 [result, setResult] = useState({ 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 (
@@ -60,8 +85,20 @@ export function AgentInputForm({ agentName }: { agentName: string }) { > {result.status === "submitting" ? "sending…" : "send"} + {canCancel(turnState) && ( + + )}
{result.status === "sent" &&

sent

} + {result.status === "cancelled" && ( +

{cancelNote(result.acted)}

+ )} {result.status === "error" && ( )} diff --git a/frontend/packages/swarm-ui/src/agent/terminal/AgentTermPreview.tsx b/frontend/packages/swarm-ui/src/agent/terminal/AgentTermPreview.tsx index 96cf645d..ad0cd60e 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/AgentTermPreview.tsx +++ b/frontend/packages/swarm-ui/src/agent/terminal/AgentTermPreview.tsx @@ -46,6 +46,7 @@ import { useSwarmAgentStateStream, type TokenUsage, } from "./useSwarmAgentStateStream.js"; +import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import { AgentInputForm } from "./AgentInputForm.js"; import "./AgentTermPreview.css"; @@ -145,7 +146,13 @@ export function StateBadges({ <> + {header.turn_state} ( + ) + + } + title="how long the agent has been in this state" /> {/* Only the non-boring case gets a badge — a healthy running agent (`agent_state: "up"`) doesn't need one for the @@ -168,6 +175,13 @@ export function StateBadges({ } /> )} + {withModel && ( + + )} {ctx !== null && (
- {subagent === undefined && } + {subagent === undefined && ( + + )} ); } diff --git a/frontend/packages/swarm-ui/src/agent/terminal/cancel.ts b/frontend/packages/swarm-ui/src/agent/terminal/cancel.ts new file mode 100644 index 00000000..fef11a3e --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/terminal/cancel.ts @@ -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"; +} diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index 9966834f..4d59b5d8 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -34,6 +34,7 @@ import { UserMenu } from "./UserMenu.js"; import { useHideDebug } from "./useHideDebug.js"; import { SettingsMenu } from "@hive/shared/settings-menu.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 { useApplyMotionOverride } from "@hive/shared/motion-apply.js"; import { isFullBleedRoute, visibleNavItems } from "./routes.js"; @@ -422,6 +423,7 @@ export function Shell({ children }: { children: ComponentChildren }) {
+ diff --git a/frontend/packages/swarm-ui/test/cancel.test.mjs b/frontend/packages/swarm-ui/test/cancel.test.mjs new file mode 100644 index 00000000..9dd0be10 --- /dev/null +++ b/frontend/packages/swarm-ui/test/cancel.test.mjs @@ -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"); +});