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