feat(maestro): multi-view web UI — splits, tabs, drag-dock, highlights
ci / rust (push) Successful in 5m46s

Render the content pane as tabs of nested split trees, each session leaf a live
attach mounted through the multi-mount pool:
- #30 arbitrarily-nested H/V splits with mouse-resizable dividers; per-pane
  ⏻/▥/▤/✕ tools; closing a pane collapses its parent split.
- #31 tabs, each holding its own tree; add / switch / close.
- #32 drag-to-dock: drag a pane title or a sidebar session onto a pane for a
  5-zone dock (edges split, centre swaps/replaces); a sidebar drag moves an
  already-open session.
- #33 focused pane at full strength, others dim; sidebar shows focused (loud)
  vs open-elsewhere (quiet) with a stable per-session colour mapping rows to
  panes.

Opening a session fills the focused pane (single-pane parity by default);
multi-pane is opt-in via split / drag / tabs. Panes auto-close when their
session ends (guarded against eviction and intentional close). The steady
"connected" label is hidden as noise.

Closes #30, #31, #32, #33. Part of epic #37.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 09:20:57 +00:00
co-authored by Claude Opus 4.8
parent 5a7e3c1b09
commit 159b7ecd6e
8 changed files with 1100 additions and 78 deletions
+318
View File
@@ -1899,3 +1899,321 @@ textarea {
color: var(--accent);
border-color: var(--accent);
}
/* =========================================================================
multi-view (#30 splits, #31 tabs, #33 highlights)
Tabs of nested split trees; each session leaf hosts a live .attach. The
focused pane reads at full strength, the rest recede — and the sidebar row
for the focused session matches it (loud), open-elsewhere rows stay quiet.
========================================================================= */
.mv {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* tab bar */
.mv-tabs {
display: flex;
align-items: stretch;
gap: 2px;
height: var(--bar-h);
padding: 0 var(--sp-2);
background: var(--surface);
border-bottom: 1px solid var(--border);
overflow-x: auto;
scrollbar-width: none;
flex: none;
}
.mv-tab {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
padding: 0 var(--sp-3);
align-self: center;
height: calc(var(--bar-h) - 8px);
border-radius: var(--r-1);
font: 500 var(--fs-12) / 1 var(--font-mono);
color: var(--muted);
background: transparent;
border: 1px solid transparent;
cursor: pointer;
white-space: nowrap;
}
.mv-tab:hover {
background: var(--surface-2);
color: var(--ink);
}
.mv-tab.is-active {
background: var(--surface-3);
color: var(--ink);
border-color: var(--border);
}
.mv-tab-close {
opacity: 0.5;
font-size: var(--fs-13);
line-height: 1;
}
.mv-tab-close:hover {
opacity: 1;
color: var(--danger);
}
.mv-tab-add {
align-self: center;
width: 24px;
height: 24px;
border-radius: var(--r-1);
color: var(--muted);
font-size: var(--fs-15);
background: transparent;
border: none;
cursor: pointer;
}
.mv-tab-add:hover {
background: var(--surface-2);
color: var(--ink);
}
/* canvas + welcome */
.mv-canvas {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
background: var(--bg);
}
.mv-canvas > .mv-split,
.mv-canvas > .mv-pane {
position: absolute;
inset: var(--sp-2);
}
.mv-canvas > .content-welcome {
position: absolute;
inset: 0;
margin: auto;
height: max-content;
}
/* splits + dividers */
.mv-split {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.mv-split.row {
flex-direction: row;
}
.mv-split.col {
flex-direction: column;
}
.mv-split > .mv-slot {
min-width: 0;
min-height: 0;
display: flex;
}
.mv-split > .mv-slot > * {
flex: 1;
min-width: 0;
min-height: 0;
}
.mv-divider {
flex: none;
position: relative;
z-index: 2;
}
.mv-split.row > .mv-divider {
width: 6px;
cursor: col-resize;
}
.mv-split.col > .mv-divider {
height: 6px;
cursor: row-resize;
}
.mv-divider::after {
content: "";
position: absolute;
inset: 0;
margin: auto;
background: var(--border);
}
.mv-split.row > .mv-divider::after {
width: 1px;
height: 100%;
}
.mv-split.col > .mv-divider::after {
height: 1px;
width: 100%;
}
.mv-divider:hover::after,
.mv-divider.is-dragging::after {
background: var(--accent);
}
/* a single pane = wrapper around a live .attach (or an empty placeholder) */
.mv-pane {
position: relative;
display: flex;
min-width: 0;
min-height: 0;
border: 1px solid var(--border);
border-radius: var(--r-2);
overflow: hidden;
background: var(--term-bg);
}
.mv-pane-host {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
}
.mv-pane-host > .attach {
flex: 1;
min-width: 0;
min-height: 0;
}
/* per-session colour strip — maps a pane to its sidebar row */
.mv-pane::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 2px;
background: var(--mv-color, transparent);
z-index: 3;
pointer-events: none;
}
/* focused pane: accent ring + glow, full strength */
.mv-pane.is-focused {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent), 0 0 22px -8px var(--accent-ring);
}
/* unfocused panes recede so the eye finds the live one */
.mv-pane:not(.is-focused) {
border-color: color-mix(in oklab, var(--border) 70%, transparent);
}
.mv-pane:not(.is-focused) .attach-body {
opacity: 0.45;
}
.mv-pane:not(.is-focused) .attach-header {
opacity: 0.7;
}
/* per-pane back button is meaningless in multi-view (use Close pane instead) */
.mv-pane .attach-header .back {
display: none;
}
.mv-pane-empty {
flex: 1;
display: grid;
place-items: center;
padding: var(--sp-4);
color: var(--faint);
font: var(--fs-12) / 1.5 var(--font-mono);
text-align: center;
}
/* pane tools cluster (disconnect / split / close), top-right corner. The attach
header's own disconnect button is hidden — ⏻ lives here as the leftmost tool. */
.mv-pane-tools {
position: absolute;
top: 6px;
right: 4px;
z-index: 4;
display: flex;
gap: 2px;
opacity: 0;
pointer-events: none;
transition: opacity var(--dur-fast) var(--ease-out);
}
.mv-pane .attach-header .disconnect {
display: none;
}
.mv-tool-off:hover {
background: var(--danger-tint);
color: var(--danger);
}
.mv-pane:hover .mv-pane-tools {
opacity: 1;
pointer-events: auto;
}
/* The tools share the header's right corner with the connection-state label, so
hide that label while hovering (tools showing) — no overlap. */
.mv-pane:hover .attach-state {
visibility: hidden;
}
/* "connected" is noise — a streaming terminal already shows it's connected. Only
the transient/meaningful states surface (connecting/error); a closed session
auto-closes its pane, so "closed" is rarely seen. */
.attach-state[data-state="open"] {
display: none;
}
.mv-tool {
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: var(--r-1);
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--muted);
font-size: var(--fs-12);
cursor: pointer;
}
.mv-tool:hover {
background: var(--surface-3);
color: var(--ink);
}
/* drag-to-dock (issue #32): a cursor-following ghost + the target pane's drop
zone highlight. */
.mv-drag-ghost {
position: fixed;
z-index: var(--z-toast);
pointer-events: none;
padding: 2px 10px;
border-radius: var(--r-2);
background: var(--surface-3);
border: 1px solid var(--accent);
color: var(--ink);
font: 600 var(--fs-12) / 1.4 var(--font-mono);
box-shadow: var(--shadow-2);
}
.mv-dock-overlay {
position: absolute;
inset: 0;
z-index: 5;
pointer-events: none;
}
.mv-dock-zone {
background: color-mix(in oklab, var(--accent) 30%, transparent);
border: 1px solid var(--accent);
border-radius: var(--r-1);
}
/* sidebar two-tier highlight + colour dot (issue #33).
.is-active = focused pane (loud, from existing rule); .is-open = open
elsewhere (quiet). The dot uses the session's colour so a row maps to a pane. */
.tree-leaf.is-open {
background: color-mix(in oklab, var(--accent) 6%, transparent);
}
.tree-leaf.is-open::after,
.tree-leaf.is-active::after {
content: "";
position: absolute;
right: 9px;
top: 50%;
width: 7px;
height: 7px;
margin-top: -3.5px;
border-radius: 50%;
background: var(--mv-color, var(--accent));
pointer-events: none;
}
.tree-leaf.is-open::after {
opacity: 0.7;
}
.tree-leaf.is-active::after {
box-shadow: 0 0 8px var(--mv-color, var(--accent-ring));
}