Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: the cancel badge clears when the next turn starts

This commit is contained in:
iris 2026-10-28 20:16:11 +01:00
commit 8060e91939
3 changed files with 65 additions and 6 deletions

View file

@ -1,12 +1,19 @@
// <CancelTurnBadge> — "cancel turn" among the state badges over the terminal. // <CancelTurnBadge> — "cancel turn" among the state badges over the terminal.
// Shown only while the agent is in a turn (`canCancel`). POSTs // Shown only while the agent is in a turn (`canCancel`). POSTs
// `/api/agents/{name}/cancel`; the answer says whether a turn was running, and // `/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 // the badge shows that (or the failure) in place of its label, so a cancel is
// click, so a cancel is never silently a no-op. // never silently a no-op. The answer clears when the next turn starts
import { useState } from "preact/hooks"; // (`statusOnTurnChange`), so every turn can be cancelled; a failure can also be
// retried by clicking it.
import { useEffect, useRef, useState } from "preact/hooks";
import { Badge } from "@hive/shared/badge.js"; import { Badge } from "@hive/shared/badge.js";
import { readApiError } from "@hive/shared/api-error.js"; import { readApiError } from "@hive/shared/api-error.js";
import { canCancel, cancelNote, cancelPath } from "./cancel.js"; import {
canCancel,
cancelNote,
cancelPath,
statusOnTurnChange,
} from "./cancel.js";
type CancelState = type CancelState =
| { status: "idle" } | { status: "idle" }
@ -22,6 +29,13 @@ export function CancelTurnBadge({
turnState: string; turnState: string;
}) { }) {
const [state, setState] = useState<CancelState>({ status: "idle" }); const [state, setState] = useState<CancelState>({ status: "idle" });
const prevTurn = useRef<string | undefined>(undefined);
useEffect(() => {
const next = statusOnTurnChange(state.status, prevTurn.current, turnState);
prevTurn.current = turnState;
if (next !== state.status) setState({ status: next } as CancelState);
}, [turnState]);
async function cancel() { async function cancel() {
setState({ status: "cancelling" }); setState({ status: "cancelling" });
@ -42,8 +56,6 @@ export function CancelTurnBadge({
} }
} }
// 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") { if (state.status === "done") {
return <Badge variant="quiet" value={cancelNote(state.acted)} />; return <Badge variant="quiet" value={cancelNote(state.acted)} />;
} }

View file

@ -16,3 +16,26 @@ export function cancelPath(agentName: string): string {
export function cancelNote(acted: boolean): string { export function cancelNote(acted: boolean): string {
return acted ? "turn cancelled" : "nothing was running"; return acted ? "turn cancelled" : "nothing was running";
} }
/** What the cancel badge is showing. */
export type CancelStatus = "idle" | "cancelling" | "done" | "error";
/**
* The badge's status once the agent's turn state moves from `prev` to `now`.
* The answer ("turn cancelled") stays up after the turn it stopped ends, and
* clears when the next turn starts, so that turn can be cancelled too. A
* failure clears as soon as its turn ends: with no turn running, there is
* nothing left to retry.
*/
export function statusOnTurnChange(
status: CancelStatus,
prev: string | undefined,
now: string | undefined,
): CancelStatus {
if (status === "cancelling") return status;
const wasIn = canCancel(prev);
const isIn = canCancel(now);
if (isIn && !wasIn) return "idle";
if (!isIn && wasIn && status === "error") return "idle";
return status;
}

View file

@ -5,6 +5,7 @@ import {
canCancel, canCancel,
cancelNote, cancelNote,
cancelPath, cancelPath,
statusOnTurnChange,
} from "../src/agent/terminal/cancel.ts"; } from "../src/agent/terminal/cancel.ts";
test("cancel is offered while the agent is in a turn, and only then", () => { test("cancel is offered while the agent is in a turn, and only then", () => {
@ -23,3 +24,26 @@ test("the note says whether a turn was stopped", () => {
assert.equal(cancelNote(true), "turn cancelled"); assert.equal(cancelNote(true), "turn cancelled");
assert.equal(cancelNote(false), "nothing was running"); assert.equal(cancelNote(false), "nothing was running");
}); });
test("the answer stays after its turn ends and clears when the next starts", () => {
assert.equal(statusOnTurnChange("done", "thinking", "idle"), "done");
assert.equal(statusOnTurnChange("done", "idle", "idle"), "done");
assert.equal(statusOnTurnChange("done", "idle", "thinking"), "idle");
});
test("a failure clears when its turn ends, or a new one starts", () => {
assert.equal(statusOnTurnChange("error", "thinking", "thinking"), "error");
assert.equal(statusOnTurnChange("error", "thinking", "idle"), "idle");
assert.equal(statusOnTurnChange("error", "idle", "thinking"), "idle");
});
test("a cancel in flight is left alone", () => {
assert.equal(
statusOnTurnChange("cancelling", "thinking", "idle"),
"cancelling",
);
assert.equal(
statusOnTurnChange("cancelling", "idle", "thinking"),
"cancelling",
);
});