feat(maestro): tile-first web spawn form — host/command tiles, recents, worktree-by-branch, generated name (#27)
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>
This commit is contained in:
2026-06-20 21:22:25 +00:00
co-authored by Claude Opus 4.8
parent f38b948aa6
commit b3ef2e543e
8 changed files with 792 additions and 137 deletions
+23
View File
@@ -3,8 +3,11 @@ import type {
CreateSessionRequest,
DaemonInfo,
DirListing,
GitRoot,
RecentDirs,
Session,
Uuid,
WebInfo,
} from "./types.js";
async function jsonOrError<T>(res: Response): Promise<T> {
@@ -58,6 +61,26 @@ export async function listDir(daemonId: string, path: string): Promise<DirListin
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
}
/** Identity + spawn presets of the daemon this web app is connected to (#26). */
export async function getWebInfo(): Promise<WebInfo> {
return jsonOrError(await fetch("/api/web/info", { credentials: "same-origin" }));
}
/** Recently-used working dirs on `daemonId`'s host, newest-first and deduped by
* dir (#26). Drives the spawn form's recents picker. */
export async function recentDirs(daemonId: string): Promise<RecentDirs> {
const url = `/api/daemons/${encodeURIComponent(daemonId)}/recent-dirs`;
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
}
/** Probe the enclosing git repo of `path` on `daemonId`'s host (#26). `root` is
* absent when the path isn't inside a repo. `path` may be `~`/relative/absolute;
* the daemon resolves it against its home dir. */
export async function gitRoot(daemonId: string, path: string): Promise<GitRoot> {
const url = `/api/daemons/${encodeURIComponent(daemonId)}/gitroot?path=${encodeURIComponent(path)}`;
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
}
export function eventsWsUrl(): string {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${location.host}/api/events`;
+18 -11
View File
@@ -273,7 +273,8 @@ export function renderTopbar(store: CatalogStore): HTMLElement {
spawnBtn.className = "btn btn-primary spawn-btn";
spawnBtn.textContent = "+ Spawn";
spawnBtn.addEventListener("click", () => {
openSpawnModal(store.snapshot().daemons);
const snap = store.snapshot();
openSpawnModal(snap.daemons, undefined, snap.sessions);
});
actions.appendChild(spawnBtn);
return header;
@@ -387,9 +388,10 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
treeHost.replaceChildren();
if (sessions.length === 0) {
treeHost.appendChild(
renderEmpty(state.daemons.size === 0, filter.trim().length > 0, () =>
openSpawnModal(store.snapshot().daemons),
),
renderEmpty(state.daemons.size === 0, filter.trim().length > 0, () => {
const snap = store.snapshot();
openSpawnModal(snap.daemons, undefined, snap.sessions);
}),
);
return;
}
@@ -406,13 +408,18 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
const cloneHandler = (ev: Event) => {
const s = (ev as CustomEvent<Session>).detail;
const rawName = typeof s.metadata?.name === "string" ? s.metadata.name : "";
openSpawnModal(store.snapshot().daemons, {
daemon_id: s.daemon_id,
cwd: s.cwd,
command: s.command,
name: rawName,
label: sessionLabel(s) ?? "",
});
const snap = store.snapshot();
openSpawnModal(
snap.daemons,
{
daemon_id: s.daemon_id,
cwd: s.cwd,
command: s.command,
name: rawName,
label: sessionLabel(s) ?? "",
},
snap.sessions,
);
};
document.addEventListener("catalog-clone", cloneHandler);
+4 -1
View File
@@ -70,7 +70,10 @@ function renderWelcome(): HTMLElement {
spawn.type = "button";
spawn.className = "btn btn-primary";
spawn.textContent = "+ Spawn session";
spawn.addEventListener("click", () => openSpawnModal(store.snapshot().daemons));
spawn.addEventListener("click", () => {
const snap = store.snapshot();
openSpawnModal(snap.daemons, undefined, snap.sessions);
});
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
return el;
}
+59 -2
View File
@@ -23,6 +23,57 @@ function sess(
};
}
/** Stub the spawn-form backend endpoints (#26) so `?mock&spawn` renders the full
* tile-first flow — presets, recents, gitroot — with no daemon running. Dev-only;
* delegates every other request to the real `fetch`. */
function stubSpawnApi(): void {
const real = globalThis.fetch.bind(globalThis);
const json = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const path = url.startsWith("http") ? new URL(url).pathname + new URL(url).search : url;
if (path.startsWith("/api/web/info")) {
return Promise.resolve(
json({
daemon_id: "thinkpad",
hostname: "thinkpad.lan",
version: "0.1.0",
daemon_url: "wss://thinkpad.lan/daemon",
presets: [
{ id: "claude", label: "Claude", command: "claude" },
{ id: "opencode", label: "opencode", command: "opencode" },
{ id: "zsh", label: "Shell", command: "zsh" },
{ id: "custom", label: "Custom…", command: "" },
],
}),
);
}
if (path.includes("/recent-dirs")) {
return Promise.resolve(
json({
dirs: [
{ cwd: "/home/dev/git/maestro", last_command: "claude", last_used: "2026-06-20T10:00:00Z" },
{ cwd: "/home/dev/git/dotfiles", last_command: "zsh", last_used: "2026-06-19T18:00:00Z" },
{ cwd: "/home/dev/work/acme/api", last_command: "opencode", last_used: "2026-06-18T09:00:00Z" },
],
truncated: false,
}),
);
}
if (path.includes("/gitroot")) {
const root = path.includes("maestro") || path.includes("dotfiles")
? { root: "/home/dev/git/maestro" }
: {};
return Promise.resolve(json(root));
}
return real(input, init);
};
}
export function seedMock(store: CatalogStore): void {
const daemons: DaemonInfo[] = [
{ daemon_id: "thinkpad", hostname: "thinkpad.lan", version: "0.1.0", daemon_url: "wss://thinkpad.lan/daemon", home_dir: "/home/dev" },
@@ -57,10 +108,16 @@ export function seedMock(store: CatalogStore): void {
sessions,
});
// `?mock&spawn` auto-opens the spawn modal for visual review.
// `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn
// backend endpoints stubbed so presets/recents/gitroot all render.
if (location.search.includes("spawn")) {
stubSpawnApi();
void import("./spawn.js").then((m) =>
m.openSpawnModal(new Map(daemons.map((d) => [d.daemon_id, d]))),
m.openSpawnModal(
new Map(daemons.map((d) => [d.daemon_id, d])),
undefined,
new Map(sessions.map((s) => [s.id, s])),
),
);
}
}
+30
View File
@@ -0,0 +1,30 @@
// Client-side session-handle generator for the spawn form's instant reroll
// (#23/#27). Mirrors the wordlists in `crates/maestro-names/src/lib.rs` verbatim
// — the Rust generator is the source of truth, but the web wants a zero-round-
// trip reroll, so the lists are duplicated here. Keep them in sync.
/** Musical adjectives — tempo, dynamics, and articulation markings. */
const ADJECTIVES = [
"allegro", "andante", "adagio", "presto", "vivace", "largo", "grave", "lento",
"moderato", "crescendo", "staccato", "legato", "forte", "dolce", "agitato",
"cantabile", "sostenuto", "tremolo", "rubato", "marcato", "brillante",
"maestoso", "grazioso", "espressivo", "tranquillo", "animato", "spirito",
"sotto", "vivo", "feroce",
];
/** Musical nouns — instruments and forms from the concert hall. */
const NOUNS = [
"cello", "violin", "viola", "oboe", "flute", "clarinet", "bassoon", "trumpet",
"trombone", "tuba", "horn", "timpani", "harp", "piano", "organ", "lyre",
"lute", "fugue", "sonata", "octave", "chord", "cadence", "prelude", "rondo",
"nocturne", "scherzo", "refrain", "coda", "motif", "overture",
];
function pick<T>(list: T[]): T {
return list[Math.floor(Math.random() * list.length)];
}
/** A fresh `adjective-noun` handle, e.g. `allegro-cello`. */
export function generateName(): string {
return `${pick(ADJECTIVES)}-${pick(NOUNS)}`;
}
+442 -123
View File
@@ -1,6 +1,13 @@
import { createSession } from "./api.js";
import { attachDirAutocomplete } from "./dirAutocomplete.js";
import type { CreateSessionRequest, DaemonInfo } from "./types.js";
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. */
@@ -12,9 +19,22 @@ export interface SpawnPrefill {
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();
@@ -23,52 +43,61 @@ export function openSpawnModal(
host.id = "modal-host";
host.className = "modal-backdrop";
host.innerHTML = `
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="spawn-title">
<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">
<label>
Daemon
<select name="daemon_id" required></select>
</label>
<label>
Working directory
<input name="cwd" type="text" required placeholder="/home/user/project" inputmode="text" autocomplete="off" />
</label>
<label>
Command
<input name="command" type="text" required value="claude" placeholder="bash" autocomplete="off" />
</label>
<label>
Name (optional)
<input name="name" type="text" placeholder="e.g. impl" autocomplete="off" />
</label>
<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>
<fieldset class="worktree">
<legend>
<label class="row-inline">
<input type="checkbox" name="worktree_enabled" />
Create git worktree
</label>
</legend>
<label>
Base repo
<input name="worktree_base_repo" type="text" placeholder="/path/to/repo" autocomplete="off" />
</label>
<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>
</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>
@@ -79,73 +108,343 @@ export function openSpawnModal(
`;
document.body.appendChild(host);
const select = host.querySelector("select[name=daemon_id]") as HTMLSelectElement;
if (daemons.size === 0) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "(no daemons connected)";
opt.disabled = true;
opt.selected = true;
select.appendChild(opt);
} else {
for (const d of daemons.values()) {
const opt = document.createElement("option");
opt.value = d.daemon_id;
opt.textContent = `${d.daemon_id} (${d.hostname})`;
select.appendChild(opt);
}
}
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;
// Prefill the working directory with the selected daemon's home dir, and keep
// it in sync when the daemon changes — unless the user has typed their own.
const cwdInput = host.querySelector("input[name=cwd]") as HTMLInputElement;
let cwdEdited = false;
cwdInput.addEventListener("input", () => {
cwdEdited = true;
// ---- 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();
});
const applyHomeDir = () => {
if (cwdEdited) return;
const home = daemons.get(select.value)?.home_dir;
if (home) cwdInput.value = home;
};
select.addEventListener("change", applyHomeDir);
applyHomeDir();
// Clone: seed the form from an existing session. cwd is marked edited so the
// home-dir sync won't clobber it on daemon change.
if (prefill) {
(host.querySelector("#spawn-title") as HTMLElement).textContent =
"Clone session";
const set = (name: string, value: string | undefined) => {
if (value == null) return;
const field = host.querySelector(
`[name=${name}]`,
) as HTMLInputElement | HTMLSelectElement | null;
if (field) field.value = value;
};
if (prefill.daemon_id && daemons.has(prefill.daemon_id)) {
select.value = prefill.daemon_id;
// ---- 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;
}
set("command", prefill.command);
set("name", prefill.name);
set("label", prefill.label);
if (prefill.cwd) {
cwdInput.value = prefill.cwd;
cwdEdited = true;
for (const tile of cmdTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
}
}
// Live directory autocomplete (NAP-329) on the path fields, scoped to the
// currently-selected daemon — re-reading `select.value` per query means it
// tracks host changes for free.
const baseRepoInput = host.querySelector(
"input[name=worktree_base_repo]",
) as HTMLInputElement;
const autocompletes = [
attachDirAutocomplete(cwdInput, () => select.value),
attachDirAutocomplete(baseRepoInput, () => select.value),
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();
@@ -163,49 +462,56 @@ export function openSpawnModal(
{ once: true },
);
const form = host.querySelector("#spawn-form") as HTMLFormElement;
const errEl = form.querySelector(".form-error") as HTMLElement;
// ---- submit ----------------------------------------------------------
form.addEventListener("submit", async (e) => {
e.preventDefault();
errEl.textContent = "";
const data = new FormData(form);
const daemonId = String(data.get("daemon_id") ?? "");
const cwd = String(data.get("cwd") ?? "").trim();
const command = String(data.get("command") ?? "").trim();
if (!daemonId || !cwd || !command) {
errEl.textContent = "Daemon, cwd, and command are required.";
if (!selectedHostId) {
errEl.textContent = "Pick a host.";
return;
}
const name = String(data.get("name") ?? "").trim();
const label = String(data.get("label") ?? "").trim();
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: daemonId,
daemon_id: selectedHostId,
cwd,
command,
metadata,
};
if (data.get("worktree_enabled")) {
const baseRepo = String(data.get("worktree_base_repo") ?? "").trim();
const branch = String(data.get("worktree_branch") ?? "").trim();
if (!baseRepo || !branch) {
errEl.textContent = "Worktree requires base repo and branch.";
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: baseRepo,
base_repo: inferredBaseRepo,
branch,
cleanup_on_kill: Boolean(data.get("worktree_cleanup_on_kill")),
cleanup_on_kill: Boolean(
(host.querySelector("input[name=worktree_cleanup_on_kill]") as HTMLInputElement).checked,
),
};
}
const submitBtn = form.querySelector(
"button[type=submit]",
) as HTMLButtonElement;
const submitBtn = form.querySelector("button[type=submit]") as HTMLButtonElement;
submitBtn.disabled = true;
try {
const session = await createSession(req);
@@ -216,7 +522,20 @@ export function openSpawnModal(
submitBtn.disabled = false;
}
});
const firstInput = form.querySelector("input, select") as HTMLElement | null;
firstInput?.focus();
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** 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, "\\$&");
}
+177
View File
@@ -1421,6 +1421,183 @@ textarea {
border-radius: var(--r-2);
}
/* ---------- spawn form: tiles + recents picker (#27) ---------- */
.spawn-modal .field {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.field-label {
font-size: var(--fs-13);
color: var(--ink);
}
/* One-click tiles: host row + command row. */
.tile-row {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.tile {
flex: 1 1 auto;
min-width: 96px;
display: flex;
flex-direction: column;
gap: 2px;
align-items: flex-start;
padding: var(--sp-2) var(--sp-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-2);
color: var(--ink);
font-family: inherit;
cursor: pointer;
text-align: left;
transition:
border-color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out);
}
.tile:hover {
border-color: var(--border-strong);
}
.tile.is-active {
border-color: var(--accent);
background: var(--accent-tint);
}
.tile-label {
font-size: var(--fs-14);
font-weight: 600;
}
.tile-sub {
font-size: var(--fs-12);
color: var(--muted);
font-family: var(--font-mono);
}
.tile-more {
flex: 0 0 auto;
min-width: 0;
justify-content: center;
align-items: center;
color: var(--muted);
}
/* Recent-dirs picker rows. */
.picker {
display: flex;
flex-direction: column;
gap: var(--sp-1);
max-height: 220px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
.recent-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-3);
padding: 6px var(--sp-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-2);
color: var(--ink);
font-family: inherit;
cursor: pointer;
text-align: left;
transition:
border-color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out);
}
.recent-row:hover {
border-color: var(--border-strong);
}
.recent-row.is-active {
border-color: var(--accent);
background: var(--accent-tint);
}
.recent-dir {
font-family: var(--font-mono);
font-size: var(--fs-14);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recent-cmd {
flex: 0 0 auto;
font-family: var(--font-mono);
font-size: var(--fs-12);
color: var(--muted);
}
.recent-other .recent-dir {
color: var(--muted);
font-family: inherit;
font-style: italic;
}
.picker-loading {
margin: 0;
padding: 6px var(--sp-3);
color: var(--faint);
font-size: var(--fs-13);
}
.raw-wrap {
display: flex;
}
.raw-input {
width: 100%;
}
/* Worktree-by-branch: inferred repo line + inline hint. */
.wt-hint {
margin: 0;
font-size: var(--fs-12);
color: var(--muted);
}
.wt-body {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.wt-repo {
margin: 0;
font-family: var(--font-mono);
font-size: var(--fs-12);
color: var(--muted);
}
/* Generated-name field with a reroll button. */
.name-row {
display: flex;
gap: var(--sp-2);
align-items: center;
}
.name-row input {
flex: 1 1 auto;
}
.reroll {
flex: 0 0 auto;
}
/* ---------- directory autocomplete (NAP-329) ---------- */
.dir-ac {
+39
View File
@@ -37,6 +37,45 @@ export interface DirListing {
truncated?: boolean;
}
/** A spawn-form command preset advertised by the daemon (#22/#26). The `custom`
* preset has an empty `command` and reveals the free-text command input. */
export interface SpawnPreset {
id: string;
label: string;
command: string;
}
/** `GET /api/web/info` — the connected daemon's identity plus its spawn presets
* (#26). Mirrors the daemon-side `WebInfo`. */
export interface WebInfo {
daemon_id: string;
hostname: string;
version: string;
daemon_url: string;
/** Command presets (claude/opencode/zsh + custom), config-resolved. */
presets: SpawnPreset[];
}
/** One deduplicated recent working dir for the spawn form's recents picker
* (#24/#26). */
export interface RecentDir {
cwd: string;
last_command: string;
last_used: IsoTimestamp;
}
/** `GET /api/daemons/:id/recent-dirs` — a host's recent working dirs (#26). */
export interface RecentDirs {
dirs: RecentDir[];
truncated?: boolean;
}
/** `GET /api/daemons/:id/gitroot?path=` — the enclosing repo toplevel of a path
* on a host, or absent when the path isn't inside a repo (#26). */
export interface GitRoot {
root?: string;
}
export interface WorktreeSpec {
base_repo: string;
branch: string;