diff --git a/frontend/packages/swarm-ui/src/agent/terminal/CancelTurnBadge.tsx b/frontend/packages/swarm-ui/src/agent/terminal/CancelTurnBadge.tsx index 1fcf0d85..4171654e 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/CancelTurnBadge.tsx +++ b/frontend/packages/swarm-ui/src/agent/terminal/CancelTurnBadge.tsx @@ -1,12 +1,19 @@ // — "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"; +// the badge shows that (or the failure) in place of its label, so a cancel is +// never silently a no-op. The answer clears when the next turn starts +// (`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 { readApiError } from "@hive/shared/api-error.js"; -import { canCancel, cancelNote, cancelPath } from "./cancel.js"; +import { + canCancel, + cancelNote, + cancelPath, + statusOnTurnChange, +} from "./cancel.js"; type CancelState = | { status: "idle" } @@ -22,6 +29,13 @@ export function CancelTurnBadge({ turnState: string; }) { const [state, setState] = useState({ status: "idle" }); + const prevTurn = useRef(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() { 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") { return ; } diff --git a/frontend/packages/swarm-ui/src/agent/terminal/cancel.ts b/frontend/packages/swarm-ui/src/agent/terminal/cancel.ts index fef11a3e..6c25f2dd 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/cancel.ts +++ b/frontend/packages/swarm-ui/src/agent/terminal/cancel.ts @@ -16,3 +16,26 @@ export function cancelPath(agentName: string): string { export function cancelNote(acted: boolean): string { 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; +} diff --git a/frontend/packages/swarm-ui/test/cancel.test.mjs b/frontend/packages/swarm-ui/test/cancel.test.mjs index 9dd0be10..41caebc4 100644 --- a/frontend/packages/swarm-ui/test/cancel.test.mjs +++ b/frontend/packages/swarm-ui/test/cancel.test.mjs @@ -5,6 +5,7 @@ import { canCancel, cancelNote, cancelPath, + statusOnTurnChange, } from "../src/agent/terminal/cancel.ts"; 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(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", + ); +});