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:
Miguel Palhas
2026-09-02 14:44:55 +01:00
co-authored by Claude Opus 5
parent 28778b2224
commit 6be0dba74e
18 changed files with 448 additions and 429 deletions
+6 -5
View File
@@ -100,10 +100,11 @@ Three surfaces, all DOM built in TS, styled by class:
no-sessions, no-search-match.
- **Attach** — header (back, `session <id>`, 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`.
+3 -2
View File
@@ -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
+4 -4
View File
@@ -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);
+3 -3
View File
@@ -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();
+5 -5
View File
@@ -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:
+1 -1
View File
@@ -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" },
],
},
+2 -17
View File
@@ -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<Node, { kind: "leaf" }>;
@@ -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;
+28 -20
View File
@@ -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<Session>).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<void> {
showMultiView();
const [discovered, name] = await Promise.all([
fetchProjects().catch(() => [] as Project[]),
fetchNames(1)
@@ -281,18 +287,20 @@ async function requestFastForward(): Promise<void> {
// 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) => {
+1 -1
View File
@@ -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();
+24 -161
View File
@@ -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<void> {
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<void> {
// 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", () => {
+22 -129
View File
@@ -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<string>;
/** 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<Draft>, 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<string, SpawnFormHandle>();
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<Draft>, 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,
+4 -33
View File
@@ -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);
});
});
+1 -20
View File
@@ -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 {
+11 -17
View File
@@ -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 <form> 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 = `
<div class="field">
<span class="field-label">Command</span>
@@ -193,8 +190,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
<button type="submit" class="btn btn-primary">Spawn</button>
</div>
`;
// 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<void> {
if (!cwd || !inferredBaseRepo) return;
const token = ++branchToken;
+132
View File
@@ -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);
});
});
+96
View File
@@ -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?.();
}
+5 -5
View File
@@ -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<Draft> {
export function spawnSimilarSeed(s: Session): SpawnFormSeed {
const wt = s.worktree ?? null;
return {
command: s.command,
+100 -6
View File
@@ -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