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.
-
+
);
}
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.
-
+
);
}
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 === "done" && (
-
- {result.message} {agent}
-
- )}
- {result.status === "error" && (
-
- )}
+
+ (
+ <>
+ {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.
)}
-
- {result.status === "done" && (
-
- linked {result.label} ({result.url}) to{" "}
- {agent}
-
- )}
- {result.status === "error" && (
-
- )}
+
+ (
+ <>
+ 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.
-
- {result.status === "done" && (
-
- stored a github token for {agent}
-
- )}
- {result.status === "error" && (
-
- )}
+
+ (
+ <>
+ 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.
-
- {result.status === "done" && (
-
- linked {result.account} to {agent}
- {result.userId ? (
- <>
- {" "}
- — logged in as {result.userId}
- >
- ) : null}
-
- )}
- {result.status === "error" && (
-
- )}
+
+ (
+ <>
+ 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 `
+ );
+}
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");
+});