diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts
index 1b130ce..8053f98 100644
--- a/web/src/spawnForm.ts
+++ b/web/src/spawnForm.ts
@@ -337,6 +337,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
emit();
}
+ /** Re-highlight the operator's already-picked tile after `renderCommandTiles`
+ * rebuilds the row from scratch (new tiles carry no `is-active` class) —
+ * without touching `command`, since that was already settled. */
+ function reapplyActiveTile(): void {
+ for (const tile of cmdTiles.querySelectorAll(".tile")) {
+ tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
+ }
+ }
+
function renderCommandTiles(): void {
cmdTiles.replaceChildren();
for (const p of presets) {
@@ -345,13 +354,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
tile.className = "tile";
tile.dataset.id = p.id;
tile.innerHTML = `${escapeHtml(p.label)}`;
- tile.addEventListener("click", () => selectPreset(p.id));
+ tile.addEventListener("click", () => {
+ commandTouched = true;
+ selectPreset(p.id);
+ });
cmdTiles.appendChild(tile);
}
}
+ // Set once the operator picks a tile or types a custom command — guards
+ // `applyInitialCommand()` the same way `nameTouched` guards a late name
+ // batch: a late `/api/info` resolution must never overwrite a field the
+ // operator has already touched (#76).
+ let commandTouched = false;
+
cmdRaw.addEventListener("input", () => {
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
+ commandTouched = true;
command = cmdRaw.value.trim();
emit();
}
@@ -367,7 +386,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (info.presets?.length) {
presets = info.presets;
renderCommandTiles();
- applyInitialCommand();
+ if (commandTouched) {
+ reapplyActiveTile();
+ } else {
+ applyInitialCommand();
+ }
}
})
.catch(() => {
diff --git a/web/src/spawnFormPreset.test.ts b/web/src/spawnFormPreset.test.ts
new file mode 100644
index 0000000..e7fe7bc
--- /dev/null
+++ b/web/src/spawnFormPreset.test.ts
@@ -0,0 +1,124 @@
+// @vitest-environment happy-dom
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
+
+/** Stubs fetch so `/api/info` only resolves once `resolveInfo()` is called —
+ * everything else (names, projects, gitroot) answers immediately with an
+ * empty/default body. Lets a test pick a preset (or type a custom command)
+ * mid-flight, then observe what the late `/api/info` resolution does to it. */
+function stubDeferredInfo(): { resolveInfo: () => void } {
+ let releaseInfo: () => void = () => {};
+ const infoGate = new Promise((resolve) => {
+ releaseInfo = resolve;
+ });
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes("/api/info")) {
+ await infoGate;
+ return new Response(
+ JSON.stringify({
+ hostname: "h",
+ version: "0",
+ home_dir: "/home/user",
+ presets: [
+ { id: "claude", label: "Claude", command: "claude" },
+ { id: "fish", label: "Fish", command: "fish" },
+ { id: "custom", label: "Custom…", command: "" },
+ ],
+ }),
+ { status: 200 },
+ );
+ }
+ if (url.includes("/api/names")) {
+ return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
+ }
+ return new Response(JSON.stringify([]), { status: 200 });
+ }),
+ );
+ return { resolveInfo: releaseInfo };
+}
+
+describe("spawnForm preset selection vs. late /api/info (#76)", () => {
+ let handle: SpawnFormHandle | null = null;
+
+ afterEach(() => {
+ handle?.destroy();
+ handle = null;
+ vi.unstubAllGlobals();
+ });
+
+ async function drain(): Promise {
+ for (let i = 0; i < 40; i++) await Promise.resolve();
+ }
+
+ it("keeps an operator-picked tile after /api/info resolves late", async () => {
+ const { resolveInfo } = stubDeferredInfo();
+ let values: SpawnFormValues | undefined;
+ handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
+ const form = handle.el;
+ document.body.appendChild(form);
+ await drain();
+
+ // Fallback presets are up; the operator picks "zsh" before /api/info lands.
+ const zshTile = [...form.querySelectorAll(".tile")].find(
+ (t) => t.dataset.id === "zsh",
+ )!;
+ zshTile.click();
+ expect(zshTile.classList.contains("is-active")).toBe(true);
+
+ // /api/info lands late with a preset list that doesn't even include "zsh".
+ resolveInfo();
+ await drain();
+
+ // The operator's pick must not be discarded back to the new list's first
+ // entry — `onChange` only starts firing once the initial load settles
+ // (`ready`), which happens right here, so this is the first place the
+ // published command is observable.
+ expect(values?.command).toBe("zsh");
+ });
+
+ it("keeps an operator-typed custom command after /api/info resolves late", async () => {
+ const { resolveInfo } = stubDeferredInfo();
+ let values: SpawnFormValues | undefined;
+ handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
+ const form = handle.el;
+ document.body.appendChild(form);
+ await drain();
+
+ const customTile = [...form.querySelectorAll(".tile")].find(
+ (t) => t.dataset.id === "custom",
+ )!;
+ customTile.click();
+ const cmdRaw = form.querySelector(".cmd-raw")!;
+ cmdRaw.value = "htop";
+ cmdRaw.dispatchEvent(new Event("input"));
+
+ resolveInfo();
+ await drain();
+
+ expect(values?.command).toBe("htop");
+ expect(cmdRaw.value).toBe("htop");
+ });
+
+ it("still applies the real presets' first entry when the operator never touched the command", async () => {
+ const { resolveInfo } = stubDeferredInfo();
+ let values: SpawnFormValues | undefined;
+ handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
+ const form = handle.el;
+ document.body.appendChild(form);
+ await drain();
+
+ resolveInfo();
+ await drain();
+
+ // Untouched: the refined preset list is still free to settle the command.
+ expect(values?.command).toBe("claude");
+ const fishTile = [...form.querySelectorAll(".tile")].find(
+ (t) => t.dataset.id === "fish",
+ );
+ expect(fishTile).toBeDefined();
+ });
+});