feat(desktop): mount Start Restart and Move controls

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-04 17:46:29 -04:00
parent 8b26bb20b5
commit def4f23776
3 changed files with 375 additions and 2 deletions

View File

@@ -0,0 +1,133 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { JSDOM } from "jsdom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { DesktopLifecycleControl } from "./DesktopLifecycleControl.tsx";
import { relayClient } from "../../../shared/api/relayClient.ts";
test("mounted Start exposes unavailable provisioning and exact retry; Restart resolves source", async () => {
const dom = new JSDOM("<div id='root'></div>", {
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 client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
client.setQueryData(
["relay-agents"],
[
{ pubkey: "agent", name: "Owned agent", ownerPubkey: "owner" },
{ pubkey: "foreign", name: "Foreign agent", ownerPubkey: "other" },
],
);
const scope = { owner: "owner", community: "wss://one.example" };
const originals = {
fetch: relayClient.fetchEvents,
publish: relayClient.publishEvent,
};
const prepared = [],
sent = [];
let stop = "failed";
window.__TAURI_INTERNALS__ = {
invoke: async (command, args) => {
assert.equal(args.owner, scope.owner);
assert.equal(args.community, scope.community);
if (command === "observe_desktop_placement") return;
if (command === "read_desktop_placement") return ["source", "selection"];
if (
command === "prepare_desktop_lifecycle" ||
command === "prepare_desktop_stop"
) {
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 args.request.action === "status"
? "running"
: "provisioning_unavailable";
if (command === "read_desktop_stop_results") return stop;
throw Error(command);
},
};
relayClient.fetchEvents = async () => [];
relayClient.publishEvent = async (event, _timeout, _failure, check) => {
check();
sent.push(event);
};
const root = createRoot(document.getElementById("root"));
const click = (text) =>
React.act(async () =>
[...document.querySelectorAll("button")]
.find((b) => b.textContent === text)
.click(),
);
const select = (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 () =>
root.render(
React.createElement(
QueryClientProvider,
{ client },
React.createElement(DesktopLifecycleControl, {
scope,
desktops: [
{ id: "source", name: "Source" },
{ id: "destination", name: "Destination" },
],
}),
),
),
);
assert.doesNotMatch(document.body.textContent, /Foreign agent/);
await select("Agent to place", "agent");
await select("Destination Desktop", "destination");
await click("Start on destination");
assert.match(
document.body.textContent,
/keyless launch provisioning is unavailable/,
);
assert.equal(prepared[0].desktop, "destination");
await click("Retry same request");
assert.equal(prepared.length, 1);
assert.equal(sent[0], sent[1]);
await click("Restart on current Desktop");
const restart = prepared.at(-1);
assert.equal(
restart.desktop,
"source",
"destination picker must not redirect Restart",
);
assert.equal(restart.action, "restart");
assert.equal(restart.observed, prepared.at(-2).id);
await click("Move to destination");
assert.match(document.body.textContent, /destination was not started/);
const count = prepared.length;
stop = "stopped";
await React.act(async () => {});
assert.equal(prepared.length, count, "late Stop cannot resume failed Move");
assert.doesNotMatch(document.body.textContent, /Retry same request/);
} finally {
await React.act(async () => root.unmount());
client.clear();
relayClient.fetchEvents = originals.fetch;
relayClient.publishEvent = originals.publish;
dom.window.close();
}
});

View File

@@ -0,0 +1,226 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/shared/ui/button";
import type { RelayEvent } from "@/shared/api/types";
import type { DesktopRow, DesktopScope } from "../desktopList";
import {
lifecycleClient,
receiveLifecycle,
type LifecycleOutcome,
} from "../desktopLifecycle";
import { useRelayAgentsQuery } from "../hooks";
export function DesktopLifecycleReceiver({
scope,
}: {
scope: DesktopScope | null;
}) {
const [error, setError] = useState("");
const { owner, community } = scope ?? {};
useEffect(() => {
if (!owner || !community) return;
let active = true;
let close: (() => void) | undefined;
setError("");
void receiveLifecycle({ owner, community }, () => active, setError)
.then((fn) => {
if (active) close = fn;
else fn();
})
.catch(() => {
if (active) setError("Desktop lifecycle receiver is unavailable.");
});
return () => {
active = false;
close?.();
};
}, [owner, community]);
return error ? (
<p role="status" className="text-xs text-muted-foreground">
{error}
</p>
) : null;
}
function message(outcome: LifecycleOutcome) {
switch (outcome) {
case "running":
return "Desktop confirmed a running local process. This does not prove model readiness.";
case "provisioning_unavailable":
return "Destination keyless launch provisioning is unavailable. No new process was started.";
case "stopped":
return "Desktop reports the agent stopped.";
case "failed":
return "Desktop rejected or failed the operation. No successful launch was confirmed.";
default:
return "Operation unconfirmed. A dispatched effect may still finish; no automatic retry will run.";
}
}
/** Start/Move choose destination; Restart has no host picker and resolves actual current state. */
export function DesktopLifecycleControl({
scope,
desktops,
}: {
scope: DesktopScope;
desktops: DesktopRow[];
}) {
const agents = useRelayAgentsQuery();
const [agent, setAgent] = useState("");
const [destination, setDestination] = useState("");
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState("");
const [request, setRequest] = useState<RelayEvent | null>(null);
const active = useRef(true);
const generation = useRef(0);
useEffect(() => {
active.current = true;
return () => {
active.current = false;
generation.current++;
};
}, []);
const run = async (action: "start" | "restart" | "move" | "retry") => {
const token = ++generation.current;
const valid = () => active.current && generation.current === token;
const client = lifecycleClient(scope, valid);
setBusy(true);
setStatus("Checking authenticated Desktop state…");
if (action !== "retry") setRequest(null);
try {
if (action === "move") {
const outcome = await client.move(
agent,
destination,
desktops.map((d) => d.id),
(stage) => {
if (valid()) setStatus(stage);
},
);
client.check();
setStatus(message(outcome));
} else {
const next =
action === "retry"
? request
: action === "start"
? await client.start(destination, agent)
: await client.restart(
agent,
desktops.map((d) => d.id),
);
if (!next) throw new Error("No request to retry");
client.check();
setRequest(next);
setStatus("Request sent. Waiting for the Desktop’s actual result…");
const outcome = await client.send(next);
client.check();
setStatus(message(outcome));
}
} catch (error) {
if (valid())
setStatus(
error instanceof Error ? error.message : "Operation unconfirmed",
);
} finally {
if (valid()) setBusy(false);
}
};
const reset = () => {
setRequest(null);
setStatus("");
};
return (
<section
aria-label="Agent placement controls"
className="space-y-2 rounded border p-3"
>
<h3 className="text-sm font-medium">Start, restart, or move an agent</h3>
<label className="block text-xs">
Agent
<select
aria-label="Agent to place"
value={agent}
disabled={busy}
onChange={(e) => {
setAgent(e.target.value);
reset();
}}
className="ml-2 rounded border bg-background p-1"
>
<option value="">Choose your agent</option>
{(agents.data ?? [])
.filter((a) => a.ownerPubkey === scope.owner)
.map((a) => (
<option key={a.pubkey} value={a.pubkey}>
{a.name}
</option>
))}
</select>
</label>
<Button
size="sm"
variant="outline"
disabled={!agent || busy}
onClick={() => void run("restart")}
>
Restart on current Desktop
</Button>
<label className="block text-xs">
Destination for Start or Move
<select
aria-label="Destination Desktop"
value={destination}
disabled={busy}
onChange={(e) => {
setDestination(e.target.value);
reset();
}}
className="ml-2 rounded border bg-background p-1"
>
<option value="">Choose a Desktop</option>
{desktops.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
</label>
<p className="text-xs text-muted-foreground">
Start may overlap with an agent still running elsewhere until that
Desktop reconnects. Move starts the destination only after source Stop
is confirmed. Nothing transfers files, configuration, or keys.
</p>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={!agent || !destination || busy}
onClick={() => void run("start")}
>
Start on destination
</Button>
<Button
size="sm"
variant="outline"
disabled={!agent || !destination || busy}
onClick={() => void run("move")}
>
Move to destination
</Button>
{request && (
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => void run("retry")}
>
Retry same request
</Button>
)}
</div>
{status && (
<p role="status" className="text-xs">
{status}
</p>
)}
</section>
);
}

View File

@@ -1,4 +1,8 @@
import { DesktopStopControl, DesktopStopReceiver } from "./DesktopStopControl";
import {
DesktopLifecycleControl,
DesktopLifecycleReceiver,
} from "./DesktopLifecycleControl";
import { DesktopStopControl } from "./DesktopStopControl";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useIdentityQuery } from "@/shared/api/hooks";
@@ -62,6 +66,7 @@ function useDesktopList() {
/** Startup and Agents share the existing owner/community query cache. */
export function DesktopListStartup() {
const [epoch, setEpoch] = useState(0);
const { refetch } = useDesktopList();
const { refetch: pulse } = useDesktopObservations(useDesktopScope());
const { refetch: report } = useDesktopCapabilities(useDesktopScope());
@@ -71,6 +76,7 @@ export function DesktopListStartup() {
void report();
}, DESKTOP_PULSE_MS);
const unsubscribe = relayClient.subscribeToReconnects(() => {
setEpoch((n) => n + 1);
void refetch();
void pulse();
void report();
@@ -80,7 +86,8 @@ export function DesktopListStartup() {
unsubscribe();
};
}, [refetch, pulse, report]);
return <DesktopStopReceiver scope={useDesktopScope()} />;
const scope = useDesktopScope();
return <DesktopLifecycleReceiver key={`lifecycle:${epoch}`} scope={scope} />;
}
export function KnownDesktops() {
@@ -170,6 +177,13 @@ export function DesktopListView({
<p role="status">Partial list: showing up to 100 profiles.</p>
)}
{list && !list.rows.length && !error && <p>No Desktop profiles found.</p>}
{scope && list && (
<DesktopLifecycleControl
key={`${scope.owner}:${scope.community}`}
scope={scope}
desktops={list.rows}
/>
)}
<ul className="space-y-2">
{list?.rows.map((row) => (
<li key={row.id} className="text-sm">