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:
parent
81439fa710
commit
214d7000d3
8 changed files with 119 additions and 20 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<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 (
|
||||
<div class="ui-agent-input">
|
||||
<form class="ui-agent-input-form" onSubmit={submit}>
|
||||
|
|
@ -60,8 +85,20 @@ export function AgentInputForm({ agentName }: { agentName: string }) {
|
|||
>
|
||||
{result.status === "submitting" ? "sending…" : "send"}
|
||||
</Button>
|
||||
{canCancel(turnState) && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={cancel}
|
||||
disabled={result.status === "submitting"}
|
||||
>
|
||||
cancel turn
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
{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" && (
|
||||
<ApiErrorPanel context="failed to send" problem={result.problem} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<>
|
||||
<Badge
|
||||
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
|
||||
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 && (
|
||||
<Badge
|
||||
label="ctx"
|
||||
|
|
@ -296,7 +310,9 @@ export function AgentTermPreview({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{subagent === undefined && <AgentInputForm agentName={agentName} />}
|
||||
{subagent === undefined && (
|
||||
<AgentInputForm agentName={agentName} turnState={header?.turn_state} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
18
frontend/packages/swarm-ui/src/agent/terminal/cancel.ts
Normal file
18
frontend/packages/swarm-ui/src/agent/terminal/cancel.ts
Normal 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";
|
||||
}
|
||||
|
|
@ -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 }) {
|
|||
<div class="shell-header-actions">
|
||||
<SettingsMenu themeKey={THEME_KEY} motionKey={MOTION_KEY}>
|
||||
<HideDebugSetting />
|
||||
<ExpandDetailsSetting />
|
||||
</SettingsMenu>
|
||||
<LinksMenu />
|
||||
<UserMenu />
|
||||
|
|
|
|||
25
frontend/packages/swarm-ui/test/cancel.test.mjs
Normal file
25
frontend/packages/swarm-ui/test/cancel.test.mjs
Normal 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");
|
||||
});
|
||||
Loading…
Reference in a new issue