swarm-ui: cancel turn is a badge over the terminal, not a button by send
This commit is contained in:
parent
214d7000d3
commit
2f2b4aa9e2
4 changed files with 84 additions and 44 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue