From 651cb1ba2643a282a58e1ce87a96d8544d4c0d03 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 7 Sep 2026 11:11:16 -0400 Subject: [PATCH] fix(desktop): retire lifecycle continuations when controls are canceled Signed-off-by: Logan Johnson --- .../ui/DesktopLifecycleControl.test.mjs | 122 ++++++++++++++++++ .../agents/ui/DesktopLifecycleControl.tsx | 23 +++- 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs index 17aff55e75..302964c799 100644 --- a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs @@ -261,3 +261,125 @@ test("terminal receiver failure is a scope-owned notification, not pre-shell lay dom.window.close(); } }); + +for (const interruption of ["cancel", "account", "community", "unmount"]) { + test(`mounted ${interruption} retires a pending Stop and never starts on late confirmation`, async () => { + const dom = new JSDOM("
", { + url: "https://desktop.test", + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const { createRoot } = await import("react-dom/client"); + const query = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + query.setQueryData( + ["relay-agents"], + [{ pubkey: "agent", name: "Agent", ownerPubkey: "owner" }], + ); + const original = { + fetch: relayClient.fetchEvents, + publish: relayClient.publishEvent, + }; + const prepared = []; + let confirmStop; + window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + if (command === "observe_desktop_placement") return; + if (command === "read_desktop_placement") + return ["source", "selection"]; + if (command.startsWith("prepare_desktop_")) { + const request = { + id: `request-${prepared.length}`, + kind: command.endsWith("_stop") ? 50180 : 50182, + ...args, + }; + prepared.push(request); + return request; + } + if (command === "read_desktop_lifecycle_results") return "running"; + if (command === "read_desktop_stop_results") + return new Promise((resolve) => { + confirmStop = resolve; + }); + throw Error(command); + }, + }; + relayClient.fetchEvents = async () => []; + relayClient.publishEvent = async (_event, _timeout, _failure, check) => + check(); + const root = createRoot(document.getElementById("root")); + const scope = { owner: "owner", community: "wss://one.example" }; + const render = (nextScope) => + root.render( + React.createElement( + QueryClientProvider, + { client: query }, + React.createElement(DesktopLifecycleControl, { + scope: nextScope, + desktops: [ + { id: "source", name: "Source" }, + { id: "target", name: "Target" }, + ], + }), + ), + ); + const click = async (text) => + React.act(async () => + [...document.querySelectorAll("button")] + .find((b) => b.textContent === text) + .click(), + ); + const select = async (label, value) => + React.act(async () => { + const element = document.querySelector(`select[aria-label="${label}"]`); + element.value = value; + element.dispatchEvent( + new dom.window.Event("change", { bubbles: true }), + ); + }); + try { + await React.act(async () => render(scope)); + await select("Agent to place", "agent"); + await select("Destination Desktop", "target"); + await click("Move to destination"); + assert.equal(typeof confirmStop, "function"); + if (interruption === "cancel") await click("Cancel waiting"); + else if (interruption === "unmount") + await React.act(async () => root.unmount()); + else + await React.act(async () => + render({ + ...scope, + [interruption === "account" ? "owner" : "community"]: "changed", + }), + ); + await React.act(async () => confirmStop("stopped")); + assert.equal(prepared.filter((r) => r.action === "start").length, 0); + assert.doesNotMatch( + document.body.textContent, + /Retry same request|Source Stop confirmed/, + ); + if (interruption === "cancel") + assert.match( + document.body.textContent, + /Dispatched operations may still finish/, + ); + if (interruption === "account" || interruption === "community") + assert.equal( + document.querySelector('select[aria-label="Agent to place"]').value, + "", + ); + } finally { + await React.act(async () => root.unmount()); + query.clear(); + relayClient.fetchEvents = original.fetch; + relayClient.publishEvent = original.publish; + dom.window.close(); + } + }); +} diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx index 8212eb7258..b022c409dd 100644 --- a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx @@ -82,11 +82,16 @@ export function DesktopLifecycleControl({ const generation = useRef(0); useEffect(() => { active.current = true; + setAgent(""); + setDestination(""); + setRequest(null); + setStatus(""); + setBusy(false); return () => { active.current = false; generation.current++; }; - }, []); + }, [scope.owner, scope.community]); const run = async (action: "start" | "restart" | "move" | "retry") => { const token = ++generation.current; const valid = () => active.current && generation.current === token; @@ -215,6 +220,22 @@ export function DesktopLifecycleControl({ > Move to destination + {busy && ( + + )} {request && (