diff --git a/web/src/attach.ts b/web/src/attach.ts index 35bf2ac..fe53d9d 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -4,54 +4,73 @@ import "@xterm/xterm/css/xterm.css"; import type { ClientControl, Uuid } from "./types.js"; -interface AttachHandle { - dispose: () => void; +/** + * A single live attach: an xterm terminal bound to a daemon-broker WebSocket. + * + * The connection is **decoupled from the UI**: it owns a long-lived WS that + * keeps streaming PTY output into the terminal buffer whether or not the view + * is currently mounted. The shell moves `el` between the visible content pane + * and a hidden cache; `onMount`/`onUnmount` only adjust focus + sizing, never + * the connection. Only `dispose()` (eviction, an explicit disconnect, or the + * session ending) tears the socket down — so switching away and back is + * instant, with scrollback already in place. + */ +export interface AttachConnection { + readonly sessionId: Uuid; + /** The `.attach` element (header + terminal body) to mount into a pane. */ + readonly el: HTMLElement; + /** Becomes the visible view: fit to the pane, focus, sync size. */ + onMount(): void; + /** Parked into the cache: keep streaming, just stop driving focus/resize. */ + onUnmount(): void; + /** Fully disconnect: close the WS, dispose the terminal, remove the element. */ + dispose(): void; } -function statusMessage(host: HTMLElement, kind: "info" | "error", text: string): void { - host.innerHTML = `
${text}
`; +export interface AttachOptions { + /** Invoked when the user clicks the view's explicit "disconnect" control. */ + onRequestClose?: (sessionId: Uuid) => void; } /** * Same-origin broker URL. The browser loads from `maestro-web`, which - * reverse-proxies this WebSocket to the co-located daemon's - * `/api/attach/:id` broker endpoint. The daemon short-circuits to the local - * PTY when it owns the session; cross-daemon brokering lands with NAP-247. + * reverse-proxies this WebSocket to the co-located daemon's `/api/attach/:id` + * broker endpoint (the daemon short-circuits to the local PTY when it owns the + * session, or brokers to the owning peer — NAP-247/NAP-327). */ function brokerStreamUrl(sessionId: Uuid): string { const proto = location.protocol === "https:" ? "wss:" : "ws:"; return `${proto}//${location.host}/api/attach/${encodeURIComponent(sessionId)}`; } -export async function renderAttach( - root: HTMLElement, +export function createAttachConnection( sessionId: Uuid, -): Promise { - root.innerHTML = ""; - - const shell = document.createElement("div"); - shell.className = "attach"; - shell.innerHTML = ` + opts: AttachOptions = {}, +): AttachConnection { + const el = document.createElement("div"); + el.className = "attach"; + el.innerHTML = `
- ← + ← session ${sessionId.slice(0, 8)} connecting… +
`; - root.appendChild(shell); - const body = shell.querySelector(".attach-body") as HTMLDivElement; - const stateEl = shell.querySelector(".attach-state") as HTMLSpanElement; + const body = el.querySelector(".attach-body") as HTMLDivElement; + const stateEl = el.querySelector(".attach-state") as HTMLSpanElement; + const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement; const setState = (s: string, kind: "connecting" | "open" | "closed" | "error") => { stateEl.textContent = s; stateEl.dataset.state = kind; }; - statusMessage(body, "info", "Connecting…"); - - body.innerHTML = ""; + disconnectBtn.addEventListener("click", () => { + opts.onRequestClose?.(sessionId); + }); const term = new Terminal({ cursorBlink: true, @@ -75,14 +94,15 @@ export async function renderAttach( term.loadAddon(fit); term.open(body); + let mounted = false; const tryFit = () => { + if (!mounted) return; // parked views have no meaningful size to fit to try { fit.fit(); } catch { - // ignore — fit fails if container is detached + // ignore — fit fails if the container is detached or zero-sized } }; - tryFit(); const ws = new WebSocket(brokerStreamUrl(sessionId)); ws.binaryType = "arraybuffer"; @@ -100,16 +120,15 @@ export async function renderAttach( ws.onopen = () => { setState("connected", "open"); sendControl({ type: "Resize", cols: term.cols, rows: term.rows }); - term.focus(); + if (mounted) term.focus(); }; ws.onmessage = (ev) => { if (typeof ev.data === "string") { - // Text frames: ignore unknown control messages; we just need a parse. try { JSON.parse(ev.data) as ClientControl; } catch { - // ignore + // ignore unknown control frames } return; } @@ -139,25 +158,40 @@ export async function renderAttach( }); window.addEventListener("resize", onResize); - // Keep focus + sizing correct when the visual viewport shifts (mobile keyboards). const visual = window.visualViewport ?? null; visual?.addEventListener("resize", onResize); let disposed = false; - const dispose = () => { - if (disposed) return; - disposed = true; - window.removeEventListener("resize", onResize); - visual?.removeEventListener("resize", onResize); - dataDisp.dispose(); - resizeDisp.dispose(); - try { - ws.close(); - } catch { - // ignore - } - term.dispose(); - }; - return { dispose }; + return { + sessionId, + el, + onMount() { + mounted = true; + // Defer a tick so the element has its content-pane dimensions before fit. + requestAnimationFrame(() => { + tryFit(); + sendControl({ type: "Resize", cols: term.cols, rows: term.rows }); + if (ws.readyState === WebSocket.OPEN) term.focus(); + }); + }, + onUnmount() { + mounted = false; + }, + dispose() { + if (disposed) return; + disposed = true; + window.removeEventListener("resize", onResize); + visual?.removeEventListener("resize", onResize); + dataDisp.dispose(); + resizeDisp.dispose(); + try { + ws.close(); + } catch { + // ignore + } + term.dispose(); + el.remove(); + }, + }; } diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 5d9361c..b792ce5 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -204,61 +204,97 @@ function renderEmpty(noDaemons: boolean, filtering: boolean): HTMLElement { return el; } -export function renderCatalog(root: HTMLElement, store: CatalogStore): () => void { - root.innerHTML = ""; - - const shell = document.createElement("div"); - shell.className = "catalog"; - +/** + * The global app header (full width, above the sidebar/content split): brand + + * the app-level actions (spawn, theme toggle). Kept out of the sidebar so these + * stay put and reachable regardless of which pane is showing. + */ +export function renderTopbar(store: CatalogStore): HTMLElement { const header = document.createElement("header"); - header.className = "catalog-header"; + header.className = "app-header"; header.innerHTML = ` -
+

maestro

-
-
- -
+
+
`; - shell.appendChild(header); - - const actions = header.querySelector(".catalog-actions") as HTMLElement; + const actions = header.querySelector(".app-actions") as HTMLElement; actions.appendChild(createThemeToggle()); const spawnBtn = document.createElement("button"); spawnBtn.type = "button"; spawnBtn.className = "btn btn-primary spawn-btn"; spawnBtn.textContent = "+ Spawn"; + spawnBtn.addEventListener("click", () => { + openSpawnModal(store.snapshot().daemons); + }); actions.appendChild(spawnBtn); + return header; +} + +/** Live handle to the persistent sidebar (built once, survives navigation). */ +export interface SidebarHandle { + /** The `