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:
parent
892675628d
commit
73f875eab8
10 changed files with 211 additions and 15 deletions
|
|
@ -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
|
`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
|
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
|
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
|
([`claude-login.md`](claude-login.md)). The agent's own web UI login still
|
||||||
works too.
|
works too.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { JobsPage } from "./pages/JobsPage.js";
|
||||||
import { ReportsPage } from "./pages/reports/ReportsPage.js";
|
import { ReportsPage } from "./pages/reports/ReportsPage.js";
|
||||||
import { StartPage } from "./pages/StartPage.js";
|
import { StartPage } from "./pages/StartPage.js";
|
||||||
import { Panel } from "./ui/panel/Panel.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
|
// An address from before the tabs were folded together: replace it with the
|
||||||
// tab that holds the page now, query kept.
|
// tab that holds the page now, query kept.
|
||||||
|
|
@ -34,6 +35,7 @@ function NotFound() {
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<Router hook={useGuardedLocation}>
|
<Router hook={useGuardedLocation}>
|
||||||
|
<ToastHost />
|
||||||
<Shell>
|
<Shell>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/" component={StartPage} />
|
<Route path="/" component={StartPage} />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
/* <ClaudeLoginDialog> */
|
|
||||||
.claude-login-ok {
|
|
||||||
margin-block: 1em;
|
|
||||||
}
|
|
||||||
|
|
@ -13,8 +13,8 @@ import { Button } from "../../ui/button/Button.js";
|
||||||
import { FormStack } from "../../ui/form-stack/FormStack.js";
|
import { FormStack } from "../../ui/form-stack/FormStack.js";
|
||||||
import { Dialog } from "../../ui/dialog/Dialog.js";
|
import { Dialog } from "../../ui/dialog/Dialog.js";
|
||||||
import { ExternalLink } from "../../ui/external-link/ExternalLink.js";
|
import { ExternalLink } from "../../ui/external-link/ExternalLink.js";
|
||||||
import { Hint } from "../../ui/hint/Hint.js";
|
|
||||||
import { Panel } from "../../ui/panel/Panel.js";
|
import { Panel } from "../../ui/panel/Panel.js";
|
||||||
|
import { showToast } from "../../ui/toast/toasts.js";
|
||||||
import { TextField } from "../../ui/text-field/TextField.js";
|
import { TextField } from "../../ui/text-field/TextField.js";
|
||||||
import {
|
import {
|
||||||
afterCode,
|
afterCode,
|
||||||
|
|
@ -25,9 +25,9 @@ import {
|
||||||
leavesLoginRunning,
|
leavesLoginRunning,
|
||||||
pastedCode,
|
pastedCode,
|
||||||
startOutlivedDialog,
|
startOutlivedDialog,
|
||||||
|
storedMessage,
|
||||||
type LoginStep,
|
type LoginStep,
|
||||||
} from "./ClaudeLoginSteps.js";
|
} from "./ClaudeLoginSteps.js";
|
||||||
import "./ClaudeLoginDialog.css";
|
|
||||||
|
|
||||||
async function problemOf(r: Response): Promise<ProblemDetails | null> {
|
async function problemOf(r: Response): Promise<ProblemDetails | null> {
|
||||||
return r.ok ? null : await readApiError(r);
|
return r.ok ? null : await readApiError(r);
|
||||||
|
|
@ -80,16 +80,24 @@ export function ClaudeLoginDialog({
|
||||||
if (step.step !== "code" || pasted === null) return;
|
if (step.step !== "code" || pasted === null) return;
|
||||||
const { url } = step;
|
const { url } = step;
|
||||||
setStep({ step: "submitting", url });
|
setStep({ step: "submitting", url });
|
||||||
|
let next: LoginStep;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(claudeLoginPath(agent, true), {
|
const r = await fetch(claudeLoginPath(agent, true), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ code: pasted }),
|
body: JSON.stringify({ code: pasted }),
|
||||||
});
|
});
|
||||||
setStep(afterCode(await problemOf(r)));
|
next = afterCode(await problemOf(r));
|
||||||
} catch (err) {
|
} 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("");
|
setCode("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,12 +130,6 @@ export function ClaudeLoginDialog({
|
||||||
</FormStack>
|
</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" && (
|
{step.step === "failed" && (
|
||||||
<ApiErrorPanel
|
<ApiErrorPanel
|
||||||
context="the claude login failed; close and start again"
|
context="the claude login failed; close and start again"
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,11 @@ export function afterCode(problem: ProblemDetails | null): LoginStep {
|
||||||
return problem === null ? { step: "stored" } : { step: "failed", problem };
|
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
|
// Whether closing the dialog leaves a login running that should be
|
||||||
// stopped.
|
// stopped.
|
||||||
export function leavesLoginRunning(s: LoginStep): boolean {
|
export function leavesLoginRunning(s: LoginStep): boolean {
|
||||||
|
|
|
||||||
37
frontend/packages/swarm-ui/src/ui/toast/Toast.css
Normal file
37
frontend/packages/swarm-ui/src/ui/toast/Toast.css
Normal 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);
|
||||||
|
}
|
||||||
43
frontend/packages/swarm-ui/src/ui/toast/Toast.tsx
Normal file
43
frontend/packages/swarm-ui/src/ui/toast/Toast.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
frontend/packages/swarm-ui/src/ui/toast/toasts.ts
Normal file
59
frontend/packages/swarm-ui/src/ui/toast/toasts.ts
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
leavesLoginRunning,
|
leavesLoginRunning,
|
||||||
pastedCode,
|
pastedCode,
|
||||||
startOutlivedDialog,
|
startOutlivedDialog,
|
||||||
|
storedMessage,
|
||||||
} from "../src/agent/claude-login/ClaudeLoginSteps.ts";
|
} from "../src/agent/claude-login/ClaudeLoginSteps.ts";
|
||||||
|
|
||||||
test("the routes name the agent, escaped", () => {
|
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(" abc-def \n"), "abc-def");
|
||||||
assert.equal(pastedCode(" \n "), null);
|
assert.equal(pastedCode(" \n "), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the stored toast names the agent", () => {
|
||||||
|
assert.match(storedMessage("atlas"), /^Login stored\. atlas /);
|
||||||
|
});
|
||||||
|
|
|
||||||
46
frontend/packages/swarm-ui/test/toasts.test.mjs
Normal file
46
frontend/packages/swarm-ui/test/toasts.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue