From 41cc2f5e8e9547e1f1034830e6bf8c5f5109471e Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 21 Oct 2026 20:58:54 +0200 Subject: [PATCH] swarm-ui: stop a claude login whose dialog closed while it was starting --- .../src/agent/claude-login/ClaudeLoginDialog.tsx | 8 +++++++- .../src/agent/claude-login/ClaudeLoginSteps.ts | 10 ++++++++++ frontend/packages/swarm-ui/test/claude-login.test.mjs | 7 +++++++ 3 files changed, 24 insertions(+), 1 deletion(-) 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 fa91a72e..77e799c1 100644 --- a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx +++ b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx @@ -2,7 +2,8 @@ // login minter. Opening it starts the login (`POST …/claude-login`), which // answers with the URL claude printed; the operator opens it and pastes the // code back (`POST …/claude-login/code`). Closing it while the code is -// still awaited stops the login (`DELETE …/claude-login`). +// still awaited stops the login (`DELETE …/claude-login`). Closing while the +// start is still in flight stops it once the start answers. // // Mounted only while open, so each open is a fresh login. import { useEffect, useState } from "preact/hooks"; @@ -18,6 +19,7 @@ import { claudeLoginPath, leavesLoginRunning, pastedCode, + startOutlivedDialog, type LoginStep, } from "./ClaudeLoginSteps.js"; import "./ClaudeLoginDialog.css"; @@ -42,6 +44,10 @@ export function ClaudeLoginDialog({ try { const r = await fetch(claudeLoginPath(agent), { method: "POST" }); const problem = await problemOf(r); + if (startOutlivedDialog(live, problem)) { + void fetch(claudeLoginPath(agent), { method: "DELETE" }); + return; + } const next = afterStart( problem ? { problem } : ((await r.json()) as { url: string }), ); diff --git a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginSteps.ts b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginSteps.ts index 5366130b..50f46660 100644 --- a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginSteps.ts +++ b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginSteps.ts @@ -34,6 +34,16 @@ export function leavesLoginRunning(s: LoginStep): boolean { return s.step === "code"; } +// Whether a start that answered after the dialog went away left a login +// running: it did unless the start itself failed. The dialog stops it then, +// since nothing is left to. +export function startOutlivedDialog( + dialogLive: boolean, + problem: ProblemDetails | null, +): boolean { + return !dialogLive && problem === null; +} + // What the code form may submit: the pasted code without surrounding // whitespace, or nothing. export function pastedCode(raw: string): string | null { diff --git a/frontend/packages/swarm-ui/test/claude-login.test.mjs b/frontend/packages/swarm-ui/test/claude-login.test.mjs index e61e05ff..4f707e34 100644 --- a/frontend/packages/swarm-ui/test/claude-login.test.mjs +++ b/frontend/packages/swarm-ui/test/claude-login.test.mjs @@ -9,6 +9,7 @@ import { claudeLoginPath, leavesLoginRunning, pastedCode, + startOutlivedDialog, } from "../src/agent/claude-login/ClaudeLoginSteps.ts"; test("the routes name the agent, escaped", () => { @@ -48,6 +49,12 @@ test("only a login waiting for its code is stopped on close", () => { assert.equal(leavesLoginRunning({ step: "stored" }), false); }); +test("a start that answers after the dialog closed is stopped, unless it failed", () => { + assert.equal(startOutlivedDialog(false, null), true); + assert.equal(startOutlivedDialog(true, null), false); + assert.equal(startOutlivedDialog(false, { detail: "no minter" }), false); +}); + 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);