diff --git a/web/src/spawn.ts b/web/src/spawn.ts
index a3c7040..34683c8 100644
--- a/web/src/spawn.ts
+++ b/web/src/spawn.ts
@@ -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 {
Spawn session
-
+
`;
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 = `${escapeHtml(p.label)}`;
- 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 {
- 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 = `
- ${display}
- ${escapeHtml(dir.last_command)}`;
- 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 = `Other directory…`;
- 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 {
- 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 = {};
- 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";
+ }
}
-
diff --git a/web/src/spawnForm.test.ts b/web/src/spawnForm.test.ts
new file mode 100644
index 0000000..4b7dd1b
--- /dev/null
+++ b/web/src/spawnForm.test.ts
@@ -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");
+ });
+});
diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts
new file mode 100644
index 0000000..26e1a3b
--- /dev/null
+++ b/web/src/spawnForm.ts
@@ -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 = {};
+ 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