From 811b23cd6b25de24a19773be49c4b1ba91083bba Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 6 Nov 2026 18:47:34 +0100 Subject: [PATCH] swarm-ui: forms share one submit flow, result line and field column useSubmit holds the idle, sending, done and error state and the request flow that six forms each wrote out; FormResult shows the success line and the problem panel; FormStack is the column the link, claude-login, inference and limits forms each styled by hand. The create-agent, link account, backend-env and agent-input forms use the submit flow; the link, claude-login, inference and limits forms use FormStack. --- .../agent/claude-login/ClaudeLoginDialog.css | 10 +- .../agent/claude-login/ClaudeLoginDialog.tsx | 5 +- .../src/agent/detail/AgentInference.css | 7 - .../src/agent/detail/AgentInference.tsx | 5 +- .../swarm-ui/src/agent/detail/AgentLimits.css | 7 - .../swarm-ui/src/agent/detail/AgentLimits.tsx | 5 +- .../src/agent/detail/AllowedDomains.tsx | 5 +- .../src/agent/terminal/AgentInputForm.css | 1 - .../src/agent/terminal/AgentInputForm.tsx | 53 +++--- .../swarm-ui/src/pages/ComponentsPage.tsx | 36 ++++ .../swarm-ui/src/pages/CreateAgentForm.tsx | 100 +++++------ .../swarm-ui/src/pages/LinkBackendEnvForm.tsx | 98 +++++------ .../src/pages/LinkForgeAccountForm.tsx | 95 +++++------ .../src/pages/LinkGithubAccountForm.tsx | 80 ++++----- .../src/pages/LinkMatrixAccountForm.css | 17 -- .../src/pages/LinkMatrixAccountForm.tsx | 157 +++++++++--------- .../swarm-ui/src/ui/form-stack/FormStack.css | 12 ++ .../swarm-ui/src/ui/form-stack/FormStack.tsx | 26 +++ .../swarm-ui/src/ui/submit/FormResult.css | 3 + .../swarm-ui/src/ui/submit/FormResult.tsx | 32 ++++ .../packages/swarm-ui/src/ui/submit/submit.ts | 42 +++++ .../swarm-ui/src/ui/submit/useSubmit.ts | 40 +++++ .../packages/swarm-ui/test/submit.test.mjs | 58 +++++++ 23 files changed, 517 insertions(+), 377 deletions(-) delete mode 100644 frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.css create mode 100644 frontend/packages/swarm-ui/src/ui/form-stack/FormStack.css create mode 100644 frontend/packages/swarm-ui/src/ui/form-stack/FormStack.tsx create mode 100644 frontend/packages/swarm-ui/src/ui/submit/FormResult.css create mode 100644 frontend/packages/swarm-ui/src/ui/submit/FormResult.tsx create mode 100644 frontend/packages/swarm-ui/src/ui/submit/submit.ts create mode 100644 frontend/packages/swarm-ui/src/ui/submit/useSubmit.ts create mode 100644 frontend/packages/swarm-ui/test/submit.test.mjs diff --git a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.css b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.css index 40060f64..cae0a5c7 100644 --- a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.css +++ b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.css @@ -1,12 +1,4 @@ -/* — the code form's column, as - `LinkMatrixAccountForm.css` lays its form out. */ -.claude-login-form { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 0.75em; - margin-top: 1em; -} +/* */ .claude-login-ok { margin-block: 1em; } diff --git a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx index 3ce0167a..96264c84 100644 --- a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx +++ b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx @@ -10,6 +10,7 @@ import { useEffect, useState } from "preact/hooks"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Button } from "../../ui/button/Button.js"; +import { FormStack } from "../../ui/form-stack/FormStack.js"; import { Dialog } from "../../ui/dialog/Dialog.js"; import { ExternalLink } from "../../ui/external-link/ExternalLink.js"; import { Hint } from "../../ui/hint/Hint.js"; @@ -103,7 +104,7 @@ export function ClaudeLoginDialog({ the claude login page , sign in with this agent's account, and paste the code it shows.

- )} {step.step === "stored" && ( diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentInference.css b/frontend/packages/swarm-ui/src/agent/detail/AgentInference.css index 3b7490b8..73c78b0d 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentInference.css +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentInference.css @@ -20,10 +20,3 @@ .ui-agent-inference-source { color: var(--muted); } -.ui-agent-inference-form { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 0.75em; - margin-top: 1em; -} diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx index 063b2603..6eace45b 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx @@ -22,6 +22,7 @@ import { Panel } from "../../ui/panel/Panel.js"; import { SelectField } from "../../ui/select-field/SelectField.js"; import { TextField } from "../../ui/text-field/TextField.js"; import { Button } from "../../ui/button/Button.js"; +import { FormStack } from "../../ui/form-stack/FormStack.js"; import "../../ui/badge-row/BadgeRow.css"; import "./AgentInference.css"; import { MAX_SUBAGENTS, maxSubagentsValue } from "./max-subagents.js"; @@ -365,7 +366,7 @@ function InferenceForm({ started with, and subagents already running past a lowered cap keep running.

-
+ {problem ? ( @@ -385,7 +386,7 @@ function InferenceForm({ clear the swarm's setting - +
); } diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.css b/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.css index 6297ef5f..1a414525 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.css +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.css @@ -14,13 +14,6 @@ .ui-agent-limits-muted { color: var(--muted); } -.ui-agent-limits-form { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 0.5em; - margin-top: 1em; -} .ui-agent-limits-hint { margin: 0 0 0.5em; } diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx index 917501be..05d722d9 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx @@ -18,6 +18,7 @@ import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Badge } from "@hive/shared/badge.js"; import { ActionRow } from "../../ui/action-row/ActionRow.js"; import { Button } from "../../ui/button/Button.js"; +import { FormStack } from "../../ui/form-stack/FormStack.js"; import { Dialog } from "../../ui/dialog/Dialog.js"; import { Hint } from "../../ui/hint/Hint.js"; import { Panel } from "../../ui/panel/Panel.js"; @@ -231,7 +232,7 @@ function LimitsForm({ hive. The hive reads a change within about 30 seconds, and a running agent takes it at its next spawn or rebuild.

-
+ - +
); } diff --git a/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx b/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx index 9a14767e..c822c72b 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx @@ -27,6 +27,7 @@ import { Badge } from "@hive/shared/badge.js"; import { ActionRow } from "../../ui/action-row/ActionRow.js"; import { BadgePopover } from "../../ui/badge-popover/BadgePopover.js"; import { Button } from "../../ui/button/Button.js"; +import { FormStack } from "../../ui/form-stack/FormStack.js"; import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js"; import { Dialog } from "../../ui/dialog/Dialog.js"; import { Panel } from "../../ui/panel/Panel.js"; @@ -214,7 +215,7 @@ export function AddForm({ : "A name that resolves to a private address stays blocked. The entry adds to the names each hive's own configuration allows."}{" "} The hive reads a change within about 30 seconds.

-
void save(e)}> + void save(e)}> - +
); } diff --git a/frontend/packages/swarm-ui/src/agent/terminal/AgentInputForm.css b/frontend/packages/swarm-ui/src/agent/terminal/AgentInputForm.css index 0c8acb2b..6514f376 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/AgentInputForm.css +++ b/frontend/packages/swarm-ui/src/agent/terminal/AgentInputForm.css @@ -13,5 +13,4 @@ } .ui-agent-input-sent { margin-block-start: 0.25rem; - color: var(--green); } diff --git a/frontend/packages/swarm-ui/src/agent/terminal/AgentInputForm.tsx b/frontend/packages/swarm-ui/src/agent/terminal/AgentInputForm.tsx index 90284649..0b39639f 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/AgentInputForm.tsx +++ b/frontend/packages/swarm-ui/src/agent/terminal/AgentInputForm.tsx @@ -5,42 +5,27 @@ // sender from the gateway, not from this request). import { useState } from "preact/hooks"; 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 { Hint } from "../../ui/hint/Hint.js"; +import { useSubmit } from "../../ui/submit/useSubmit.js"; import "./AgentInputForm.css"; -type SubmitState = - | { status: "idle" } - | { status: "submitting" } - | { status: "sent" } - | { status: "error"; problem: ProblemDetails }; - export function AgentInputForm({ agentName }: { agentName: string }) { const [body, setBody] = useState(""); - const [result, setResult] = useState({ status: "idle" }); + const { state, submitting, run } = useSubmit(); - async function submit(e: Event) { + function submit(e: Event) { e.preventDefault(); - setResult({ status: "submitting" }); - try { - const r = await fetch( - `/api/agents/${encodeURIComponent(agentName)}/input`, - { + void run( + () => + fetch(`/api/agents/${encodeURIComponent(agentName)}/input`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ body }), - }, - ); - if (!r.ok) { - setResult({ status: "error", problem: await readApiError(r) }); - return; - } - setResult({ status: "sent" }); - setBody(""); - } catch (err) { - setResult({ status: "error", problem: { detail: String(err) } }); - } + }), + { onDone: () => setBody("") }, + ); } return ( @@ -53,17 +38,17 @@ export function AgentInputForm({ agentName }: { agentName: string }) { required onInput={setBody} /> - - {result.status === "sent" &&

sent

} - {result.status === "error" && ( - + {state.status === "done" && ( + + sent + + )} + {state.status === "error" && ( + )} ); diff --git a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx index c26c4018..8d797e5b 100644 --- a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx @@ -41,7 +41,10 @@ import { ExpandableCardListSkeleton, } from "../ui/expandable-card/ExpandableCard.js"; import { ExternalLink } from "../ui/external-link/ExternalLink.js"; +import { FormStack } from "../ui/form-stack/FormStack.js"; import { Hint, type HintTone } from "../ui/hint/Hint.js"; +import { FormResult } from "../ui/submit/FormResult.js"; +import type { SubmitState } from "../ui/submit/submit.js"; import { Toolbar } from "../ui/toolbar/Toolbar.js"; import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js"; import { Popover } from "../ui/popover/Popover.js"; @@ -52,6 +55,14 @@ import "./ComponentsPage.css"; const HINT_TONES: HintTone[] = ["muted", "warn", "error", "ok"]; +const SUBMIT_SAMPLES: [string, SubmitState][] = [ + ["done", { status: "done", value: undefined }], + [ + "error", + { status: "error", problem: { detail: "the agent is not listening" } }, + ], +]; + function Section({ title, children, @@ -631,6 +642,31 @@ export function ComponentsPage() { +
+ + e.preventDefault()}> + {}} + /> + + + + {SUBMIT_SAMPLES.map(([label, state]) => ( + + "stored the token"} + failed="failed to link the account" + /> + + ))} +
+
(null); const [hivesError, setHivesError] = useState(null); - const [result, setResult] = useState({ status: "idle" }); + // The done value is the queued job's node id. + const { state: result, run, reset } = useSubmit(); // The optional settings sections, left empty unless the operator fills them. const [cpu, setCpu] = useState(""); const [memory, setMemory] = useState(""); @@ -218,48 +214,52 @@ export function CreateAgentForm({ const refusal = result.status === "error" ? agentsMdRefusal(result.problem) : null; - async function submit() { - setResult({ status: "submitting" }); - try { - // A section left empty is left out, so the request is the plain - // name-and-hive one unless the operator set something. - const limits = capsBody(cpu, memory); - const settings = draftBody(inference); - const r = await fetch("/api/agents", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name, - hive, - ...(Object.keys(limits).length > 0 ? { limits } : {}), - ...(Object.keys(settings).length > 0 ? { inference: settings } : {}), - permissions: catalogue - ? requestPermissions(selected, catalogue.default) - : undefined, - ...requestInternet(internet), - archetype: requestArchetype(picked), - ...agentsMdBody(agentsMd), + function submit() { + // A section left empty is left out, so the request is the plain + // name-and-hive one unless the operator set something. + const limits = capsBody(cpu, memory); + const settings = draftBody(inference); + void run( + () => + fetch("/api/agents", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name, + hive, + ...(Object.keys(limits).length > 0 ? { limits } : {}), + ...(Object.keys(settings).length > 0 + ? { inference: settings } + : {}), + permissions: catalogue + ? requestPermissions(selected, catalogue.default) + : undefined, + ...requestInternet(internet), + archetype: requestArchetype(picked), + ...agentsMdBody(agentsMd), + }), }), - }); - if (!r.ok) { - const problem = await readApiError(r); - setResult({ status: "error", problem }); + { + parse: async (r) => ((await r.json()) as CreateAgentResponse).node_id, + onDone: () => { + setName(""); + setAgentsMd(""); + pickArchetype(""); + if (catalogue) + setSelected( + initialSelection( + catalogue.all, + catalogue.default, + extraPermissions, + ), + ); + }, // The text's own refusal shows beside its box, so go back to it. - if (agentsMdRefusal(problem) !== null) setStep("instructions"); - return; - } - const data = (await r.json()) as CreateAgentResponse; - setResult({ status: "done", nodeId: data.node_id }); - setName(""); - setAgentsMd(""); - pickArchetype(""); - if (catalogue) - setSelected( - initialSelection(catalogue.all, catalogue.default, extraPermissions), - ); - } catch (err) { - setResult({ status: "error", problem: { detail: String(err) } }); - } + onError: (problem) => { + if (agentsMdRefusal(problem) !== null) setStep("instructions"); + }, + }, + ); } const problem = stepProblem(step, { @@ -275,12 +275,12 @@ export function CreateAgentForm({ function onSubmit(e: Event) { e.preventDefault(); if (done || result.status === "submitting") return; - if (step === "summary") void submit(); + if (step === "summary") submit(); else if (problem === null) setStep(nextStep(step)); } function createAnother() { - setResult({ status: "idle" }); + reset(); setStep("identity"); } @@ -329,7 +329,7 @@ export function CreateAgentForm({ {done && (

- Queued as job node #{result.nodeId} —{" "} + Queued as job node #{result.value} —{" "} watch it in jobs.

diff --git a/frontend/packages/swarm-ui/src/pages/LinkBackendEnvForm.tsx b/frontend/packages/swarm-ui/src/pages/LinkBackendEnvForm.tsx index 70db4764..16444ea3 100644 --- a/frontend/packages/swarm-ui/src/pages/LinkBackendEnvForm.tsx +++ b/frontend/packages/swarm-ui/src/pages/LinkBackendEnvForm.tsx @@ -8,19 +8,12 @@ // stored value. A save replaces whatever is stored. Whether one is stored // shows on the agent panel (`LinkedAccounts`). import { useState } from "preact/hooks"; -import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; -import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Panel } from "../ui/panel/Panel.js"; import { TextAreaField } from "../ui/text-area-field/TextAreaField.js"; import { Button } from "../ui/button/Button.js"; -import { Hint } from "../ui/hint/Hint.js"; -import "./LinkMatrixAccountForm.css"; - -type SubmitState = - | { status: "idle" } - | { status: "submitting" } - | { status: "done"; message: string } - | { status: "error"; problem: ProblemDetails }; +import { FormStack } from "../ui/form-stack/FormStack.js"; +import { FormResult } from "../ui/submit/FormResult.js"; +import { useSubmit } from "../ui/submit/useSubmit.js"; // A swarm-controller without the route answers 404 with no problem body. const NO_ROUTE = @@ -36,46 +29,37 @@ export function LinkBackendEnvForm({ onClose?: () => void; }) { const [env, setEnv] = useState(""); - const [result, setResult] = useState({ status: "idle" }); + // The success line is what the request did, so it is the done value. + const { state, submitting: busy, run } = useSubmit(); - async function send(method: "PUT" | "DELETE", done: string) { - setResult({ status: "submitting" }); - try { - const r = await fetch( - `/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/backend-env`, - method === "PUT" - ? { - method, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ env }), - } - : { method }, - ); - if (!r.ok) { - const problem = await readApiError(r); - setResult({ - status: "error", - problem: - r.status === 404 && !problem.detail - ? { detail: NO_ROUTE } - : problem, - }); - return; - } - setResult({ status: "done", message: done }); - // The store holds the text; nothing here needs it. - setEnv(""); - } catch (err) { - setResult({ status: "error", problem: { detail: String(err) } }); - } + function send(method: "PUT" | "DELETE", done: string) { + void run( + () => + fetch( + `/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/backend-env`, + method === "PUT" + ? { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ env }), + } + : { method }, + ), + { + parse: () => done, + refine: (r, problem) => + r.status === 404 && !problem.detail ? { detail: NO_ROUTE } : problem, + // The store holds the text; nothing here needs it. + onDone: () => setEnv(""), + }, + ); } function save(e: Event) { e.preventDefault(); - void send("PUT", "stored the API-key environment for"); + send("PUT", "stored the API-key environment for"); } - const busy = result.status === "submitting"; return ( backendEnvironmentFile has content stores that file again.

- + ( + <> + {message} {agent} + + )} + failed="failed to update the API-key environment" + />
); } diff --git a/frontend/packages/swarm-ui/src/pages/LinkForgeAccountForm.tsx b/frontend/packages/swarm-ui/src/pages/LinkForgeAccountForm.tsx index 9a5c1537..5abaa277 100644 --- a/frontend/packages/swarm-ui/src/pages/LinkForgeAccountForm.tsx +++ b/frontend/packages/swarm-ui/src/pages/LinkForgeAccountForm.tsx @@ -9,12 +9,12 @@ // set: no route hands a token back. Linked labels and URLs show on // the agent panel (`LinkedAccounts`). import { useState } from "preact/hooks"; -import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; -import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Panel } from "../ui/panel/Panel.js"; import { TextField } from "../ui/text-field/TextField.js"; import { Button } from "../ui/button/Button.js"; -import { Hint } from "../ui/hint/Hint.js"; +import { FormStack } from "../ui/form-stack/FormStack.js"; +import { FormResult } from "../ui/submit/FormResult.js"; +import { useSubmit } from "../ui/submit/useSubmit.js"; import { checkLink, LABEL_PATTERN, @@ -22,17 +22,10 @@ import { linkUrl, type ForgeLinkKind, } from "../agent/detail/account-api.js"; -import "./LinkMatrixAccountForm.css"; // Prefilled for gitlab; the field stays editable for a self-hosted instance. const GITLAB_DEFAULT_URL = "https://gitlab.com"; -type SubmitState = - | { status: "idle" } - | { status: "submitting" } - | { status: "done"; label: string; url: string } - | { status: "error"; problem: ProblemDetails }; - export function LinkForgeAccountForm({ kind, hive, @@ -47,35 +40,37 @@ export function LinkForgeAccountForm({ const [label, setLabel] = useState(""); const [url, setUrl] = useState(kind === "gitlab" ? GITLAB_DEFAULT_URL : ""); const [token, setToken] = useState(""); - const [result, setResult] = useState({ status: "idle" }); + // The label is kept with the result, so the success message reads against + // what was sent even if the field is edited afterwards. + const { state, submitting, run, fail } = useSubmit<{ + label: string; + url: string; + }>(); - async function submit(e: Event) { + function submit(e: Event) { e.preventDefault(); const invalid = checkLink(label, url, token); if (invalid) { - setResult({ status: "error", problem: { detail: invalid } }); + fail({ detail: invalid }); return; } - setResult({ status: "submitting" }); - try { - const r = await fetch(linkUrl(hive, agent, kind, label), { - method: "PUT", - headers: { "content-type": "application/json" }, - body: linkBody(url, token), - }); - if (!r.ok) { - setResult({ status: "error", problem: await readApiError(r) }); - return; - } - const body = (await r.json().catch(() => ({}))) as { url?: string }; - // The label is stored with the result, so the success message reads - // against what was sent even if the field is edited afterwards. - setResult({ status: "done", label, url: body.url ?? url.trim() }); - // The store holds the token now; nothing here needs it. - setToken(""); - } catch (err) { - setResult({ status: "error", problem: { detail: String(err) } }); - } + const sent = label; + void run( + () => + fetch(linkUrl(hive, agent, kind, sent), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: linkBody(url, token), + }), + { + parse: async (r) => { + const body = (await r.json().catch(() => ({}))) as { url?: string }; + return { label: sent, url: body.url ?? url.trim() }; + }, + // The store holds the token now; nothing here needs it. + onDone: () => setToken(""), + }, + ); } return ( @@ -107,7 +102,7 @@ export function LinkForgeAccountForm({ the external forge first; nothing is created there.

)} - + ( + <> + linked {linked.label} ({linked.url}) to{" "} + {agent} + + )} + failed="failed to link the account" + /> ); } diff --git a/frontend/packages/swarm-ui/src/pages/LinkGithubAccountForm.tsx b/frontend/packages/swarm-ui/src/pages/LinkGithubAccountForm.tsx index 4032bdac..ae7b0688 100644 --- a/frontend/packages/swarm-ui/src/pages/LinkGithubAccountForm.tsx +++ b/frontend/packages/swarm-ui/src/pages/LinkGithubAccountForm.tsx @@ -7,20 +7,13 @@ // One token per agent. A blind set: no route hands a token back. // Whether one is stored shows on the agent panel (`LinkedAccounts`). import { useState } from "preact/hooks"; -import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; -import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Panel } from "../ui/panel/Panel.js"; import { TextField } from "../ui/text-field/TextField.js"; import { Button } from "../ui/button/Button.js"; import { ExternalLink } from "../ui/external-link/ExternalLink.js"; -import { Hint } from "../ui/hint/Hint.js"; -import "./LinkMatrixAccountForm.css"; - -type SubmitState = - | { status: "idle" } - | { status: "submitting" } - | { status: "done" } - | { status: "error"; problem: ProblemDetails }; +import { FormStack } from "../ui/form-stack/FormStack.js"; +import { FormResult } from "../ui/submit/FormResult.js"; +import { useSubmit } from "../ui/submit/useSubmit.js"; export function LinkGithubAccountForm({ hive, @@ -32,30 +25,23 @@ export function LinkGithubAccountForm({ onClose?: () => void; }) { const [token, setToken] = useState(""); - const [result, setResult] = useState({ status: "idle" }); + const { state, submitting, run } = useSubmit(); - async function submit(e: Event) { + function submit(e: Event) { e.preventDefault(); - setResult({ status: "submitting" }); - try { - const r = await fetch( - `/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/github-account`, - { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ token }), - }, - ); - if (!r.ok) { - setResult({ status: "error", problem: await readApiError(r) }); - return; - } - setResult({ status: "done" }); + void run( + () => + fetch( + `/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/github-account`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token }), + }, + ), // The store holds the token; nothing here needs it. - setToken(""); - } catch (err) { - setResult({ status: "error", problem: { detail: String(err) } }); - } + { onDone: () => setToken("") }, + ); } return ( @@ -74,7 +60,7 @@ export function LinkGithubAccountForm({ ; GitHub notifications also need the notifications scope.

- + ( + <> + stored a github token for {agent} + + )} + failed="failed to link the account" + /> ); } diff --git a/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.css b/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.css deleted file mode 100644 index ef5f5e7a..00000000 --- a/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.css +++ /dev/null @@ -1,17 +0,0 @@ -/* — rendered inside a `Dialog` from `AgentsPage`, - one panel wide. Column layout + `.link-matrix-account-result-ok` - mirror `CreateAgentForm.css`'s `.create-agent-fields`/`.create-agent-result-ok` - — same shape, kept as its own file rather than sharing that - one since "form" here means a different component's layout, not a - generic kit primitive (see that file's own top comment on why - `ui/form-field` stays the only truly shared piece). */ -.link-matrix-account-form { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 0.75em; - margin-top: 1em; -} -.link-matrix-account-result-ok { - margin-block: 1em; -} diff --git a/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx b/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx index fde23cc2..9f543296 100644 --- a/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx +++ b/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx @@ -26,14 +26,13 @@ // `WantedMenu`-sized, which is why that one stays inline in // `AgentsPage.tsx` and this one doesn't). import { useState } from "preact/hooks"; -import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; -import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Panel } from "../ui/panel/Panel.js"; import { TextField } from "../ui/text-field/TextField.js"; import { SelectField } from "../ui/select-field/SelectField.js"; import { Button } from "../ui/button/Button.js"; -import { Hint } from "../ui/hint/Hint.js"; -import "./LinkMatrixAccountForm.css"; +import { FormStack } from "../ui/form-stack/FormStack.js"; +import { FormResult } from "../ui/submit/FormResult.js"; +import { useSubmit } from "../ui/submit/useSubmit.js"; type Mode = "token" | "password"; @@ -45,15 +44,13 @@ type Mode = "token" | "password"; // gate, this just avoids a round-trip for an obviously-bad name. const ACCOUNT_PATTERN = "[A-Za-z0-9_\\-]{1,63}"; -type SubmitState = - | { status: "idle" } - | { status: "submitting" } - // `userId` is set in password mode (the server resolves it from the - // login response) and `undefined` in token mode — see - // `PutMatrixAccountResponse`'s own doc for why token mode doesn't - // spend a round trip resolving one. - | { status: "done"; account: string; userId?: string } - | { status: "error"; problem: ProblemDetails }; +// `userId` is set in password mode (the server resolves it from the login +// response) and `undefined` in token mode — see `PutMatrixAccountResponse`'s +// own doc for why token mode doesn't spend a round trip resolving one. +interface Linked { + account: string; + userId?: string; +} const MODE_OPTIONS = [ { value: "token", label: "paste a token" }, @@ -78,7 +75,7 @@ export function LinkMatrixAccountForm({ const [userId, setUserId] = useState(""); const [password, setPassword] = useState(""); const [homeserver, setHomeserver] = useState(""); - const [result, setResult] = useState({ status: "idle" }); + const { state, submitting, run } = useSubmit(); // A secret lives in state only while it is being typed and sent: it is // dropped when the request ends, whether it worked or not, and when the @@ -88,51 +85,53 @@ export function LinkMatrixAccountForm({ setPassword(""); } - async function submit(e: Event) { + function submit(e: Event) { e.preventDefault(); - setResult({ status: "submitting" }); - try { - const r = await fetch( - `/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/matrix-accounts/${encodeURIComponent(account)}`, - { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify( - mode === "token" - ? { - mode, - token, - // Omitted rather than sent empty — the contract marks - // it optional in token mode, and an empty string - // isn't "no homeserver", it's a homeserver named "". - homeserver: homeserver.trim() || undefined, - } - : { - mode, - user_id: userId, - password, - // Required in password mode — the field below enforces - // that before this branch is ever reachable. - homeserver: homeserver.trim(), - }, - ), + const sent = account; + void run( + async () => { + try { + return await fetch( + `/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/matrix-accounts/${encodeURIComponent(sent)}`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify( + mode === "token" + ? { + mode, + token, + // Omitted rather than sent empty — the contract marks + // it optional in token mode, and an empty string + // isn't "no homeserver", it's a homeserver named "". + homeserver: homeserver.trim() || undefined, + } + : { + mode, + user_id: userId, + password, + // Required in password mode — the field below enforces + // that before this branch is ever reachable. + homeserver: homeserver.trim(), + }, + ), + }, + ); + } finally { + clearSecrets(); + } + }, + { + // The account name is kept with the result, so the success message + // reads against what was sent even if the field is edited afterwards. + parse: async (r) => { + const body = (await r.json().catch(() => ({}))) as { + user_id?: string; + }; + return { account: sent, userId: body.user_id }; }, - ); - clearSecrets(); - if (!r.ok) { - setResult({ status: "error", problem: await readApiError(r) }); - return; - } - const body = (await r.json().catch(() => ({}))) as { - user_id?: string; - }; - // The account name is stored with the result, so the success message - // reads against what was sent even if the field is edited afterwards. - setResult({ status: "done", account, userId: body.user_id }); - } catch (err) { - clearSecrets(); - setResult({ status: "error", problem: { detail: String(err) } }); - } + }, + ); } return ( @@ -146,7 +145,7 @@ export function LinkMatrixAccountForm({ daemon reads it from there within two minutes — nothing here restarts anything directly.

- + ( + <> + linked {linked.account} to {agent} + {linked.userId ? ( + <> + {" "} + — logged in as {linked.userId} + + ) : null} + + )} + failed="failed to link the account" + /> ); } diff --git a/frontend/packages/swarm-ui/src/ui/form-stack/FormStack.css b/frontend/packages/swarm-ui/src/ui/form-stack/FormStack.css new file mode 100644 index 00000000..90558960 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/form-stack/FormStack.css @@ -0,0 +1,12 @@ +.ui-form-stack { + display: flex; + flex-direction: column; + gap: 0.75em; + margin-top: 1em; +} +.ui-form-stack-start { + align-items: flex-start; +} +.ui-form-stack-stretch { + align-items: stretch; +} diff --git a/frontend/packages/swarm-ui/src/ui/form-stack/FormStack.tsx b/frontend/packages/swarm-ui/src/ui/form-stack/FormStack.tsx new file mode 100644 index 00000000..87bd8fa6 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/form-stack/FormStack.tsx @@ -0,0 +1,26 @@ +// — a `
` whose fields and buttons stack in one column, with +// the gap the forms in dialogs and panels share. The fields keep their own +// width (`align="start"`) or fill the form (`align="stretch"`). +import type { ComponentChildren } from "preact"; +import "./FormStack.css"; + +export function FormStack({ + onSubmit, + align = "start", + class: extraClass, + children, +}: { + onSubmit: (e: Event) => void; + align?: "start" | "stretch"; + class?: string; + children: ComponentChildren; +}) { + const classes = ["ui-form-stack", `ui-form-stack-${align}`, extraClass] + .filter(Boolean) + .join(" "); + return ( + + {children} +
+ ); +} diff --git a/frontend/packages/swarm-ui/src/ui/submit/FormResult.css b/frontend/packages/swarm-ui/src/ui/submit/FormResult.css new file mode 100644 index 00000000..1a39baa3 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/submit/FormResult.css @@ -0,0 +1,3 @@ +.ui-form-result { + margin-block: 1em; +} diff --git a/frontend/packages/swarm-ui/src/ui/submit/FormResult.tsx b/frontend/packages/swarm-ui/src/ui/submit/FormResult.tsx new file mode 100644 index 00000000..60cf1bd6 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/submit/FormResult.tsx @@ -0,0 +1,32 @@ +// — what a form says after it sent its request: a success line +// when it is done, the problem panel when it failed, nothing otherwise. The +// success line is a `Hint` in the `ok` tone. +import type { ComponentChildren } from "preact"; +import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; +import { Hint } from "../hint/Hint.js"; +import type { SubmitState } from "./submit.js"; +import "./FormResult.css"; + +export function FormResult({ + state, + done, + failed, +}: { + state: SubmitState; + /** The success line, given what the reply parsed to. */ + done: (value: T) => ComponentChildren; + /** What failed, as the panel's heading says it: "failed to link the account". */ + failed: string; +}) { + if (state.status === "done") { + return ( + + {done(state.value)} + + ); + } + if (state.status === "error") { + return ; + } + return null; +} diff --git a/frontend/packages/swarm-ui/src/ui/submit/submit.ts b/frontend/packages/swarm-ui/src/ui/submit/submit.ts new file mode 100644 index 00000000..d7546f80 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/submit/submit.ts @@ -0,0 +1,42 @@ +// The state of a form that sends one request and shows how it went: idle, +// sending, done (with whatever the success parsed out of the reply) or an +// error the form shows as a problem panel. `submitOutcome` is the request flow +// itself, with no UI in it, so a test can run it; `useSubmit` wraps it in +// component state. +import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; + +export type SubmitState = + | { status: "idle" } + | { status: "submitting" } + | { status: "done"; value: T } + | { status: "error"; problem: ProblemDetails }; + +export interface SubmitOptions { + /** Read the success reply; the result is the `done` state's `value`. */ + parse?: (reply: Response) => T | Promise; + /** Reword a refusal, for a status that deserves a plainer message than the + * server's own. */ + refine?: (reply: Response, problem: ProblemDetails) => ProblemDetails; +} + +export async function submitOutcome( + request: () => Promise, + { parse, refine }: SubmitOptions = {}, +): Promise< + { status: "done"; value: T } | { status: "error"; problem: ProblemDetails } +> { + try { + const reply = await request(); + if (!reply.ok) { + const problem = await readApiError(reply); + return { + status: "error", + problem: refine ? refine(reply, problem) : problem, + }; + } + const value = (parse ? await parse(reply) : undefined) as T; + return { status: "done", value }; + } catch (err) { + return { status: "error", problem: { detail: String(err) } }; + } +} diff --git a/frontend/packages/swarm-ui/src/ui/submit/useSubmit.ts b/frontend/packages/swarm-ui/src/ui/submit/useSubmit.ts new file mode 100644 index 00000000..26e6342c --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/submit/useSubmit.ts @@ -0,0 +1,40 @@ +// `useSubmit` — the submit state of one form (see `submit.ts`). `run` sends +// the request and sets the state; `onDone` and `onError` are for what the form +// does with the outcome besides showing it (clear a field, go back a step). +// `fail` shows a problem the form found itself, without sending anything; +// `reset` returns to idle, for a form that starts over. +import { useState } from "preact/hooks"; +import type { ProblemDetails } from "@hive/shared/api-error.js"; +import { + submitOutcome, + type SubmitOptions, + type SubmitState, +} from "./submit.js"; + +export interface RunOptions extends SubmitOptions { + onDone?: (value: T) => void; + onError?: (problem: ProblemDetails) => void; +} + +export function useSubmit() { + const [state, setState] = useState>({ status: "idle" }); + + async function run( + request: () => Promise, + { onDone, onError, ...options }: RunOptions = {}, + ) { + setState({ status: "submitting" }); + const outcome = await submitOutcome(request, options); + setState(outcome); + if (outcome.status === "done") onDone?.(outcome.value); + else onError?.(outcome.problem); + } + + return { + state, + submitting: state.status === "submitting", + run, + fail: (problem: ProblemDetails) => setState({ status: "error", problem }), + reset: () => setState({ status: "idle" }), + }; +} diff --git a/frontend/packages/swarm-ui/test/submit.test.mjs b/frontend/packages/swarm-ui/test/submit.test.mjs new file mode 100644 index 00000000..646dae69 --- /dev/null +++ b/frontend/packages/swarm-ui/test/submit.test.mjs @@ -0,0 +1,58 @@ +// The request flow every form shares: a refusal becomes a problem, a failure +// to send becomes a problem, a success parses its reply. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { submitOutcome } from "../src/ui/submit/submit.ts"; + +const problem = (status, body) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/problem+json" }, + }); + +test("an ok reply is done, with nothing parsed by default", async () => { + const out = await submitOutcome( + async () => new Response(null, { status: 204 }), + ); + assert.deepEqual(out, { status: "done", value: undefined }); +}); + +test("an ok reply is parsed into the done value", async () => { + const out = await submitOutcome( + async () => new Response(JSON.stringify({ node_id: 7 })), + { parse: async (r) => (await r.json()).node_id }, + ); + assert.deepEqual(out, { status: "done", value: 7 }); +}); + +test("a refusal carries the server's problem", async () => { + const out = await submitOutcome(async () => + problem(409, { title: "conflict", detail: "already linked" }), + ); + assert.equal(out.status, "error"); + assert.equal(out.problem.detail, "already linked"); +}); + +test("refine can reword a refusal by status", async () => { + const out = await submitOutcome(async () => problem(404, {}), { + refine: (r, p) => + r.status === 404 && !p.detail ? { ...p, detail: "no such agent" } : p, + }); + assert.equal(out.status, "error"); + assert.equal(out.problem.detail, "no such agent"); +}); + +test("a request that throws is an error with the message", async () => { + const out = await submitOutcome(async () => { + throw new Error("offline"); + }); + assert.equal(out.status, "error"); + assert.match(out.problem.detail, /offline/); +}); + +test("a parse that throws is an error, not a done", async () => { + const out = await submitOutcome(async () => new Response("not json"), { + parse: (r) => r.json(), + }); + assert.equal(out.status, "error"); +});