merge: web inline draft pane (#57)

This commit is contained in:
Miguel Palhas
2026-08-23 19:54:50 +01:00
9 changed files with 727 additions and 411 deletions
+17 -2
View File
@@ -11,8 +11,23 @@ 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 }
| { kind: "leaf"; id: string; sessionId: Uuid | null; draft?: Draft | null }
| { kind: "split"; dir: Dir; a: Node; b: Node; ratio: number };
export type Leaf = Extract<Node, { kind: "leaf" }>;
@@ -89,7 +104,7 @@ export function splitLeaf(
dir: Dir,
after: boolean,
): string {
const fresh: Leaf = { kind: "leaf", id: freshId("l"), sessionId };
const fresh: Leaf = { kind: "leaf", id: freshId("l"), sessionId, draft: null };
const rec = (n: Node): Node => {
if (n.kind === "leaf") {
if (n.id !== targetId) return n;
+3
View File
@@ -236,6 +236,9 @@ 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.
onOpenSessionsChange: updateShellRoute,
onLayoutChange: scheduleSave,
});
let contentShowsMv = false;
+36 -3
View File
@@ -1,8 +1,9 @@
// 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 (freshly split, not yet filled) show a
// placeholder. Drag-to-dock is a later issue (#32); mobile projection is #35.
// 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.
import type { SidebarHandle } from "./catalog.js";
import type { ConnectionPool } from "./connections.js";
@@ -22,6 +23,7 @@ import {
} from "./layout.js";
import { sessionColor } from "./sessioncolor.js";
import type { AttachInfo } from "./attach.js";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
export interface MultiViewOptions {
pool: ConnectionPool;
@@ -33,6 +35,9 @@ 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). */
onOpenSessionsChange?: () => void;
/** Called after any layout change (structure, focus, or divider resize) so the
* shell can persist it (issue #34). */
onLayoutChange?: () => void;
@@ -60,6 +65,10 @@ 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>();
const el = document.createElement("div");
el.className = "mv";
const tabBar = document.createElement("div");
@@ -133,7 +142,26 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const host = document.createElement("div");
host.className = "mv-pane-host";
if (!leaf.sessionId) {
host.innerHTML = `<div class="mv-pane-empty">Empty pane — pick a session from the sidebar.</div>`;
// 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) => {
leaf.sessionId = session.id;
leaf.draft = null;
const t = activeTab(layout);
if (leaves(t.root).includes(leaf)) t.focusedLeafId = leaf.id;
render();
opts.onOpenSessionsChange?.();
},
});
draftForms.add(form);
host.appendChild(form.el);
}
const tools = document.createElement("div");
@@ -326,6 +354,7 @@ 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();
}
@@ -333,6 +362,8 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
// ---- top-level render ----
function render(): void {
for (const f of draftForms) f.destroy();
draftForms.clear();
renderTabs();
canvas.replaceChildren();
const tab = activeTab(layout);
@@ -569,6 +600,7 @@ 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];
tab.focusedLeafId = target.id;
} else if (sessionId) {
const loc = locateSession(layout, sessionId);
@@ -576,6 +608,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const t2 = findLeaf(tab.root, targetLeafId);
if (t2) {
t2.sessionId = sessionId;
t2.draft = null;
tab.focusedLeafId = t2.id;
}
}
+40 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { hydrate } from "./persistence.js";
import { leaves } from "./layout.js";
import { hydrate, serializeLayout } from "./persistence.js";
import { leaves, type Layout } from "./layout.js";
describe("hydrate", () => {
it("rejects anything that is not a tab-bearing object", () => {
@@ -61,4 +61,42 @@ 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", () => {
const layout = hydrate({
tabs: [
{ id: "t", root: { kind: "leaf", id: "l1", sessionId: null, draft: { command: "zsh", cwd: "/x" } } },
],
activeTabId: "t",
});
expect(leaves(layout!.tabs[0].root)[0]).not.toHaveProperty("draft");
});
});
describe("serializeLayout", () => {
it("strips draft state so a reload gives back an empty form", () => {
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/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]);
});
});
+19 -1
View File
@@ -14,12 +14,30 @@ const VERSION = 1;
export function saveLayout(layout: Layout): void {
try {
localStorage.setItem(KEY, JSON.stringify({ v: VERSION, layout }));
localStorage.setItem(KEY, serializeLayout(layout));
} catch {
// Storage full or unavailable (private mode) — persistence is best-effort.
}
}
/** 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 || node.kind !== "leaf") return node;
return { ...node, draft: null };
}
export function loadLayout(): Layout | null {
let raw: string | null;
try {
+29 -389
View File
@@ -1,26 +1,12 @@
import { escapeHtml } from "./html.js";
import { createSession, getDaemonInfo, recentDirs, gitRoot } from "./api.js";
import { attachDirAutocomplete, type DirAutocompleteHandle } from "./dirAutocomplete.js";
import { generateName } from "./names.js";
import type { CreateSessionRequest, RecentDir, SpawnPreset } from "./types.js";
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
// 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 interface SpawnPrefill {
cwd?: string;
command?: string;
name?: string;
}
/** Built-ins used when the daemon's `/api/info` presets can't be fetched
* (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. */
const FALLBACK_PRESETS: SpawnPreset[] = [
{ id: "claude", label: "Claude", command: "claude" },
{ id: "opencode", label: "opencode", command: "opencode" },
{ id: "zsh", label: "Shell", command: "zsh" },
{ id: "custom", label: "Custom…", command: "" },
];
export type SpawnPrefill = SpawnFormSeed;
export function openSpawnModal(prefill?: SpawnPrefill): void {
const existing = document.getElementById("modal-host");
@@ -35,331 +21,35 @@ export function openSpawnModal(prefill?: SpawnPrefill): void {
<h2 id="spawn-title">Spawn session</h2>
<button type="button" class="btn btn-icon close" aria-label="Close">✕</button>
</header>
<form class="modal-body" id="spawn-form">
<div class="field">
<span class="field-label">Command</span>
<div class="tile-row" id="cmd-tiles"></div>
<input class="raw-input" id="cmd-raw" name="command_raw" type="text" hidden
placeholder="bash -l" autocomplete="off" />
</div>
<div class="field">
<span class="field-label">Working directory</span>
<div class="picker" id="recents"></div>
<div class="raw-wrap" id="cwd-raw-wrap" hidden>
<input class="raw-input" id="cwd-raw" name="cwd_raw" type="text"
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
</div>
</div>
<div class="field">
<span class="field-label">Name</span>
<div class="name-row">
<input name="name" type="text" autocomplete="off" />
<button type="button" class="btn btn-icon reroll" id="reroll" aria-label="Generate a new name" title="New name">↻</button>
</div>
</div>
<fieldset class="worktree">
<legend>
<label class="row-inline">
<input type="checkbox" name="worktree_enabled" id="wt-enabled" />
Create git worktree
</label>
</legend>
<p class="wt-hint" id="wt-hint" hidden></p>
<div class="wt-body" id="wt-body" hidden>
<p class="wt-repo" id="wt-repo"></p>
<label>
Branch
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
</label>
<label class="row-inline">
<input type="checkbox" name="worktree_cleanup_on_kill" />
Remove worktree when session is killed
</label>
</div>
</fieldset>
<p class="form-error" role="alert" aria-live="polite"></p>
<div class="modal-actions">
<button type="button" class="btn btn-ghost cancel">Cancel</button>
<button type="submit" class="btn btn-primary">Spawn</button>
</div>
</form>
<div class="form-slot"></div>
</div>
`;
document.body.appendChild(host);
const form = host.querySelector("#spawn-form") as HTMLFormElement;
const errEl = form.querySelector(".form-error") as HTMLElement;
const cmdTiles = host.querySelector("#cmd-tiles") as HTMLElement;
const cmdRaw = host.querySelector("#cmd-raw") as HTMLInputElement;
const recentsEl = host.querySelector("#recents") as HTMLElement;
const cwdRawWrap = host.querySelector("#cwd-raw-wrap") as HTMLElement;
const cwdRaw = host.querySelector("#cwd-raw") as HTMLInputElement;
const wtEnabled = host.querySelector("#wt-enabled") as HTMLInputElement;
const wtHint = host.querySelector("#wt-hint") as HTMLElement;
const wtBody = host.querySelector("#wt-body") as HTMLElement;
const wtRepo = host.querySelector("#wt-repo") as HTMLElement;
const branchInput = host.querySelector("input[name=worktree_branch]") as HTMLInputElement;
const nameInput = host.querySelector("input[name=name]") as HTMLInputElement;
// ---- form state ------------------------------------------------------
let homeDir = "";
let presets: SpawnPreset[] = FALLBACK_PRESETS;
let selectedPresetId = "";
let command = ""; // resolved command (preset's, or the raw custom input)
let cwd = ""; // chosen working directory
let inferredBaseRepo: string | null = null; // from the gitroot probe
let probeToken = 0; // guards async gitroot probes against races
// A clone's cwd, applied once the (async) recents load for the initial host
// settles — so the fetch can't race past and clobber the seeded directory.
let cloneCwd: string | null = prefill?.cwd ?? null;
// ---- name (also seeds the worktree branch) ---------------------------
// The worktree branch defaults to the session name — including a clone's
// prefilled name — and tracks it on reroll/edit until the user types their
// own branch.
let branchEdited = false;
const syncBranchToName = () => {
if (!branchEdited) branchInput.value = nameInput.value;
};
nameInput.value = prefill?.name ?? generateName();
nameInput.addEventListener("input", syncBranchToName);
branchInput.addEventListener("input", () => {
branchEdited = true;
const formHandle = createSpawnForm({
seed: prefill,
class: "modal-body",
onSuccess(session) {
close();
location.hash = `#/attach/${encodeURIComponent(session.id)}`;
},
});
(host.querySelector("#reroll") as HTMLButtonElement).addEventListener("click", () => {
nameInput.value = generateName();
syncBranchToName();
});
syncBranchToName(); // seed the branch from the initial name
(host.querySelector(".form-slot") as HTMLElement).replaceWith(formHandle.el);
// ---- command tiles ---------------------------------------------------
function selectPreset(id: string): void {
selectedPresetId = id;
const preset = presets.find((p) => p.id === id);
const isCustom = !preset || preset.command === "";
cmdRaw.hidden = !isCustom;
if (isCustom) {
command = cmdRaw.value.trim();
cmdRaw.focus();
} else {
command = preset!.command;
}
for (const tile of cmdTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
}
}
function renderCommandTiles(): void {
cmdTiles.replaceChildren();
for (const p of presets) {
const tile = document.createElement("button");
tile.type = "button";
tile.className = "tile";
tile.dataset.id = p.id;
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
tile.addEventListener("click", () => selectPreset(p.id));
cmdTiles.appendChild(tile);
}
}
cmdRaw.addEventListener("input", () => {
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
command = cmdRaw.value.trim();
}
});
// Presets ride `/api/info`; render fallbacks immediately, then refine.
renderCommandTiles();
applyInitialCommand();
void getDaemonInfo()
.then((info) => {
homeDir = info.home_dir ?? "";
if (info.presets?.length) {
presets = info.presets;
renderCommandTiles();
applyInitialCommand();
}
})
.catch(() => {
/* keep fallbacks */
});
// Pick a sensible starting command: a clone's command (matched to a preset or
// dropped into custom), else the first non-custom preset.
function applyInitialCommand(): void {
const wanted = prefill?.command;
if (wanted) {
const match = presets.find((p) => p.command === wanted && p.command !== "");
if (match) {
selectPreset(match.id);
} else {
const custom = presets.find((p) => p.command === "");
cmdRaw.value = wanted;
selectPreset(custom ? custom.id : "custom");
command = wanted;
}
return;
}
const first = presets.find((p) => p.command !== "");
if (first) selectPreset(first.id);
}
// ---- working-directory picker ---------------------------------------
function showRawCwd(prefillValue?: string): void {
cwdRawWrap.hidden = false;
if (prefillValue != null) cwd = prefillValue;
cwdRaw.value = cwd;
markActiveRecent(null);
cwdRaw.focus();
cwdRaw.select();
}
cwdRaw.addEventListener("input", () => {
cwd = cwdRaw.value.trim();
if (wtEnabled.checked) probeGitRoot();
});
function markActiveRecent(activeCwd: string | null): void {
for (const row of recentsEl.querySelectorAll(".recent-row")) {
row.classList.toggle(
"is-active",
(row as HTMLElement).dataset.cwd === activeCwd,
);
}
}
function selectRecent(dir: RecentDir): void {
cwd = dir.cwd;
cwdRawWrap.hidden = true;
markActiveRecent(dir.cwd);
// Picking a recent dir can pre-select the command it was last used with.
const match = presets.find((p) => p.command === dir.last_command && p.command !== "");
if (match) selectPreset(match.id);
if (wtEnabled.checked) probeGitRoot();
}
async function loadRecents(): Promise<void> {
recentsEl.replaceChildren(loadingRow());
let dirs: RecentDir[] = [];
try {
dirs = (await recentDirs()).dirs ?? [];
} catch {
dirs = [];
}
recentsEl.replaceChildren();
for (const dir of dirs) {
recentsEl.appendChild(recentRow(dir));
}
// Persistent escape hatch + empty-history fallback.
recentsEl.appendChild(otherDirRow());
if (cloneCwd) {
// A clone seeds the directory verbatim into the raw input, regardless of
// the recorded history.
showRawCwd(cloneCwd);
cloneCwd = null;
} else if (dirs.length === 0) {
// No history: default to raw input prefilled with the daemon's home dir.
showRawCwd(homeDir);
} else {
cwdRawWrap.hidden = true;
}
}
function recentRow(dir: RecentDir): HTMLElement {
const row = document.createElement("button");
row.type = "button";
row.className = "recent-row";
row.dataset.cwd = dir.cwd;
// Abbreviate exactly like the sidebar: home → `~`, then collapse leading
// segments to initials (`~/g/n/maestro`). The full path stays in the tooltip
// and `dataset.cwd` (what's actually submitted).
const display = abbreviateFolderName(abbreviateCwd(dir.cwd, homeDir));
row.innerHTML = `
<span class="recent-dir" title="${escapeHtml(dir.cwd)}">${display}</span>
<span class="recent-cmd">${escapeHtml(dir.last_command)}</span>`;
row.addEventListener("click", () => selectRecent(dir));
return row;
}
function otherDirRow(): HTMLElement {
const row = document.createElement("button");
row.type = "button";
row.className = "recent-row recent-other";
row.innerHTML = `<span class="recent-dir">Other directory…</span>`;
row.addEventListener("click", () => showRawCwd(cwd || homeDir));
return row;
}
function loadingRow(): HTMLElement {
const p = document.createElement("p");
p.className = "picker-loading";
p.textContent = "Loading recent directories…";
return p;
}
// ---- worktree (branch-only; base_repo inferred via /gitroot) ----------
async function probeGitRoot(): Promise<void> {
const token = ++probeToken;
if (!cwd) {
inferredBaseRepo = null;
wtBody.hidden = true;
wtHint.hidden = false;
wtHint.textContent = "Choose a working directory first.";
return;
}
wtHint.hidden = false;
wtHint.textContent = "Checking for a git repository…";
wtBody.hidden = true;
let root: string | undefined;
try {
root = (await gitRoot(cwd)).root;
} catch {
root = undefined;
}
if (token !== probeToken) return; // a newer probe superseded us
if (root) {
inferredBaseRepo = root;
wtHint.hidden = true;
wtBody.hidden = false;
wtRepo.textContent = `repo: ${root} (inferred)`;
} else {
inferredBaseRepo = null;
wtEnabled.checked = false;
wtBody.hidden = true;
wtHint.hidden = false;
wtHint.textContent = "Not inside a git repository — pick a dir in a repo to use a worktree.";
}
}
wtEnabled.addEventListener("change", () => {
if (wtEnabled.checked) {
probeGitRoot();
} else {
inferredBaseRepo = null;
wtBody.hidden = true;
wtHint.hidden = true;
}
});
void loadRecents();
// ---- clone seed ------------------------------------------------------
if (prefill) {
(host.querySelector("#spawn-title") as HTMLElement).textContent = "Clone session";
// The cwd itself is applied by loadRecents once the initial host's recents
// settle (see `cloneCwd`), so a slow fetch can't clobber it.
}
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
const autocompletes: DirAutocompleteHandle[] = [attachDirAutocomplete(cwdRaw)];
// ---- lifecycle -------------------------------------------------------
const close = () => {
autocompletes.forEach((a) => a.destroy());
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.querySelector(".cancel")?.addEventListener("click", close);
host.addEventListener("click", (e) => {
if (e.target === host) close();
});
@@ -371,58 +61,8 @@ export function openSpawnModal(prefill?: SpawnPrefill): void {
{ once: true },
);
// ---- submit ----------------------------------------------------------
form.addEventListener("submit", async (e) => {
e.preventDefault();
errEl.textContent = "";
if (!cwd) {
errEl.textContent = "Pick a working directory.";
return;
}
if (!command) {
errEl.textContent = "Pick a command (or fill the custom command).";
return;
}
const metadata: Record<string, unknown> = {};
const name = nameInput.value.trim();
if (name) metadata.name = name;
const req: CreateSessionRequest = {
cwd,
command,
metadata,
};
if (wtEnabled.checked) {
const branch = branchInput.value.trim();
if (!inferredBaseRepo) {
errEl.textContent = "Worktree needs a directory inside a git repository.";
return;
}
if (!branch) {
errEl.textContent = "Worktree requires a branch name.";
return;
}
req.worktree = {
base_repo: inferredBaseRepo,
branch,
cleanup_on_kill: Boolean(
(host.querySelector("input[name=worktree_cleanup_on_kill]") as HTMLInputElement).checked,
),
};
}
const submitBtn = form.querySelector("button[type=submit]") as HTMLButtonElement;
submitBtn.disabled = true;
try {
const session = await createSession(req);
close();
location.hash = `#/attach/${encodeURIComponent(session.id)}`;
} catch (err) {
errEl.textContent = (err as Error).message;
submitBtn.disabled = false;
}
});
// ---- clone seed ------------------------------------------------------
if (prefill) {
(host.querySelector("#spawn-title") as HTMLElement).textContent = "Clone session";
}
}
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { buildSpawnRequest, type SpawnFormValues } from "./spawnForm.js";
const base: SpawnFormValues = {
command: "claude",
cwd: "/home/user/project",
name: "amber-fox",
worktreeEnabled: false,
worktreeBranch: "",
worktreeCleanup: false,
};
describe("buildSpawnRequest", () => {
it("builds a plain request with the picked cwd and command", () => {
const res = buildSpawnRequest(base, null);
expect(res).toEqual({
req: { cwd: "/home/user/project", command: "claude", metadata: { name: "amber-fox" } },
});
});
it("omits metadata.name when the name is blank", () => {
const res = buildSpawnRequest({ ...base, name: " " }, null);
expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } });
});
it("attaches a trimmed branch and cleanup flag when worktree is enabled", () => {
const res = buildSpawnRequest(
{ ...base, worktreeEnabled: true, worktreeBranch: " feat/foo ", worktreeCleanup: true },
"/home/user/project",
);
expect(res).toEqual({
req: {
cwd: base.cwd,
command: base.command,
metadata: { name: "amber-fox" },
worktree: { base_repo: "/home/user/project", branch: "feat/foo", cleanup_on_kill: true },
},
});
});
it("rejects a missing working directory", () => {
expect(buildSpawnRequest({ ...base, cwd: "" }, null)).toHaveProperty("error");
});
it("rejects a missing command", () => {
expect(buildSpawnRequest({ ...base, command: "" }, null)).toHaveProperty("error");
});
it("rejects a worktree without an inferred base repo", () => {
expect(buildSpawnRequest({ ...base, worktreeEnabled: true, worktreeBranch: "b" }, null))
.toHaveProperty("error");
});
it("rejects a worktree without a branch name", () => {
expect(buildSpawnRequest({ ...base, worktreeEnabled: true, worktreeBranch: " " }, "/repo"))
.toHaveProperty("error");
});
});
+495
View File
@@ -0,0 +1,495 @@
import { escapeHtml } from "./html.js";
import { createSession, getDaemonInfo, recentDirs, gitRoot } from "./api.js";
import { attachDirAutocomplete } from "./dirAutocomplete.js";
import { generateName } from "./names.js";
import type { CreateSessionRequest, RecentDir, Session, SpawnPreset } from "./types.js";
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
/** 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. */
export interface SpawnFormSeed {
command?: string;
cwd?: string;
name?: string;
worktreeEnabled?: boolean;
worktreeBranch?: string;
worktreeCleanup?: boolean;
cwdRawVisible?: boolean;
}
/** The form's current values, reported on every change so a caller (the draft
* pane) can hang them off its layout leaf. */
export interface SpawnFormValues {
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 function buildSpawnRequest(
values: SpawnFormValues,
baseRepo: string | null,
): { req: CreateSessionRequest } | { error: string } {
if (!values.cwd) return { error: "Pick a working directory." };
if (!values.command) return { error: "Pick a command (or fill the custom command)." };
const metadata: Record<string, unknown> = {};
const name = values.name.trim();
if (name) metadata.name = name;
const req: CreateSessionRequest = {
cwd: values.cwd,
command: values.command,
metadata,
};
if (values.worktreeEnabled) {
const branch = values.worktreeBranch.trim();
if (!baseRepo) return { error: "Worktree needs a directory inside a git repository." };
if (!branch) return { error: "Worktree requires a branch name." };
req.worktree = {
base_repo: baseRepo,
branch,
cleanup_on_kill: values.worktreeCleanup,
};
}
return { req };
}
/** Built-ins used when the daemon's `/api/info` presets can't be fetched
* (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. */
const FALLBACK_PRESETS: SpawnPreset[] = [
{ id: "claude", label: "Claude", command: "claude" },
{ id: "opencode", label: "opencode", command: "opencode" },
{ id: "zsh", label: "Shell", command: "zsh" },
{ id: "custom", label: "Custom…", command: "" },
];
export interface SpawnFormHandle {
el: HTMLFormElement;
/** Remove global listeners (dir autocomplete) — call when unmounting. */
destroy(): void;
}
export interface SpawnFormOptions {
seed?: SpawnFormSeed;
/** Extra class(es) for the <form> root (e.g. `modal-body` in the modal). */
class?: string;
/** Fired whenever any value changes (input, tile pick, checkbox). */
onChange?: (values: SpawnFormValues) => void;
/** Called with the created session after a successful submit. */
onSuccess: (session: Session) => void;
}
/** The spawn form itself — preset tiles, recent-dirs picker, name, worktree.
* Shared by the modal (`spawn.ts`) and inline draft panes (issue #57). */
export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const prefill = opts.seed ?? {};
const form = document.createElement("form");
form.className = `spawn-form${opts.class ? ` ${opts.class}` : ""}`;
form.innerHTML = `
<div class="field">
<span class="field-label">Command</span>
<div class="tile-row cmd-tiles"></div>
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
placeholder="bash -l" autocomplete="off" />
</div>
<div class="field">
<span class="field-label">Working directory</span>
<div class="picker recents"></div>
<div class="raw-wrap cwd-raw-wrap" hidden>
<input class="raw-input cwd-raw" name="cwd_raw" type="text"
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
</div>
</div>
<div class="field">
<span class="field-label">Name</span>
<div class="name-row">
<input name="name" type="text" autocomplete="off" />
<button type="button" class="btn btn-icon reroll" aria-label="Generate a new name" title="New name">↻</button>
</div>
</div>
<fieldset class="worktree">
<legend>
<label class="row-inline">
<input type="checkbox" name="worktree_enabled" />
Create git worktree
</label>
</legend>
<p class="wt-hint" hidden></p>
<div class="wt-body" hidden>
<p class="wt-repo"></p>
<label>
Branch
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
</label>
<label class="row-inline">
<input type="checkbox" name="worktree_cleanup_on_kill" />
Remove worktree when session is killed
</label>
</div>
</fieldset>
<p class="form-error" role="alert" aria-live="polite"></p>
<div class="form-actions">
<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 (modal + draft panes), so ids would collide.
const errEl = form.querySelector(".form-error") as HTMLElement;
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement;
const recentsEl = form.querySelector(".recents") as HTMLElement;
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
const wtHint = form.querySelector(".wt-hint") as HTMLElement;
const wtBody = form.querySelector(".wt-body") as HTMLElement;
const wtRepo = form.querySelector(".wt-repo") as HTMLElement;
const branchInput = form.querySelector("input[name=worktree_branch]") as HTMLInputElement;
const cleanupInput = form.querySelector(
"input[name=worktree_cleanup_on_kill]",
) as HTMLInputElement;
const nameInput = form.querySelector("input[name=name]") as HTMLInputElement;
// ---- form state ------------------------------------------------------
let homeDir = "";
let presets: SpawnPreset[] = FALLBACK_PRESETS;
let selectedPresetId = "";
let command = ""; // resolved command (preset's, or the raw custom input)
let cwd = ""; // chosen working directory
let inferredBaseRepo: string | null = null; // from the gitroot probe
let probeToken = 0; // guards async gitroot probes against races
// A seed's cwd, applied once the (async) recents load for the initial host
// settles — so the fetch can't race past and clobber the seeded directory.
let seedCwd: string | null = prefill.cwd ?? null;
const emit = () => {
opts.onChange?.({
command,
cwd,
name: nameInput.value,
worktreeEnabled: wtEnabled.checked,
worktreeBranch: branchInput.value,
worktreeCleanup: cleanupInput.checked,
cwdRawVisible: !cwdRawWrap.hidden,
});
};
// ---- name (also seeds the worktree branch) ---------------------------
// The worktree branch defaults to the session name — including a clone's
// prefilled name — and tracks it on reroll/edit until the user types their
// own branch.
let branchEdited = Boolean(prefill.worktreeBranch);
const syncBranchToName = () => {
if (!branchEdited) branchInput.value = nameInput.value;
};
nameInput.value = prefill.name ?? generateName();
nameInput.addEventListener("input", () => {
syncBranchToName();
emit();
});
branchInput.addEventListener("input", () => {
branchEdited = true;
emit();
});
(form.querySelector(".reroll") as HTMLButtonElement).addEventListener("click", () => {
nameInput.value = generateName();
syncBranchToName();
emit();
});
syncBranchToName(); // seed the branch from the initial name
// ---- command tiles ---------------------------------------------------
function selectPreset(id: string): void {
selectedPresetId = id;
const preset = presets.find((p) => p.id === id);
const isCustom = !preset || preset.command === "";
cmdRaw.hidden = !isCustom;
if (isCustom) {
command = cmdRaw.value.trim();
cmdRaw.focus();
} else {
command = preset!.command;
}
for (const tile of cmdTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
}
emit();
}
function renderCommandTiles(): void {
cmdTiles.replaceChildren();
for (const p of presets) {
const tile = document.createElement("button");
tile.type = "button";
tile.className = "tile";
tile.dataset.id = p.id;
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
tile.addEventListener("click", () => selectPreset(p.id));
cmdTiles.appendChild(tile);
}
}
cmdRaw.addEventListener("input", () => {
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
command = cmdRaw.value.trim();
emit();
}
});
// Presets ride `/api/info`; render fallbacks immediately, then refine.
renderCommandTiles();
applyInitialCommand();
const infoLoad = getDaemonInfo()
.then((info) => {
homeDir = info.home_dir ?? "";
if (info.presets?.length) {
presets = info.presets;
renderCommandTiles();
applyInitialCommand();
}
})
.catch(() => {
/* keep fallbacks */
});
// Pick a sensible starting command: a seed's command (matched to a preset or
// dropped into custom), else the first non-custom preset.
function applyInitialCommand(): void {
const wanted = prefill.command;
if (wanted) {
const match = presets.find((p) => p.command === wanted && p.command !== "");
if (match) {
selectPreset(match.id);
} else {
const custom = presets.find((p) => p.command === "");
cmdRaw.value = wanted;
selectPreset(custom ? custom.id : "custom");
command = wanted;
}
return;
}
const first = presets.find((p) => p.command !== "");
if (first) selectPreset(first.id);
}
// ---- working-directory picker ---------------------------------------
function showRawCwd(prefillValue?: string, focus = true): void {
cwdRawWrap.hidden = false;
if (prefillValue != null) cwd = prefillValue;
cwdRaw.value = cwd;
markActiveRecent(null);
if (focus) {
cwdRaw.focus();
cwdRaw.select();
}
}
cwdRaw.addEventListener("input", () => {
cwd = cwdRaw.value.trim();
emit();
if (wtEnabled.checked) probeGitRoot();
});
function markActiveRecent(activeCwd: string | null): void {
for (const row of recentsEl.querySelectorAll(".recent-row")) {
row.classList.toggle(
"is-active",
(row as HTMLElement).dataset.cwd === activeCwd,
);
}
}
function selectRecent(dir: RecentDir): void {
cwd = dir.cwd;
cwdRawWrap.hidden = true;
markActiveRecent(dir.cwd);
emit();
// Picking a recent dir can pre-select the command it was last used with.
const match = presets.find((p) => p.command === dir.last_command && p.command !== "");
if (match) selectPreset(match.id);
if (wtEnabled.checked) probeGitRoot();
}
async function loadRecents(): Promise<void> {
recentsEl.replaceChildren(loadingRow());
let dirs: RecentDir[] = [];
try {
dirs = (await recentDirs()).dirs ?? [];
} catch {
dirs = [];
}
recentsEl.replaceChildren();
for (const dir of dirs) {
recentsEl.appendChild(recentRow(dir));
}
// Persistent escape hatch + empty-history fallback.
recentsEl.appendChild(otherDirRow());
if (seedCwd) {
// A seed sets the directory verbatim, regardless of recorded history.
// `cwdRawVisible === false` marks a draft restore whose cwd came from a
// picked recent row: keep the picker and don't steal focus. Otherwise
// land focused in the raw input (clone / empty-history flows).
if (prefill.cwdRawVisible === false) {
cwd = seedCwd;
cwdRaw.value = seedCwd;
markActiveRecent(cwd);
} else {
showRawCwd(seedCwd);
}
seedCwd = null;
} else if (dirs.length === 0) {
// No history: default to raw input prefilled with the daemon's home dir.
showRawCwd(homeDir);
} else {
cwdRawWrap.hidden = true;
}
// The seeded cwd is in place — a restored worktree can probe now.
if (wtEnabled.checked) void probeGitRoot();
}
function recentRow(dir: RecentDir): HTMLElement {
const row = document.createElement("button");
row.type = "button";
row.className = "recent-row";
row.dataset.cwd = dir.cwd;
// Abbreviate exactly like the sidebar: home → `~`, then collapse leading
// segments to initials (`~/g/n/maestro`). The full path stays in the tooltip
// and `dataset.cwd` (what's actually submitted).
const display = abbreviateFolderName(abbreviateCwd(dir.cwd, homeDir));
row.innerHTML = `
<span class="recent-dir" title="${escapeHtml(dir.cwd)}">${display}</span>
<span class="recent-cmd">${escapeHtml(dir.last_command)}</span>`;
row.addEventListener("click", () => selectRecent(dir));
return row;
}
function otherDirRow(): HTMLElement {
const row = document.createElement("button");
row.type = "button";
row.className = "recent-row recent-other";
row.innerHTML = `<span class="recent-dir">Other directory…</span>`;
row.addEventListener("click", () => showRawCwd(cwd || homeDir));
return row;
}
function loadingRow(): HTMLElement {
const p = document.createElement("p");
p.className = "picker-loading";
p.textContent = "Loading recent directories…";
return p;
}
// ---- worktree (branch-only; base_repo inferred via /gitroot) ----------
async function probeGitRoot(): Promise<void> {
const token = ++probeToken;
if (!cwd) {
inferredBaseRepo = null;
wtBody.hidden = true;
wtHint.hidden = false;
wtHint.textContent = "Choose a working directory first.";
return;
}
wtHint.hidden = false;
wtHint.textContent = "Checking for a git repository…";
wtBody.hidden = true;
let root: string | undefined;
try {
root = (await gitRoot(cwd)).root;
} catch {
root = undefined;
}
if (token !== probeToken) return; // a newer probe superseded us
if (root) {
inferredBaseRepo = root;
wtHint.hidden = true;
wtBody.hidden = false;
wtRepo.textContent = `repo: ${root} (inferred)`;
} else {
inferredBaseRepo = null;
wtEnabled.checked = false;
wtBody.hidden = true;
wtHint.hidden = false;
wtHint.textContent = "Not inside a git repository — pick a dir in a repo to use a worktree.";
}
}
wtEnabled.addEventListener("change", () => {
emit();
if (wtEnabled.checked) {
probeGitRoot();
} else {
inferredBaseRepo = null;
wtBody.hidden = true;
wtHint.hidden = true;
}
});
cleanupInput.addEventListener("change", emit);
// A seeded worktree restores its checkbox/body up front; its gitroot probe
// waits for loadRecents to apply the seeded cwd.
if (prefill.worktreeEnabled || prefill.worktreeBranch || prefill.worktreeCleanup) {
wtEnabled.checked = Boolean(prefill.worktreeEnabled);
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
cleanupInput.checked = Boolean(prefill.worktreeCleanup);
wtBody.hidden = !wtEnabled.checked;
wtHint.hidden = true;
emit();
}
// Seed ordering: the raw-cwd application races the async info load (which
// provides homeDir for the recents' abbreviations), so recents wait for it.
void infoLoad.then(() => loadRecents());
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
const autocomplete = attachDirAutocomplete(cwdRaw);
// ---- submit ----------------------------------------------------------
form.addEventListener("submit", async (e) => {
e.preventDefault();
errEl.textContent = "";
const built = buildSpawnRequest(
{
command,
cwd,
name: nameInput.value,
worktreeEnabled: wtEnabled.checked,
worktreeBranch: branchInput.value,
worktreeCleanup: cleanupInput.checked,
},
inferredBaseRepo,
);
if ("error" in built) {
errEl.textContent = built.error;
return;
}
const submitBtn = form.querySelector("button[type=submit]") as HTMLButtonElement;
submitBtn.disabled = true;
try {
const session = await createSession(built.req);
opts.onSuccess(session);
} catch (err) {
errEl.textContent = (err as Error).message;
submitBtn.disabled = false;
}
});
return {
el: form,
destroy() {
autocomplete.destroy();
form.remove();
},
};
}
+29 -14
View File
@@ -1280,7 +1280,8 @@ textarea {
scrollbar-color: var(--border-strong) transparent;
}
.modal-body label {
.modal-body label,
.spawn-form label {
display: flex;
flex-direction: column;
gap: 5px;
@@ -1289,7 +1290,8 @@ textarea {
letter-spacing: 0.01em;
}
.modal-body label.row-inline {
.modal-body label.row-inline,
.spawn-form label.row-inline {
flex-direction: row;
align-items: center;
gap: var(--sp-2);
@@ -1300,7 +1302,8 @@ textarea {
.modal-body input[type="text"],
.modal-body input[type="search"],
.modal-body select {
.modal-body select,
.spawn-form input[type="text"] {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-2);
@@ -1316,20 +1319,23 @@ textarea {
}
.modal-body input[type="text"]:hover,
.modal-body select:hover {
.modal-body select:hover,
.spawn-form input[type="text"]:hover {
border-color: var(--border-strong);
}
.modal-body input[type="text"]:focus,
.modal-body input[type="search"]:focus,
.modal-body select:focus {
.modal-body select:focus,
.spawn-form input[type="text"]:focus {
outline: none;
border-color: var(--accent);
background: var(--surface);
box-shadow: 0 0 0 3px var(--accent-tint);
}
.modal-body input[type="checkbox"] {
.modal-body input[type="checkbox"],
.spawn-form input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--accent);
@@ -1375,7 +1381,8 @@ textarea {
/* ---------- spawn form: tiles + recents picker (#27) ---------- */
.spawn-modal .field {
.spawn-modal .field,
.spawn-form .field {
display: flex;
flex-direction: column;
gap: var(--sp-2);
@@ -1546,6 +1553,13 @@ textarea {
flex: 1 1 auto;
}
.form-actions {
display: flex;
gap: var(--sp-2);
justify-content: flex-end;
margin-top: var(--sp-2);
}
.reroll {
flex: 0 0 auto;
}
@@ -2056,14 +2070,15 @@ textarea {
display: none;
}
.mv-pane-empty {
/* 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 {
flex: 1;
display: grid;
place-items: center;
padding: var(--sp-4);
color: var(--faint);
font: var(--fs-12) / 1.5 var(--font-mono);
text-align: center;
min-width: 0;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
/* pane tools cluster (disconnect / split / close), top-right corner. The attach