diff --git a/DESIGN.md b/DESIGN.md index 7cb8220..cbf589a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -100,10 +100,11 @@ Three surfaces, all DOM built in TS, styled by class: no-sessions, no-search-match. - **Attach** — header (back, `session `, live/closed/error state pill) over a framed dark xterm with a green cursor. -- **Draft pane** — the spawn form mounted inline in an empty split pane, - focusable like any session pane; mono inputs with green focus ring; grouped - worktree fieldset. Spawn opens one (reusing an empty focused pane, else - splitting), and "Spawn similar" seeds one from a session's dir + command. +- **Spawn modal** — the spawn form in a centered dialog (a bottom sheet below + 600px) over a blurred backdrop; mono inputs with green focus ring; grouped + worktree fieldset. Escape, Cancel, ✕ and a backdrop click all dismiss it. + Submitting gives the new session its own tab; "Spawn similar" seeds the form + from a session's dir + command and puts the result beside it. Interactive elements ship default / hover / focus-visible / active / disabled states; the accent green is used only for action, selection, focus, and live @@ -112,5 +113,5 @@ status — never decoration. ## Dev review aids (DEV-only, stripped from production) `web/src/mock.ts` + query params seed a representative catalog without a running -daemon: `?mock` (seed sessions), `?mock&spawn` (open a draft pane), +daemon: `?mock` (seed sessions), `?mock&spawn` (open the spawn modal), `?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`. diff --git a/web/README.md b/web/README.md index 5451a56..246acf3 100644 --- a/web/README.md +++ b/web/README.md @@ -22,8 +22,9 @@ src/ events.ts # `/api/events` WebSocket subscriber catalog.ts # session tree view (groups by working directory) attach.ts # xterm.js + attach WS view - multiview.ts # tabs + split panes; draft panes host the spawn form + multiview.ts # tabs + split panes spawnForm.ts # shared spawn form (preset tiles, project picker, worktree) + spawnModal.ts # dialog chrome around the spawn form theme.ts # dark/light theme toggle + persistence types.ts # mirror of `maestro-protocol` JSON shapes styles.css # design-system tokens + component styles @@ -41,7 +42,7 @@ follows the OS by default and can be pinned via the header toggle (persisted in `mock.ts` is a DEV-only fixture (stripped from production builds): visit the dev server with `?mock` to seed a representative catalog without a running daemon, -`?mock&spawn` to open a draft pane, and `?theme=light|dark` to pin a theme. +`?mock&spawn` to open the spawn modal, and `?theme=light|dark` to pin a theme. Routing is hash-based: - `#/` → catalog diff --git a/web/src/catalog.ts b/web/src/catalog.ts index c4e5a00..651bab5 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -26,7 +26,7 @@ import type { TreeNode } from "./tree.js"; import { buildTree } from "./tree.js"; import { createThemeToggle } from "./theme.js"; -/** Ask the shell to open a new draft pane (#62). Handled in main.ts, where the +/** Ask the shell to open the spawn modal. Handled in main.ts, where the * multi-view controller lives; dispatched from every Spawn affordance. */ export const OPEN_SPAWN_EVENT = "catalog-open-spawn"; export function requestOpenSpawn(): void { @@ -38,7 +38,7 @@ export function requestOpenSpawn(): void { * connection pool. */ export const FOCUS_PANE_EVENT = "catalog-focus-pane"; -/** Ask the shell to open a draft pane seeded from an existing session +/** Ask the shell to open the spawn modal seeded from an existing session * ("Spawn similar"). Detail is the session being cloned. */ export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar"; @@ -395,7 +395,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S spawnBtn.className = "btn btn-primary spawn-btn"; spawnBtn.textContent = "+ Spawn"; // The keyboard pair sits right here in the tooltip (#67). - spawnBtn.title = "Draft pane: n — fast-forward spawn: N"; + spawnBtn.title = "Spawn form: n — fast-forward spawn: N"; spawnBtn.addEventListener("click", () => requestOpenSpawn()); footer.appendChild(spawnBtn); el.appendChild(footer); @@ -578,7 +578,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S document.addEventListener("catalog-rerender", rerenderHandler); // "Spawn similar" (#28) is dispatched here and handled in main.ts, where the - // multi-view controller can seed a draft pane with the session's dir/command. + // multi-view controller can seed the spawn modal with the session's dir/command. const unsubscribe = store.subscribe(refresh); diff --git a/web/src/dirAutocomplete.ts b/web/src/dirAutocomplete.ts index 3ec9114..b9d9d07 100644 --- a/web/src/dirAutocomplete.ts +++ b/web/src/dirAutocomplete.ts @@ -9,7 +9,7 @@ // ↑/↓ move the highlight // Enter accept the highlighted entry (and suppress form submit) // Esc close the dropdown, refocus the input, and swallow the key so it -// can't also discard a surrounding draft pane +// can't also discard the surrounding spawn modal // // Once dismissed (Esc or blur) the popup stays closed until the operator types // again — refocusing alone doesn't reopen it. @@ -181,8 +181,8 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH if (e.key === "Escape") { if (open) { e.preventDefault(); - // Swallow the key: an Escape that bubbles would also discard the - // draft pane hosting this form. + // Swallow the key: an Escape that bubbles would also close the + // spawn modal hosting this form. e.stopPropagation(); dismiss(); input.focus(); diff --git a/web/src/fastForward.ts b/web/src/fastForward.ts index 39d620c..6a47f44 100644 --- a/web/src/fastForward.ts +++ b/web/src/fastForward.ts @@ -1,12 +1,12 @@ // Fast-forward spawn (#67): one keystroke, every default resolved, no form. // Pure resolution only — the caller (main.ts) owns the fetches and the POST, -// and falls back to a seeded draft pane when anything is missing. +// and falls back to a seeded spawn form when anything is missing. import type { CreateSessionRequest, Project, SpawnPreset } from "./types.js"; import { sortProjects } from "./projectList.js"; /** What one fast-forward keystroke resolved. Anything absent means "open a - * draft pre-filled with the rest instead of spawning". */ + * form pre-filled with the rest instead of spawning". */ export interface FastForwardResolution { project?: Project; command?: string; @@ -47,7 +47,7 @@ export function buildFastForwardRequest( }; } -/** Seed for the fallback draft pane: whatever did resolve goes into the +/** Seed for the fallback spawn form: whatever did resolve goes into the * form; the rest stays with its own defaults. */ export function fastForwardSeed(res: FastForwardResolution) { return { @@ -55,8 +55,8 @@ export function fastForwardSeed(res: FastForwardResolution) { command: res.command ?? "", name: res.name ?? "", // Left undefined rather than false when nothing resolved: the form - // defaults the toggle on, and `false` would stick, so the fallback draft - // would disagree with both a normally-opened draft and the TUI's fast path + // defaults the toggle on, and `false` would stick, so the fallback form + // would disagree with both a normally-opened form and the TUI's fast path // (#67 review). worktreeEnabled: res.project ? true : undefined, worktreeBranch: diff --git a/web/src/help.ts b/web/src/help.ts index 895a8b2..e6a0565 100644 --- a/web/src/help.ts +++ b/web/src/help.ts @@ -17,7 +17,7 @@ export const SHORTCUTS: ShortcutGroup[] = [ { title: "Sessions", items: [ - { keys: ["n"], description: "Open a spawn draft pane" }, + { keys: ["n"], description: "Open the spawn form" }, { keys: ["N"], description: "Spawn a session with every default resolved" }, ], }, diff --git a/web/src/layout.ts b/web/src/layout.ts index bf98155..51a2ec5 100644 --- a/web/src/layout.ts +++ b/web/src/layout.ts @@ -11,23 +11,8 @@ import type { Uuid } from "./types.js"; export type Dir = "row" | "col"; -/** Half-filled spawn-form state for a draft pane (issue #57): an empty leaf - * renders the spawn form instead of a placeholder, and each pane's values - * hang off its own leaf so two drafts stay independent. Never persisted — - * a reload gives back an empty form (persistence strips it). */ -export interface Draft { - command: string; - cwd: string; - name: string; - worktreeEnabled?: boolean; - worktreeBranch?: string; - worktreeCleanup?: boolean; - /** Whether the cwd lives in the raw text input (vs a picked recent row). */ - cwdRawVisible?: boolean; -} - export type Node = - | { kind: "leaf"; id: string; sessionId: Uuid | null; draft?: Draft | null } + | { kind: "leaf"; id: string; sessionId: Uuid | null } | { kind: "split"; dir: Dir; a: Node; b: Node; ratio: number }; export type Leaf = Extract; @@ -104,7 +89,7 @@ export function splitLeaf( dir: Dir, after: boolean, ): string { - const fresh: Leaf = { kind: "leaf", id: freshId("l"), sessionId, draft: null }; + const fresh: Leaf = { kind: "leaf", id: freshId("l"), sessionId }; const rec = (n: Node): Node => { if (n.kind === "leaf") { if (n.id !== targetId) return n; diff --git a/web/src/main.ts b/web/src/main.ts index e9d40c7..d9a0e60 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -21,6 +21,7 @@ import { resolveFastForward, } from "./fastForward.js"; import { FALLBACK_PRESETS } from "./spawnForm.js"; +import { openSpawnModal } from "./spawnModal.js"; import { spawnSimilarSeed } from "./spawnSimilar.js"; import { mountHud, type Hud } from "./hud.js"; import { emptyLayout, type Layout, openSessions } from "./layout.js"; @@ -219,8 +220,8 @@ mv = createMultiView({ infoFor, onDisconnect: closePane, renderWelcome, - // A draft-pane submit (#57) fills its leaf in place; the shell route may flip - // from catalog (no sessions) to attach. + // A spawn fills a pane with its new session; the shell route may flip from + // catalog (no sessions) to attach. onOpenSessionsChange: updateShellRoute, onLayoutChange: scheduleSave, }); @@ -234,32 +235,37 @@ function showMultiView(): void { updateShellRoute(); } -// Spawn affordances (#62): the sidebar's footer button, its empty state, and -// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one from the +// Spawn affordances: the sidebar's footer button, its empty state, and +// `?mock&spawn` all open the spawn modal; "Spawn similar" seeds one from the // cloned session (see spawnSimilar.ts). function requestOpenSpawn(): void { - showMultiView(); - // A new tab, not a split of the pane you were reading (#100). "Spawn - // similar" and the fast-forward key below deliberately keep the old - // behaviour: both act on the session in front of you, so a split beside it - // is the point. - mv.openDraft(undefined, { newTab: true }); + // The session lands in a new tab, not a split of the pane you were reading + // (#100). "Spawn similar" and the fast-forward key below deliberately keep + // the old behaviour: both act on the session in front of you, so a split + // beside it is the point. + openSpawnModal({ onSuccess: (session) => mountSpawned(session, { newTab: true }) }); } document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn()); document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => { const s = (ev as CustomEvent).detail; - showMultiView(); - mv.openDraft(spawnSimilarSeed(s)); + openSpawnModal({ seed: spawnSimilarSeed(s), onSuccess: (session) => mountSpawned(session) }); }); -// Fast-forward spawn (#67): the draft pane's keystroke neighbour spawns +/** Show a freshly spawned session: bring the multi-view up (the shell may still + * be on the catalog route) and give the session a pane. */ +function mountSpawned(session: Session, placement?: { newTab?: boolean }): void { + showMultiView(); + mv.openSessionBeside(session.id, placement); + updateShellRoute(); +} + +// Fast-forward spawn (#67): the spawn modal's keystroke neighbour spawns // immediately with every default resolved — most recent project, its last // command (or the first non-custom preset), a fresh name, worktree on with // a new branch named after that name. Anything unresolvable (no projects -// known yet, no command, no generated name) opens a draft pre-filled with +// known yet, no command, no generated name) opens the form pre-filled with // what did resolve instead. async function requestFastForward(): Promise { - showMultiView(); const [discovered, name] = await Promise.all([ fetchProjects().catch(() => [] as Project[]), fetchNames(1) @@ -281,18 +287,20 @@ async function requestFastForward(): Promise { // Mount it, the way the TUI's fast path attaches. Without this the // keystroke creates a session and leaves the UI untouched, so it reads // as a dead key (#67 review). - mv.openSessionBeside(session.id); - updateShellRoute(); + mountSpawned(session); return; } catch { - // A failed POST degrades to the same pre-filled draft as an + // A failed POST degrades to the same pre-filled form as an // unresolvable default — never a silent dead keystroke. } } - mv.openDraft(fastForwardSeed(resolved)); + openSpawnModal({ + seed: fastForwardSeed(resolved), + onSuccess: (session) => mountSpawned(session), + }); } -// Keyboard pair (#67): `n` opens a draft pane, `N` fast-forwards a spawn. +// Keyboard pair (#67): `n` opens the spawn modal, `N` fast-forwards a spawn. // Ignored while typing anywhere (form fields, the terminal's hidden // textarea, search) so an attached session's keys are never hijacked. document.addEventListener("keydown", (e) => { diff --git a/web/src/mock.ts b/web/src/mock.ts index 597f958..2717dcb 100644 --- a/web/src/mock.ts +++ b/web/src/mock.ts @@ -147,7 +147,7 @@ export function seedMock(store: CatalogStore): void { sessions, }); - // `?mock&spawn` auto-opens a draft pane for visual review, with the spawn + // `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn // backend endpoints stubbed so presets/projects/gitroot all render. if (location.search.includes("spawn")) { stubSpawnApi(); diff --git a/web/src/multiview.test.ts b/web/src/multiview.test.ts index 19957e3..b82230a 100644 --- a/web/src/multiview.test.ts +++ b/web/src/multiview.test.ts @@ -55,12 +55,12 @@ function makeMv(layout: Layout): MultiView { }); } -/** Drain the microtask queue so the draft form's async seed application lands. */ +/** Drain the microtask queue so any pending async work lands. */ async function flush(): Promise { for (let i = 0; i < 20; i++) await Promise.resolve(); } -describe("multiview openDraft (#62)", () => { +describe("multiview empty panes", () => { beforeEach(() => { stubFetch(); document.body.replaceChildren(); @@ -70,144 +70,23 @@ describe("multiview openDraft (#62)", () => { vi.unstubAllGlobals(); }); - it("turns an empty tab into a draft pane", async () => { - const layout = emptyLayout(); - const mv = makeMv(layout); - document.body.appendChild(mv.el); - - mv.openDraft(); - await flush(); - - const ls = leaves(layout.tabs[0].root); - expect(ls).toHaveLength(1); - expect(ls[0].sessionId).toBeNull(); - expect(ls[0].draft).not.toBeNull(); - expect(mv.el.querySelector(".mv-draft")).toBeTruthy(); - }); - - it("splits the focused session pane into a fresh draft", async () => { + it("renders a placeholder, not a spawn form", async () => { const layout = emptyLayout(); const tab = layout.tabs[0]; const first = splitLeaf(tab, "", "s1", "row", true); - tab.focusedLeafId = first; + tab.focusedLeafId = splitLeaf(tab, first, null, "row", true); const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); - - mv.openDraft(); await flush(); - const ls = leaves(tab.root); - expect(ls).toHaveLength(2); - expect(ls[0].sessionId).toBe("s1"); - const draftLeaf = ls.find((l) => l.sessionId === null)!; - expect(draftLeaf.draft).not.toBeNull(); - expect(tab.focusedLeafId).toBe(draftLeaf.id); expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(2); - }); - - it("reuses the focused pane when it is already empty", async () => { - const layout = emptyLayout(); - const tab = layout.tabs[0]; - const first = splitLeaf(tab, "", "s1", "row", true); - const empty = splitLeaf(tab, first, null, "row", true); - tab.focusedLeafId = empty; - const mv = makeMv(layout); - document.body.appendChild(mv.el); - mv.render(); - - mv.openDraft(); - await flush(); - - const ls = leaves(tab.root); - expect(ls).toHaveLength(2); - const target = ls.find((l) => l.id === empty)!; - expect(target.draft).not.toBeNull(); - expect(tab.focusedLeafId).toBe(empty); - }); - - it("keeps the draft form instance when an unrelated session closes (#68)", async () => { - const layout = emptyLayout(); - const tab = layout.tabs[0]; - const s1 = splitLeaf(tab, "", "s1", "row", true); - const draftLeaf = splitLeaf(tab, s1, null, "row", true); - tab.focusedLeafId = draftLeaf; - const mv = makeMv(layout); - document.body.appendChild(mv.el); - mv.render(); - await flush(); - - const nameInput = mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement; - nameInput.value = "wip"; - nameInput.dispatchEvent(new Event("input")); - await flush(); - expect(leaves(tab.root).find((l) => l.id === draftLeaf)?.draft?.name).toBe("wip"); - - const formBefore = mv.el.querySelector(".mv-draft"); - mv.closeSession("s1"); - await flush(); - - expect(mv.el.querySelector(".mv-draft")).toBe(formBefore); - expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip"); - }); - - it("reuses an already-synced empty pane instead of splitting (#72)", async () => { - const layout = emptyLayout(); - const tab = layout.tabs[0]; - const first = splitLeaf(tab, "", "s1", "row", true); - const empty = splitLeaf(tab, first, null, "row", true); - tab.focusedLeafId = empty; - const mv = makeMv(layout); - document.body.appendChild(mv.el); - mv.render(); - // Let the mounted form's loadProjects() settle and auto-sync its picked - // defaults into leaf.draft before openDraft runs (#72's dead-branch window). - await flush(); - expect(leaves(tab.root).find((l) => l.id === empty)?.draft).not.toBeNull(); - - mv.openDraft(); - await flush(); - - expect(leaves(tab.root)).toHaveLength(2); - expect(tab.focusedLeafId).toBe(empty); - }); - - it("keeps a seed after reusing an already-synced empty pane (#72)", async () => { - const layout = emptyLayout(); - const tab = layout.tabs[0]; - const first = splitLeaf(tab, "", "s1", "row", true); - const empty = splitLeaf(tab, first, null, "row", true); - tab.focusedLeafId = empty; - const mv = makeMv(layout); - document.body.appendChild(mv.el); - mv.render(); - await flush(); - - mv.openDraft({ cwd: "/home/user/proj", command: "claude" }); - await flush(); - - const leaf = leaves(tab.root).find((l) => l.id === empty)!; - expect(leaf.draft?.cwd).toBe("/home/user/proj"); - expect(leaf.draft?.command).toBe("claude"); - const cwdRaw = mv.el.querySelector(".mv-draft .cwd-raw") as HTMLInputElement; - expect(cwdRaw.value).toBe("/home/user/proj"); - }); - - it("seeds a clone's cwd and command into the draft", async () => { - const layout = emptyLayout(); - const mv = makeMv(layout); - document.body.appendChild(mv.el); - - mv.openDraft({ cwd: "/home/user/proj", command: "claude" }); - await flush(); - - const leaf = leaves(layout.tabs[0].root)[0]; - expect(leaf.draft?.cwd).toBe("/home/user/proj"); - expect(leaf.draft?.command).toBe("claude"); + expect(mv.el.querySelector(".mv-pane-empty")).toBeTruthy(); + expect(mv.el.querySelector(".spawn-form")).toBeNull(); }); }); -describe("multiview pane-drag swap (#73)", () => { +describe("multiview pane-drag swap", () => { beforeEach(() => { stubFetch(); document.body.replaceChildren(); @@ -223,8 +102,8 @@ describe("multiview pane-drag swap (#73)", () => { * updateDock() in multiview.ts, since neither is exposed on MultiView. */ async function dragPaneOntoCenter(sourcePane: HTMLElement, targetPane: HTMLElement): Promise { // The real drag-start gate requires the pointerdown target to sit inside - // an `.attach-header` (attach.ts's session title bar). Draft panes mount - // no such element, so stand one in for the gate check itself. + // an `.attach-header` (attach.ts's session title bar), which the fake pool + // never mounts; stand one in for the gate check itself. const marker = document.createElement("div"); marker.className = "attach-header"; sourcePane.appendChild(marker); @@ -254,40 +133,26 @@ describe("multiview pane-drag swap (#73)", () => { await flush(); } - it("moves each draft's form along with its data when two draft panes are swapped", async () => { + it("swaps the two panes' sessions when one is dropped on the other's centre", async () => { const layout = emptyLayout(); const tab = layout.tabs[0]; - const leaf1 = splitLeaf(tab, "", null, "row", true); - const leaf2 = splitLeaf(tab, leaf1, null, "row", true); + const leaf1 = splitLeaf(tab, "", "s1", "row", true); + const leaf2 = splitLeaf(tab, leaf1, "s2", "row", true); const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); await flush(); const paneOf = (leafId: string) => mv.el.querySelector(`[data-leaf-id="${leafId}"]`) as HTMLElement; - const nameInputOf = (leafId: string) => paneOf(leafId).querySelector("input[name=name]") as HTMLInputElement; - - nameInputOf(leaf1).value = "west"; - nameInputOf(leaf1).dispatchEvent(new Event("input")); - nameInputOf(leaf2).value = "east"; - nameInputOf(leaf2).dispatchEvent(new Event("input")); - await flush(); - expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("west"); - expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("east"); - await dragPaneOntoCenter(paneOf(leaf1), paneOf(leaf2)); - // The leaf data swaps (already correct pre-fix)... - expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("east"); - expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("west"); - // ...and the mounted forms must follow, not stay behind showing the old - // pane's values (#73). - expect(nameInputOf(leaf1).value).toBe("east"); - expect(nameInputOf(leaf2).value).toBe("west"); + expect(leaves(tab.root).find((l) => l.id === leaf1)?.sessionId).toBe("s2"); + expect(leaves(tab.root).find((l) => l.id === leaf2)?.sessionId).toBe("s1"); + expect(tab.focusedLeafId).toBe(leaf2); }); }); -describe("openDraft newTab (#100)", () => { +describe("openSessionBeside newTab (#100)", () => { beforeEach(() => { stubFetch(); document.body.replaceChildren(); @@ -306,7 +171,7 @@ describe("openDraft newTab (#100)", () => { document.body.appendChild(mv.el); mv.render(); - mv.openDraft(undefined, { newTab: true }); + mv.openSessionBeside("s2", { newTab: true }); await flush(); expect(layout.tabs).toHaveLength(2); @@ -314,11 +179,10 @@ describe("openDraft newTab (#100)", () => { const original = leaves(layout.tabs[0].root); expect(original).toHaveLength(1); expect(original[0].sessionId).toBe("s1"); - // The draft is alone in the new, now-active tab. + // The new session is alone in the new, now-active tab. const fresh = leaves(layout.tabs[1].root); expect(fresh).toHaveLength(1); - expect(fresh[0].sessionId).toBeNull(); - expect(fresh[0].draft).not.toBeNull(); + expect(fresh[0].sessionId).toBe("s2"); expect(layout.activeTabId).toBe(layout.tabs[1].id); }); @@ -330,7 +194,7 @@ describe("openDraft newTab (#100)", () => { document.body.appendChild(mv.el); mv.render(); - mv.openDraft(); + mv.openSessionBeside("s2"); await flush(); expect(layout.tabs).toHaveLength(1); @@ -399,7 +263,7 @@ describe("multiview closeSession tab cleanup (#137)", () => { expect(leaves(tab.root)).toHaveLength(1); }); - it("keeps a tab holding a draft pane", async () => { + it("keeps a tab holding an empty pane", async () => { const layout = emptyLayout(); const tab = layout.tabs[0]; const s1 = splitLeaf(tab, "", "s1", "row", true); @@ -478,7 +342,7 @@ describe("multiview mobile single pane", () => { expect(layout.tabs).toHaveLength(1); }); - it("keeps a spawn draft in the current pane rather than a new tab", async () => { + it("keeps a spawned session in the current pane rather than a new tab", async () => { const layout = emptyLayout(); const tab = layout.tabs[0]; tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true); @@ -486,14 +350,13 @@ describe("multiview mobile single pane", () => { document.body.appendChild(mv.el); mv.render(); - mv.openDraft(undefined, { newTab: true }); + mv.openSessionBeside("s2", { newTab: true }); await flush(); expect(layout.tabs).toHaveLength(1); const ls = leaves(tab.root); expect(ls).toHaveLength(1); - expect(ls[0].sessionId).toBeNull(); - expect(ls[0].draft).not.toBeNull(); + expect(ls[0].sessionId).toBe("s2"); }); it("renders one pane and no pane pills for a multi-leaf tab", () => { diff --git a/web/src/multiview.ts b/web/src/multiview.ts index 2b8b07c..dc16132 100644 --- a/web/src/multiview.ts +++ b/web/src/multiview.ts @@ -1,9 +1,8 @@ // The multi-view renderer/controller (issues #30 splits, #31 tabs, #33 // highlights). Owns the tab bar + canvas inside the content pane, builds the // active tab's split tree into DOM, and mounts a live AttachConnection into each -// session leaf via the pool. Empty leaves are draft panes (#57): they render -// the spawn form inline, and submitting replaces the draft with that session's -// terminal in place. Drag-to-dock is issue #32; mobile projection is #35. +// session leaf via the pool. Empty leaves (freshly split, not yet filled) show a +// placeholder. Drag-to-dock is issue #32; mobile projection is #35. import type { SidebarHandle } from "./catalog.js"; import type { ConnectionPool } from "./connections.js"; @@ -13,7 +12,6 @@ import { closeTab, findLeaf, type Dir, - type Draft, type Layout, leaves, locateSession, @@ -23,7 +21,6 @@ import { splitLeaf, } from "./layout.js"; import type { AttachInfo } from "./attach.js"; -import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js"; export interface MultiViewOptions { pool: ConnectionPool; @@ -35,8 +32,8 @@ export interface MultiViewOptions { onDisconnect: (sessionId: string) => void; /** Welcome content for an empty tab. */ renderWelcome: () => HTMLElement; - /** Called when a draft-pane submit turns an empty pane into a session pane, - * so the shell can re-evaluate its route (sidebar vs attach layout). */ + /** Called when a spawn fills a pane with a new session, so the shell can + * re-evaluate its route (sidebar vs attach layout). */ onOpenSessionsChange?: () => void; /** Called after any layout change (structure, focus, or divider resize) so the * shell can persist it (issue #34). */ @@ -48,15 +45,11 @@ export interface MultiView { /** Bring a session into the layout: focus it if already open, else fill the * focused pane (or open the first pane in an empty tab). */ openSession: (sessionId: string) => void; - openSessionBeside: (sessionId: string) => void; + openSessionBeside: (sessionId: string, opts?: { newTab?: boolean }) => void; /** Drop the pane showing `sessionId` (the session was disconnected/killed). */ closeSession: (sessionId: string) => void; /** Every session currently shown in some pane, across all tabs. */ openSessions: () => Set; - /** Open a draft pane (issue #62): reuse the focused pane if it is already - * empty, otherwise split the focused pane. `seed` pre-fills the form - * ("Spawn similar" clones a session's cwd + command). */ - openDraft: (seed?: Partial, opts?: { newTab?: boolean }) => void; /** Start dragging a sidebar session into the canvas (issue #32). The * controller owns drag-vs-tap: a tap lets the row's normal click open it, a * drag shows drop zones to place it. */ @@ -70,12 +63,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView { const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts; const persist = () => opts.onLayoutChange?.(); - // Draft-pane forms (#57) carry global listeners (dir autocomplete), so forms - // whose leaves are gone are destroyed on each render. Forms for draft leaves - // that survive the render are reused in place (#68): rebuilding one mid-edit - // would drop focus, caret and selection, and re-fetch info/projects/names. - const draftForms = new Map(); - const el = document.createElement("div"); el.className = "mv"; const tabBar = document.createElement("div"); @@ -141,38 +128,10 @@ export function createMultiView(opts: MultiViewOptions): MultiView { const host = document.createElement("div"); host.className = "mv-pane-host"; if (!leaf.sessionId) { - // Draft pane (#57): an empty pane is a spawn form. Values hang off the - // leaf (never persisted) so each draft keeps its own half-filled state; - // submitting fills this leaf with the new session in place. - const form = draftForms.get(leaf.id); - if (form) { - host.appendChild(form.el); - } else { - const created = createSpawnForm({ - seed: leaf.draft ?? undefined, - class: "mv-draft", - onChange: (values) => { - leaf.draft = values; - }, - onSuccess: (session) => { - // The pane can be closed while the POST is in flight. Mounting the - // session onto a leaf no longer in the tree would strand it in no - // pane at all, so only claim the leaf while it is still there — - // searching every tab, since the operator may have switched tabs - // while the request was in flight. - const host = layout.tabs.find((t) => leaves(t.root).includes(leaf)); - if (host) { - leaf.sessionId = session.id; - leaf.draft = null; - host.focusedLeafId = leaf.id; - } - render(); - opts.onOpenSessionsChange?.(); - }, - }); - draftForms.set(leaf.id, created); - host.appendChild(created.el); - } + const empty = document.createElement("div"); + empty.className = "mv-pane-empty"; + empty.textContent = "Empty pane — pick a session from the sidebar, or press n to spawn."; + host.appendChild(empty); } const tools = document.createElement("div"); @@ -365,7 +324,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView { // Fill the focused pane (VS Code style: open replaces the active group). const target = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || leaves(tab.root)[0]; target.sessionId = sessionId; - target.draft = null; tab.focusedLeafId = target.id; render(); } @@ -375,9 +333,10 @@ export function createMultiView(opts: MultiViewOptions): MultiView { /// `openSession` deliberately replaces the focused pane (VS Code style) for /// a sidebar click, but the fast path must never do that: pressing the key /// while attached would drop the running session out of the layout - /// entirely. Follows `openDraft`'s rule instead — reuse an empty pane, else - /// split beside the focused one (#67 review). - function openSessionBeside(sessionId: string): void { + /// entirely. Reuses an empty pane, else splits beside the focused one + /// (#67 review). `newTab` opens its own tab instead of halving whatever the + /// operator was reading (#100) — what the Spawn button asks for. + function openSessionBeside(sessionId: string, placement?: { newTab?: boolean }): void { const loc = locateSession(layout, sessionId); if (loc) { layout.activeTabId = loc.tab.id; @@ -385,6 +344,15 @@ export function createMultiView(opts: MultiViewOptions): MultiView { render(); return; } + // Mobile keeps a single tab and a single pane, so a new tab is not an + // option there: the session takes over the pane below. + if (placement?.newTab && !isMobile) { + const fresh = addTab(layout); + fresh.focusedLeafId = splitLeaf(fresh, "", sessionId, "row", true); + render(); + opts.onOpenSessionsChange?.(); + return; + } const tab = activeTab(layout); if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) { tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true); @@ -397,7 +365,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView { // evicted session keeps running and stays a tap away in the sidebar. if (!target.sessionId || isMobile) { target.sessionId = sessionId; - target.draft = null; } else { tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true); } @@ -405,66 +372,8 @@ export function createMultiView(opts: MultiViewOptions): MultiView { opts.onOpenSessionsChange?.(); } - function openDraft(seed?: Partial, opts?: { newTab?: boolean }): void { - const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed }); - // Mobile keeps a single tab and a single pane: the spawn form takes over the - // current pane instead of opening a tab or a split. - if (opts?.newTab && !isMobile) { - // Spawn opens its own tab rather than halving whatever you were reading - // (#100). The empty-pane reuse below still applies within a tab. - const fresh = addTab(layout); - fresh.focusedLeafId = splitLeaf(fresh, "", null, "row", true); - findLeaf(fresh.root!, fresh.focusedLeafId)!.draft = draft(); - render(); - return; - } - const tab = activeTab(layout); - if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) { - // Empty tab (or stale focus): the draft becomes the first pane. - tab.focusedLeafId = splitLeaf(tab, "", null, "row", true); - findLeaf(tab.root!, tab.focusedLeafId)!.draft = draft(); - render(); - return; - } - const target = findLeaf(tab.root, tab.focusedLeafId)!; - if (!target.sessionId || isMobile) { - // Empty = no session, not `!draft` (#72): the mounted form auto-syncs - // picked defaults into `draft` once ready, so that's never reliably null. - target.sessionId = null; - target.draft = draft(); - const stale = draftForms.get(target.id); - if (stale) { - stale.destroy(); - draftForms.delete(target.id); - } - } else { - // Split a fresh draft beside the focused pane. - tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true); - findLeaf(tab.root, tab.focusedLeafId)!.draft = draft(); - } - render(); - } - // ---- top-level render ---- - /** Destroy forms whose leaf is no longer a draft (leaf removed or filled by - * a session). Surviving draft leaves keep their form instance (#68). */ - function pruneForms(tab: { root: Node | null }): void { - const alive = new Set( - tab.root - ? leaves(tab.root) - .filter((l) => !l.sessionId) - .map((l) => l.id) - : [], - ); - for (const [id, f] of draftForms) { - if (!alive.has(id)) { - f.destroy(); - draftForms.delete(id); - } - } - } - function render(): void { renderTabs(); canvas.replaceChildren(); @@ -478,7 +387,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView { el.classList.toggle("mv-solo-tab", layout.tabs.length === 1); if (!tab.root) { - pruneForms(tab); pool.setVisible([]); pool.setFocus(null); canvas.appendChild(renderWelcome()); @@ -494,14 +402,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView { if (isMobile) { renderMobile(tab); - pruneForms(tab); persist(); return; } const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = []; canvas.appendChild(buildTree(tab.root, hosts)); - pruneForms(tab); pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info }))); syncFocus(); persist(); @@ -697,17 +603,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView { if (source.kind === "pane" && self) { const dragged = findLeaf(tab.root, self)!; [dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId]; - [dragged.draft, target.draft] = [target.draft, dragged.draft]; - // The leaf data moved but each pane's mounted form is still keyed to - // its old leaf (#73) — tear both down like openDraft's reuse case - // (#72) so render() remounts fresh forms seeded from the swapped data. - for (const id of [dragged.id, target.id]) { - const stale = draftForms.get(id); - if (stale) { - stale.destroy(); - draftForms.delete(id); - } - } tab.focusedLeafId = target.id; } else if (sessionId) { const loc = locateSession(layout, sessionId); @@ -715,7 +610,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView { const t2 = findLeaf(tab.root, targetLeafId); if (t2) { t2.sessionId = sessionId; - t2.draft = null; tab.focusedLeafId = t2.id; } } @@ -741,7 +635,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView { el, openSession, openSessionBeside, - openDraft, closeSession, openSessions: () => openSessions(layout), beginSessionDrag, diff --git a/web/src/persistence.test.ts b/web/src/persistence.test.ts index a626f58..1d38583 100644 --- a/web/src/persistence.test.ts +++ b/web/src/persistence.test.ts @@ -62,7 +62,7 @@ describe("hydrate", () => { expect(leaves(layout!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s"]); }); - it("never restores draft state, even if a stale blob carries it", () => { + it("ignores leaf fields a stale blob carries that the layout no longer has", () => { const layout = hydrate({ tabs: [ { id: "t", root: { kind: "leaf", id: "l1", sessionId: null, draft: { command: "zsh", cwd: "/x" } } }, @@ -74,7 +74,7 @@ describe("hydrate", () => { }); describe("serializeLayout", () => { - it("strips draft state so a reload gives back an empty form", () => { + it("round-trips the tree, its sessions and its split ratios", () => { const layout = hydrate({ tabs: [ { @@ -82,6 +82,7 @@ describe("serializeLayout", () => { root: { kind: "split", dir: "row", + ratio: 0.3, a: { kind: "leaf", id: "l1", sessionId: "s1" }, b: { kind: "leaf", id: "l2", sessionId: null }, }, @@ -89,40 +90,10 @@ describe("serializeLayout", () => { ], activeTabId: "t", }) as Layout; - const draftLeaf = leaves(layout.tabs[0].root).find((l) => !l.sessionId)!; - draftLeaf.draft = { command: "claude", cwd: "/home/user/project", name: "amber-fox" }; const restored = hydrate(JSON.parse(serializeLayout(layout)).layout); expect(restored).not.toBeNull(); - for (const leaf of leaves(restored!.tabs[0].root)) { - expect(leaf.draft ?? null).toBeNull(); - } expect(leaves(restored!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1", null]); - }); - - it("keeps the draft out of the stored blob itself, not just out of hydrate", () => { - // A draft pane only exists once a tab has split, so the strip has to walk - // into split branches; asserting on the restored layout alone passes even - // when the draft was written to localStorage, because hydrate drops it. - const layout = hydrate({ - tabs: [ - { - id: "t", - root: { - kind: "split", - dir: "row", - a: { kind: "leaf", id: "l1", sessionId: "s1" }, - b: { kind: "leaf", id: "l2", sessionId: null }, - }, - }, - ], - activeTabId: "t", - }) as Layout; - const draftLeaf = leaves(layout.tabs[0].root).find((l) => !l.sessionId)!; - draftLeaf.draft = { command: "claude", cwd: "/home/user/secret-project", name: "amber-fox" }; - - const raw = serializeLayout(layout); - expect(raw).not.toContain("secret-project"); - expect(raw).not.toContain("amber-fox"); + expect((restored!.tabs[0].root as { ratio: number }).ratio).toBe(0.3); }); }); diff --git a/web/src/persistence.ts b/web/src/persistence.ts index 4a60195..27701cc 100644 --- a/web/src/persistence.ts +++ b/web/src/persistence.ts @@ -20,27 +20,8 @@ export function saveLayout(layout: Layout): void { } } -/** Serialise the layout for storage with draft state stripped (issue #57): a - * reload gives back an empty form, never a stale half-filled one. */ export function serializeLayout(layout: Layout): string { - const clean: Layout = { - ...layout, - tabs: layout.tabs.map((t) => ({ - ...t, - root: stripDrafts(t.root), - })), - }; - return JSON.stringify({ v: VERSION, layout: clean }); -} - -function stripDrafts(node: Node | null): Node | null { - if (!node) return node; - // Recurse: a draft pane only exists once a tab has split, so a shallow strip - // would persist every half-filled form it was meant to drop. - if (node.kind === "split") { - return { ...node, a: stripDrafts(node.a)!, b: stripDrafts(node.b)! }; - } - return { ...node, draft: null }; + return JSON.stringify({ v: VERSION, layout }); } export function loadLayout(): Layout | null { diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 9a45e5f..5202db3 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -18,9 +18,9 @@ import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } * from the repository's existing branches (#61). */ export type WorktreeBranchMode = "new" | "existing"; -/** Seed values for the spawn form — used by "clone" to open a draft with the - * same cwd, command, and name as an existing session, and by draft panes to - * restore their own half-filled state after a re-render. */ +/** Seed values for the spawn form — used by "Spawn similar" to open the modal + * with the same cwd, command, and name as an existing session, and by the + * fast-forward fallback to pre-fill whatever it did resolve. */ export interface SpawnFormSeed { command?: string; cwd?: string; @@ -35,8 +35,7 @@ export interface SpawnFormSeed { cwdRawVisible?: boolean; } -/** The form's current values, reported on every change so a caller (the draft - * pane) can hang them off its layout leaf. */ +/** The form's current values, reported on every change. */ export interface SpawnFormValues { command: string; cwd: string; @@ -109,8 +108,6 @@ export interface SpawnFormHandle { export interface SpawnFormOptions { seed?: SpawnFormSeed; - /** Extra class(es) for the
root (e.g. `mv-draft` in a draft pane). */ - class?: string; /** Fired whenever any value changes (input, tile pick, checkbox). */ onChange?: (values: SpawnFormValues) => void; /** Called with the created session after a successful submit. */ @@ -118,12 +115,12 @@ export interface SpawnFormOptions { } /** The spawn form itself — preset tiles, project picker, name, worktree. - * Mounted inline by draft panes (issue #57). */ + * Mounted inside the spawn modal (spawnModal.ts). */ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { const prefill = opts.seed ?? {}; const form = document.createElement("form"); - form.className = `spawn-form${opts.class ? ` ${opts.class}` : ""}`; + form.className = "spawn-form"; form.innerHTML = `
Command @@ -193,8 +190,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
`; - // Field queries are scoped to this form instance — several forms can be - // mounted at once (one per draft pane), so ids would collide. + // Field queries are scoped to this form instance rather than by id, so a + // second form mounted before the first is torn down cannot collide with it. const errEl = form.querySelector(".form-error") as HTMLElement; const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement; @@ -241,9 +238,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { // `emit` stays silent until the initial load settles and goes silent again // once the form is destroyed. Construction runs with `cwd === ""` and the // seed is only restored two fetches later, so emitting in between would - // blank the leaf's draft; and `multiview.render()` rebuilds forms while the - // old one's fetches are still in flight, so a destroyed form emitting would - // clobber the live one's leaf (#57 review). + // report blank values as if the operator had cleared the form. let ready = false; let destroyed = false; const branchValue = () => (branchMode === "existing" ? branchSelect.value : branchInput.value); @@ -341,7 +336,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { // ---- command tiles --------------------------------------------------- // `focus` is false for the two initial applications (construction, and again - // when `/api/info` resolves): a draft pane must not grab the caret out from + // when `/api/info` resolves): the form must not grab the caret out from // under the operator seconds after it mounts. function selectPreset(id: string, focus = true): void { selectedPresetId = id; @@ -792,8 +787,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { /** Fetch the target repo's branches once per cwd. Like every async load in * this form: token-guarded, and nothing touches the DOM (or emits) after - * destroy() — a late response into a rebuilt form would corrupt that - * form's draft (#57 review). */ + * destroy() — a late response into a closed modal would throw. */ async function loadBranches(): Promise { if (!cwd || !inferredBaseRepo) return; const token = ++branchToken; diff --git a/web/src/spawnModal.test.ts b/web/src/spawnModal.test.ts new file mode 100644 index 0000000..eb7a15f --- /dev/null +++ b/web/src/spawnModal.test.ts @@ -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 { + 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); + }); +}); diff --git a/web/src/spawnModal.ts b/web/src/spawnModal.ts new file mode 100644 index 0000000..0093c1a --- /dev/null +++ b/web/src/spawnModal.ts @@ -0,0 +1,96 @@ +// The spawn modal: dialog chrome around the shared spawn form (spawnForm.ts). +// +// One modal at a time — opening while one is up replaces it. Escape, the ✕ and +// a click on the backdrop all close it; the form's own Escape handlers (the +// directory autocomplete) swallow the key first, so dismissing a dropdown never +// also discards the form. + +import { createSpawnForm, type SpawnFormSeed } from "./spawnForm.js"; +import type { Session } from "./types.js"; + +/** Seed values for the spawn form — used by "Spawn similar" to open the dialog + * with the same cwd, command and name as an existing session. */ +export type SpawnPrefill = SpawnFormSeed; + +export interface SpawnModalOptions { + seed?: SpawnPrefill; + /** Called with the created session after a successful submit; the modal has + * already closed by then, so the caller decides where it lands. */ + onSuccess: (session: Session) => void; +} + +/** Closer for the modal currently up, so opening a second one tears the first + * one down properly — dropping its node alone would strand the form's global + * listeners (dir autocomplete) and the Escape handler. */ +let closeCurrent: (() => void) | null = null; + +export function openSpawnModal(opts: SpawnModalOptions): void { + closeSpawnModal(); + + const host = document.createElement("div"); + host.id = "modal-host"; + host.className = "modal-backdrop"; + const dialog = document.createElement("div"); + dialog.className = "modal spawn-modal"; + dialog.setAttribute("role", "dialog"); + dialog.setAttribute("aria-modal", "true"); + dialog.setAttribute("aria-labelledby", "spawn-title"); + const header = document.createElement("header"); + header.className = "modal-header"; + const title = document.createElement("h2"); + title.id = "spawn-title"; + // "Spawn similar" arrives with a seed; the title says which act this is. + title.textContent = opts.seed ? "Spawn similar" : "Spawn session"; + const closeBtn = document.createElement("button"); + closeBtn.type = "button"; + closeBtn.className = "btn btn-icon close"; + closeBtn.setAttribute("aria-label", "Close"); + closeBtn.textContent = "✕"; + header.append(title, closeBtn); + dialog.appendChild(header); + host.appendChild(dialog); + document.body.appendChild(host); + + const form = createSpawnForm({ + seed: opts.seed, + onSuccess: (session) => { + close(); + opts.onSuccess(session); + }, + }); + dialog.appendChild(form.el); + + function close(): void { + if (closeCurrent === close) closeCurrent = null; + window.removeEventListener("keydown", onKeydown); + form.destroy(); + host.remove(); + } + closeCurrent = close; + + function onKeydown(e: KeyboardEvent): void { + if (e.key === "Escape") { + e.preventDefault(); + close(); + } + } + + // The modal keeps its Cancel affordance; the shared form ships only Submit. + const cancel = document.createElement("button"); + cancel.type = "button"; + cancel.className = "btn btn-ghost cancel"; + cancel.textContent = "Cancel"; + cancel.addEventListener("click", close); + form.el.querySelector(".form-actions")?.prepend(cancel); + + closeBtn.addEventListener("click", close); + host.addEventListener("click", (e) => { + if (e.target === host) close(); + }); + window.addEventListener("keydown", onKeydown); +} + +/** Close whatever spawn modal is up. No-op when none is. */ +export function closeSpawnModal(): void { + closeCurrent?.(); +} diff --git a/web/src/spawnSimilar.ts b/web/src/spawnSimilar.ts index 0d85f9b..4b3abbb 100644 --- a/web/src/spawnSimilar.ts +++ b/web/src/spawnSimilar.ts @@ -1,10 +1,10 @@ -// "Spawn similar" (#28): the seed a cloned session hands to a draft pane. -// Pure — the caller owns opening the pane. +// "Spawn similar" (#28): the seed a cloned session hands to the spawn modal. +// Pure — the caller owns opening the dialog. -import type { Draft } from "./layout.js"; +import type { SpawnFormSeed } from "./spawnForm.js"; import type { Session } from "./types.js"; -/** Clone a session into draft values: same project, same command, same +/** Clone a session into form values: same project, same command, same * worktree decision, fresh name. * * A worktree session seeds its *base repository*, not the checkout the daemon @@ -19,7 +19,7 @@ import type { Session } from "./types.js"; * * The name is left out on purpose: a clone is a new session and takes its own * generated handle. */ -export function spawnSimilarSeed(s: Session): Partial { +export function spawnSimilarSeed(s: Session): SpawnFormSeed { const wt = s.worktree ?? null; return { command: s.command, diff --git a/web/src/styles.css b/web/src/styles.css index d6224ef..205ea26 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1448,6 +1448,77 @@ input.leaf-rename:focus { border-color: var(--accent-strong); } +/* ---------- modal ---------- */ + +.modal-backdrop { + position: fixed; + inset: 0; + background: oklch(0.12 0.01 256 / 0.6); + backdrop-filter: blur(3px); + display: flex; + align-items: flex-end; + justify-content: center; + padding: 0; + z-index: var(--z-modal); + animation: fade var(--dur) var(--ease-out); +} + +@media (min-width: 600px) { + .modal-backdrop { + align-items: center; + padding: var(--sp-6); + } +} + +.modal { + background: var(--surface); + border-radius: var(--r-3) var(--r-3) 0 0; + border: 1px solid var(--border); + width: 100%; + max-width: 560px; + max-height: 92vh; + display: flex; + flex-direction: column; + box-shadow: var(--shadow-2); + animation: sheet-up var(--dur-slow) var(--ease-out); +} + +@media (min-width: 600px) { + .modal { + border-radius: var(--r-3); + animation: pop var(--dur) var(--ease-out); + } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--sp-3); + border-bottom: 1px solid var(--border); +} + +.modal-header h2 { + margin: 0; + font-size: var(--fs-15); + font-weight: 600; + display: inline-flex; + align-items: center; + gap: var(--sp-2); +} + +.modal-header h2::before { + content: "+"; + color: var(--accent); + font-weight: 700; +} + +/* The form is the modal's scrolling body: it must be allowed to shrink below + its content height for `max-height` on the dialog to bite. */ +.modal .spawn-form { + min-height: 0; +} + /* ---------- forms ---------- */ .spawn-form { @@ -1878,7 +1949,7 @@ input.leaf-rename:focus { .dir-ac { position: fixed; - z-index: var(--z-toast); /* above the draft pane it completes within */ + z-index: var(--z-toast); /* above the modal it completes within */ margin: 0; padding: var(--sp-1); list-style: none; @@ -2015,6 +2086,26 @@ input.leaf-rename:focus { } } +@keyframes fade { + from { + opacity: 0; + } +} + +@keyframes sheet-up { + from { + transform: translateY(16px); + opacity: 0; + } +} + +@keyframes pop { + from { + transform: scale(0.98); + opacity: 0; + } +} + @keyframes blink { 0%, 50% { @@ -2336,12 +2427,15 @@ input.leaf-rename:focus { .mv-pane:not(.is-focused) .attach-header { opacity: 0.7; } -/* Draft pane (#57): an empty pane renders the shared spawn form inline. - The host is already a flex column; the form fills and scrolls within it. */ -.mv-pane-host > .mv-draft { +/* A pane with no session yet: a quiet hint rather than blank terminal black. */ +.mv-pane-empty { flex: 1; - min-width: 0; - min-height: 0; + display: grid; + place-items: center; + padding: var(--sp-3); + text-align: center; + color: var(--faint); + font-size: var(--fs-13); } /* pane tools cluster (disconnect / split / close), top-right corner. The attach