Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: cancel turn is a badge over the terminal, not a button by send

This commit is contained in:
iris 2026-10-28 20:12:11 +01:00
commit 2f2b4aa9e2
4 changed files with 84 additions and 44 deletions

View file

@ -360,9 +360,11 @@ 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.
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.
While the agent is in a turn, a **cancel turn** badge appears among the state
badges over the terminal, in its own tab. It asks the agent's harness to stop
the turn, then shows whether one was running ("turn cancelled" or "nothing was
running"); a failure shows "cancel failed" with the reason on hover, and a click
tries again. Subagents have no message box and no cancel badge.
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

View file

@ -8,25 +8,15 @@ 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,
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;
}) {
export function AgentInputForm({ agentName }: { agentName: string }) {
const [body, setBody] = useState("");
const [result, setResult] = useState<SubmitState>({ status: "idle" });
@ -53,21 +43,6 @@ export function AgentInputForm({
}
}
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}>
@ -85,20 +60,8 @@ export function AgentInputForm({
>
{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} />
)}

View file

@ -48,6 +48,7 @@ import {
} from "./useSwarmAgentStateStream.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import { AgentInputForm } from "./AgentInputForm.js";
import { CancelTurnBadge } from "./CancelTurnBadge.js";
import "./AgentTermPreview.css";
const NEAR_BOTTOM_PX = 48;
@ -277,6 +278,12 @@ export function AgentTermPreview({
/>
)}
{header && <StateBadges header={header} ctx={ctx} cost={cost} />}
{header && subagent === undefined && (
<CancelTurnBadge
agentName={agentName}
turnState={header.turn_state}
/>
)}
</div>
{!fullHeight && (
<Badge
@ -310,9 +317,7 @@ export function AgentTermPreview({
)}
</div>
</div>
{subagent === undefined && (
<AgentInputForm agentName={agentName} turnState={header?.turn_state} />
)}
{subagent === undefined && <AgentInputForm agentName={agentName} />}
</div>
);
}

View file

@ -0,0 +1,70 @@
// <CancelTurnBadge> — "cancel turn" among the state badges over the terminal.
// Shown only while the agent is in a turn (`canCancel`). POSTs
// `/api/agents/{name}/cancel`; the answer says whether a turn was running, and
// the badge shows that (or the failure) in place of its label until the next
// click, so a cancel is never silently a no-op.
import { useState } from "preact/hooks";
import { Badge } from "@hive/shared/badge.js";
import { readApiError } from "@hive/shared/api-error.js";
import { canCancel, cancelNote, cancelPath } from "./cancel.js";
type CancelState =
| { status: "idle" }
| { status: "cancelling" }
| { status: "done"; acted: boolean }
| { status: "error"; detail: string };
export function CancelTurnBadge({
agentName,
turnState,
}: {
agentName: string;
turnState: string;
}) {
const [state, setState] = useState<CancelState>({ status: "idle" });
async function cancel() {
setState({ status: "cancelling" });
try {
const r = await fetch(cancelPath(agentName), { method: "POST" });
if (!r.ok) {
const p = await readApiError(r);
setState({
status: "error",
detail: p.detail ?? p.title ?? `status ${p.status ?? r.status}`,
});
return;
}
const { acted } = (await r.json()) as { acted: boolean };
setState({ status: "done", acted });
} catch (err) {
setState({ status: "error", detail: String(err) });
}
}
// The answer outlives the turn it stopped: once the agent leaves
// `thinking`, the note is the only thing worth keeping on screen.
if (state.status === "done") {
return <Badge variant="quiet" value={cancelNote(state.acted)} />;
}
if (state.status === "error") {
return (
<Badge
tone="negative"
value="cancel failed"
title={state.detail}
onClick={cancel}
/>
);
}
if (!canCancel(turnState)) return null;
return (
<Badge
variant="quiet"
value={state.status === "cancelling" ? "cancelling…" : "cancel turn"}
title="stop the agent's current turn"
onClick={state.status === "cancelling" ? undefined : cancel}
disabled={state.status === "cancelling"}
/>
);
}