ci / rust (push) Successful in 4m29s
Rework web/src/spawn.ts from raw text boxes into a low-friction, tile-first flow: pick host tile → pick command tile → pick recent dir → Spawn, name already filled. - Command tiles from the daemon's advertised presets (GET /api/web/info, #26), with built-in fallback; `custom` reveals the free-text command input. - Host tiles replace the <select> (label hostname, sub daemon_id); most- recently-spawned-on host sorts first + pre-selected; overflow behind "More…". - Recent-dirs picker per host (GET /api/daemons/:id/recent-dirs, #26), rows show dir + last command, re-queried on host change; picking a dir can pre-select its last command. Persistent "Other directory…" drops to the raw input + NAP-329 autocomplete; empty history prefills raw with home_dir. - Worktree by branch alone: toggle infers base_repo via /gitroot from the chosen dir (#26); non-repo dir disables the toggle with an inline hint; WorktreeSpec.base_repo filled client-side (wire unchanged). - Name field prefilled with a generated cyberpunk handle (names.ts mirrors the maestro-names wordlists); ↻ rerolls. Adds api.ts getWebInfo/recentDirs/gitRoot, the WebInfo/SpawnPreset/RecentDirs/ GitRoot types, and stubs the new endpoints in the `?mock&spawn` review fixture. Closes #27 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
542 lines
19 KiB
TypeScript
542 lines
19 KiB
TypeScript
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<string, DaemonInfo>,
|
|
prefill?: SpawnPrefill,
|
|
sessions?: Map<string, Session>,
|
|
): void {
|
|
const existing = document.getElementById("modal-host");
|
|
if (existing) existing.remove();
|
|
|
|
const host = document.createElement("div");
|
|
host.id = "modal-host";
|
|
host.className = "modal-backdrop";
|
|
host.innerHTML = `
|
|
<div class="modal spawn-modal" role="dialog" aria-modal="true" aria-labelledby="spawn-title">
|
|
<header class="modal-header">
|
|
<h2 id="spawn-title">Spawn session</h2>
|
|
<button type="button" class="btn btn-icon close" aria-label="Close">✕</button>
|
|
</header>
|
|
<form class="modal-body" id="spawn-form">
|
|
<div class="field">
|
|
<span class="field-label">Host</span>
|
|
<div class="tile-row" id="host-tiles"></div>
|
|
</div>
|
|
<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>
|
|
<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>
|
|
<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>
|
|
<label>
|
|
Label (optional)
|
|
<input name="label" type="text" placeholder="personal/maestro" autocomplete="off" />
|
|
</label>
|
|
<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>
|
|
`;
|
|
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 = `<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/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<void> {
|
|
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 = `
|
|
<span class="recent-dir">${escapeHtml(dir.cwd)}</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", () => {
|
|
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<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(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<string, number>();
|
|
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 = `
|
|
<span class="tile-label">${escapeHtml(d.hostname)}</span>
|
|
<span class="tile-sub">${escapeHtml(d.daemon_id)}</span>`;
|
|
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 = `<span class="tile-label">More…</span>`;
|
|
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<string, unknown> = {};
|
|
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, ">")
|
|
.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, "\\$&");
|
|
}
|