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:
+83
-3
@@ -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, ">")
|
||||
.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
|
||||
? `<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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user