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 = `
-