feat(maestro): terminal-native web UI pass + design kit
ci / rust (push) Successful in 4m26s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 12:11:43 +00:00
co-authored by Claude Opus 4.8
parent eb2162889e
commit c02ff02849
9 changed files with 1130 additions and 164 deletions
+83 -3
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* 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
? `<span class="at-name">${esc(info.name)}</span>` +
(info.command && info.command !== info.name
? `<span class="at-cmd">${esc(info.command)}</span>`
: "") +
(info.daemon ? `<span class="at-host">${esc(info.daemon)}</span>` : "")
: `session <span class="sid">${esc(sessionId.slice(0, 8))}</span>`;
const titleAttr = info
? `${info.name} · ${info.command} @ ${info.daemon} (${sessionId})`
: sessionId;
el.innerHTML = `
<header class="attach-header">
<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-title" title="${esc(titleAttr)}">${titleHtml}</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>
@@ -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();
+119 -21
View File
@@ -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 = `<span class="chevron">${isCollapsed ? "▸" : "▾"}</span><span class="folder-name">${escapeHtml(node.name)}</span><span class="folder-count">${countLeaves(node)}</span>`;
// Single down-chevron; CSS rotates it -90° (→ right) when collapsed, so the
// glyph and the rotation never fight each other.
header.innerHTML = `<span class="chevron">▾</span><span class="folder-name">${escapeHtml(node.name)}</span><span class="folder-count">${countLeaves(node)}</span>`;
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 = `
<span class="leaf-status" aria-hidden="true"></span>
<span class="leaf-main">
<span class="leaf-name">${escapeHtml(name)}</span>
<span class="leaf-meta">
<span class="leaf-daemon">${escapeHtml(s.daemon_id)}</span>
<span class="leaf-cmd">${escapeHtml(s.command)}</span>
</span>
</span>
<span class="leaf-name">${escapeHtml(name)}</span>
${showCmd ? `<span class="leaf-meta"><span class="leaf-cmd">${escapeHtml(s.command)}</span></span>` : ""}
${running ? "" : `<span class="leaf-badge">exited</span>`}
<button class="leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
<span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Clone into a new session" aria-label="Clone ${escapeHtml(name)}">⧉</button>
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
</span>
`;
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 = `
<div class="empty-glyph" aria-hidden="true">+</div>
<div class="empty-title">No sessions yet</div>
<p class="empty-hint">Press <kbd>+ Spawn</kbd> to start your first session.</p>`;
<p class="empty-hint">Start your first session — it'll show up here.</p>
<div class="empty-actions"></div>`;
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 {
<span class="brand-dot" aria-hidden="true"></span>
<h1>maestro<span class="blink" aria-hidden="true">_</span></h1>
</a>
<div class="app-status"></div>
<div class="app-actions"></div>
`;
// 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<Session>).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();
},
};
+11 -5
View File
@@ -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);
}
+268
View File
@@ -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);
}
+272
View File
@@ -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 = `<span class="chevron">▾</span><span class="folder-name">${name}</span><span class="folder-count">${count}</span>`;
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 = `
<span class="leaf-name">${opts.name}</span>
${opts.cmd !== opts.name ? `<span class="leaf-meta"><span class="leaf-cmd">${opts.cmd}</span></span>` : ""}
${opts.status === "dead" ? `<span class="leaf-badge">exited</span>` : ""}
<span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Clone" aria-label="Clone ${opts.name}">⧉</button>
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${opts.name}">✕</button>
</span>`;
return row;
}
export function renderKit(): HTMLElement {
const page = h("div", "kit");
// --- header ---
const header = h("header", "kit-header");
header.appendChild(
h(
"div",
"kit-brand",
`<span class="brand-dot" aria-hidden="true"></span>
<h1>maestro<span class="blink" aria-hidden="true">_</span> · design kit</h1>`,
),
);
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 = `<option>thinkpad</option><option>tower</option><option>pi</option>`;
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 = `
<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 start a new one.</p>
<div class="empty-actions"></div>`;
welcome.querySelector(".empty-actions")!.appendChild(btn("btn btn-primary", "+ Spawn session"));
const noSessions = h("div", "empty");
noSessions.innerHTML = `
<div class="empty-glyph" aria-hidden="true">+</div>
<div class="empty-title">No sessions yet</div>
<p class="empty-hint">Start your first session — it'll show up here.</p>
<div class="empty-actions"></div>`;
noSessions.querySelector(".empty-actions")!.appendChild(btn("btn btn-primary", "+ Spawn session"));
const waiting = h("div", "empty connecting");
waiting.innerHTML = `
<div class="empty-glyph" aria-hidden="true">◴</div>
<div class="empty-title">Waiting for a daemon</div>
<p class="empty-hint">Start a <code>maestro daemon</code> on a host and it will appear here.</p>`;
body.append(welcome, noSessions, waiting);
main.appendChild(el);
}
return page;
}
+35 -7
View File
@@ -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<void> {
const store = new CatalogStore();
store.start();
@@ -46,9 +60,16 @@ function renderWelcome(): HTMLElement {
<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>`;
Pick a session from the list, or start a new one. Open sessions keep
streaming in the background — switching back is instant.
</p>
<div class="empty-actions"></div>`;
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
+18 -7
View File
@@ -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.
+38 -1
View File
@@ -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<string, DaemonInfo>): 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<string, DaemonInfo>,
prefill?: SpawnPrefill,
): void {
const existing = document.getElementById("modal-host");
if (existing) existing.remove();
@@ -98,6 +111,30 @@ export function openSpawnModal(daemons: Map<string, DaemonInfo>): 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.
+286 -120
View File
@@ -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;
}
}