// The spawn modal: dialog chrome around the shared spawn form (spawnForm.ts). // // One modal at a time — opening while one is up replaces it. Escape, the ✕ and // a click on the backdrop all close it; the form's own Escape handlers (the // directory autocomplete) swallow the key first, so dismissing a dropdown never // also discards the form. import { createSpawnForm, type SpawnFormSeed } from "./spawnForm.js"; import type { Session } from "./types.js"; /** Seed values for the spawn form — used by "Spawn similar" to open the dialog * with the same cwd, command and name as an existing session. */ export type SpawnPrefill = SpawnFormSeed; export interface SpawnModalOptions { seed?: SpawnPrefill; /** Called with the created session after a successful submit; the modal has * already closed by then, so the caller decides where it lands. */ onSuccess: (session: Session) => void; } /** Closer for the modal currently up, so opening a second one tears the first * one down properly — dropping its node alone would strand the form's global * listeners (dir autocomplete) and the Escape handler. */ let closeCurrent: (() => void) | null = null; export function openSpawnModal(opts: SpawnModalOptions): void { closeSpawnModal(); const host = document.createElement("div"); host.id = "modal-host"; host.className = "modal-backdrop"; const dialog = document.createElement("div"); dialog.className = "modal spawn-modal"; dialog.setAttribute("role", "dialog"); dialog.setAttribute("aria-modal", "true"); dialog.setAttribute("aria-labelledby", "spawn-title"); const header = document.createElement("header"); header.className = "modal-header"; const title = document.createElement("h2"); title.id = "spawn-title"; // "Spawn similar" arrives with a seed; the title says which act this is. title.textContent = opts.seed ? "Spawn similar" : "Spawn session"; const closeBtn = document.createElement("button"); closeBtn.type = "button"; closeBtn.className = "btn btn-icon close"; closeBtn.setAttribute("aria-label", "Close"); closeBtn.textContent = "✕"; header.append(title, closeBtn); dialog.appendChild(header); host.appendChild(dialog); document.body.appendChild(host); const form = createSpawnForm({ seed: opts.seed, onSuccess: (session) => { close(); opts.onSuccess(session); }, }); dialog.appendChild(form.el); // The project filter owns the keyboard on open: typing narrows the list and // Tab from here reaches the command tiles, matching the TUI's draft pane. form.el.querySelector(".project-filter")?.focus(); function close(): void { if (closeCurrent === close) closeCurrent = null; window.removeEventListener("keydown", onKeydown); form.destroy(); host.remove(); } closeCurrent = close; function onKeydown(e: KeyboardEvent): void { if (e.key === "Escape") { e.preventDefault(); close(); } } // The modal keeps its Cancel affordance; the shared form ships only Submit. const cancel = document.createElement("button"); cancel.type = "button"; cancel.className = "btn btn-ghost cancel"; cancel.textContent = "Cancel"; cancel.addEventListener("click", close); form.el.querySelector(".form-actions")?.prepend(cancel); closeBtn.addEventListener("click", close); host.addEventListener("click", (e) => { if (e.target === host) close(); }); window.addEventListener("keydown", onKeydown); } /** Close whatever spawn modal is up. No-op when none is. */ export function closeSpawnModal(): void { closeCurrent?.(); }