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 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.
While the agent is in a turn, a **cancel turn** button sits beside **send**. It While the agent is in a turn, a **cancel turn** badge appears among the state
asks the agent's harness to stop the turn and says whether one was running badges over the terminal, in its own tab. It asks the agent's harness to stop
("turn cancelled" or "nothing was running"). Subagents have no message box. 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 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

@ -8,25 +8,15 @@ 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({ export function AgentInputForm({ agentName }: { agentName: string }) {
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" });
@ -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 ( 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}>
@ -85,20 +60,8 @@ export function AgentInputForm({
> >
{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

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