From 73f875eab890a8cfc824999360b6bb361eb19cfa Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 9 Nov 2026 20:50:32 +0100 Subject: [PATCH] swarm ui: a stored claude login closes the dialog and shows a toast The login dialog stayed open with a success line after the code was accepted, though nothing is left to do in it. It now closes and a toast says the login is stored. The toast is a new swarm-ui primitive: a short note raised with showToast, rendered by a ToastHost at the app root, gone after a few seconds or on click. --- docs/2-handbook/swarm/ui.md | 3 +- frontend/packages/swarm-ui/src/App.tsx | 2 + .../agent/claude-login/ClaudeLoginDialog.css | 4 -- .../agent/claude-login/ClaudeLoginDialog.tsx | 22 +++---- .../agent/claude-login/ClaudeLoginSteps.ts | 5 ++ .../packages/swarm-ui/src/ui/toast/Toast.css | 37 ++++++++++++ .../packages/swarm-ui/src/ui/toast/Toast.tsx | 43 ++++++++++++++ .../packages/swarm-ui/src/ui/toast/toasts.ts | 59 +++++++++++++++++++ .../swarm-ui/test/claude-login.test.mjs | 5 ++ .../packages/swarm-ui/test/toasts.test.mjs | 46 +++++++++++++++ 10 files changed, 211 insertions(+), 15 deletions(-) delete mode 100644 frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.css create mode 100644 frontend/packages/swarm-ui/src/ui/toast/Toast.css create mode 100644 frontend/packages/swarm-ui/src/ui/toast/Toast.tsx create mode 100644 frontend/packages/swarm-ui/src/ui/toast/toasts.ts create mode 100644 frontend/packages/swarm-ui/test/toasts.test.mjs diff --git a/docs/2-handbook/swarm/ui.md b/docs/2-handbook/swarm/ui.md index 8e5ff913b..b1e71e238 100644 --- a/docs/2-handbook/swarm/ui.md +++ b/docs/2-handbook/swarm/ui.md @@ -67,7 +67,8 @@ An agent created here or with `swarmctl agent create` starts `paused`; set it `up` from its card. A card shows **login needed** when the agent's own turn-state header says its claude has no session, and a **log in** button that runs the claude login from a dialog: open the URL it shows, then paste -the code back. The credentials go to the agent through the secret store +the code back. When the secret store has the login, the dialog closes and a +short toast in the top-right corner says so. The credentials go to the agent through the secret store ([`claude-login.md`](claude-login.md)). The agent's own web UI login still works too. diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index c38abe463..a4006a3a0 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -16,6 +16,7 @@ import { JobsPage } from "./pages/JobsPage.js"; import { ReportsPage } from "./pages/reports/ReportsPage.js"; import { StartPage } from "./pages/StartPage.js"; import { Panel } from "./ui/panel/Panel.js"; +import { ToastHost } from "./ui/toast/Toast.js"; // An address from before the tabs were folded together: replace it with the // tab that holds the page now, query kept. @@ -34,6 +35,7 @@ function NotFound() { export function App() { return ( + diff --git a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.css b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.css deleted file mode 100644 index cae0a5c75..000000000 --- a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.css +++ /dev/null @@ -1,4 +0,0 @@ -/* */ -.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 96264c844..e9390eada 100644 --- a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx +++ b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx @@ -13,8 +13,8 @@ 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"; import { Panel } from "../../ui/panel/Panel.js"; +import { showToast } from "../../ui/toast/toasts.js"; import { TextField } from "../../ui/text-field/TextField.js"; import { afterCode, @@ -25,9 +25,9 @@ import { leavesLoginRunning, pastedCode, startOutlivedDialog, + storedMessage, type LoginStep, } from "./ClaudeLoginSteps.js"; -import "./ClaudeLoginDialog.css"; async function problemOf(r: Response): Promise { return r.ok ? null : await readApiError(r); @@ -80,16 +80,24 @@ export function ClaudeLoginDialog({ if (step.step !== "code" || pasted === null) return; const { url } = step; setStep({ step: "submitting", url }); + let next: LoginStep; try { const r = await fetch(claudeLoginPath(agent, true), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code: pasted }), }); - setStep(afterCode(await problemOf(r))); + next = afterCode(await problemOf(r)); } catch (err) { - setStep(afterCode({ detail: String(err) })); + next = afterCode({ detail: String(err) }); } + if (next.step === "stored") { + // Nothing is left to do in the dialog: close it and say so in a toast. + onClose(); + showToast(storedMessage(agent), "ok"); + return; + } + setStep(next); setCode(""); } @@ -122,12 +130,6 @@ export function ClaudeLoginDialog({ )} - {step.step === "stored" && ( - - )} {step.step === "failed" && ( : a fixed stack top-right above the page. The host takes no + clicks itself, so it never blocks the page below it; each toast does. A + toast raised while a modal dialog is open sits behind the dialog's top + layer, so raise one after closing the dialog. */ +.ui-toast-host { + position: fixed; + top: 1em; + right: 1em; + z-index: 1100; + display: flex; + flex-direction: column; + gap: 0.5em; + max-width: min(28em, 92vw); + pointer-events: none; +} +.ui-toast { + pointer-events: auto; + cursor: pointer; + background: var(--bg-elev); + border: 1px solid var(--border); + border-left-width: 3px; + border-radius: 0.4em; + box-shadow: 0 4px 20px -6px var(--crust); + padding: 0.6em 0.9em; + font-size: 0.9em; + color: var(--fg); + white-space: pre-wrap; +} +.ui-toast-info { + border-left-color: var(--purple-dim); +} +.ui-toast-ok { + border-left-color: var(--green); +} +.ui-toast-error { + border-left-color: var(--red); +} diff --git a/frontend/packages/swarm-ui/src/ui/toast/Toast.tsx b/frontend/packages/swarm-ui/src/ui/toast/Toast.tsx new file mode 100644 index 000000000..83a5aa3af --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/toast/Toast.tsx @@ -0,0 +1,43 @@ +// — renders the toasts `showToast` raised, stacked top-right +// above the page. Mounted once, at the app root. A toast removes itself after +// its duration; a click dismisses it sooner. An error is announced as an +// alert, the others as a status. +import { useEffect, useState } from "preact/hooks"; +import { + currentToasts, + dismissToast, + subscribeToasts, + type Toast, +} from "./toasts.js"; +import "./Toast.css"; + +function ToastItem({ toast }: { toast: Toast }) { + useEffect(() => { + const timer = setTimeout(() => dismissToast(toast.id), toast.duration); + return () => clearTimeout(timer); + }, [toast.id, toast.duration]); + return ( +
dismissToast(toast.id)} + > + {toast.message} +
+ ); +} + +export function ToastHost() { + const [list, setList] = useState(currentToasts()); + useEffect(() => { + setList(currentToasts()); + return subscribeToasts(() => setList(currentToasts())); + }, []); + return ( +
+ {list.map((t) => ( + + ))} +
+ ); +} diff --git a/frontend/packages/swarm-ui/src/ui/toast/toasts.ts b/frontend/packages/swarm-ui/src/ui/toast/toasts.ts new file mode 100644 index 000000000..a71fe339e --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/toast/toasts.ts @@ -0,0 +1,59 @@ +// The toast list, kept out of the component so `npm test` runs it. A toast is +// a short note that needs no answer: it shows for a few seconds and goes away +// by itself, and a click dismisses it sooner. Anything that shows one calls +// `showToast`; `` renders the list. + +export type ToastTone = "info" | "ok" | "error"; + +export interface Toast { + id: number; + message: string; + tone: ToastTone; + /** How long it stays, in ms. */ + duration: number; +} + +/** An error lingers longer, since it is the one worth reading twice. */ +export function toastDuration(tone: ToastTone): number { + return tone === "error" ? 8000 : 4000; +} + +let toasts: Toast[] = []; +let nextId = 1; +const listeners = new Set<() => void>(); + +function emit(): void { + for (const l of listeners) l(); +} + +/** Shows a toast and returns its id. */ +export function showToast( + message: string, + tone: ToastTone = "info", + duration: number = toastDuration(tone), +): number { + const id = nextId++; + toasts = [...toasts, { id, message, tone, duration }]; + emit(); + return id; +} + +/** Removes a toast; an id that is gone already does nothing. */ +export function dismissToast(id: number): void { + if (!toasts.some((t) => t.id === id)) return; + toasts = toasts.filter((t) => t.id !== id); + emit(); +} + +/** The toasts on screen, oldest first. */ +export function currentToasts(): readonly Toast[] { + return toasts; +} + +/** Calls `fn` on every change; returns the function that stops it. */ +export function subscribeToasts(fn: () => void): () => void { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; +} diff --git a/frontend/packages/swarm-ui/test/claude-login.test.mjs b/frontend/packages/swarm-ui/test/claude-login.test.mjs index 8ba401cea..194b0560a 100644 --- a/frontend/packages/swarm-ui/test/claude-login.test.mjs +++ b/frontend/packages/swarm-ui/test/claude-login.test.mjs @@ -12,6 +12,7 @@ import { leavesLoginRunning, pastedCode, startOutlivedDialog, + storedMessage, } from "../src/agent/claude-login/ClaudeLoginSteps.ts"; test("the routes name the agent, escaped", () => { @@ -77,3 +78,7 @@ test("a pasted code is trimmed, and a blank one is nothing", () => { assert.equal(pastedCode(" abc-def \n"), "abc-def"); assert.equal(pastedCode(" \n "), null); }); + +test("the stored toast names the agent", () => { + assert.match(storedMessage("atlas"), /^Login stored\. atlas /); +}); diff --git a/frontend/packages/swarm-ui/test/toasts.test.mjs b/frontend/packages/swarm-ui/test/toasts.test.mjs new file mode 100644 index 000000000..4ff04e3f9 --- /dev/null +++ b/frontend/packages/swarm-ui/test/toasts.test.mjs @@ -0,0 +1,46 @@ +// The toast list. Run by `npm test`, which Node runs with its built-in test +// runner and type stripping, so the `.ts` module is imported as it is. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + currentToasts, + dismissToast, + showToast, + subscribeToasts, + toastDuration, +} from "../src/ui/toast/toasts.ts"; + +test("an error stays longer than the other tones", () => { + assert.ok(toastDuration("error") > toastDuration("ok")); + assert.equal(toastDuration("info"), toastDuration("ok")); +}); + +test("a toast is listed with its tone and duration, oldest first", () => { + const a = showToast("first", "ok"); + const b = showToast("second", "error", 100); + const list = currentToasts().filter((t) => t.id === a || t.id === b); + assert.deepEqual( + list.map((t) => [t.message, t.tone, t.duration]), + [ + ["first", "ok", toastDuration("ok")], + ["second", "error", 100], + ], + ); + dismissToast(a); + dismissToast(b); +}); + +test("dismissing removes one toast and tells the subscribers once", () => { + const a = showToast("keep"); + const b = showToast("drop"); + let calls = 0; + const stop = subscribeToasts(() => calls++); + dismissToast(b); + dismissToast(b); + assert.equal(calls, 1); + assert.ok(currentToasts().some((t) => t.id === a)); + assert.ok(!currentToasts().some((t) => t.id === b)); + stop(); + dismissToast(a); + assert.equal(calls, 1); +});