import { createSession, getWebInfo, recentDirs, gitRoot } from "./api.js"; import { attachDirAutocomplete, type DirAutocompleteHandle } from "./dirAutocomplete.js"; import { generateName } from "./names.js"; import type { CreateSessionRequest, DaemonInfo, RecentDir, Session, SpawnPreset, } from "./types.js"; /** Seed values for the spawn form — used by "clone" to reopen the dialog with * the same daemon, cwd, command, name, and label as an existing session. */ export interface SpawnPrefill { daemon_id?: string; cwd?: string; command?: string; name?: string; label?: string; } /** Built-ins used when the daemon's `/api/web/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: "" }, ]; /** How many host tiles to show before collapsing the rest behind "More…". */ const MAX_HOST_TILES = 4; export function openSpawnModal( daemons: Map, prefill?: SpawnPrefill, sessions?: Map, ): void { const existing = document.getElementById("modal-host"); if (existing) existing.remove(); const host = document.createElement("div"); host.id = "modal-host"; host.className = "modal-backdrop"; host.innerHTML = ` `; document.body.appendChild(host); const form = host.querySelector("#spawn-form") as HTMLFormElement; const errEl = form.querySelector(".form-error") as HTMLElement; const hostTiles = host.querySelector("#host-tiles") 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 selectedHostId = ""; 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 let showAllHosts = false; // 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 ------------------------------------------------------------ nameInput.value = prefill?.name ?? generateName(); (host.querySelector("#reroll") as HTMLButtonElement).addEventListener("click", () => { nameInput.value = generateName(); }); // ---- 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/web/info`; render fallbacks immediately, then refine. renderCommandTiles(); applyInitialCommand(); getWebInfo() .then((info) => { 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(selectedHostId)).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 // this host's history. showRawCwd(cloneCwd); cloneCwd = null; } else if (dirs.length === 0) { // No history: default to raw input prefilled with the host's home dir. const home = daemons.get(selectedHostId)?.home_dir ?? ""; showRawCwd(home); } 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; row.innerHTML = ` ${escapeHtml(dir.cwd)} ${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", () => { const home = daemons.get(selectedHostId)?.home_dir ?? ""; showRawCwd(cwd || home); }); 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(selectedHostId, 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; } }); // ---- host tiles ------------------------------------------------------ // Order hosts by most-recently-spawned-on first, so the common case is the // pre-selected tile. function orderedHosts(): DaemonInfo[] { const list = [...daemons.values()]; const lastUsed = new Map(); for (const s of sessions?.values() ?? []) { const t = Date.parse(s.created_at); if (Number.isFinite(t)) { lastUsed.set(s.daemon_id, Math.max(lastUsed.get(s.daemon_id) ?? 0, t)); } } return list.sort((a, b) => (lastUsed.get(b.daemon_id) ?? 0) - (lastUsed.get(a.daemon_id) ?? 0)); } function selectHost(id: string): void { selectedHostId = id; for (const tile of hostTiles.querySelectorAll(".tile")) { tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id); } // Recents + any worktree inference are host-scoped — refresh on change. cwd = ""; inferredBaseRepo = null; if (wtEnabled.checked) { wtEnabled.checked = false; wtBody.hidden = true; wtHint.hidden = true; } void loadRecents(); } function renderHostTiles(): void { hostTiles.replaceChildren(); const ordered = orderedHosts(); if (ordered.length === 0) { const empty = document.createElement("p"); empty.className = "picker-loading"; empty.textContent = "(no daemons connected)"; hostTiles.appendChild(empty); return; } const visible = showAllHosts ? ordered : ordered.slice(0, MAX_HOST_TILES); for (const d of visible) { const tile = document.createElement("button"); tile.type = "button"; tile.className = "tile tile-host"; tile.dataset.id = d.daemon_id; tile.innerHTML = ` ${escapeHtml(d.hostname)} ${escapeHtml(d.daemon_id)}`; tile.addEventListener("click", () => selectHost(d.daemon_id)); hostTiles.appendChild(tile); } if (!showAllHosts && ordered.length > MAX_HOST_TILES) { const more = document.createElement("button"); more.type = "button"; more.className = "tile tile-more"; more.innerHTML = `More…`; more.addEventListener("click", () => { showAllHosts = true; renderHostTiles(); // Keep the current selection highlighted after re-render. if (selectedHostId) { hostTiles .querySelector(`.tile[data-id="${cssEscape(selectedHostId)}"]`) ?.classList.add("is-active"); } }); hostTiles.appendChild(more); } } renderHostTiles(); // Pre-select: a clone's host if present & connected, else the most-recent. const ordered = orderedHosts(); const initialHost = prefill?.daemon_id && daemons.has(prefill.daemon_id) ? prefill.daemon_id : ordered[0]?.daemon_id; if (initialHost) selectHost(initialHost); // ---- clone seed ------------------------------------------------------ if (prefill) { (host.querySelector("#spawn-title") as HTMLElement).textContent = "Clone session"; (host.querySelector("input[name=label]") as HTMLInputElement).value = prefill.label ?? ""; // 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, () => selectedHostId), ]; // ---- lifecycle ------------------------------------------------------- const close = () => { autocompletes.forEach((a) => a.destroy()); host.remove(); }; host.querySelector(".close")?.addEventListener("click", close); host.querySelector(".cancel")?.addEventListener("click", close); host.addEventListener("click", (e) => { if (e.target === host) close(); }); window.addEventListener( "keydown", (e) => { if (e.key === "Escape") close(); }, { once: true }, ); // ---- submit ---------------------------------------------------------- form.addEventListener("submit", async (e) => { e.preventDefault(); errEl.textContent = ""; if (!selectedHostId) { errEl.textContent = "Pick a host."; return; } 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(); const label = (host.querySelector("input[name=label]") as HTMLInputElement).value.trim(); if (name) metadata.name = name; if (label) metadata.label = label; const req: CreateSessionRequest = { daemon_id: selectedHostId, 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; } }); } function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } /** Escape a string for use inside a CSS attribute selector. Uses the native * `CSS.escape` when available, with a conservative fallback. */ function cssEscape(s: string): string { const cssApi = (globalThis as { CSS?: { escape?: (v: string) => string } }).CSS; if (cssApi?.escape) return cssApi.escape(s); return s.replace(/["\\]/g, "\\$&"); }