feat(maestro): web UI persistent sidebar + content split w/ background connection pool (NAP-338)
ci / rust (push) Successful in 5m24s
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:
+77
-43
@@ -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
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user