diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index e4ccf214..934a8153 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -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 diff --git a/frontend/packages/swarm-ui/src/agent/terminal/AgentInputForm.tsx b/frontend/packages/swarm-ui/src/agent/terminal/AgentInputForm.tsx index 2a96fad5..90284649 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/AgentInputForm.tsx +++ b/frontend/packages/swarm-ui/src/agent/terminal/AgentInputForm.tsx @@ -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({ 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 (
@@ -85,20 +60,8 @@ export function AgentInputForm({ > {result.status === "submitting" ? "sending…" : "send"} - {canCancel(turnState) && ( - - )}
{result.status === "sent" &&

sent

} - {result.status === "cancelled" && ( -

{cancelNote(result.acted)}

- )} {result.status === "error" && ( )} diff --git a/frontend/packages/swarm-ui/src/agent/terminal/AgentTermPreview.tsx b/frontend/packages/swarm-ui/src/agent/terminal/AgentTermPreview.tsx index ad0cd60e..2b725410 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/AgentTermPreview.tsx +++ b/frontend/packages/swarm-ui/src/agent/terminal/AgentTermPreview.tsx @@ -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 && } + {header && subagent === undefined && ( + + )}
{!fullHeight && ( - {subagent === undefined && ( - - )} + {subagent === undefined && } ); } diff --git a/frontend/packages/swarm-ui/src/agent/terminal/CancelTurnBadge.tsx b/frontend/packages/swarm-ui/src/agent/terminal/CancelTurnBadge.tsx new file mode 100644 index 00000000..1fcf0d85 --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/terminal/CancelTurnBadge.tsx @@ -0,0 +1,70 @@ +// — "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({ 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 ; + } + if (state.status === "error") { + return ( + + ); + } + if (!canCancel(turnState)) return null; + return ( + + ); +}