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
3 changed files with 84 additions and 44 deletions

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"}
/>
);
}