This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/spawn.ts
T
naps62-yoloandClaude Opus 4.7 e68c9b6d73 feat(maestro): web client (Vite + TS + xterm.js)
NAP-218. Browser UI for the prototype: catalog tree derived from
`metadata.label`, live updates over `WS /api/events`, search across
session name+label, spawn modal (with optional git worktree), and an
attach view that opens the daemon's per-session WS for xterm.js.

Framework choice: vanilla TypeScript. The interactivity surface is
small enough that pulling in a framework wouldn't pay back; xterm.js
does the heavy lifting. Easy to swap later without protocol impact.

Hash-based routing (`#/`, `#/attach/<id>`) keeps the registry's
static surface dumb — no SPA fallback needed; concurrent attaches
work by opening the same hash URL in another tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:57:11 +00:00

156 lines
5.3 KiB
TypeScript

import { createSession } from "./api.js";
import type { CreateSessionRequest, DaemonInfo } from "./types.js";
export function openSpawnModal(daemons: Map<string, DaemonInfo>): 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" 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 placeholder="bash" autocomplete="off" />
</label>
<label>
Name (optional)
<input name="name" type="text" placeholder="e.g. impl" autocomplete="off" />
</label>
<label>
Label (optional, e.g. <code>personal/maestro</code>)
<input name="label" type="text" placeholder="group/name" 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>
<button type="submit" class="btn btn-primary">Spawn</button>
</div>
</form>
</div>
`;
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 close = () => 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 },
);
const form = host.querySelector("#spawn-form") as HTMLFormElement;
const errEl = form.querySelector(".form-error") as HTMLElement;
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.";
return;
}
const name = String(data.get("name") ?? "").trim();
const label = String(data.get("label") ?? "").trim();
const metadata: Record<string, unknown> = {};
if (name) metadata.name = name;
if (label) metadata.label = label;
const req: CreateSessionRequest = {
daemon_id: daemonId,
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.";
return;
}
req.worktree = {
base_repo: baseRepo,
branch,
cleanup_on_kill: Boolean(data.get("worktree_cleanup_on_kill")),
};
}
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;
}
});
const firstInput = form.querySelector("input, select") as HTMLElement | null;
firstInput?.focus();
}