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
7 changed files with 211 additions and 15 deletions

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