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
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 = ` +
+ Command +
+ +
+
+ Working directory +
+ +
+
+ Name +
+ + +
+
+
+ + + + + +
+ +
+ +
+ `; + // 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 = `${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(); + 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 { + 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 = ` + ${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", () => { + 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(); + }, + }; +} diff --git a/web/src/styles.css b/web/src/styles.css index 5061384..cd01222 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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