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 = `
← - session ${sessionId.slice(0, 8)} + ${titleHtml} connecting…
@@ -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; } }