Watch
0
0
Fork
You've already forked hyperhive
0

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.
This commit is contained in:
iris 2026-11-09 20:50:32 +01:00
commit 73f875eab8
10 changed files with 211 additions and 15 deletions

View file

@ -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.

View file

@ -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 (
<Router hook={useGuardedLocation}>
<ToastHost />
<Shell>
<Switch>
<Route path="/" component={StartPage} />

View file

@ -1,4 +0,0 @@
/* <ClaudeLoginDialog> */
.claude-login-ok {
margin-block: 1em;
}

View file

@ -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<ProblemDetails | null> {
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({
</FormStack>
</>
)}
{step.step === "stored" && (
<Hint tone="ok" class="claude-login-ok">
Stored. <strong>{agent}</strong> picks the login up within a minute
and resumes.
</Hint>
)}
{step.step === "failed" && (
<ApiErrorPanel
context="the claude login failed; close and start again"

View file

@ -28,6 +28,11 @@ export function afterCode(problem: ProblemDetails | null): LoginStep {
return problem === null ? { step: "stored" } : { step: "failed", problem };
}
// What the toast says once the login is stored.
export function storedMessage(agent: string): string {
return `Login stored. ${agent} picks it up within a minute and resumes.`;
}
// Whether closing the dialog leaves a login running that should be
// stopped.
export function leavesLoginRunning(s: LoginStep): boolean {

View file

@ -0,0 +1,37 @@
/* <ToastHost>: 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);
}

View file

@ -0,0 +1,43 @@
// <ToastHost> — 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 (
<div
class={`ui-toast ui-toast-${toast.tone}`}
role={toast.tone === "error" ? "alert" : "status"}
onClick={() => dismissToast(toast.id)}
>
{toast.message}
</div>
);
}
export function ToastHost() {
const [list, setList] = useState<readonly Toast[]>(currentToasts());
useEffect(() => {
setList(currentToasts());
return subscribeToasts(() => setList(currentToasts()));
}, []);
return (
<div class="ui-toast-host">
{list.map((t) => (
<ToastItem key={t.id} toast={t} />
))}
</div>
);
}

View file

@ -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`; `<ToastHost>` 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);
};
}

View file

@ -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 /);
});

View file

@ -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);
});