From c02ff02849b89936cbc5161f606cb4586e0fb4a3 Mon Sep 17 00:00:00 2001
From: naps62-yolo
Date: Sat, 20 Jun 2026 12:11:43 +0000
Subject: [PATCH] feat(maestro): terminal-native web UI pass + design kit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Live-review iteration on the browser SPA, driven by crit feedback.
Sidebar / session tree
- Compact single-line leaf rows; status shown as a faint "alive" row wash
instead of per-row dots (exited rows dim + keep the "exited" badge).
- Host is now the first level of nesting (host → label path → session); the
inline @daemon is dropped from rows.
- Flush, borderless search input spanning the sidebar; drag-to-resize grip on
the sidebar edge (width persisted to localStorage); narrower default.
- Per-row clone action (opens the spawn modal prefilled); right-pointing
chevron when collapsed.
Chrome / layout
- Buttons swapped to a bracket terminal style ([ + Spawn ]); ghost/icon drop
the brackets. Old filled styles removed.
- Squared corners globally (radius tokens) and tighter, denser spacing.
- Session tally + daemon chips moved into the centered app header.
- Shorter control height; shared --bar-h aligns the search and attach bars.
Attach / terminal
- Useful attach title (name · command @ host) over a bare UUID.
- Robust fit: compute columns from the viewport's scrollbar-excluded
clientWidth (fixes mobile under-fit and desktop overflow); ResizeObserver +
font-load refit; full-bleed terminal on mobile.
Spawn / empty states
- Spawn modal accepts a prefill (clone); real "+ Spawn" buttons in welcome and
no-sessions empty states.
Dev
- New ?kit design-kit page (kit.ts/kit.css, DEV-only) showcasing components.
- Richer ?mock fixture with deeper label nesting.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
web/src/attach.ts | 86 ++++++++-
web/src/catalog.ts | 140 +++++++++++---
web/src/connections.ts | 16 +-
web/src/kit.css | 268 +++++++++++++++++++++++++++
web/src/kit.ts | 272 +++++++++++++++++++++++++++
web/src/main.ts | 42 ++++-
web/src/mock.ts | 25 ++-
web/src/spawn.ts | 39 +++-
web/src/styles.css | 406 +++++++++++++++++++++++++++++------------
9 files changed, 1130 insertions(+), 164 deletions(-)
create mode 100644 web/src/kit.css
create mode 100644 web/src/kit.ts
diff --git a/web/src/attach.ts b/web/src/attach.ts
index fe53d9d..ddc9243 100644
--- a/web/src/attach.ts
+++ b/web/src/attach.ts
@@ -32,6 +32,22 @@ export interface AttachOptions {
onRequestClose?: (sessionId: Uuid) => void;
}
+/** What the header shows about the session (name / command / host). Optional —
+ * falls back to a short session id when the session isn't known yet. */
+export interface AttachInfo {
+ name: string;
+ command: string;
+ daemon: string;
+}
+
+function esc(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
/**
* Same-origin broker URL. The browser loads from `maestro-web`, which
* reverse-proxies this WebSocket to the co-located daemon's `/api/attach/:id`
@@ -46,13 +62,26 @@ function brokerStreamUrl(sessionId: Uuid): string {
export function createAttachConnection(
sessionId: Uuid,
opts: AttachOptions = {},
+ info?: AttachInfo,
): AttachConnection {
const el = document.createElement("div");
el.className = "attach";
+ // Useful identity over a bare UUID: name · command @ host. The full id stays
+ // in the tooltip for when you really need it.
+ const titleHtml = info
+ ? `${esc(info.name)}` +
+ (info.command && info.command !== info.name
+ ? `${esc(info.command)}`
+ : "") +
+ (info.daemon ? `${esc(info.daemon)}` : "")
+ : `session ${esc(sessionId.slice(0, 8))}`;
+ const titleAttr = info
+ ? `${info.name} · ${info.command} @ ${info.daemon} (${sessionId})`
+ : sessionId;
el.innerHTML = `
@@ -95,12 +124,52 @@ export function createAttachConnection(
term.open(body);
let mounted = false;
+ // We compute the fit ourselves instead of FitAddon.fit(): the addon subtracts
+ // a scrollbar width that xterm reports as `offsetWidth - scrollArea || 15`,
+ // so with the scrollbar hidden it falls back to a phantom 15px and the grid
+ // stops ~1–2 columns short of the edge. Same math, minus that subtraction.
const tryFit = () => {
if (!mounted) return; // parked views have no meaningful size to fit to
try {
- fit.fit();
+ const el2 = term.element;
+ const parent = el2?.parentElement;
+ const core = (term as unknown as {
+ _core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } };
+ })._core;
+ const cell = core?._renderService?.dimensions?.css?.cell;
+ if (!el2 || !parent || !cell || !cell.width || !cell.height) {
+ fit.fit(); // fall back if internals aren't ready yet
+ return;
+ }
+ // Measure the viewport's real inner width (clientWidth excludes any
+ // scrollbar), not the container's — so columns always fit inside the
+ // viewport whether or not xterm reserves a scrollbar. Falls back to the
+ // padded container box before the viewport exists.
+ const vp = el2.querySelector(".xterm-viewport") as HTMLElement | null;
+ let availW: number;
+ let availH: number;
+ if (vp && vp.clientWidth > 0) {
+ availW = vp.clientWidth;
+ availH = vp.clientHeight;
+ } else {
+ const ps = getComputedStyle(parent);
+ const ts = getComputedStyle(el2);
+ availW =
+ parseInt(ps.width) -
+ (parseInt(ts.paddingLeft) + parseInt(ts.paddingRight) || 0);
+ availH =
+ parseInt(ps.height) -
+ (parseInt(ts.paddingTop) + parseInt(ts.paddingBottom) || 0);
+ }
+ const cols = Math.max(2, Math.floor(availW / cell.width));
+ const rows = Math.max(1, Math.floor(availH / cell.height));
+ if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows);
} catch {
- // ignore — fit fails if the container is detached or zero-sized
+ try {
+ fit.fit();
+ } catch {
+ // ignore — fit fails if the container is detached or zero-sized
+ }
}
};
@@ -161,6 +230,16 @@ export function createAttachConnection(
const visual = window.visualViewport ?? null;
visual?.addEventListener("resize", onResize);
+ // FitAddon only recomputes on the events we call it from — a window resize
+ // misses container-driven changes (sidebar drag, mobile layout settling,
+ // orientation, late font load). Observe the body directly so columns always
+ // match the real rendered width and the terminal fills edge to edge.
+ const ro = new ResizeObserver(() => tryFit());
+ ro.observe(body);
+ // Re-fit once the monospace font is actually loaded — cell width changes when
+ // the fallback is swapped out, which otherwise leaves stale columns.
+ document.fonts?.ready?.then(() => tryFit()).catch(() => {});
+
let disposed = false;
return {
@@ -181,6 +260,7 @@ export function createAttachConnection(
dispose() {
if (disposed) return;
disposed = true;
+ ro.disconnect();
window.removeEventListener("resize", onResize);
visual?.removeEventListener("resize", onResize);
dataDisp.dispose();
diff --git a/web/src/catalog.ts b/web/src/catalog.ts
index b792ce5..d6cf1b4 100644
--- a/web/src/catalog.ts
+++ b/web/src/catalog.ts
@@ -34,11 +34,15 @@ function buildTree(sessions: Session[], filter: string): TreeNode {
for (const s of sessions) {
if (lc) {
- const haystack = `${sessionName(s)} ${sessionLabel(s) ?? ""}`.toLowerCase();
+ const haystack =
+ `${s.daemon_id} ${sessionName(s)} ${sessionLabel(s) ?? ""}`.toLowerCase();
if (!haystack.includes(lc)) continue;
}
const label = sessionLabel(s);
- const parts = label ? label.split("/").filter(Boolean) : [];
+ // Host is the first level of nesting; the label path nests beneath it, so
+ // the daemon no longer needs to ride along on each leaf.
+ const labelParts = label ? label.split("/").filter(Boolean) : [];
+ const parts = [s.daemon_id, ...labelParts];
let cursor = root;
for (const part of parts) {
const path = cursor.path ? `${cursor.path}/${part}` : part;
@@ -74,7 +78,9 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
header.style.paddingLeft = `${depth * 16}px`;
const isCollapsed = collapsed.has(node.path);
header.setAttribute("aria-expanded", String(!isCollapsed));
- header.innerHTML = `${isCollapsed ? "▸" : "▾"}${escapeHtml(node.name)}${countLeaves(node)}`;
+ // Single down-chevron; CSS rotates it -90° (→ right) when collapsed, so the
+ // glyph and the rotation never fight each other.
+ header.innerHTML = `▾${escapeHtml(node.name)}${countLeaves(node)}`;
header.addEventListener("click", () => {
if (collapsed.has(node.path)) collapsed.delete(node.path);
else collapsed.add(node.path);
@@ -104,18 +110,25 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
"aria-label",
`${name}, ${running ? "running" : "exited"}, on daemon ${s.daemon_id}`,
);
+ // The host is now the enclosing folder, so only surface the command — and
+ // only when it isn't already what the name shows.
+ const showCmd = s.command !== name;
row.innerHTML = `
-
-
- ${escapeHtml(name)}
-
- ${escapeHtml(s.daemon_id)}
- ${escapeHtml(s.command)}
-
-
+ ${escapeHtml(name)}
+ ${showCmd ? `${escapeHtml(s.command)}` : ""}
${running ? "" : `exited`}
-
+
+
+
+
`;
+ const cloneBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
+ cloneBtn.addEventListener("click", (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ // The sidebar (which has the daemon list) opens the prefilled spawn modal.
+ document.dispatchEvent(new CustomEvent("catalog-clone", { detail: s }));
+ });
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
killBtn.addEventListener("click", async (ev) => {
ev.preventDefault();
@@ -179,7 +192,11 @@ function renderStatusbar(daemons: DaemonInfo[], sessions: Session[]): HTMLElemen
return wrap;
}
-function renderEmpty(noDaemons: boolean, filtering: boolean): HTMLElement {
+function renderEmpty(
+ noDaemons: boolean,
+ filtering: boolean,
+ onSpawn: () => void,
+): HTMLElement {
const el = document.createElement("div");
el.className = "empty";
if (filtering) {
@@ -200,7 +217,14 @@ function renderEmpty(noDaemons: boolean, filtering: boolean): HTMLElement {
el.innerHTML = `
+
No sessions yet
- Press + Spawn to start your first session.
`;
+ Start your first session — it'll show up here.
+ `;
+ const spawn = document.createElement("button");
+ spawn.type = "button";
+ spawn.className = "btn btn-primary";
+ spawn.textContent = "+ Spawn session";
+ spawn.addEventListener("click", onSpawn);
+ (el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
return el;
}
@@ -217,9 +241,32 @@ export function renderTopbar(store: CatalogStore): HTMLElement {
maestro_
+
`;
+
+ // Session tally + daemon chips live centered in the header (not the sidebar).
+ const statusHost = header.querySelector(".app-status") as HTMLElement;
+ const renderStatus = () => {
+ const state = store.snapshot();
+ statusHost.replaceChildren(
+ renderStatusbar([...state.daemons.values()], [...state.sessions.values()]),
+ );
+ };
+ store.subscribe(renderStatus);
+ renderStatus();
+
const actions = header.querySelector(".app-actions") as HTMLElement;
+ // DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
+ // clicking inside a proxied preview (e.g. crit live) that has no address bar.
+ if (import.meta.env.DEV) {
+ const kit = document.createElement("a");
+ kit.className = "btn btn-ghost";
+ kit.href = "?kit";
+ kit.textContent = "kit";
+ kit.title = "Open the design kit (DEV)";
+ actions.appendChild(kit);
+ }
actions.appendChild(createThemeToggle());
const spawnBtn = document.createElement("button");
spawnBtn.type = "button";
@@ -269,13 +316,50 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
`;
el.appendChild(searchHeader);
- const statusHost = document.createElement("div");
- el.appendChild(statusHost);
-
const treeHost = document.createElement("div");
treeHost.className = "tree";
el.appendChild(treeHost);
+ // Drag-to-resize handle on the sidebar's right edge. Persists the chosen
+ // width as a :root CSS var the shell grid reads; clamped to a sane range.
+ const SIDEBAR_W_KEY = "maestro-sidebar-w";
+ const storedW = localStorage.getItem(SIDEBAR_W_KEY);
+ if (storedW) document.documentElement.style.setProperty("--sidebar-w", storedW);
+ const handle = document.createElement("div");
+ handle.className = "sidebar-resize";
+ handle.setAttribute("aria-hidden", "true");
+ el.appendChild(handle);
+ let dragStartX = 0;
+ let dragStartW = 0;
+ let dragging = false;
+ handle.addEventListener("pointerdown", (e) => {
+ dragging = true;
+ dragStartX = e.clientX;
+ dragStartW = el.getBoundingClientRect().width;
+ handle.setPointerCapture(e.pointerId);
+ document.body.style.userSelect = "none";
+ e.preventDefault();
+ });
+ handle.addEventListener("pointermove", (e) => {
+ if (!dragging) return;
+ const w = Math.max(200, Math.min(560, dragStartW + (e.clientX - dragStartX)));
+ document.documentElement.style.setProperty("--sidebar-w", `${w}px`);
+ });
+ const endDrag = (e: PointerEvent) => {
+ if (!dragging) return;
+ dragging = false;
+ try {
+ handle.releasePointerCapture(e.pointerId);
+ } catch {
+ // pointer may already be released
+ }
+ document.body.style.userSelect = "";
+ const cur = document.documentElement.style.getPropertyValue("--sidebar-w");
+ if (cur) localStorage.setItem(SIDEBAR_W_KEY, cur);
+ };
+ handle.addEventListener("pointerup", endDrag);
+ handle.addEventListener("pointercancel", endDrag);
+
const search = searchHeader.querySelector(".search") as HTMLInputElement;
let filter = "";
search.addEventListener("input", () => {
@@ -299,14 +383,13 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
const refresh = () => {
const state = store.snapshot();
const sessions = [...state.sessions.values()];
- statusHost.replaceChildren(
- renderStatusbar([...state.daemons.values()], sessions),
- );
const tree = buildTree(sessions, filter);
treeHost.replaceChildren();
if (sessions.length === 0) {
treeHost.appendChild(
- renderEmpty(state.daemons.size === 0, filter.trim().length > 0),
+ renderEmpty(state.daemons.size === 0, filter.trim().length > 0, () =>
+ openSpawnModal(store.snapshot().daemons),
+ ),
);
return;
}
@@ -319,6 +402,20 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
const rerenderHandler = () => refresh();
document.addEventListener("catalog-rerender", rerenderHandler);
+ // Clone a session: reopen the spawn modal prefilled with its options.
+ const cloneHandler = (ev: Event) => {
+ const s = (ev as CustomEvent).detail;
+ const rawName = typeof s.metadata?.name === "string" ? s.metadata.name : "";
+ openSpawnModal(store.snapshot().daemons, {
+ daemon_id: s.daemon_id,
+ cwd: s.cwd,
+ command: s.command,
+ name: rawName,
+ label: sessionLabel(s) ?? "",
+ });
+ };
+ document.addEventListener("catalog-clone", cloneHandler);
+
const unsubscribe = store.subscribe(refresh);
return {
@@ -329,6 +426,7 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
},
dispose() {
document.removeEventListener("catalog-rerender", rerenderHandler);
+ document.removeEventListener("catalog-clone", cloneHandler);
unsubscribe();
},
};
diff --git a/web/src/connections.ts b/web/src/connections.ts
index 2666f9a..5bf948f 100644
--- a/web/src/connections.ts
+++ b/web/src/connections.ts
@@ -1,4 +1,8 @@
-import { createAttachConnection, type AttachConnection } from "./attach.js";
+import {
+ createAttachConnection,
+ type AttachConnection,
+ type AttachInfo,
+} from "./attach.js";
import type { Uuid } from "./types.js";
/**
@@ -41,7 +45,7 @@ export class ConnectionPool {
* 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 {
+ activate(id: Uuid, pane: HTMLElement, info?: AttachInfo): void {
if (this.activeId && this.activeId !== id) this.park(this.activeId);
let conn = this.cache.get(id);
@@ -50,9 +54,11 @@ export class ConnectionPool {
this.cache.delete(id);
this.cache.set(id, conn);
} else {
- conn = createAttachConnection(id, {
- onRequestClose: (sid) => this.requestClose(sid),
- });
+ conn = createAttachConnection(
+ id,
+ { onRequestClose: (sid) => this.requestClose(sid) },
+ info,
+ );
this.cache.set(id, conn);
this.evict(id);
}
diff --git a/web/src/kit.css b/web/src/kit.css
new file mode 100644
index 0000000..062c9a3
--- /dev/null
+++ b/web/src/kit.css
@@ -0,0 +1,268 @@
+/* =========================================================================
+ maestro web — design kit (DEV-only)
+ Loaded only by kit.ts (?kit). Layout for the specimen page + proposed
+ terminal-native button variants offered for critique. Nothing here ships:
+ kit.ts is dynamically imported behind import.meta.env.DEV.
+ ========================================================================= */
+
+.kit {
+ height: 100%;
+ overflow: auto;
+ background: var(--bg);
+ color: var(--ink);
+ scrollbar-width: thin;
+ scrollbar-color: var(--border-strong) transparent;
+}
+
+.kit-header {
+ position: sticky;
+ top: 0;
+ z-index: var(--z-sticky);
+ display: flex;
+ align-items: center;
+ gap: var(--sp-3);
+ padding: var(--sp-3) var(--sp-5);
+ border-bottom: 1px solid var(--border);
+ background: color-mix(in oklab, var(--surface) 82%, transparent);
+ backdrop-filter: blur(8px);
+}
+
+.kit-brand {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--sp-2);
+}
+
+.kit-brand h1 {
+ margin: 0;
+ font-size: var(--fs-15);
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+
+.kit-header-actions {
+ margin-left: auto;
+ display: inline-flex;
+ align-items: center;
+ gap: var(--sp-2);
+}
+
+.kit-main {
+ max-width: 1080px;
+ margin: 0 auto;
+ padding: var(--sp-6) var(--sp-5) var(--sp-8);
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-8);
+}
+
+.kit-section-head {
+ margin-bottom: var(--sp-4);
+}
+
+.kit-section-title {
+ margin: 0;
+ font-size: var(--fs-13);
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--accent);
+}
+
+.kit-section-note {
+ margin: 6px 0 0;
+ font-family: var(--font-sans);
+ font-size: var(--fs-13);
+ color: var(--muted);
+ max-width: 70ch;
+}
+
+.kit-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: var(--sp-4);
+}
+
+/* A specimen cell: the component above, its name below. */
+.kit-cell {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-2);
+ align-items: flex-start;
+}
+
+.kit-slot {
+ display: flex;
+ align-items: center;
+ min-height: 44px;
+ padding: var(--sp-3);
+ border: 1px dashed var(--border);
+ border-radius: var(--r-2);
+ background: color-mix(in oklab, var(--surface) 40%, var(--bg));
+}
+
+.kit-slot .modal-body {
+ padding: 0;
+ min-width: 200px;
+}
+
+.kit-label {
+ font-size: var(--fs-11);
+ color: var(--faint);
+ letter-spacing: 0.01em;
+}
+
+/* color swatches */
+.kit-swatches {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+ gap: var(--sp-3);
+}
+
+.kit-swatch {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.kit-swatch-chip {
+ height: 48px;
+ border-radius: var(--r-2);
+ border: 1px solid var(--border);
+}
+
+/* session-row specimen sits in a sidebar-width container so wrapping/ellipsis
+ behave like the real sidebar. */
+.kit-tree-host {
+ display: block;
+}
+
+.kit-tree {
+ width: clamp(280px, 26vw, 360px);
+ padding: var(--sp-2) 0;
+ background: color-mix(in oklab, var(--surface) 26%, var(--bg));
+ border: 1px solid var(--border);
+ border-radius: var(--r-2);
+}
+
+.kit-empties {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: var(--sp-4);
+ align-items: start;
+}
+
+.kit-empties .empty {
+ border: 1px solid var(--border);
+ border-radius: var(--r-2);
+ background: color-mix(in oklab, var(--surface) 30%, var(--bg));
+}
+
+/* -------------------------------------------------------------------------
+ Proposed terminal-native button variants (critique only)
+ ------------------------------------------------------------------------- */
+
+.kit-btn-bracket,
+.kit-btn-prompt,
+.kit-btn-block,
+.kit-btn-underline {
+ appearance: none;
+ cursor: pointer;
+ font-family: var(--font-mono);
+ font-size: var(--fs-13);
+ height: var(--control-h);
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: transparent;
+ border: 1px solid transparent;
+ color: var(--ink);
+ transition:
+ color var(--dur-fast) var(--ease-out),
+ border-color var(--dur-fast) var(--ease-out),
+ background var(--dur-fast) var(--ease-out);
+}
+
+/* [ + Spawn ] — brackets are the chrome */
+.kit-btn-bracket {
+ padding: 0 var(--sp-2);
+}
+.kit-btn-bracket::before {
+ content: "[";
+ color: var(--faint);
+}
+.kit-btn-bracket::after {
+ content: "]";
+ color: var(--faint);
+}
+.kit-btn-bracket:hover {
+ color: var(--accent);
+}
+.kit-btn-bracket:hover::before,
+.kit-btn-bracket:hover::after {
+ color: var(--accent);
+}
+.kit-btn-bracket.is-primary {
+ color: var(--accent);
+ font-weight: 600;
+}
+.kit-btn-bracket.is-primary::before,
+.kit-btn-bracket.is-primary::after {
+ color: var(--accent);
+}
+.kit-btn-bracket.is-primary:hover {
+ background: var(--accent-tint);
+}
+
+/* › spawn — a shell prompt */
+.kit-btn-prompt {
+ padding: 0 var(--sp-2);
+ color: var(--muted);
+}
+.kit-btn-prompt::before {
+ content: "›";
+ color: var(--accent);
+ font-weight: 700;
+}
+.kit-btn-prompt:hover {
+ color: var(--ink);
+}
+
+/* SPAWN — boxed, uppercase, tight */
+.kit-btn-block {
+ padding: 0 var(--sp-3);
+ border-color: var(--border-strong);
+ border-radius: var(--r-1);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-size: var(--fs-11);
+ font-weight: 600;
+ color: var(--muted);
+}
+.kit-btn-block:hover {
+ color: var(--accent);
+ border-color: var(--accent);
+ background: var(--accent-tint);
+}
+
+/* spawn — underline only */
+.kit-btn-underline {
+ padding: 0 2px;
+ border-radius: 0;
+ border-bottom: 1px solid var(--border-strong);
+ color: var(--muted);
+}
+.kit-btn-underline:hover {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+}
+
+.kit-btn-bracket:focus-visible,
+.kit-btn-prompt:focus-visible,
+.kit-btn-block:focus-visible,
+.kit-btn-underline:focus-visible {
+ outline: 2px solid var(--accent-ring);
+ outline-offset: 2px;
+ border-radius: var(--r-1);
+}
diff --git a/web/src/kit.ts b/web/src/kit.ts
new file mode 100644
index 0000000..7f04d19
--- /dev/null
+++ b/web/src/kit.ts
@@ -0,0 +1,272 @@
+// Dev-only design kit. Lazily imported behind `import.meta.env.DEV` from
+// main.ts (`?kit`), so it never ships in a production bundle. Renders every
+// component specimen on one scrollable page so the visual system can be
+// critiqued as a whole — buttons (including terminal-native variants for
+// review), inputs, chips, badges, session rows, and empty states.
+import "./kit.css";
+import { createThemeToggle } from "./theme.js";
+
+function h(tag: string, className?: string, html?: string): HTMLElement {
+ const el = document.createElement(tag);
+ if (className) el.className = className;
+ if (html != null) el.innerHTML = html;
+ return el;
+}
+
+/** A titled section with an optional note and a content host. */
+function section(title: string, note?: string): { el: HTMLElement; body: HTMLElement } {
+ const el = h("section", "kit-section");
+ const head = h("div", "kit-section-head");
+ head.appendChild(h("h2", "kit-section-title", title));
+ if (note) head.appendChild(h("p", "kit-section-note", note));
+ el.appendChild(head);
+ const body = h("div", "kit-row");
+ el.appendChild(body);
+ return { el, body };
+}
+
+/** A labelled specimen cell so each variant is named for critique. */
+function specimen(label: string, node: Node): HTMLElement {
+ const cell = h("div", "kit-cell");
+ const slot = h("div", "kit-slot");
+ slot.appendChild(node);
+ cell.append(slot, h("span", "kit-label", label));
+ return cell;
+}
+
+function btn(className: string, label: string, disabled = false): HTMLButtonElement {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = className;
+ b.textContent = label;
+ b.disabled = disabled;
+ return b;
+}
+
+/** A collapsible folder header, matching the real sidebar tree markup. */
+function folderHeader(name: string, count: number, depth: number): HTMLElement {
+ const b = h("button", "tree-folder-header");
+ (b as HTMLButtonElement).type = "button";
+ b.style.paddingLeft = `${depth * 16}px`;
+ b.setAttribute("aria-expanded", "true");
+ b.innerHTML = `▾${name}${count}`;
+ return b;
+}
+
+function leafRow(opts: {
+ name: string;
+ daemon: string;
+ cmd: string;
+ status: "running" | "dead";
+ active?: boolean;
+ indent?: number;
+}): HTMLElement {
+ const row = h("a", "tree-leaf");
+ (row as HTMLAnchorElement).href = "#/kit";
+ row.dataset.status = opts.status;
+ if (opts.active) row.classList.add("is-active");
+ row.style.paddingLeft = `${(opts.indent ?? 0) * 16 + 8}px`;
+ row.innerHTML = `
+ ${opts.name}
+ ${opts.cmd !== opts.name ? `${opts.cmd}` : ""}
+ ${opts.status === "dead" ? `exited` : ""}
+
+
+
+ `;
+ return row;
+}
+
+export function renderKit(): HTMLElement {
+ const page = h("div", "kit");
+
+ // --- header ---
+ const header = h("header", "kit-header");
+ header.appendChild(
+ h(
+ "div",
+ "kit-brand",
+ `
+ maestro_ · design kit
`,
+ ),
+ );
+ const headerActions = h("div", "kit-header-actions");
+ const back = document.createElement("a");
+ back.className = "btn btn-ghost";
+ back.href = "?mock";
+ back.textContent = "← back to app";
+ headerActions.append(back, createThemeToggle());
+ header.appendChild(headerActions);
+ page.appendChild(header);
+
+ const main = h("div", "kit-main");
+ page.appendChild(main);
+
+ // --- color tokens ---
+ {
+ const { el, body } = section(
+ "Color tokens",
+ "Slate neutrals (hue ~256) + signal-green = alive. Swap by theme toggle.",
+ );
+ body.classList.add("kit-swatches");
+ const tokens = [
+ "--bg",
+ "--surface",
+ "--surface-2",
+ "--surface-3",
+ "--border",
+ "--ink",
+ "--muted",
+ "--faint",
+ "--accent",
+ "--danger",
+ "--warn",
+ ];
+ for (const t of tokens) {
+ const sw = h("div", "kit-swatch");
+ const chip = h("div", "kit-swatch-chip");
+ chip.style.background = `var(${t})`;
+ sw.append(chip, h("span", "kit-label", t));
+ body.appendChild(sw);
+ }
+ main.appendChild(el);
+ }
+
+ // --- buttons: current system ---
+ {
+ const { el, body } = section(
+ "Buttons — current",
+ "The shipping button styles — now the bracket terminal style, as used across the header, modal, and empty states.",
+ );
+ body.append(
+ specimen("btn-primary", btn("btn btn-primary", "+ Spawn")),
+ specimen("btn (default)", btn("btn", "Cancel")),
+ specimen("btn-ghost", btn("btn btn-ghost", "Dismiss")),
+ specimen("btn-icon", btn("btn btn-icon", "✕")),
+ specimen("btn:disabled", btn("btn btn-primary", "+ Spawn", true)),
+ );
+ main.appendChild(el);
+ }
+
+ // --- buttons: terminal-native variants for critique ---
+ {
+ const { el, body } = section(
+ "Buttons — other explorations",
+ "The bracket variant was adopted as the app style (above). These remain as alternates — not used in the app.",
+ );
+ body.append(
+ specimen("bracket", btn("kit-btn-bracket", "+ Spawn")),
+ specimen("bracket primary", btn("kit-btn-bracket is-primary", "+ Spawn")),
+ specimen("prompt caret", btn("kit-btn-prompt", "spawn")),
+ specimen("block", btn("kit-btn-block", "SPAWN")),
+ specimen("underline", btn("kit-btn-underline", "spawn")),
+ );
+ main.appendChild(el);
+ }
+
+ // --- inputs ---
+ {
+ const { el, body } = section("Inputs", "Mono inputs with a green focus ring.");
+ const search = h("input") as HTMLInputElement;
+ search.type = "search";
+ search.className = "search";
+ search.placeholder = "Search sessions…";
+ const text = h("div", "modal-body");
+ const ti = document.createElement("input");
+ ti.type = "text";
+ ti.placeholder = "branch-name";
+ text.appendChild(ti);
+ const sel = h("div", "modal-body");
+ const s = document.createElement("select");
+ s.innerHTML = ``;
+ sel.appendChild(s);
+ body.append(
+ specimen("search", search),
+ specimen("text input", text),
+ specimen("select", sel),
+ );
+ main.appendChild(el);
+ }
+
+ // --- chips & badges ---
+ {
+ const { el, body } = section("Chips & badges", "Status-bearing pills and counts.");
+ body.append(
+ specimen("daemon-chip", h("span", "daemon-chip", "thinkpad")),
+ specimen("daemon-empty", h("span", "daemon-empty", "no daemons connected")),
+ specimen("folder-count", h("span", "folder-count", "4")),
+ specimen("leaf-badge", h("span", "leaf-badge", "exited")),
+ specimen(
+ "attach-state · open",
+ h("span", "attach-state", "live"),
+ ),
+ );
+ (body.lastElementChild?.querySelector(".attach-state") as HTMLElement)?.setAttribute(
+ "data-state",
+ "open",
+ );
+ const connecting = specimen("attach-state · connecting", h("span", "attach-state", "connecting"));
+ (connecting.querySelector(".attach-state") as HTMLElement).setAttribute("data-state", "connecting");
+ const closed = specimen("attach-state · closed", h("span", "attach-state", "closed"));
+ (closed.querySelector(".attach-state") as HTMLElement).setAttribute("data-state", "closed");
+ body.append(connecting, closed);
+ main.appendChild(el);
+ }
+
+ // --- session rows, nested by project label ---
+ {
+ const { el, body } = section(
+ "Session rows — nested by host, then project",
+ "Host is the first level of nesting; the project label nests beneath it, so the daemon no longer rides on each row. “Alive” is a faint row wash; exited rows recede with a badge; the open session is highlighted.",
+ );
+ const list = h("div", "kit-tree");
+ list.append(
+ folderHeader("thinkpad", 3, 0),
+ folderHeader("maestro", 3, 1),
+ folderHeader("web", 2, 2),
+ leafRow({ name: "redesign", daemon: "", cmd: "claude", status: "running", indent: 3 }),
+ leafRow({ name: "dev-server", daemon: "", cmd: "vite dev", status: "running", indent: 3, active: true }),
+ folderHeader("server", 1, 2),
+ leafRow({ name: "registry-broker", daemon: "", cmd: "claude", status: "running", indent: 3 }),
+ folderHeader("tower", 2, 0),
+ folderHeader("work", 1, 1),
+ folderHeader("acme", 1, 2),
+ leafRow({ name: "billing-api", daemon: "", cmd: "claude", status: "running", indent: 3 }),
+ leafRow({ name: "scratch", daemon: "", cmd: "bash", status: "dead", indent: 1 }),
+ folderHeader("pi", 1, 0),
+ leafRow({ name: "monitor", daemon: "", cmd: "btop", status: "running", indent: 1 }),
+ );
+ body.classList.add("kit-tree-host");
+ body.appendChild(list);
+ main.appendChild(el);
+ }
+
+ // --- empty states ---
+ {
+ const { el, body } = section("Empty states", "Each one teaches the next action.");
+ body.classList.add("kit-empties");
+ const welcome = h("div", "empty");
+ welcome.innerHTML = `
+ ⌘
+ No session open
+ Pick a session from the list, or start a new one.
+ `;
+ welcome.querySelector(".empty-actions")!.appendChild(btn("btn btn-primary", "+ Spawn session"));
+ const noSessions = h("div", "empty");
+ noSessions.innerHTML = `
+ +
+ No sessions yet
+ Start your first session — it'll show up here.
+ `;
+ noSessions.querySelector(".empty-actions")!.appendChild(btn("btn btn-primary", "+ Spawn session"));
+ const waiting = h("div", "empty connecting");
+ waiting.innerHTML = `
+ ◴
+ Waiting for a daemon
+ Start a maestro daemon on a host and it will appear here.
`;
+ body.append(welcome, noSessions, waiting);
+ main.appendChild(el);
+ }
+
+ return page;
+}
diff --git a/web/src/main.ts b/web/src/main.ts
index 97caa3e..a4ad2c6 100644
--- a/web/src/main.ts
+++ b/web/src/main.ts
@@ -1,15 +1,29 @@
import { renderSidebar, renderTopbar } from "./catalog.js";
import { ConnectionPool } from "./connections.js";
import { CatalogStore } from "./events.js";
+import { openSpawnModal } from "./spawn.js";
import { initTheme } from "./theme.js";
+import { sessionName } from "./types.js";
import "./styles.css";
initTheme();
-const root = document.getElementById("app");
-if (!root) {
+const rootEl = document.getElementById("app");
+if (!rootEl) {
throw new Error("missing #app");
}
+const root: HTMLElement = rootEl;
+
+// Dev-only: `?kit` renders the standalone design kit instead of the app shell,
+// so the component system can be reviewed as a whole. Stripped from production
+// builds by Vite (dynamic import behind import.meta.env.DEV).
+if (import.meta.env.DEV && location.search.includes("kit")) {
+ void import("./kit.js").then((m) => root.replaceChildren(m.renderKit()));
+} else {
+ void bootApp();
+}
+
+async function bootApp(): Promise {
const store = new CatalogStore();
store.start();
@@ -46,9 +60,16 @@ function renderWelcome(): HTMLElement {
⌘
No session open
- Pick a session from the list, or press + Spawn to start one.
- Open sessions keep streaming in the background — switching back is instant.
-
`;
+ Pick a session from the list, or start a new one. Open sessions keep
+ streaming in the background — switching back is instant.
+
+ `;
+ const spawn = document.createElement("button");
+ spawn.type = "button";
+ spawn.className = "btn btn-primary";
+ spawn.textContent = "+ Spawn session";
+ spawn.addEventListener("click", () => openSpawnModal(store.snapshot().daemons));
+ (el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
return el;
}
@@ -78,10 +99,15 @@ function parseRoute(): Route {
function dispatch(): void {
const route = parseRoute();
switch (route.kind) {
- case "attach":
+ case "attach": {
shellEl.dataset.route = "attach";
- pool.activate(route.sessionId, content);
+ const s = store.snapshot().sessions.get(route.sessionId);
+ const info = s
+ ? { name: sessionName(s), command: s.command, daemon: s.daemon_id }
+ : undefined;
+ pool.activate(route.sessionId, content, info);
return;
+ }
case "not-found":
shellEl.dataset.route = "notfound";
pool.parkActive();
@@ -97,3 +123,5 @@ function dispatch(): void {
window.addEventListener("hashchange", dispatch);
dispatch();
+
+} // bootApp
diff --git a/web/src/mock.ts b/web/src/mock.ts
index 38e9a81..10363c2 100644
--- a/web/src/mock.ts
+++ b/web/src/mock.ts
@@ -30,13 +30,24 @@ export function seedMock(store: CatalogStore): void {
{ daemon_id: "pi", hostname: "pi.lan", version: "0.1.0", daemon_url: "wss://pi.lan/daemon", home_dir: "/home/pi" },
];
const sessions: Session[] = [
- sess("a1b2c3d4-0000-0000-0000-000000000001", "thinkpad", "claude", "Running", { name: "redesign", label: "maestro/web" }),
- sess("a1b2c3d4-0000-0000-0000-000000000002", "thinkpad", "claude", "Running", { name: "registry-broker", label: "maestro/server" }),
- sess("a1b2c3d4-0000-0000-0000-000000000003", "tower", "cargo watch -x test", "Running", { name: "test-loop", label: "maestro/server" }),
- sess("a1b2c3d4-0000-0000-0000-000000000004", "tower", "bash", "Dead", { name: "scratch", label: "maestro/web" }),
- sess("a1b2c3d4-0000-0000-0000-000000000005", "tower", "opencode", "Running", { name: "docs", label: "personal" }),
- sess("a1b2c3d4-0000-0000-0000-000000000006", "pi", "btop", "Running", { name: "monitor", label: "personal" }),
- sess("a1b2c3d4-0000-0000-0000-000000000007", "pi", "ssh gateway", "Dead", {}),
+ // maestro/web/* — a project with nested sub-labels (two levels deep)
+ sess("a1b2c3d4-0000-0000-0000-000000000001", "thinkpad", "claude", "Running", { name: "redesign", label: "maestro/web/frontend" }),
+ sess("a1b2c3d4-0000-0000-0000-000000000002", "thinkpad", "vite dev", "Running", { name: "dev-server", label: "maestro/web/frontend" }),
+ sess("a1b2c3d4-0000-0000-0000-000000000003", "tower", "bash", "Dead", { name: "scratch", label: "maestro/web/frontend" }),
+ sess("a1b2c3d4-0000-0000-0000-000000000004", "thinkpad", "claude", "Running", { name: "e2e-fixes", label: "maestro/web/testing" }),
+ // maestro/server/* — sibling project, also nested
+ sess("a1b2c3d4-0000-0000-0000-000000000005", "thinkpad", "claude", "Running", { name: "registry-broker", label: "maestro/server/registry" }),
+ sess("a1b2c3d4-0000-0000-0000-000000000006", "tower", "cargo watch -x test", "Running", { name: "test-loop", label: "maestro/server/registry" }),
+ sess("a1b2c3d4-0000-0000-0000-000000000007", "tower", "cargo run -- daemon", "Running", { name: "daemon", label: "maestro/server/daemon" }),
+ // work/* — a second top-level group with its own client nesting
+ sess("a1b2c3d4-0000-0000-0000-000000000008", "tower", "claude", "Running", { name: "billing-api", label: "work/acme/api" }),
+ sess("a1b2c3d4-0000-0000-0000-000000000009", "tower", "pnpm dev", "Dead", { name: "dashboard", label: "work/acme/web" }),
+ // personal/* — flat-ish group
+ sess("a1b2c3d4-0000-0000-0000-000000000010", "tower", "opencode", "Running", { name: "dotfiles", label: "personal" }),
+ sess("a1b2c3d4-0000-0000-0000-000000000011", "pi", "btop", "Running", { name: "monitor", label: "personal" }),
+ // ungrouped — no label, falls to the tree root (name from command)
+ sess("a1b2c3d4-0000-0000-0000-000000000012", "pi", "ssh gateway", "Dead", {}),
+ sess("a1b2c3d4-0000-0000-0000-000000000013", "thinkpad", "htop", "Running", {}),
];
// The store applies RegistryEvents internally; feed a Snapshot the same way
// the WS would. `apply` is private, so reach it through an index cast.
diff --git a/web/src/spawn.ts b/web/src/spawn.ts
index e625aba..b0490e4 100644
--- a/web/src/spawn.ts
+++ b/web/src/spawn.ts
@@ -2,7 +2,20 @@ import { createSession } from "./api.js";
import { attachDirAutocomplete } from "./dirAutocomplete.js";
import type { CreateSessionRequest, DaemonInfo } from "./types.js";
-export function openSpawnModal(daemons: Map): void {
+/** Seed values for the spawn form — used by "clone" to reopen the dialog with
+ * the same daemon, cwd, command, name, and label as an existing session. */
+export interface SpawnPrefill {
+ daemon_id?: string;
+ cwd?: string;
+ command?: string;
+ name?: string;
+ label?: string;
+}
+
+export function openSpawnModal(
+ daemons: Map,
+ prefill?: SpawnPrefill,
+): void {
const existing = document.getElementById("modal-host");
if (existing) existing.remove();
@@ -98,6 +111,30 @@ export function openSpawnModal(daemons: Map): void {
select.addEventListener("change", applyHomeDir);
applyHomeDir();
+ // Clone: seed the form from an existing session. cwd is marked edited so the
+ // home-dir sync won't clobber it on daemon change.
+ if (prefill) {
+ (host.querySelector("#spawn-title") as HTMLElement).textContent =
+ "Clone session";
+ const set = (name: string, value: string | undefined) => {
+ if (value == null) return;
+ const field = host.querySelector(
+ `[name=${name}]`,
+ ) as HTMLInputElement | HTMLSelectElement | null;
+ if (field) field.value = value;
+ };
+ if (prefill.daemon_id && daemons.has(prefill.daemon_id)) {
+ select.value = prefill.daemon_id;
+ }
+ set("command", prefill.command);
+ set("name", prefill.name);
+ set("label", prefill.label);
+ if (prefill.cwd) {
+ cwdInput.value = prefill.cwd;
+ cwdEdited = true;
+ }
+ }
+
// Live directory autocomplete (NAP-329) on the path fields, scoped to the
// currently-selected daemon — re-reading `select.value` per query means it
// tracks host changes for free.
diff --git a/web/src/styles.css b/web/src/styles.css
index 5e11d4a..f478349 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -31,16 +31,22 @@
--sp-6: 24px;
--sp-8: 32px;
- /* ---- radii ---- */
- --r-1: 6px;
- --r-2: 9px;
- --r-3: 14px;
- --r-pill: 999px;
+ /* ---- radii ----
+ Terminal-native: crisp, near-square corners. Round dots (status, brand)
+ use border-radius:50% directly and are unaffected; these tokens govern
+ rectangles (buttons, inputs, cards, tags) and stay sharp on purpose. */
+ --r-1: 2px;
+ --r-2: 3px;
+ --r-3: 4px;
+ --r-pill: 3px;
/* ---- sizing ---- */
--row-h: 46px;
- --control-h: 38px;
+ --control-h: 30px;
--tap: 40px;
+ /* Height of the two top-of-pane bars (sidebar search + attach header) — shared
+ so they line up across the split. */
+ --bar-h: 34px;
/* ---- motion ---- */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
@@ -248,18 +254,18 @@ textarea {
border-radius: var(--r-1);
}
-/* ---------- buttons ---------- */
+/* ---------- buttons ----------
+ Terminal-native: the brackets are the chrome. Default + primary wear
+ [ brackets ]; ghost/icon drop them for quieter nav and single glyphs. */
.btn {
- --_bg: var(--surface-2);
appearance: none;
- border: 1px solid var(--border);
- background: var(--_bg);
+ border: 1px solid transparent;
+ background: transparent;
color: var(--ink);
- padding: 0 var(--sp-4);
+ padding: 0 var(--sp-1);
height: var(--control-h);
min-height: var(--control-h);
- border-radius: var(--r-2);
cursor: pointer;
font-family: var(--font-mono);
font-size: var(--fs-13);
@@ -268,17 +274,31 @@ textarea {
display: inline-flex;
align-items: center;
justify-content: center;
- gap: var(--sp-2);
+ gap: 4px;
white-space: nowrap;
transition:
+ color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out),
- border-color var(--dur-fast) var(--ease-out),
transform var(--dur-fast) var(--ease-out);
}
+.btn::before {
+ content: "[";
+ color: var(--faint);
+}
+
+.btn::after {
+ content: "]";
+ color: var(--faint);
+}
+
.btn:hover {
- background: var(--surface-3);
- border-color: var(--border-strong);
+ color: var(--accent);
+}
+
+.btn:hover::before,
+.btn:hover::after {
+ color: var(--accent);
}
.btn:active {
@@ -292,25 +312,32 @@ textarea {
}
.btn-primary {
- --_bg: var(--accent);
- border-color: transparent;
- color: var(--accent-ink);
+ color: var(--accent);
font-weight: 600;
}
-.btn-primary:hover {
- background: var(--accent-strong);
- border-color: transparent;
+.btn-primary::before,
+.btn-primary::after {
+ color: var(--accent);
}
+.btn-primary:hover {
+ background: var(--accent-tint);
+}
+
+/* Ghost: no brackets — quiet nav (back, disconnect, theme, kit) and icons. */
.btn-ghost {
- --_bg: transparent;
- border-color: transparent;
+ color: var(--muted);
+}
+
+.btn-ghost::before,
+.btn-ghost::after {
+ content: none;
}
.btn-ghost:hover {
+ color: var(--ink);
background: var(--surface-2);
- border-color: var(--border);
}
.btn-icon {
@@ -319,6 +346,15 @@ textarea {
font-size: var(--fs-15);
}
+.btn-icon::before,
+.btn-icon::after {
+ content: none;
+}
+
+.btn-icon:hover {
+ background: var(--surface-2);
+}
+
/* ---------- app shell: sidebar + content ---------- */
/* Global app header: brand + app-level actions (spawn, theme toggle), spanning
@@ -328,17 +364,45 @@ textarea {
align-items: center;
gap: var(--sp-3);
flex: 0 0 auto;
- padding: var(--sp-3) var(--sp-4);
+ padding: var(--sp-1) var(--sp-3);
border-bottom: 1px solid var(--border);
background: color-mix(in oklab, var(--surface) 82%, transparent);
backdrop-filter: blur(8px);
}
+/* Three-part header: brand (left), status (centered), actions (right). Equal
+ flex on the outer two keeps the status group truly centered. */
+.app-header .brand {
+ flex: 1 1 0;
+ min-width: 0;
+ margin-right: 0;
+}
+
+.app-status {
+ flex: 0 1 auto;
+ display: flex;
+ justify-content: center;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.app-status .statusbar {
+ border-bottom: 0;
+ background: transparent;
+ padding: 0;
+ flex-wrap: nowrap;
+}
+
+.app-status .statusbar .spacer {
+ display: none;
+}
+
.app-actions {
display: flex;
align-items: center;
gap: var(--sp-2);
- margin-left: auto;
+ flex: 1 1 0;
+ justify-content: flex-end;
}
.app-shell {
@@ -351,7 +415,8 @@ textarea {
@media (min-width: 860px) {
.app-shell {
display: grid;
- grid-template-columns: clamp(280px, 26vw, 360px) minmax(0, 1fr);
+ grid-template-columns:
+ var(--sidebar-w, clamp(220px, 22vw, 320px)) minmax(0, 1fr);
}
}
@@ -361,21 +426,64 @@ textarea {
min-height: 0;
height: 100%;
overflow: hidden;
+ position: relative;
background: color-mix(in oklab, var(--surface) 26%, var(--bg));
border-right: 1px solid var(--border);
}
+/* Drag-to-resize grip on the sidebar's right edge (desktop split only). */
+.sidebar-resize {
+ position: absolute;
+ top: 0;
+ right: -3px;
+ width: 6px;
+ height: 100%;
+ cursor: col-resize;
+ z-index: var(--z-sticky);
+ touch-action: none;
+}
+
+.sidebar-resize:hover,
+.sidebar-resize:active {
+ background: var(--accent-ring);
+}
+
+@media (max-width: 859px) {
+ .sidebar-resize {
+ display: none;
+ }
+ .app-status {
+ display: none;
+ }
+}
+
/* Sidebar's own slim header: just the session search (app-level actions live
in the global top bar). */
+/* The search input IS the header — it spans edge to edge, flush, with just a
+ bottom rule for separation. No wrapper padding, no boxed border/radius. */
.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;
+ height: var(--bar-h);
+ border: 0;
+ border-bottom: 1px solid var(--border);
+ border-radius: 0;
+ background: transparent;
+}
+
+.sidebar-search .search:hover {
+ background: var(--surface-2);
+ border-bottom-color: var(--border-strong);
+}
+
+.sidebar-search .search:focus {
+ background: var(--surface-2);
+ border-bottom-color: var(--accent);
+ box-shadow: none;
}
.content {
@@ -419,7 +527,7 @@ textarea {
flex-wrap: wrap;
align-items: center;
gap: var(--sp-3);
- padding: var(--sp-3) var(--sp-4);
+ padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border);
background: color-mix(in oklab, var(--surface) 82%, transparent);
backdrop-filter: blur(8px);
@@ -497,7 +605,7 @@ textarea {
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-3);
- padding: var(--sp-2) var(--sp-4);
+ padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border);
background: var(--surface);
font-size: var(--fs-12);
@@ -588,7 +696,7 @@ textarea {
.tree {
flex: 1 1 auto;
overflow: auto;
- padding: var(--sp-2) 0 var(--sp-8);
+ padding: var(--sp-1) 0 var(--sp-4);
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
@@ -659,112 +767,85 @@ textarea {
.tree-leaf {
display: flex;
align-items: center;
- gap: var(--sp-3);
- min-height: var(--row-h);
- padding: var(--sp-2) var(--sp-4) var(--sp-2) var(--sp-3);
- border-left: 2px solid transparent;
+ gap: var(--sp-2);
+ min-height: 30px;
+ margin: 1px var(--sp-2);
+ padding: 4px var(--sp-3);
+ border-radius: var(--r-1);
color: var(--ink);
position: relative;
- transition: background var(--dur-fast) var(--ease-out);
+ transition:
+ background var(--dur-fast) var(--ease-out),
+ box-shadow var(--dur-fast) var(--ease-out);
}
-.tree-leaf::after {
- content: "";
- position: absolute;
- left: var(--sp-4);
- right: var(--sp-4);
- bottom: 0;
- height: 1px;
- background: var(--border);
- opacity: 0.5;
+/* "Alive" reads as a faint wash on the row itself — no per-row status icon. */
+.tree-leaf[data-status="running"] {
+ background: color-mix(in oklab, var(--accent) 6%, transparent);
}
.tree-leaf:hover {
- background: var(--surface);
+ background: var(--surface-2);
+}
+
+.tree-leaf[data-status="running"]:hover {
+ background: color-mix(in oklab, var(--accent) 13%, var(--surface-2));
}
.tree-leaf:focus-visible {
outline: none;
- background: var(--surface);
- border-left-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent-ring);
}
/* The session currently mounted in the content pane. */
-.tree-leaf.is-active {
+.tree-leaf.is-active,
+.tree-leaf.is-active[data-status="running"] {
background: var(--accent-tint);
- border-left-color: var(--accent);
+ box-shadow: inset 0 0 0 1px var(--accent-ring);
}
.tree-leaf.is-active .leaf-name {
color: var(--ink);
}
-.tree-leaf.is-active::after {
- opacity: 0;
-}
-
+/* Exited sessions recede: dimmed name + an "exited" badge carry status, so it's
+ never the running tint's absence alone doing the work. */
.tree-leaf[data-status="dead"] {
color: var(--muted);
}
-.leaf-status {
- width: 10px;
- height: 10px;
- flex: 0 0 10px;
- border-radius: 50%;
- position: relative;
-}
-
-[data-status="running"] .leaf-status {
- background: var(--live);
- box-shadow: 0 0 0 3px var(--live-glow);
-}
-
-[data-status="running"] .leaf-status::before {
- content: "";
- position: absolute;
- inset: -3px;
- border-radius: 50%;
- border: 1px solid var(--live);
- opacity: 0.6;
- animation: ripple 2.4s var(--ease-out) infinite;
-}
-
-[data-status="dead"] .leaf-status {
- background: transparent;
- border: 1.5px solid var(--faint);
-}
-
-.leaf-main {
- display: flex;
- flex-direction: column;
- flex: 1 1 auto;
- min-width: 0;
- gap: 1px;
+.tree-leaf[data-status="dead"] .leaf-name {
+ color: var(--muted);
+ font-weight: 400;
}
.leaf-name {
font-weight: 500;
- font-size: var(--fs-14);
+ font-size: var(--fs-13);
+ flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaf-meta {
- font-size: var(--fs-12);
+ font-size: var(--fs-11);
color: var(--muted);
+ margin-left: auto;
+ padding-left: var(--sp-2);
+ min-width: 0;
+ flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
- display: flex;
- align-items: center;
+ display: inline-flex;
+ align-items: baseline;
gap: var(--sp-2);
}
.leaf-daemon {
color: var(--faint);
+ flex: 0 0 auto;
}
.leaf-daemon::before {
@@ -774,6 +855,9 @@ textarea {
.leaf-cmd {
color: var(--muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.leaf-badge {
@@ -787,36 +871,50 @@ textarea {
flex: 0 0 auto;
}
-.leaf-kill {
- background: transparent;
+.leaf-actions {
+ position: absolute;
+ right: var(--sp-1);
+ top: 50%;
+ transform: translateY(-50%);
+ display: inline-flex;
+ gap: 2px;
+}
+
+.leaf-act {
+ background: var(--surface-3);
color: var(--faint);
- border: 1px solid transparent;
- border-radius: var(--r-2);
- width: var(--tap);
- height: var(--tap);
+ border: 1px solid var(--border);
+ border-radius: var(--r-1);
+ width: 24px;
+ height: 24px;
cursor: pointer;
- flex: 0 0 var(--tap);
display: inline-flex;
align-items: center;
justify-content: center;
- font-size: var(--fs-14);
+ font-size: var(--fs-12);
transition:
color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out),
- border-color var(--dur-fast) var(--ease-out),
- opacity var(--dur-fast) var(--ease-out);
+ border-color var(--dur-fast) var(--ease-out);
}
@media (hover: hover) {
- .leaf-kill {
+ .leaf-actions {
opacity: 0;
+ transition: opacity var(--dur-fast) var(--ease-out);
}
- .tree-leaf:hover .leaf-kill,
- .tree-leaf:focus-within .leaf-kill {
+ .tree-leaf:hover .leaf-actions,
+ .tree-leaf:focus-within .leaf-actions {
opacity: 1;
}
}
+.leaf-clone:hover {
+ color: var(--accent);
+ background: var(--accent-tint);
+ border-color: color-mix(in oklab, var(--accent) 40%, transparent);
+}
+
.leaf-kill:hover {
color: var(--danger);
background: var(--danger-tint);
@@ -826,7 +924,7 @@ textarea {
/* ---------- empty / loading ---------- */
.empty {
- padding: var(--sp-8) var(--sp-4);
+ padding: var(--sp-5) var(--sp-4);
color: var(--muted);
text-align: center;
display: flex;
@@ -871,6 +969,12 @@ textarea {
color: var(--ink);
}
+.empty-actions {
+ display: flex;
+ gap: var(--sp-2);
+ margin-top: var(--sp-2);
+}
+
.empty.connecting .empty-glyph {
color: var(--warn);
animation: pulse 1.6s var(--ease-out) infinite;
@@ -888,16 +992,27 @@ textarea {
.attach-header {
display: flex;
align-items: center;
- gap: var(--sp-3);
- padding: var(--sp-2) var(--sp-3);
+ gap: var(--sp-2);
+ height: var(--bar-h);
+ padding: 0 var(--sp-2);
border-bottom: 1px solid var(--border);
background: var(--surface);
}
+/* Compact controls so the bar hugs the title — no tall buttons. */
+.attach-header .btn {
+ height: 26px;
+ min-height: 26px;
+}
+
+.attach-header .btn-icon {
+ width: 26px;
+}
+
.attach-header .back {
- font-size: var(--fs-17);
- width: var(--tap);
- height: var(--control-h);
+ font-size: var(--fs-15);
+ width: 28px;
+ height: 26px;
}
/* Desktop keeps the session list permanently in view, so the in-header "back"
@@ -932,12 +1047,37 @@ textarea {
color: var(--muted);
}
+.attach-title .at-name {
+ color: var(--ink);
+ font-weight: 500;
+}
+
+.attach-title .at-cmd {
+ color: var(--muted);
+}
+
+.attach-title .at-cmd::before {
+ content: "·";
+ margin: 0 6px;
+ color: var(--faint);
+}
+
+.attach-title .at-host {
+ color: var(--faint);
+ margin-left: 6px;
+}
+
+.attach-title .at-host::before {
+ content: "@";
+ opacity: 0.6;
+}
+
.attach-state {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-12);
- padding: 4px 10px;
+ padding: 2px var(--sp-2);
border-radius: var(--r-pill);
background: var(--surface-2);
color: var(--muted);
@@ -979,6 +1119,7 @@ textarea {
min-height: 0;
padding: var(--sp-2);
display: flex;
+ overflow: hidden;
background: var(--term-bg);
}
@@ -987,9 +1128,34 @@ textarea {
height: 100%;
}
+/* xterm hard-codes overflow-y: scroll on its viewport — an always-on bar that
+ (a) shows even with no scrollback and (b) makes FitAddon subtract its width,
+ costing a column. Hide it entirely: scroll still works via wheel/touch/keys,
+ and fit reclaims the full width. */
+/* Higher specificity than xterm's own `.xterm .xterm-viewport` (which loads
+ after this and would otherwise win the tie) so the always-on scrollbar
+ reservation actually goes away. */
+.attach-body .xterm .xterm-viewport {
+ overflow-y: auto;
+ scrollbar-width: none;
+}
+
+.attach-body .xterm .xterm-viewport::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+}
+
+/* Mobile: full-bleed terminal — the desktop frame padding wastes space and the
+ sub-cell remainder it left was triggering a stray scrollbar. */
+@media (max-width: 859px) {
+ .attach-body {
+ padding: 0;
+ }
+}
+
.attach-status {
margin: var(--sp-4);
- padding: var(--sp-3) var(--sp-4);
+ padding: var(--sp-2) var(--sp-3);
border-radius: var(--r-2);
background: var(--surface);
border: 1px solid var(--border);
@@ -1048,7 +1214,7 @@ textarea {
display: flex;
align-items: center;
justify-content: space-between;
- padding: var(--sp-4);
+ padding: var(--sp-3);
border-bottom: 1px solid var(--border);
}
@@ -1068,11 +1234,11 @@ textarea {
}
.modal-body {
- padding: var(--sp-4);
+ padding: var(--sp-3);
overflow: auto;
display: flex;
flex-direction: column;
- gap: var(--sp-3);
+ gap: var(--sp-2);
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
@@ -1220,7 +1386,7 @@ textarea {
.not-found {
margin: auto;
- padding: var(--sp-8) var(--sp-4);
+ padding: var(--sp-5) var(--sp-4);
display: flex;
flex-direction: column;
gap: var(--sp-3);
@@ -1366,7 +1532,7 @@ textarea {
flex: 1 1 100%;
justify-content: flex-start;
}
- .tree-leaf .leaf-kill {
+ .tree-leaf .leaf-actions {
opacity: 1;
}
}