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
+7
View File
@@ -30,6 +30,10 @@ export interface AttachConnection {
export interface AttachOptions {
/** Invoked when the user clicks the view's explicit "disconnect" control. */
onRequestClose?: (sessionId: Uuid) => void;
/** Invoked when the socket closes on its own — the session ended or the link
* dropped — but NOT on an intentional `dispose()` (eviction / user close). The
* shell uses this to auto-close the pane rather than leave a dead one. */
onClosed?: (sessionId: Uuid) => void;
}
/** What the header shows about the session (name / command / host). Optional —
@@ -242,6 +246,9 @@ export function createAttachConnection(
ws.onclose = (ev) => {
setState(`closed (${ev.code})`, "closed");
// Only bubble up server/network closes — an intentional dispose has already
// set `disposed`, and eviction/user-close handle their own pane teardown.
if (!disposed) opts.onClosed?.(sessionId);
};
// PTY input: every keystroke goes out as a binary frame. A pending key-bar
+58 -15
View File
@@ -337,12 +337,24 @@ export function renderTopbar(store: CatalogStore): HTMLElement {
return header;
}
/** Two-tier sidebar highlight state (issue #33): which sessions are open in some
* pane (quiet), which one is focused (loud), and a stable per-session colour so
* a row maps to its pane. */
export interface SidebarHighlights {
open: Set<string>;
focused: string | null;
colors: Map<string, string>;
}
/** Live handle to the persistent sidebar (built once, survives navigation). */
export interface SidebarHandle {
/** The `<aside>` element to mount into the shell. */
el: HTMLElement;
/** Highlight the row for `id` (or clear it with `null`) as the open session. */
/** Highlight the row for `id` (or clear it with `null`) as the focused session.
* Back-compat shim over {@link setHighlights}. */
setActive(id: string | null): void;
/** Apply the full open/focused/colour highlight state. */
setHighlights(h: SidebarHighlights): void;
/** Tear down the store subscription + listeners. */
dispose(): void;
}
@@ -354,7 +366,13 @@ export interface SidebarHandle {
* while the content pane swaps between sessions — so the store subscription and
* scroll position survive navigation. The active session is highlighted.
*/
export function renderSidebar(store: CatalogStore): SidebarHandle {
export interface SidebarOptions {
/** Pointerdown on a session row — the multi-view controller decides drag vs
* tap (issue #32). Tap falls through to the row's normal click (open). */
onLeafDrag?: (sessionId: string, e: PointerEvent) => void;
}
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
const el = document.createElement("aside");
el.className = "sidebar";
@@ -378,6 +396,21 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
treeHost.className = "tree";
el.appendChild(treeHost);
// Drag a session row into the canvas (issue #32). Delegated so it survives
// tree re-renders; the controller handles drag-vs-tap (tap still navigates).
treeHost.addEventListener("pointerdown", (e) => {
if (e.button !== 0 || !opts.onLeafDrag) return;
const leaf = (e.target as HTMLElement).closest<HTMLElement>(".tree-leaf");
const id = leaf?.dataset.sessionId;
if (id) opts.onLeafDrag(id, e);
});
// The rows are <a> elements, so the browser's native link drag-and-drop would
// otherwise hijack pointermove and our live drop-zone preview wouldn't show
// until release. Suppress it so the pointer-based drag runs from the start.
treeHost.addEventListener("dragstart", (e) => {
if ((e.target as HTMLElement).closest(".tree-leaf")) e.preventDefault();
});
// 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";
@@ -425,16 +458,18 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
refresh();
});
let activeId: string | null = null;
const applyActive = () => {
for (const n of treeHost.querySelectorAll(".tree-leaf.is-active")) {
n.classList.remove("is-active");
}
if (activeId) {
const row = treeHost.querySelector(
`.tree-leaf[data-session-id="${CSS.escape(activeId)}"]`,
);
row?.classList.add("is-active");
let highlights: SidebarHighlights = { open: new Set(), focused: null, colors: new Map() };
const applyHighlights = () => {
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
const id = n.dataset.sessionId;
if (!id) continue;
const focused = highlights.focused === id;
const open = highlights.open.has(id);
n.classList.toggle("is-active", focused);
n.classList.toggle("is-open", open && !focused);
const color = highlights.colors.get(id);
if (color && (open || focused)) n.style.setProperty("--mv-color", color);
else n.style.removeProperty("--mv-color");
}
};
@@ -455,7 +490,7 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
if (tree.kind === "folder") {
for (const child of tree.children) treeHost.appendChild(renderNode(child, 0));
}
applyActive();
applyHighlights();
};
const rerenderHandler = () => refresh();
@@ -484,8 +519,16 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
return {
el,
setActive(id: string | null) {
activeId = id;
applyActive();
highlights = {
open: id ? new Set([id]) : new Set(),
focused: id,
colors: new Map(),
};
applyHighlights();
},
setHighlights(h: SidebarHighlights) {
highlights = h;
applyHighlights();
},
dispose() {
document.removeEventListener("catalog-rerender", rerenderHandler);
+7 -1
View File
@@ -21,6 +21,9 @@ export interface PoolOptions {
/** Fired when a view's explicit disconnect control is used; the embedder
* decides how to update the layout/route (then typically calls `close`). */
onRequestClose?: (id: Uuid) => void;
/** Fired when a connection's socket closes on its own (session ended/dropped),
* not via an intentional close/eviction. The embedder closes the pane. */
onClosed?: (id: Uuid) => void;
}
/**
@@ -76,7 +79,10 @@ export class ConnectionPool {
} else {
conn = createAttachConnection(
id,
{ onRequestClose: (sid) => this.opts.onRequestClose?.(sid) },
{
onRequestClose: (sid) => this.opts.onRequestClose?.(sid),
onClosed: (sid) => this.opts.onClosed?.(sid),
},
info,
);
this.cache.set(id, conn);
+97 -7
View File
@@ -1,15 +1,18 @@
// Multi-view layout model. The layout tree is the single source of truth for
// what's on screen: a set of tabs, each holding a binary split tree whose leaves
// are sessions. Desktop renders the tree as nested splits; mobile projects its
// leaves to tabs. (Splits/tabs UI land in later issues — see epic #37. This
// issue introduces the model and renders a single-leaf tree at parity.)
// hold a session (or nothing yet). Desktop renders the tree as nested splits;
// mobile (issue #35) projects its leaves to tabs.
//
// A session lives in at most one leaf at a time (the connection pool is
// one-socket-per-session), so a leaf's `sessionId` is unique across the whole
// layout — or null for a freshly-split, not-yet-filled pane.
import type { Uuid } from "./types.js";
export type Dir = "row" | "col";
export type Node =
| { kind: "leaf"; id: string; sessionId: Uuid }
| { kind: "leaf"; id: string; sessionId: Uuid | null }
| { kind: "split"; dir: Dir; a: Node; b: Node; ratio: number };
export type Leaf = Extract<Node, { kind: "leaf" }>;
@@ -19,7 +22,7 @@ export interface Tab {
title: string;
/** The split tree, or null for an empty tab. */
root: Node | null;
/** Which leaf has keyboard focus / drives the sidebar highlight. */
/** Which leaf has focus (drives keyboard input + the strong highlight). */
focusedLeafId: string | null;
}
@@ -29,8 +32,6 @@ export interface Layout {
}
let seq = 0;
/** Monotonic id for tabs and leaves (not persisted across reloads — restore
* assigns fresh ids). */
export function freshId(prefix = "n"): string {
return `${prefix}${++seq}`;
}
@@ -50,8 +51,97 @@ export function activeTab(layout: Layout): Tab {
return layout.tabs.find((t) => t.id === layout.activeTabId) ?? layout.tabs[0];
}
/** Distinct sessions shown in any pane of any tab. */
export function openSessions(layout: Layout): Set<Uuid> {
const out = new Set<Uuid>();
for (const t of layout.tabs) {
for (const l of leaves(t.root)) if (l.sessionId) out.add(l.sessionId);
}
return out;
}
/** Find the tab + leaf currently showing `sessionId`, if any. */
export function locateSession(
layout: Layout,
sessionId: Uuid,
): { tab: Tab; leaf: Leaf } | null {
for (const tab of layout.tabs) {
const leaf = leaves(tab.root).find((l) => l.sessionId === sessionId);
if (leaf) return { tab, leaf };
}
return null;
}
/** A layout with one empty tab. */
export function emptyLayout(): Layout {
const tab: Tab = { id: freshId("t"), title: "Tab 1", root: null, focusedLeafId: null };
return { tabs: [tab], activeTabId: tab.id };
}
// ---- mutations (return the affected leaf id where useful) ----
/** Replace `targetId` with a split of [target, fresh] (or reversed when
* `after` is false). Returns the new leaf's id. */
export function splitLeaf(
tab: Tab,
targetId: string,
sessionId: Uuid | null,
dir: Dir,
after: boolean,
): string {
const fresh: Leaf = { kind: "leaf", id: freshId("l"), sessionId };
const rec = (n: Node): Node => {
if (n.kind === "leaf") {
if (n.id !== targetId) return n;
return { kind: "split", dir, ratio: 0.5, a: after ? n : fresh, b: after ? fresh : n };
}
n.a = rec(n.a);
n.b = rec(n.b);
return n;
};
tab.root = tab.root ? rec(tab.root) : fresh;
return fresh.id;
}
/** Remove a leaf, collapsing its parent split into the sibling. */
export function removeLeaf(tab: Tab, leafId: string): void {
const rec = (n: Node): Node | null => {
if (n.kind === "leaf") return n.id === leafId ? null : n;
const a = rec(n.a);
const b = rec(n.b);
if (!a) return b;
if (!b) return a;
n.a = a;
n.b = b;
return n;
};
tab.root = tab.root ? rec(tab.root) : null;
}
export function addTab(layout: Layout): Tab {
const tab: Tab = {
id: freshId("t"),
title: `Tab ${layout.tabs.length + 1}`,
root: null,
focusedLeafId: null,
};
layout.tabs.push(tab);
layout.activeTabId = tab.id;
return tab;
}
export function closeTab(layout: Layout, tabId: string): void {
if (layout.tabs.length <= 1) {
// Keep at least one tab; just empty it.
const t = layout.tabs[0];
t.root = null;
t.focusedLeafId = null;
return;
}
const i = layout.tabs.findIndex((t) => t.id === tabId);
if (i < 0) return;
layout.tabs.splice(i, 1);
if (layout.activeTabId === tabId) {
layout.activeTabId = layout.tabs[Math.max(0, i - 1)].id;
}
}
+54 -55
View File
@@ -3,15 +3,8 @@ import { ConnectionPool } from "./connections.js";
import { installHudKeybind } from "./debug.js";
import { CatalogStore } from "./events.js";
import { mountHud, type Hud } from "./hud.js";
import {
activeTab,
emptyLayout,
findLeaf,
freshId,
leaves,
type Layout,
type Node,
} from "./layout.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
import { openSpawnModal } from "./spawn.js";
import { initTheme } from "./theme.js";
import { sessionName } from "./types.js";
@@ -67,7 +60,13 @@ const shellEl = document.createElement("div");
shellEl.className = "app-shell";
shellEl.dataset.route = "catalog";
const sidebar = renderSidebar(store);
// Forward-declared so the sidebar's drag hook can reach the controller (created
// after the welcome renderer). The hook only fires on user interaction, by which
// point `mv` is assigned.
let mv: MultiView;
const sidebar = renderSidebar(store, {
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
});
const content = document.createElement("section");
content.className = "content";
@@ -79,27 +78,35 @@ root.replaceChildren(renderTopbar(store), shellEl);
// It can mount several panes at once — the layout tree decides how many. For now
// the tree only ever holds a single leaf (splits/tabs arrive in epic #37); this
// routes the existing single-session view through that model.
// `mv` is forward-declared above (the sidebar drag hook needs it); the pool's
// disconnect callback below also references it.
let hud: Hud | undefined;
const pool = new ConnectionPool({
onFocusChange: (id) => {
sidebar.setActive(id);
onFocusChange: () => {
// Sidebar highlighting is driven by the multi-view controller; the pool only
// nudges the HUD here.
hud?.refresh();
},
onRequestClose: (id) => {
pool.close(id);
// Drop back to the welcome pane via the router.
if (location.hash !== "#/" && location.hash !== "") location.hash = "#/";
},
// Both the explicit disconnect (⏻) and a session ending on its own close the
// pane — drop its leaf (collapsing the split) and tear down the socket. A dead
// pane is never left lying around.
onRequestClose: (id) => closePane(id),
onClosed: (id) => closePane(id),
});
function closePane(id: string): void {
pool.close(id);
mv?.closeSession(id);
updateShellRoute();
}
// Debug HUD (#20): a fixed-corner overlay on the active session's inferred
// fields, off by default (Ctrl+Shift+D, or `?hud` in dev).
hud = mountHud(root, store, () => pool.focused());
// ---- layout state ----
// Single source of truth for what's mounted. Today it's derived from the hash
// route (one leaf per `#/attach/:id`); future issues let the user build splits
// and tabs on top of the same model.
// Single source of truth for what's mounted: tabs of split trees. The hash route
// is just a deep-link into it (`#/attach/:id` focuses/opens a session).
const layout: Layout = emptyLayout();
function infoFor(sessionId: string) {
@@ -107,32 +114,10 @@ function infoFor(sessionId: string) {
return s ? { name: sessionName(s), command: s.command, daemon: s.daemon_id } : undefined;
}
/** Show a single session in the active tab. */
function openInLayout(sessionId: string): void {
const tab = activeTab(layout);
const leaf: Node = { kind: "leaf", id: freshId("l"), sessionId };
tab.root = leaf;
tab.focusedLeafId = leaf.id;
}
/** Empty the active tab (welcome / not-found). */
function clearLayout(): void {
const tab = activeTab(layout);
tab.root = null;
tab.focusedLeafId = null;
}
/** Reconcile the pool's visible panes + focus with the active tab's tree. */
function renderLayout(): void {
const tab = activeTab(layout);
if (!tab.root) {
pool.parkActive();
return;
}
const ls = leaves(tab.root);
pool.setVisible(ls.map((l) => ({ id: l.sessionId, pane: content, info: infoFor(l.sessionId) })));
const focusLeaf = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : ls[0];
pool.setFocus(focusLeaf?.sessionId ?? null);
/** Drive the shell's responsive layout off whether any pane is open. */
function updateShellRoute(): void {
const hasPanes = openSessions(layout).size > 0;
shellEl.dataset.route = hasPanes ? "attach" : "catalog";
}
function renderWelcome(): HTMLElement {
@@ -168,6 +153,19 @@ function renderNotFound(raw: string): HTMLElement {
return el;
}
// The multi-view controller renders tabs + splits inside the content pane and
// mounts a live attach into each session leaf.
mv = createMultiView({ pool, layout, sidebar, infoFor, onDisconnect: closePane, renderWelcome });
let contentShowsMv = false;
function showMultiView(): void {
if (!contentShowsMv) {
content.replaceChildren(mv.el);
contentShowsMv = true;
}
mv.render();
updateShellRoute();
}
type Route =
| { kind: "catalog" }
| { kind: "attach"; sessionId: string }
@@ -185,21 +183,22 @@ function dispatch(): void {
const route = parseRoute();
switch (route.kind) {
case "attach":
shellEl.dataset.route = "attach";
openInLayout(route.sessionId);
renderLayout();
// Deep-link: open/focus the session, then show the layout.
showMultiView();
mv.openSession(route.sessionId);
updateShellRoute();
return;
case "not-found":
shellEl.dataset.route = "notfound";
clearLayout();
renderLayout();
pool.setVisible([]);
pool.setFocus(null);
content.replaceChildren(renderNotFound(route.raw));
contentShowsMv = false;
return;
case "catalog":
shellEl.dataset.route = "catalog";
clearLayout();
renderLayout();
content.replaceChildren(renderWelcome());
// Show the current layout (welcome if empty) — navigating to #/ no longer
// tears panes down.
showMultiView();
return;
}
}
+536
View File
@@ -0,0 +1,536 @@
// The multi-view renderer/controller (issues #30 splits, #31 tabs, #33
// highlights). Owns the tab bar + canvas inside the content pane, builds the
// active tab's split tree into DOM, and mounts a live AttachConnection into each
// session leaf via the pool. Empty leaves (freshly split, not yet filled) show a
// placeholder. Drag-to-dock is a later issue (#32); mobile projection is #35.
import type { SidebarHandle } from "./catalog.js";
import type { ConnectionPool } from "./connections.js";
import {
activeTab,
addTab,
closeTab,
findLeaf,
type Dir,
type Layout,
leaves,
locateSession,
type Node,
openSessions,
removeLeaf,
splitLeaf,
} from "./layout.js";
import { sessionColor } from "./sessioncolor.js";
import type { AttachInfo } from "./attach.js";
export interface MultiViewOptions {
pool: ConnectionPool;
layout: Layout;
sidebar: SidebarHandle;
/** Header text for a session's pane / attach view. */
infoFor: (sessionId: string) => AttachInfo | undefined;
/** Disconnect a session (the ⏻ tool): tear down its socket and close the pane. */
onDisconnect: (sessionId: string) => void;
/** Welcome content for an empty tab. */
renderWelcome: () => HTMLElement;
}
export interface MultiView {
el: HTMLElement;
/** Bring a session into the layout: focus it if already open, else fill the
* focused pane (or open the first pane in an empty tab). */
openSession: (sessionId: string) => void;
/** Drop the pane showing `sessionId` (the session was disconnected/killed). */
closeSession: (sessionId: string) => void;
/** Start dragging a sidebar session into the canvas (issue #32). The
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
* drag shows drop zones to place it. */
beginSessionDrag: (sessionId: string, e: PointerEvent) => void;
/** Rebuild the view from the current layout. */
render: () => void;
focusedSessionId: () => string | null;
}
export function createMultiView(opts: MultiViewOptions): MultiView {
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
const el = document.createElement("div");
el.className = "mv";
const tabBar = document.createElement("div");
tabBar.className = "mv-tabs";
const canvas = document.createElement("div");
canvas.className = "mv-canvas";
el.append(tabBar, canvas);
// ---- highlight helpers ----
function colorMap(): Map<string, string> {
const m = new Map<string, string>();
for (const id of openSessions(layout)) m.set(id, sessionColor(id));
return m;
}
function focusedLeafSession(): string | null {
const tab = activeTab(layout);
const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null;
return fl?.sessionId ?? null;
}
/** Update focus styling + pool focus + sidebar highlights without rebuilding
* the DOM (cheap path for click-to-focus). */
function syncFocus(): void {
const tab = activeTab(layout);
for (const pane of canvas.querySelectorAll<HTMLElement>(".mv-pane")) {
pane.classList.toggle("is-focused", pane.dataset.leafId === tab.focusedLeafId);
}
pool.setFocus(focusedLeafSession());
sidebar.setHighlights({
open: openSessions(layout),
focused: focusedLeafSession(),
colors: colorMap(),
});
}
function focusLeaf(leafId: string): void {
const tab = activeTab(layout);
if (tab.focusedLeafId === leafId) return;
tab.focusedLeafId = leafId;
syncFocus();
}
// ---- pane + split DOM ----
function buildPane(leaf: Extract<Node, { kind: "leaf" }>): {
el: HTMLElement;
host: HTMLElement;
sessionId: string | null;
} {
const pane = document.createElement("div");
pane.className = "mv-pane";
pane.dataset.leafId = leaf.id;
if (leaf.sessionId) {
pane.dataset.sessionId = leaf.sessionId;
pane.style.setProperty("--mv-color", sessionColor(leaf.sessionId));
}
const host = document.createElement("div");
host.className = "mv-pane-host";
if (!leaf.sessionId) {
host.innerHTML = `<div class="mv-pane-empty">Empty pane — pick a session from the sidebar.</div>`;
}
const tools = document.createElement("div");
tools.className = "mv-pane-tools";
const mkBtn = (label: string, title: string, fn: () => void) => {
const b = document.createElement("button");
b.type = "button";
b.className = "mv-tool";
b.title = title;
b.textContent = label;
b.addEventListener("pointerdown", (e) => e.stopPropagation());
b.addEventListener("click", (e) => {
e.stopPropagation();
fn();
});
return b;
};
// Order: disconnect (⏻) leftmost, then split-right / split-down / close.
if (leaf.sessionId) {
const off = mkBtn("⏻", "Disconnect", () => onDisconnect(leaf.sessionId!));
off.classList.add("mv-tool-off");
tools.appendChild(off);
}
tools.append(
mkBtn("▥", "Split right", () => splitFocused(leaf.id, "row")),
mkBtn("▤", "Split down", () => splitFocused(leaf.id, "col")),
mkBtn("✕", "Close pane", () => closePane(leaf.id)),
);
pane.append(host, tools);
pane.addEventListener(
"pointerdown",
(e) => {
focusLeaf(leaf.id);
// Drag a pane by its title bar (not its buttons or the terminal body).
const t = e.target as HTMLElement;
if (t.closest(".attach-header") && !t.closest("button, a, .mv-tool")) {
runDrag({ kind: "pane", leafId: leaf.id }, e, { deferred: true });
}
},
true,
);
return { el: pane, host, sessionId: leaf.sessionId };
}
function wireDivider(
divider: HTMLElement,
split: Extract<Node, { kind: "split" }>,
slotA: HTMLElement,
slotB: HTMLElement,
container: HTMLElement,
): void {
divider.addEventListener("pointerdown", (e) => {
e.preventDefault();
e.stopPropagation();
divider.classList.add("is-dragging");
divider.setPointerCapture(e.pointerId);
const rect = container.getBoundingClientRect();
const move = (ev: PointerEvent) => {
const frac =
split.dir === "row"
? (ev.clientX - rect.left) / rect.width
: (ev.clientY - rect.top) / rect.height;
split.ratio = Math.max(0.1, Math.min(0.9, frac));
slotA.style.flexGrow = String(split.ratio);
slotB.style.flexGrow = String(1 - split.ratio);
};
const up = (ev: PointerEvent) => {
divider.classList.remove("is-dragging");
try {
divider.releasePointerCapture(ev.pointerId);
} catch {
/* already released */
}
divider.removeEventListener("pointermove", move);
divider.removeEventListener("pointerup", up);
};
divider.addEventListener("pointermove", move);
divider.addEventListener("pointerup", up);
});
}
/** Build the tree DOM, collecting each session leaf's mount host. */
function buildTree(node: Node, hosts: { id: string; host: HTMLElement; info?: AttachInfo }[]): HTMLElement {
if (node.kind === "leaf") {
const pane = buildPane(node);
if (node.sessionId) {
hosts.push({ id: node.sessionId, host: pane.host, info: infoFor(node.sessionId) });
}
return pane.el;
}
const container = document.createElement("div");
container.className = `mv-split ${node.dir}`;
const slotA = document.createElement("div");
slotA.className = "mv-slot";
slotA.style.cssText = `flex:${node.ratio} 1 0`;
const divider = document.createElement("div");
divider.className = "mv-divider";
const slotB = document.createElement("div");
slotB.className = "mv-slot";
slotB.style.cssText = `flex:${1 - node.ratio} 1 0`;
slotA.appendChild(buildTree(node.a, hosts));
slotB.appendChild(buildTree(node.b, hosts));
container.append(slotA, divider, slotB);
wireDivider(divider, node, slotA, slotB, container);
return container;
}
// ---- tab bar ----
function renderTabs(): void {
tabBar.replaceChildren();
for (const t of layout.tabs) {
const tab = document.createElement("button");
tab.type = "button";
tab.className = "mv-tab" + (t.id === layout.activeTabId ? " is-active" : "");
const n = leaves(t.root).filter((l) => l.sessionId).length;
const label = document.createElement("span");
label.textContent = t.title + (n ? ` · ${n}` : "");
tab.appendChild(label);
tab.addEventListener("click", () => {
if (layout.activeTabId !== t.id) {
layout.activeTabId = t.id;
render();
}
});
if (layout.tabs.length > 1) {
const close = document.createElement("span");
close.className = "mv-tab-close";
close.textContent = "✕";
close.title = "Close tab";
close.addEventListener("click", (e) => {
e.stopPropagation();
closeTab(layout, t.id);
render();
});
tab.appendChild(close);
}
tabBar.appendChild(tab);
}
const add = document.createElement("button");
add.type = "button";
add.className = "mv-tab-add";
add.textContent = "+";
add.title = "New tab";
add.addEventListener("click", () => {
addTab(layout);
render();
});
tabBar.appendChild(add);
}
// ---- mutations ----
function splitFocused(leafId: string, dir: Dir): void {
const tab = activeTab(layout);
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
render();
}
function closePane(leafId: string): void {
const tab = activeTab(layout);
removeLeaf(tab, leafId);
if (tab.focusedLeafId === leafId) {
tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null;
}
render();
}
function openSession(sessionId: string): void {
const loc = locateSession(layout, sessionId);
if (loc) {
layout.activeTabId = loc.tab.id;
loc.tab.focusedLeafId = loc.leaf.id;
render();
return;
}
const tab = activeTab(layout);
if (!tab.root) {
const id = splitLeaf(tab, "", sessionId, "row", true); // root from empty
tab.focusedLeafId = id;
render();
return;
}
// Fill the focused pane (VS Code style: open replaces the active group).
const target = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || leaves(tab.root)[0];
target.sessionId = sessionId;
tab.focusedLeafId = target.id;
render();
}
// ---- top-level render ----
function render(): void {
renderTabs();
canvas.replaceChildren();
const tab = activeTab(layout);
if (!tab.root) {
pool.setVisible([]);
pool.setFocus(null);
canvas.appendChild(renderWelcome());
sidebar.setHighlights({ open: openSessions(layout), focused: null, colors: colorMap() });
return;
}
// Keep focus pointing at a real leaf.
if (!tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null;
}
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
canvas.appendChild(buildTree(tab.root, hosts));
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
syncFocus();
}
function closeSession(sessionId: string): void {
const loc = locateSession(layout, sessionId);
if (!loc) return;
removeLeaf(loc.tab, loc.leaf.id);
if (loc.tab.focusedLeafId === loc.leaf.id) {
loc.tab.focusedLeafId = leaves(loc.tab.root)[0]?.id ?? null;
}
render();
}
// ---- drag-to-dock (issue #32) ----
// Drag a pane's title bar, or a session from the sidebar, onto another pane:
// edge zones split that pane, centre swaps (pane source) or replaces (session
// source). Sidebar drags move the session if it's already open elsewhere.
type Zone = "left" | "right" | "top" | "bottom" | "center";
type DragSource = { kind: "pane"; leafId: string } | { kind: "session"; sessionId: string };
let drag: {
source: DragSource;
ghost: HTMLElement;
overlay: HTMLElement | null;
targetLeafId: string | null;
zone: Zone | null;
} | null = null;
function dragSessionId(src: DragSource): string | null {
if (src.kind === "session") return src.sessionId;
return findLeaf(activeTab(layout).root, src.leafId)?.sessionId ?? null;
}
/** The leaf in the ACTIVE tab the source occupies (skip it as a drop target). */
function dragSelfLeaf(src: DragSource): string | null {
if (src.kind === "pane") return src.leafId;
const loc = locateSession(layout, src.sessionId);
return loc && loc.tab.id === layout.activeTabId ? loc.leaf.id : null;
}
function dragLabel(src: DragSource): string {
const sid = dragSessionId(src);
if (!sid) return "empty pane";
return infoFor(sid)?.name ?? sid;
}
function makeGhost(src: DragSource): HTMLElement {
const g = document.createElement("div");
g.className = "mv-drag-ghost";
g.textContent = `⠿ ${dragLabel(src)}`;
document.body.appendChild(g);
return g;
}
function runDrag(src: DragSource, e: PointerEvent, opts: { deferred: boolean }): void {
const sx = e.clientX;
const sy = e.clientY;
let started = !opts.deferred;
if (started) drag = { source: src, ghost: makeGhost(src), overlay: null, targetLeafId: null, zone: null };
const place = (ev: PointerEvent) => {
if (drag) {
drag.ghost.style.left = `${ev.clientX + 12}px`;
drag.ghost.style.top = `${ev.clientY + 12}px`;
}
};
if (started) place(e);
const move = (ev: PointerEvent) => {
if (!started) {
if (Math.hypot(ev.clientX - sx, ev.clientY - sy) < 5) return;
started = true;
document.body.style.userSelect = "none";
drag = { source: src, ghost: makeGhost(src), overlay: null, targetLeafId: null, zone: null };
}
place(ev);
updateDock(ev);
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
if (!started) return;
document.body.style.userSelect = "";
suppressNextClick();
finishDrag();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}
/** Eat the click that follows a real drag (so a dragged sidebar row doesn't
* also navigate, and a dragged pane title doesn't trigger anything). */
function suppressNextClick(): void {
const kill = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
window.removeEventListener("click", kill, true);
};
window.addEventListener("click", kill, true);
}
function paneUnder(x: number, y: number): HTMLElement | null {
for (const node of document.elementsFromPoint(x, y)) {
const pane = (node as HTMLElement).closest?.(".mv-pane") as HTMLElement | null;
if (pane && canvas.contains(pane)) return pane;
}
return null;
}
function clearOverlay(): void {
drag?.overlay?.remove();
if (drag) drag.overlay = null;
}
function updateDock(ev: PointerEvent): void {
if (!drag) return;
clearOverlay();
const pane = paneUnder(ev.clientX, ev.clientY);
const self = dragSelfLeaf(drag.source);
if (!pane || pane.dataset.leafId === self) {
drag.targetLeafId = null;
drag.zone = null;
return;
}
const r = pane.getBoundingClientRect();
const fx = (ev.clientX - r.left) / r.width;
const fy = (ev.clientY - r.top) / r.height;
const edges = { left: fx, right: 1 - fx, top: fy, bottom: 1 - fy };
const nearest = (Object.keys(edges) as (keyof typeof edges)[]).reduce((m, k) =>
edges[k] < edges[m] ? k : m,
);
const zone: Zone = edges[nearest] < 0.28 ? nearest : "center";
drag.targetLeafId = pane.dataset.leafId!;
drag.zone = zone;
const ov = document.createElement("div");
ov.className = "mv-dock-overlay";
const hot = document.createElement("div");
hot.className = "mv-dock-zone";
const css: Record<Zone, string> = {
left: "left:0;top:0;width:50%;height:100%",
right: "left:50%;top:0;width:50%;height:100%",
top: "left:0;top:0;width:100%;height:50%",
bottom: "left:0;top:50%;width:100%;height:50%",
center: "inset:14%",
};
hot.style.cssText = "position:absolute;" + css[zone];
ov.appendChild(hot);
pane.appendChild(ov);
drag.overlay = ov;
}
function finishDrag(): void {
if (!drag) return;
const { source, targetLeafId, zone, ghost } = drag;
ghost.remove();
clearOverlay();
drag = null;
if (!targetLeafId || !zone) return;
const tab = activeTab(layout);
const self = dragSelfLeaf(source);
if (targetLeafId === self) return; // dropped on its own pane
const target = findLeaf(tab.root, targetLeafId);
if (!target) return;
const sessionId = dragSessionId(source);
if (zone === "center") {
if (source.kind === "pane" && self) {
const dragged = findLeaf(tab.root, self)!;
[dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId];
tab.focusedLeafId = target.id;
} else if (sessionId) {
const loc = locateSession(layout, sessionId);
if (loc) removeLeaf(loc.tab, loc.leaf.id);
const t2 = findLeaf(tab.root, targetLeafId);
if (t2) {
t2.sessionId = sessionId;
tab.focusedLeafId = t2.id;
}
}
render();
return;
}
// edge zone → split the target, placing the source beside it
const dir: Dir = zone === "left" || zone === "right" ? "row" : "col";
const after = zone === "right" || zone === "bottom";
const loc = sessionId ? locateSession(layout, sessionId) : null;
if (source.kind === "pane" && self) removeLeaf(tab, self);
else if (loc) removeLeaf(loc.tab, loc.leaf.id);
tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after);
render();
}
function beginSessionDrag(sessionId: string, e: PointerEvent): void {
runDrag({ kind: "session", sessionId }, e, { deferred: true });
}
return {
el,
openSession,
closeSession,
beginSessionDrag,
render,
focusedSessionId: focusedLeafSession,
};
}
+23
View File
@@ -0,0 +1,23 @@
// Stable per-session accent colour, used to map a sidebar row to the pane
// showing that session (issue #33). Hash the session id into a fixed OKLCH
// palette tuned to the maestro tokens; collisions are tolerated when there are
// more open sessions than hues — the colour is a hint, not an identity.
import type { Uuid } from "./types.js";
const PALETTE = [
"oklch(0.80 0.13 152)", // green (the house accent)
"oklch(0.80 0.12 90)", // amber
"oklch(0.76 0.13 30)", // coral
"oklch(0.78 0.11 210)", // teal
"oklch(0.74 0.13 265)", // blue
"oklch(0.76 0.14 320)", // magenta
"oklch(0.80 0.11 60)", // gold
"oklch(0.78 0.12 185)", // cyan
];
export function sessionColor(id: Uuid): string {
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
return PALETTE[h % PALETTE.length];
}
+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));
}