Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36cb03961c | ||
|
|
9b0e9751ec | ||
|
|
d1ed4f1fdc | ||
|
|
2c8758ed14 |
@@ -97,3 +97,24 @@ jobs:
|
|||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
run: pnpm -C web build
|
run: pnpm -C web build
|
||||||
|
|
||||||
|
# The flake pins pnpmDeps.hash against web/pnpm-lock.yaml (#98). A lockfile
|
||||||
|
# change invalidates the pin silently elsewhere, so the packaged build has to
|
||||||
|
# fail here too.
|
||||||
|
flake:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install nix
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://nixos.org/nix/install | sh -s -- --daemon --yes
|
||||||
|
echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: build flake packages
|
||||||
|
run: |
|
||||||
|
nix build \
|
||||||
|
.#webFrontend \
|
||||||
|
.#maestro \
|
||||||
|
.#maestro-web \
|
||||||
|
--no-link
|
||||||
|
|||||||
+25
-2
@@ -337,6 +337,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
emit();
|
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 {
|
function renderCommandTiles(): void {
|
||||||
cmdTiles.replaceChildren();
|
cmdTiles.replaceChildren();
|
||||||
for (const p of presets) {
|
for (const p of presets) {
|
||||||
@@ -345,13 +354,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
tile.className = "tile";
|
tile.className = "tile";
|
||||||
tile.dataset.id = p.id;
|
tile.dataset.id = p.id;
|
||||||
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
|
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
|
||||||
tile.addEventListener("click", () => selectPreset(p.id));
|
tile.addEventListener("click", () => {
|
||||||
|
commandTouched = true;
|
||||||
|
selectPreset(p.id);
|
||||||
|
});
|
||||||
cmdTiles.appendChild(tile);
|
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", () => {
|
cmdRaw.addEventListener("input", () => {
|
||||||
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
||||||
|
commandTouched = true;
|
||||||
command = cmdRaw.value.trim();
|
command = cmdRaw.value.trim();
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
@@ -367,7 +386,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
if (info.presets?.length) {
|
if (info.presets?.length) {
|
||||||
presets = info.presets;
|
presets = info.presets;
|
||||||
renderCommandTiles();
|
renderCommandTiles();
|
||||||
applyInitialCommand();
|
if (commandTouched) {
|
||||||
|
reapplyActiveTile();
|
||||||
|
} else {
|
||||||
|
applyInitialCommand();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -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<void>((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<void> {
|
||||||
|
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<HTMLButtonElement>(".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<HTMLButtonElement>(".tile")].find(
|
||||||
|
(t) => t.dataset.id === "custom",
|
||||||
|
)!;
|
||||||
|
customTile.click();
|
||||||
|
const cmdRaw = form.querySelector<HTMLInputElement>(".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<HTMLButtonElement>(".tile")].find(
|
||||||
|
(t) => t.dataset.id === "fish",
|
||||||
|
);
|
||||||
|
expect(fishTile).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user