Compare commits

..
Author SHA1 Message Date
Miguel Palhas 525dc9a04a fix(maestro): reuse draft forms across renders (#68)
render() destroyed and rebuilt every draft form, so an unrelated
session exiting tore down a draft mid-edit: focus, caret and
selection were lost and /api/info, /api/projects, /api/names were
refetched for a form already on screen.

Key draft forms by leaf id; rebuild only the pane DOM around them
and destroy a form when its leaf is removed or filled by a session.
2026-08-23 22:43:42 +01:00
Miguel Palhas 37338def71 merge: delete the spawn modal, amend DESIGN.md (#62) 2026-08-23 22:38:53 +01:00
Miguel Palhas 51f91b3f5d docs(maestro): amend DESIGN.md for draft panes (#62)
The spawn modal is no longer one of the three surfaces: the Components
section now describes the draft pane, and the stale ≥600px
bottom-sheet→dialog breakpoint note is dropped.
2026-08-23 22:36:51 +01:00
Miguel Palhas 5c3f9cab9f feat(maestro): replace spawn modal with draft panes (#62)
The draft pane covers every field the modal had; the modal was a second
code path for the same job. The header Spawn button, sidebar empty state,
welcome button, and ?mock&spawn now open a draft pane via
MultiView.openDraft — reusing an empty focused pane, else splitting it.
"Spawn similar" seeds a draft with the session's cwd + command.

Modal-only CSS (backdrop, sheet/dialog chrome, keyframes) is deleted;
.modal-body form styling folds into .spawn-form, which also picks up the
select styling the worktree branch picker was borrowing from the modal.
2026-08-23 22:36:48 +01:00
Miguel Palhas 117f371c41 merge: tui draft form presets, project picker, names, validation (#64) 2026-08-23 22:02:17 +01:00
13 changed files with 311 additions and 254 deletions
+8 -7
View File
@@ -79,16 +79,15 @@ no bounce. Tokens `--dur-fast`/`--dur`/`--dur-slow`.
`connecting` state pill pulses.
- Interaction: row hover/focus, button press (1px), input focus ring, chevron
rotation, theme-toggle sun/moon swap.
- Modal: bottom-sheet slide-up on mobile, scale-pop on ≥600px.
- Every animation has a `prefers-reduced-motion: reduce` path (pulses off,
transitions near-instant).
## Layout & structure
Mobile-first, structural responsiveness (not fluid type). Single `≤520px`
breakpoint stacks the header (full-width search, wrapped actions). `≥600px`
switches the spawn modal from bottom-sheet to centered dialog. A faint slate grid texture sits on `--bg` as
terminal atmosphere (very low contrast, not decoration).
breakpoint stacks the header (full-width search, wrapped actions). A faint
slate grid texture sits on `--bg` as terminal atmosphere (very low contrast,
not decoration).
## Components
@@ -101,8 +100,10 @@ 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.
- **Spawn modal** — bottom-sheet/dialog form; mono inputs with green focus
ring; grouped worktree fieldset.
- **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.
Interactive elements ship default / hover / focus-visible / active / disabled
states; the accent green is used only for action, selection, focus, and live
@@ -111,5 +112,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 the spawn modal),
daemon: `?mock` (seed sessions), `?mock&spawn` (open a draft pane),
`?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`.
+5 -4
View File
@@ -20,9 +20,10 @@ src/
main.ts # entry point, hash-route dispatch
api.ts # daemon HTTP API client
events.ts # `/api/events` WebSocket subscriber
catalog.ts # session tree view (groups by working directory)
attach.ts # xterm.js + attach WS view
spawn.ts # spawn-session modal
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
spawnForm.ts # shared spawn form (preset tiles, project picker, worktree)
theme.ts # dark/light theme toggle + persistence
types.ts # mirror of `maestro-protocol` JSON shapes
styles.css # design-system tokens + component styles
@@ -40,7 +41,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 the spawn modal, and `?theme=light|dark` to pin a theme.
`?mock&spawn` to open a draft pane, and `?theme=light|dark` to pin a theme.
Routing is hash-based:
- `#/` → catalog
+11 -12
View File
@@ -6,9 +6,15 @@ import type { Session } from "./types.js";
import { abbreviateFolderName, sessionName } from "./types.js";
import type { TreeNode } from "./tree.js";
import { buildTree } from "./tree.js";
import { openSpawnModal } from "./spawn.js";
import { createThemeToggle } from "./theme.js";
/** Ask the shell to open a new draft pane (#62). 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 {
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
}
const collapsed = new Set<string>();
function renderNode(node: TreeNode, depth: number): HTMLElement {
@@ -190,7 +196,7 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
spawnBtn.type = "button";
spawnBtn.className = "btn btn-primary spawn-btn";
spawnBtn.textContent = "+ Spawn";
spawnBtn.addEventListener("click", () => openSpawnModal());
spawnBtn.addEventListener("click", () => requestOpenSpawn());
actions.appendChild(spawnBtn);
return header;
}
@@ -342,7 +348,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
treeHost.replaceChildren();
if (sessions.length === 0) {
treeHost.appendChild(
renderEmpty(filter.trim().length > 0, () => openSpawnModal()),
renderEmpty(filter.trim().length > 0, () => requestOpenSpawn()),
);
return;
}
@@ -364,14 +370,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const rerenderHandler = () => refresh();
document.addEventListener("catalog-rerender", rerenderHandler);
// Spawn similar: reopen the spawn modal pre-seeded from a session's dir and
// command (matched back to a preset by the form) — but with a freshly
// generated name rather than the original's, since it's a new session.
const spawnSimilarHandler = (ev: Event) => {
const s = (ev as CustomEvent<Session>).detail;
openSpawnModal({ cwd: s.cwd, command: s.command });
};
document.addEventListener("catalog-spawn-similar", spawnSimilarHandler);
// "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.
const unsubscribe = store.subscribe(refresh);
@@ -391,7 +391,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
},
dispose() {
document.removeEventListener("catalog-rerender", rerenderHandler);
document.removeEventListener("catalog-spawn-similar", spawnSimilarHandler);
unsubscribe();
},
};
+2 -2
View File
@@ -1,5 +1,5 @@
// One HTML escaper. Previously written four times, in catalog.ts, spawn.ts,
// types.ts and attach.ts.
// One HTML escaper. Previously written four times, in catalog.ts, types.ts
// and attach.ts.
export function escapeHtml(s: string): string {
return s
+1 -1
View File
@@ -102,7 +102,7 @@
background: color-mix(in oklab, var(--surface) 40%, var(--bg));
}
.kit-slot .modal-body {
.kit-slot .spawn-form {
padding: 0;
min-width: 200px;
}
+3 -3
View File
@@ -135,7 +135,7 @@ export function renderKit(): HTMLElement {
{
const { el, body } = section(
"Buttons — current",
"The shipping button styles — now the bracket terminal style, as used across the header, modal, and empty states.",
"The shipping button styles — now the bracket terminal style, as used across the header, panes, and empty states.",
);
body.append(
specimen("btn-primary", btn("btn btn-primary", "+ Spawn")),
@@ -170,12 +170,12 @@ export function renderKit(): HTMLElement {
search.type = "search";
search.className = "search";
search.placeholder = "Search sessions…";
const text = h("div", "modal-body");
const text = h("div", "spawn-form");
const ti = document.createElement("input");
ti.type = "text";
ti.placeholder = "branch-name";
text.appendChild(ti);
const sel = h("div", "modal-body");
const sel = h("div", "spawn-form");
const s = document.createElement("select");
s.innerHTML = `<option>thinkpad</option><option>tower</option><option>pi</option>`;
sel.appendChild(s);
+17 -3
View File
@@ -7,9 +7,9 @@ import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
import { loadLayout, saveLayout } from "./persistence.js";
import { openSpawnModal } from "./spawn.js";
import { OPEN_SPAWN_EVENT } from "./catalog.js";
import { initTheme } from "./theme.js";
import { sessionName } from "./types.js";
import { sessionName, type Session } from "./types.js";
import { installViewportSizing } from "./viewport.js";
import "./styles.css";
@@ -212,7 +212,7 @@ function renderWelcome(): HTMLElement {
spawn.type = "button";
spawn.className = "btn btn-primary";
spawn.textContent = "+ Spawn session";
spawn.addEventListener("click", () => openSpawnModal());
spawn.addEventListener("click", () => requestOpenSpawn());
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
return el;
}
@@ -251,6 +251,20 @@ function showMultiView(): void {
updateShellRoute();
}
// Spawn affordances (#62): the header button, the sidebar's empty state, and
// `?mock&spawn` all open a draft pane; "Spawn similar" seeds one with the
// cloned session's dir + command.
function requestOpenSpawn(): void {
showMultiView();
mv.openDraft();
}
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
document.addEventListener("catalog-spawn-similar", (ev) => {
const s = (ev as CustomEvent<Session>).detail;
showMultiView();
mv.openDraft({ cwd: s.cwd, command: s.command });
});
type Route =
| { kind: "catalog" }
| { kind: "attach"; sessionId: string }
+2 -2
View File
@@ -135,10 +135,10 @@ export function seedMock(store: CatalogStore): void {
sessions,
});
// `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn
// `?mock&spawn` auto-opens a draft pane for visual review, with the spawn
// backend endpoints stubbed so presets/projects/gitroot all render.
if (location.search.includes("spawn")) {
stubSpawnApi();
void import("./spawn.js").then((m) => m.openSpawnModal());
document.dispatchEvent(new CustomEvent("catalog-open-spawn"));
}
}
+162
View File
@@ -0,0 +1,162 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { emptyLayout, leaves, type Layout } from "./layout.js";
import { splitLeaf } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
import type { ConnectionPool } from "./connections.js";
import type { SidebarHandle } from "./catalog.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: ["allegro-cello"] }
: undefined;
return new Response(JSON.stringify(body), { status: 200 });
}),
);
}
function fakePool(): ConnectionPool {
return {
setVisible() {},
setFocus() {},
} as unknown as ConnectionPool;
}
function fakeSidebar(): SidebarHandle {
return {
el: document.createElement("aside"),
setActive() {},
setHighlights() {},
dispose() {},
};
}
function makeMv(layout: Layout): MultiView {
return createMultiView({
pool: fakePool(),
layout,
sidebar: fakeSidebar(),
infoFor: () => ({ name: "sess", command: "bash" }),
onDisconnect: () => {},
renderWelcome: () => document.createElement("div"),
});
}
/** Drain the microtask queue so the draft form's async seed application lands. */
async function flush(): Promise<void> {
for (let i = 0; i < 20; i++) await Promise.resolve();
}
describe("multiview openDraft (#62)", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
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 () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
tab.focusedLeafId = first;
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("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");
});
});
+83 -29
View File
@@ -13,6 +13,7 @@ import {
closeTab,
findLeaf,
type Dir,
type Draft,
type Layout,
leaves,
locateSession,
@@ -52,6 +53,10 @@ export interface MultiView {
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>) => 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. */
@@ -65,9 +70,11 @@ 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 each
// render tears down the previous generation before rebuilding the canvas.
const draftForms = new Set<SpawnFormHandle>();
// 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";
@@ -145,30 +152,35 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
// 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 = 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.add(form);
host.appendChild(form.el);
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 tools = document.createElement("div");
@@ -366,11 +378,49 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
render();
}
function openDraft(seed?: Partial<Draft>): void {
const tab = activeTab(layout);
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
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 && !target.draft) {
// An already-empty pane becomes the draft in place.
target.draft = draft();
} 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 {
for (const f of draftForms) f.destroy();
draftForms.clear();
renderTabs();
canvas.replaceChildren();
const tab = activeTab(layout);
@@ -379,6 +429,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1);
if (!tab.root) {
pruneForms(tab);
pool.setVisible([]);
pool.setFocus(null);
canvas.appendChild(renderWelcome());
@@ -394,12 +445,14 @@ 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();
@@ -640,6 +693,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
return {
el,
openSession,
openDraft,
closeSession,
openSessions: () => openSessions(layout),
beginSessionDrag,
-68
View File
@@ -1,68 +0,0 @@
// The spawn modal (issue #27): bottom-sheet/dialog chrome around the shared
// spawn form (spawnForm.ts). The modal coexists with the inline draft panes
// (#57) until a later issue deletes it.
import { createSpawnForm, type SpawnFormSeed } from "./spawnForm.js";
/** Seed values for the spawn form — used by "clone" to reopen the dialog with
* the same cwd, command, and name as an existing session. */
export type SpawnPrefill = SpawnFormSeed;
export function openSpawnModal(prefill?: SpawnPrefill): void {
const existing = document.getElementById("modal-host");
if (existing) existing.remove();
const host = document.createElement("div");
host.id = "modal-host";
host.className = "modal-backdrop";
host.innerHTML = `
<div class="modal spawn-modal" role="dialog" aria-modal="true" aria-labelledby="spawn-title">
<header class="modal-header">
<h2 id="spawn-title">Spawn session</h2>
<button type="button" class="btn btn-icon close" aria-label="Close">✕</button>
</header>
<div class="form-slot"></div>
</div>
`;
document.body.appendChild(host);
const formHandle = createSpawnForm({
seed: prefill,
class: "modal-body",
onSuccess(session) {
close();
location.hash = `#/attach/${encodeURIComponent(session.id)}`;
},
});
(host.querySelector(".form-slot") as HTMLElement).replaceWith(formHandle.el);
const close = () => {
formHandle.destroy();
host.remove();
};
// The modal keeps its Cancel affordance; the shared form ships only Submit.
const actions = formHandle.el.querySelector(".form-actions") as HTMLElement;
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "btn btn-ghost cancel";
cancel.textContent = "Cancel";
cancel.addEventListener("click", close);
actions.prepend(cancel);
host.querySelector(".close")?.addEventListener("click", close);
host.addEventListener("click", (e) => {
if (e.target === host) close();
});
window.addEventListener(
"keydown",
(e) => {
if (e.key === "Escape") close();
},
{ once: true },
);
// ---- clone seed ------------------------------------------------------
if (prefill) {
(host.querySelector("#spawn-title") as HTMLElement).textContent = "Clone session";
}
}
+6 -6
View File
@@ -16,9 +16,9 @@ import { abbreviateCwd, abbreviateFolderName } from "./types.js";
* from the repository's existing branches (#61). */
export type WorktreeBranchMode = "new" | "existing";
/** Seed values for the spawn form — used by "clone" to reopen 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 "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. */
export interface SpawnFormSeed {
command?: string;
cwd?: string;
@@ -99,7 +99,7 @@ export interface SpawnFormHandle {
export interface SpawnFormOptions {
seed?: SpawnFormSeed;
/** Extra class(es) for the <form> root (e.g. `modal-body` in the modal). */
/** 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;
@@ -108,7 +108,7 @@ export interface SpawnFormOptions {
}
/** The spawn form itself — preset tiles, project picker, name, worktree.
* Shared by the modal (`spawn.ts`) and inline draft panes (issue #57). */
* Mounted inline by draft panes (issue #57). */
export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const prefill = opts.seed ?? {};
@@ -174,7 +174,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
</div>
`;
// Field queries are scoped to this form instance — several forms can be
// mounted at once (modal + draft panes), so ids would collide.
// mounted at once (one per draft pane), so ids would collide.
const errEl = form.querySelector(".form-error") as HTMLElement;
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
+11 -117
View File
@@ -1205,72 +1205,9 @@ textarea {
border-color: var(--accent-strong);
}
/* ---------- modal ---------- */
/* ---------- forms ---------- */
.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;
}
.modal-body {
.spawn-form {
padding: var(--sp-3);
overflow: auto;
display: flex;
@@ -1280,7 +1217,6 @@ textarea {
scrollbar-color: var(--border-strong) transparent;
}
.modal-body label,
.spawn-form label {
display: flex;
flex-direction: column;
@@ -1290,7 +1226,6 @@ textarea {
letter-spacing: 0.01em;
}
.modal-body label.row-inline,
.spawn-form label.row-inline {
flex-direction: row;
align-items: center;
@@ -1300,10 +1235,9 @@ textarea {
cursor: pointer;
}
.modal-body input[type="text"],
.modal-body input[type="search"],
.modal-body select,
.spawn-form input[type="text"] {
.spawn-form input[type="text"],
.spawn-form input[type="search"],
.spawn-form select {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-2);
@@ -1318,23 +1252,20 @@ textarea {
background var(--dur-fast) var(--ease-out);
}
.modal-body input[type="text"]:hover,
.modal-body select:hover,
.spawn-form input[type="text"]:hover {
.spawn-form input[type="text"]:hover,
.spawn-form select:hover {
border-color: var(--border-strong);
}
.modal-body input[type="text"]:focus,
.modal-body input[type="search"]:focus,
.modal-body select:focus,
.spawn-form input[type="text"]:focus {
.spawn-form input[type="text"]:focus,
.spawn-form input[type="search"]:focus,
.spawn-form select:focus {
outline: none;
border-color: var(--accent);
background: var(--surface);
box-shadow: 0 0 0 3px var(--accent-tint);
}
.modal-body input[type="checkbox"],
.spawn-form input[type="checkbox"] {
width: 18px;
height: 18px;
@@ -1359,13 +1290,6 @@ textarea {
font-size: var(--fs-13);
}
.modal-actions {
display: flex;
gap: var(--sp-2);
justify-content: flex-end;
margin-top: var(--sp-2);
}
.form-error {
color: var(--danger);
margin: 0;
@@ -1381,7 +1305,6 @@ textarea {
/* ---------- spawn form: tiles + recents picker (#27) ---------- */
.spawn-modal .field,
.spawn-form .field {
display: flex;
flex-direction: column;
@@ -1611,7 +1534,7 @@ textarea {
.dir-ac {
position: fixed;
z-index: var(--z-toast); /* above the modal it completes within */
z-index: var(--z-toast); /* above the draft pane it completes within */
margin: 0;
padding: var(--sp-1);
list-style: none;
@@ -1759,26 +1682,6 @@ textarea {
}
}
@keyframes fade {
from {
opacity: 0;
}
}
@keyframes sheet-up {
from {
transform: translateY(16px);
opacity: 0;
}
}
@keyframes pop {
from {
transform: scale(0.98);
opacity: 0;
}
}
/* ---------- responsive ---------- */
@media (max-width: 520px) {
@@ -2119,15 +2022,6 @@ textarea {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
/* The modal form got its stacking and padding from `.modal-body`; in a pane
the form is the host, so it carries them itself. */
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding: var(--sp-3);
}
/* pane tools cluster (disconnect / split / close), top-right corner. The attach