This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/connections.ts
T
naps62-yoloandClaude Opus 4.8 159b7ecd6e
ci / rust (push) Successful in 5m46s
feat(maestro): multi-view web UI — splits, tabs, drag-dock, highlights
Render the content pane as tabs of nested split trees, each session leaf a live
attach mounted through the multi-mount pool:
- #30 arbitrarily-nested H/V splits with mouse-resizable dividers; per-pane
  ⏻/▥/▤/✕ tools; closing a pane collapses its parent split.
- #31 tabs, each holding its own tree; add / switch / close.
- #32 drag-to-dock: drag a pane title or a sidebar session onto a pane for a
  5-zone dock (edges split, centre swaps/replaces); a sidebar drag moves an
  already-open session.
- #33 focused pane at full strength, others dim; sidebar shows focused (loud)
  vs open-elsewhere (quiet) with a stable per-session colour mapping rows to
  panes.

Opening a session fills the focused pane (single-pane parity by default);
multi-pane is opt-in via split / drag / tabs. Panes auto-close when their
session ends (guarded against eviction and intentional close). The steady
"connected" label is hidden as noise.

Closes #30, #31, #32, #33. Part of epic #37.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:20:57 +00:00

172 lines
5.6 KiB
TypeScript

import {
createAttachConnection,
type AttachConnection,
type AttachInfo,
} from "./attach.js";
import type { Uuid } from "./types.js";
/**
* How many *parked* (off-screen but still-connected) sessions to keep warm
* beyond whatever is currently visible. Visible panes are never counted or
* evicted — only this background buffer is bounded, so the browser doesn't hold
* an unbounded fan of open sockets. Switching back to a recently-viewed session
* is instant while it's still parked; older ones are evicted (WS closed) once
* the buffer overflows.
*/
const MAX_PARKED = 6;
export interface PoolOptions {
/** Fired when the focused session changes (drives sidebar highlight + HUD). */
onFocusChange?: (id: Uuid | null) => void;
/** Fired when a view's explicit disconnect control is used; the embedder
* decides how to update the layout/route (then typically calls `close`). */
onRequestClose?: (id: Uuid) => void;
/** Fired when a connection's socket closes on its own (session ended/dropped),
* not via an intentional close/eviction. The embedder closes the pane. */
onClosed?: (id: Uuid) => void;
}
/**
* Owns the set of live attach connections, decoupled from the router/UI.
*
* Unlike the old single-active pool, several connections can be **mounted**
* (visible) at once — one per leaf of the active tab's layout tree. The shell
* reconciles the visible set with {@link setVisible}; sessions no longer shown
* are **parked** in a hidden cache (still streaming) and bounded by an LRU. A
* connection only dies on eviction, an explicit {@link close}, or {@link dispose}.
*/
export class ConnectionPool {
/** All live connections. Insertion order doubles as LRU: oldest first. */
private readonly cache = new Map<Uuid, AttachConnection>();
/** Sessions currently mounted into a visible pane (never evicted). */
private readonly mounted = new Set<Uuid>();
private readonly hidden: HTMLElement;
private focusedId: Uuid | null = null;
private readonly opts: PoolOptions;
constructor(opts: PoolOptions = {}) {
this.opts = opts;
// 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);
}
/** The focused session, or null when nothing is focused (welcome pane). */
focused(): Uuid | null {
return this.focusedId;
}
/** Back-compat alias for {@link focused} (used by the debug HUD). */
active(): Uuid | null {
return this.focusedId;
}
isMounted(id: Uuid): boolean {
return this.mounted.has(id);
}
/**
* Ensure `id` is live and shown inside `pane`, reusing its connection if one
* exists (refreshing LRU recency). Does not touch other mounted panes.
*/
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo): void {
let conn = this.cache.get(id);
if (conn) {
this.cache.delete(id);
this.cache.set(id, conn);
} else {
conn = createAttachConnection(
id,
{
onRequestClose: (sid) => this.opts.onRequestClose?.(sid),
onClosed: (sid) => this.opts.onClosed?.(sid),
},
info,
);
this.cache.set(id, conn);
}
if (conn.el.parentElement !== pane) {
pane.replaceChildren(conn.el);
conn.onMount();
} else if (!this.mounted.has(id)) {
conn.onMount();
}
this.mounted.add(id);
this.evict();
}
/** Park `id` back into the hidden cache (still connected) and stop showing it. */
unmount(id: Uuid): void {
if (!this.mounted.delete(id)) return;
const conn = this.cache.get(id);
if (conn) {
this.hidden.appendChild(conn.el); // stays alive, just off-screen
conn.onUnmount();
}
}
/**
* Reconcile the visible set to exactly `entries` (in render order): park any
* mounted session not listed, mount/refresh each listed one in its pane.
*/
setVisible(entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[]): void {
const next = new Set(entries.map((e) => e.id));
for (const id of [...this.mounted]) {
if (!next.has(id)) this.unmount(id);
}
for (const e of entries) this.mount(e.id, e.pane, e.info);
}
setFocus(id: Uuid | null): void {
if (id === this.focusedId) return;
this.focusedId = id;
this.opts.onFocusChange?.(id);
}
/** Compat shim: show `id` alone in `pane` and focus it; park everything else. */
activate(id: Uuid, pane: HTMLElement, info?: AttachInfo): void {
this.setVisible([{ id, pane, info }]);
this.setFocus(id);
}
/** Compat shim: park all visible panes and clear focus (e.g. welcome pane). */
parkActive(): void {
for (const id of [...this.mounted]) this.unmount(id);
this.setFocus(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);
this.mounted.delete(id);
if (this.focusedId === id) this.setFocus(null);
}
dispose(): void {
for (const conn of this.cache.values()) conn.dispose();
this.cache.clear();
this.mounted.clear();
this.hidden.remove();
}
/** Drop least-recently-viewed *parked* connections beyond the buffer cap. */
private evict(): void {
while (this.cache.size - this.mounted.size > MAX_PARKED) {
let victim: Uuid | null = null;
for (const id of this.cache.keys()) {
if (!this.mounted.has(id)) {
victim = id;
break;
}
}
if (victim === null) break;
this.close(victim);
}
}
}