feat(maestro): web UI persistent sidebar + content split w/ background connection pool (NAP-338)
ci / rust (push) Successful in 5m24s

Replace the full-screen route-swap SPA with a persistent two-pane layout:

- Global top bar (brand + Spawn + theme) above a sidebar (search + status +
  session tree, highlights the active session) and a content pane (welcome or
  the attached terminal). Selecting a row swaps only the content pane; the
  sidebar and its store subscription persist.
- New `connections.ts` ConnectionPool: attach connections (terminal + WS) are
  decoupled from the UI — navigating away parks a session (keeps it streaming)
  rather than tearing it down, so switching back is instant with scrollback
  intact. LRU-bounded (MAX_LIVE=6); explicit `⏻` disconnect distinct from nav.
- `attach.ts` → mountable `createAttachConnection`; `catalog.ts` exposes
  `renderTopbar` + `renderSidebar`; `main.ts` is the shell + hash router.
- Responsive: desktop side-by-side; mobile single-pane (list ↔ terminal).
  Single session viewed at a time (split panes deferred).

Typecheck + production build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 12:49:16 +00:00
co-authored by Claude Opus 4.8
parent 4247b5706e
commit 4c49abfdf2
5 changed files with 469 additions and 113 deletions
+77 -43
View File
@@ -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 = `<div class="attach-status status-${kind}">${text}</div>`;
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<AttachHandle> {
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 = `
<header class="attach-header">
<a class="btn btn-ghost back" href="#/" aria-label="Back to catalog">←</a>
<a class="btn btn-ghost back" href="#/" aria-label="Back to catalog" title="Back to catalog">←</a>
<span class="attach-title" title="${sessionId}">session <span class="sid">${sessionId.slice(0, 8)}</span></span>
<span class="attach-state" data-state="connecting">connecting…</span>
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
</header>
<div class="attach-body"></div>
`;
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();
},
};
}
+79 -35
View File
@@ -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 = `
<div class="brand">
<a class="brand" href="#/" aria-label="maestro — clear selection">
<span class="brand-dot" aria-hidden="true"></span>
<h1>maestro<span class="blink" aria-hidden="true">_</span></h1>
</div>
<div class="catalog-actions">
<input
type="search"
class="search"
placeholder="Search sessions…"
aria-label="Search sessions"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/>
</div>
</a>
<div class="app-actions"></div>
`;
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 `<aside>` element to mount into the shell. */
el: HTMLElement;
/** Highlight the row for `id` (or clear it with `null`) as the open session. */
setActive(id: string | null): void;
/** Tear down the store subscription + listeners. */
dispose(): void;
}
/**
* Build the persistent session sidebar: brand, search, spawn, theme toggle, a
* live daemon/session status line, and the folder-grouped session tree. Unlike
* the old full-screen catalog this is mounted once by the shell and stays put
* while the content pane swaps between sessions — so the store subscription and
* scroll position survive navigation. The active session is highlighted.
*/
export function renderSidebar(store: CatalogStore): SidebarHandle {
const el = document.createElement("aside");
el.className = "sidebar";
const searchHeader = document.createElement("header");
searchHeader.className = "sidebar-search";
searchHeader.innerHTML = `
<input
type="search"
class="search"
placeholder="Search sessions…"
aria-label="Search sessions"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/>
`;
el.appendChild(searchHeader);
const statusHost = document.createElement("div");
shell.appendChild(statusHost);
el.appendChild(statusHost);
const treeHost = document.createElement("div");
treeHost.className = "tree";
shell.appendChild(treeHost);
el.appendChild(treeHost);
root.appendChild(shell);
const search = header.querySelector(".search") as HTMLInputElement;
const search = searchHeader.querySelector(".search") as HTMLInputElement;
let filter = "";
search.addEventListener("input", () => {
filter = search.value;
refresh();
});
spawnBtn.addEventListener("click", () => {
openSpawnModal(store.snapshot().daemons);
});
let activeId: string | null = null;
const applyActive = () => {
for (const n of treeHost.querySelectorAll(".tree-leaf.is-active")) {
n.classList.remove("is-active");
}
if (activeId) {
const row = treeHost.querySelector(
`.tree-leaf[data-session-id="${CSS.escape(activeId)}"]`,
);
row?.classList.add("is-active");
}
};
const refresh = () => {
const state = store.snapshot();
@@ -277,6 +313,7 @@ export function renderCatalog(root: HTMLElement, store: CatalogStore): () => voi
if (tree.kind === "folder") {
for (const child of tree.children) treeHost.appendChild(renderNode(child, 0));
}
applyActive();
};
const rerenderHandler = () => refresh();
@@ -284,8 +321,15 @@ export function renderCatalog(root: HTMLElement, store: CatalogStore): () => voi
const unsubscribe = store.subscribe(refresh);
return () => {
document.removeEventListener("catalog-rerender", rerenderHandler);
unsubscribe();
return {
el,
setActive(id: string | null) {
activeId = id;
applyActive();
},
dispose() {
document.removeEventListener("catalog-rerender", rerenderHandler);
unsubscribe();
},
};
}
+121
View File
@@ -0,0 +1,121 @@
import { createAttachConnection, type AttachConnection } from "./attach.js";
import type { Uuid } from "./types.js";
/**
* How many live attach connections to keep buffering in the background. The
* most-recently-viewed sessions stay connected so switching back to them is
* instant; older ones are evicted (their WS closed) once the cap is exceeded.
* Bounded so the browser doesn't hold an unbounded fan of open sockets.
*/
const MAX_LIVE = 6;
/**
* Owns the set of live attach connections, decoupled from the router/UI. The
* shell asks the pool to `activate(id, pane)` on navigation; the pool mounts
* that session's view and parks the previously-active one in a hidden cache —
* still connected. Connections only die on eviction, explicit `close`, or the
* caller disposing the pool.
*/
export class ConnectionPool {
/** Insertion order doubles as LRU: oldest key first, newest (active) last. */
private readonly cache = new Map<Uuid, AttachConnection>();
private readonly hidden: HTMLElement;
private activeId: Uuid | null = null;
private onActiveChange?: (id: Uuid | null) => void;
constructor(opts: { onActiveChange?: (id: Uuid | null) => void } = {}) {
this.onActiveChange = opts.onActiveChange;
// Off-screen home for parked, still-connected terminals.
this.hidden = document.createElement("div");
this.hidden.className = "conn-cache";
this.hidden.setAttribute("aria-hidden", "true");
document.body.appendChild(this.hidden);
}
/** Currently-mounted session, or null when showing the welcome pane. */
active(): Uuid | null {
return this.activeId;
}
/**
* Show `id` in `pane`, reusing its live connection if one exists. The
* previously-active view is parked (kept connected) in the hidden cache.
*/
activate(id: Uuid, pane: HTMLElement): void {
if (this.activeId && this.activeId !== id) this.park(this.activeId);
let conn = this.cache.get(id);
if (conn) {
// Refresh LRU recency.
this.cache.delete(id);
this.cache.set(id, conn);
} else {
conn = createAttachConnection(id, {
onRequestClose: (sid) => this.requestClose(sid),
});
this.cache.set(id, conn);
this.evict(id);
}
pane.replaceChildren(conn.el);
conn.onMount();
this.activeId = id;
this.onActiveChange?.(id);
}
/** Detach the active view back to the cache (still connected) — e.g. when
* navigating to the welcome pane. */
parkActive(): void {
if (this.activeId) {
this.park(this.activeId);
this.activeId = null;
this.onActiveChange?.(null);
}
}
/** Fully disconnect + forget a session. */
close(id: Uuid): void {
const conn = this.cache.get(id);
if (!conn) return;
conn.dispose();
this.cache.delete(id);
if (this.activeId === id) {
this.activeId = null;
this.onActiveChange?.(null);
}
}
dispose(): void {
for (const conn of this.cache.values()) conn.dispose();
this.cache.clear();
this.hidden.remove();
}
private park(id: Uuid): void {
const conn = this.cache.get(id);
if (!conn) return;
this.hidden.appendChild(conn.el); // stays alive, just off-screen
conn.onUnmount();
}
/** Drop least-recently-viewed connections beyond the cap (never `keep`). */
private evict(keep: Uuid): void {
while (this.cache.size > MAX_LIVE) {
let victim: Uuid | null = null;
for (const id of this.cache.keys()) {
if (id !== keep && id !== this.activeId) {
victim = id;
break;
}
}
if (!victim) break;
this.close(victim);
}
}
private requestClose(id: Uuid): void {
this.close(id);
// Drop back to the welcome pane via the router.
if (location.hash !== "#/" && location.hash !== "") location.hash = "#/";
}
}
+58 -35
View File
@@ -1,5 +1,5 @@
import { renderAttach } from "./attach.js";
import { renderCatalog } from "./catalog.js";
import { renderSidebar, renderTopbar } from "./catalog.js";
import { ConnectionPool } from "./connections.js";
import { CatalogStore } from "./events.js";
import { initTheme } from "./theme.js";
import "./styles.css";
@@ -20,6 +20,48 @@ if (import.meta.env.DEV && location.search.includes("mock")) {
void import("./mock.js").then((m) => m.seedMock(store));
}
// ---- persistent shell: sidebar (list) + content (welcome | attach) ----
const shellEl = document.createElement("div");
shellEl.className = "app-shell";
shellEl.dataset.route = "catalog";
const sidebar = renderSidebar(store);
const content = document.createElement("section");
content.className = "content";
shellEl.append(sidebar.el, content);
root.replaceChildren(renderTopbar(store), shellEl);
// The pool owns attach connections independently of the route: navigating away
// parks a session (keeps it streaming); navigating back re-mounts it instantly.
const pool = new ConnectionPool({
onActiveChange: (id) => sidebar.setActive(id),
});
function renderWelcome(): HTMLElement {
const el = document.createElement("div");
el.className = "empty content-welcome";
el.innerHTML = `
<div class="empty-glyph" aria-hidden="true">⌘</div>
<div class="empty-title">No session open</div>
<p class="empty-hint">
Pick a session from the list, or press <kbd>+ Spawn</kbd> to start one.
Open sessions keep streaming in the background — switching back is instant.
</p>`;
return el;
}
function renderNotFound(raw: string): HTMLElement {
const el = document.createElement("div");
el.className = "not-found";
el.innerHTML = `
<h1>Unknown route</h1>
<p><code>#${raw.replace(/[<&]/g, "")}</code> doesn't match anything.</p>
<a class="btn btn-primary" href="#/">Back to catalog</a>`;
return el;
}
type Route =
| { kind: "catalog" }
| { kind: "attach"; sessionId: string }
@@ -33,44 +75,25 @@ function parseRoute(): Route {
return { kind: "not-found", raw: hash };
}
let currentTeardown: (() => void) | (() => Promise<void>) | null = null;
async function dispatch() {
if (currentTeardown) {
try {
await Promise.resolve(currentTeardown());
} catch {
// ignore
}
currentTeardown = null;
}
function dispatch(): void {
const route = parseRoute();
switch (route.kind) {
case "catalog": {
const teardown = renderCatalog(root!, store);
currentTeardown = teardown;
case "attach":
shellEl.dataset.route = "attach";
pool.activate(route.sessionId, content);
return;
}
case "attach": {
const handle = await renderAttach(root!, route.sessionId);
currentTeardown = () => handle.dispose();
case "not-found":
shellEl.dataset.route = "notfound";
pool.parkActive();
content.replaceChildren(renderNotFound(route.raw));
return;
}
case "not-found": {
root!.innerHTML = `
<div class="not-found">
<h1>Unknown route</h1>
<p><code>#${route.raw}</code> doesn't match anything.</p>
<a class="btn btn-primary" href="#/">Back to catalog</a>
</div>
`;
case "catalog":
shellEl.dataset.route = "catalog";
pool.parkActive();
content.replaceChildren(renderWelcome());
return;
}
}
}
window.addEventListener("hashchange", () => {
void dispatch();
});
void dispatch();
window.addEventListener("hashchange", dispatch);
dispatch();
+134
View File
@@ -319,6 +319,92 @@ textarea {
font-size: var(--fs-15);
}
/* ---------- app shell: sidebar + content ---------- */
/* Global app header: brand + app-level actions (spawn, theme toggle), spanning
the full width above the split. */
.app-header {
display: flex;
align-items: center;
gap: var(--sp-3);
flex: 0 0 auto;
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
background: color-mix(in oklab, var(--surface) 82%, transparent);
backdrop-filter: blur(8px);
}
.app-actions {
display: flex;
align-items: center;
gap: var(--sp-2);
margin-left: auto;
}
.app-shell {
flex: 1 1 auto;
min-height: 0;
}
/* Desktop: persistent two-pane split. Sidebar holds the session list; the
content pane shows the welcome state or the attached terminal. */
@media (min-width: 860px) {
.app-shell {
display: grid;
grid-template-columns: clamp(280px, 26vw, 360px) minmax(0, 1fr);
}
}
.sidebar {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
overflow: hidden;
background: color-mix(in oklab, var(--surface) 26%, var(--bg));
border-right: 1px solid var(--border);
}
/* Sidebar's own slim header: just the session search (app-level actions live
in the global top bar). */
.sidebar-search {
flex: 0 0 auto;
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
}
.sidebar-search .search {
width: 100%;
max-width: none;
}
.content {
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
.content-welcome {
margin: auto;
max-width: 48ch;
}
/* Off-screen home for parked-but-connected terminals. Kept in the DOM (not
display:none) so the live WS keeps buffering into each terminal; on
re-mount we fit + the scrollback is already there, so switching is instant. */
.conn-cache {
position: fixed;
left: -99999px;
top: 0;
width: 1px;
height: 1px;
overflow: hidden;
}
/* ---------- catalog ---------- */
.catalog {
@@ -604,6 +690,20 @@ textarea {
box-shadow: inset 0 0 0 1px var(--accent-ring);
}
/* The session currently mounted in the content pane. */
.tree-leaf.is-active {
background: var(--accent-tint);
border-left-color: var(--accent);
}
.tree-leaf.is-active .leaf-name {
color: var(--ink);
}
.tree-leaf.is-active::after {
opacity: 0;
}
.tree-leaf[data-status="dead"] {
color: var(--muted);
}
@@ -800,6 +900,24 @@ textarea {
height: var(--control-h);
}
/* Desktop keeps the session list permanently in view, so the in-header "back"
arrow is redundant — the sidebar is the nav. Mobile (single-pane) keeps it. */
@media (min-width: 860px) {
.attach-header .back {
display: none;
}
}
.attach-header .disconnect {
color: var(--faint);
}
.attach-header .disconnect:hover {
color: var(--danger);
background: var(--danger-tint);
border-color: color-mix(in oklab, var(--danger) 40%, transparent);
}
.attach-title {
font-family: var(--font-mono);
font-size: var(--fs-13);
@@ -1253,6 +1371,22 @@ textarea {
}
}
/* Mobile: no room for a split, so show one pane at a time. The catalog route
shows the session list full-width; opening a session swaps to the terminal
(its header's ← returns to the list). The connection still lives in the pool,
so returning to it is instant. */
@media (max-width: 859px) {
.app-shell[data-route="catalog"] > .content {
display: none;
}
.app-shell:not([data-route="catalog"]) > .sidebar {
display: none;
}
.sidebar {
border-right: 0;
}
}
/* ---------- reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {