feat(maestro): spawn form back in a modal on web
Draft panes are gone: an empty pane is a placeholder again, and every spawn affordance opens a dialog around the shared spawn form. Escape, Cancel, the ✕ and a backdrop click dismiss it; a successful submit puts the session in a new tab (Spawn similar and the fast-forward fallback keep placing it beside the pane they acted on). Removes the per-leaf draft state, its persistence strip, and the form reuse/teardown machinery the panes needed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
28778b2224
commit
6be0dba74e
@@ -0,0 +1,132 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { closeSpawnModal, openSpawnModal } from "./spawnModal.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
function stubFetch(): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const body = url.includes("/api/info")
|
||||
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
|
||||
: url.includes("/api/projects")
|
||||
? []
|
||||
: url.includes("/api/names")
|
||||
? { names: ["cello"] }
|
||||
: undefined;
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Drain the microtask queue so the form's initial loads land. */
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
const modal = () => document.getElementById("modal-host");
|
||||
|
||||
describe("spawn modal", () => {
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeSpawnModal();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("mounts the shared spawn form inside a dialog", async () => {
|
||||
openSpawnModal({ onSuccess: () => {} });
|
||||
await flush();
|
||||
|
||||
const host = modal()!;
|
||||
expect(host).toBeTruthy();
|
||||
expect(host.querySelector('[role="dialog"]')).toBeTruthy();
|
||||
expect(host.querySelector("form.spawn-form")).toBeTruthy();
|
||||
expect(host.querySelector("#spawn-title")?.textContent).toBe("Spawn session");
|
||||
// The modal adds the Cancel the shared form doesn't ship.
|
||||
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("titles a seeded open as a clone", async () => {
|
||||
openSpawnModal({ seed: { command: "claude" }, onSuccess: () => {} });
|
||||
await flush();
|
||||
|
||||
expect(modal()!.querySelector("#spawn-title")?.textContent).toBe("Spawn similar");
|
||||
});
|
||||
|
||||
it("closes on Escape, on Cancel and on a backdrop click", async () => {
|
||||
for (const dismiss of [
|
||||
() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })),
|
||||
() => (modal()!.querySelector(".form-actions .cancel") as HTMLButtonElement).click(),
|
||||
() => modal()!.dispatchEvent(new MouseEvent("click", { bubbles: true })),
|
||||
() => (modal()!.querySelector(".close") as HTMLButtonElement).click(),
|
||||
]) {
|
||||
openSpawnModal({ onSuccess: () => {} });
|
||||
await flush();
|
||||
dismiss();
|
||||
expect(modal()).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves no stray Escape handler behind after closing", async () => {
|
||||
openSpawnModal({ onSuccess: () => {} });
|
||||
await flush();
|
||||
closeSpawnModal();
|
||||
|
||||
// A second Escape with no modal up must not throw on the torn-down form.
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
expect(modal()).toBeNull();
|
||||
});
|
||||
|
||||
it("opening twice tears the first one down rather than stacking dialogs", async () => {
|
||||
openSpawnModal({ onSuccess: () => {} });
|
||||
await flush();
|
||||
openSpawnModal({ onSuccess: () => {} });
|
||||
await flush();
|
||||
|
||||
expect(document.querySelectorAll("#modal-host")).toHaveLength(1);
|
||||
expect(document.querySelectorAll("form.spawn-form")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("closes before handing the created session to its caller", async () => {
|
||||
const seen: Array<{ session: Session; stillOpen: boolean }> = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/api/sessions") && init?.method === "POST") {
|
||||
return new Response(JSON.stringify({ id: "s1", cwd: "/x", command: "zsh" }), { status: 200 });
|
||||
}
|
||||
const body = url.includes("/api/info")
|
||||
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
|
||||
: url.includes("/api/projects")
|
||||
? []
|
||||
: url.includes("/api/names")
|
||||
? { names: ["cello"] }
|
||||
: undefined;
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
);
|
||||
|
||||
openSpawnModal({
|
||||
// A plain, worktree-free spawn: the point here is where the created
|
||||
// session is handed over, not what the form validates.
|
||||
seed: { command: "zsh", cwd: "/x", cwdRawVisible: true, worktreeEnabled: false },
|
||||
onSuccess: (session) => seen.push({ session, stillOpen: modal() != null }),
|
||||
});
|
||||
await flush();
|
||||
(modal()!.querySelector("form.spawn-form") as HTMLFormElement).dispatchEvent(
|
||||
new Event("submit", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].session.id).toBe("s1");
|
||||
expect(seen[0].stillOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user