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>
156 lines
5.3 KiB
TypeScript
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();
|
|
}
|