This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/multiview.ts
T
Miguel Palhas 6f8de8de7c fix(maestro): draft-pane regressions from the integration review
Six findings from reviewing the accumulated blitz/spawn-v2 diff:

- loadRecents restored the seeded cwd but never emitted it, so the leaf's
  draft kept cwd:"" and the next render reseeded the form blank.
- stripDrafts did not recurse, so every half-filled draft in a split tab
  was written to localStorage; the old test passed only because hydrate
  discards draft on the way back in.
- showRawCwd and selectPreset grabbed focus from async init paths, so a
  draft pane stole the caret seconds after mounting.
- .mv-draft inherited no layout once the form left .modal-body.
- onSuccess mounted the new session onto a leaf that may have been closed
  while the POST was in flight, stranding it in no pane.
2026-08-23 20:12:28 +01:00

649 lines
22 KiB
TypeScript

// 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 are draft panes (#57): they render
// the spawn form inline, and submitting replaces the draft with that session's
// terminal in place. Drag-to-dock is 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";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.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;
/** Called when a draft-pane submit turns an empty pane into a session pane,
* so the shell can re-evaluate its route (sidebar vs attach layout). */
onOpenSessionsChange?: () => void;
/** Called after any layout change (structure, focus, or divider resize) so the
* shell can persist it (issue #34). */
onLayoutChange?: () => void;
}
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;
/** Every session currently shown in some pane, across all tabs. */
openSessions: () => Set<string>;
/** 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 persist = () => opts.onLayoutChange?.();
// Draft-pane forms (#57) carry global listeners (dir autocomplete), so each
// render tears down the previous generation before rebuilding the canvas.
const draftForms = new Set<SpawnFormHandle>();
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);
// Mobile (issue #35): below 860px the tree projects to a one-pane-at-a-time
// strip of pills — splits/drag/resize are desktop-only. The tree is untouched,
// so widening restores the splits.
const mq = window.matchMedia("(max-width: 859px)");
let isMobile = mq.matches;
mq.addEventListener("change", (e) => {
isMobile = e.matches;
render();
});
// ---- 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();
persist();
}
// ---- 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) {
// Draft pane (#57): an empty pane is a spawn form. Values hang off the
// leaf (never persisted) so each draft keeps its own half-filled state;
// submitting fills this leaf with the new session in place.
const form = createSpawnForm({
seed: leaf.draft ?? undefined,
class: "mv-draft",
onChange: (values) => {
leaf.draft = values;
},
onSuccess: (session) => {
// The pane can be closed while the POST is in flight. Mounting the
// session onto a leaf no longer in the tree would strand it in no
// pane at all, so only claim the leaf while it is still there.
const t = activeTab(layout);
const attached = leaves(t.root).includes(leaf);
if (attached) {
leaf.sessionId = session.id;
leaf.draft = null;
t.focusedLeafId = leaf.id;
}
render();
opts.onOpenSessionsChange?.();
},
});
draftForms.add(form);
host.appendChild(form.el);
}
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.
// Splits are desktop-only; mobile keeps just disconnect + close.
if (leaf.sessionId) {
const off = mkBtn("⏻", "Disconnect", () => onDisconnect(leaf.sessionId!));
off.classList.add("mv-tool-off");
tools.appendChild(off);
}
if (!isMobile) {
tools.append(
mkBtn("▥", "Split right", () => splitFocused(leaf.id, "row")),
mkBtn("▤", "Split down", () => splitFocused(leaf.id, "col")),
);
}
tools.appendChild(mkBtn("✕", "Close pane", () => closePane(leaf.id)));
pane.append(host, tools);
pane.addEventListener(
"pointerdown",
(e) => {
focusLeaf(leaf.id);
// Drag a pane by its title bar (desktop only — no docking on mobile).
if (isMobile) return;
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);
persist(); // save the new split ratio
};
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;
target.draft = null;
tab.focusedLeafId = target.id;
render();
}
// ---- top-level render ----
function render(): void {
for (const f of draftForms) f.destroy();
draftForms.clear();
renderTabs();
canvas.replaceChildren();
const tab = activeTab(layout);
// Mobile single-tab: the tab bar carries no information (one tab, one pane),
// so CSS collapses it and we float a ☰ instead (rendered in renderMobile).
el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1);
if (!tab.root) {
pool.setVisible([]);
pool.setFocus(null);
canvas.appendChild(renderWelcome());
sidebar.setHighlights({ open: openSessions(layout), focused: null, colors: colorMap() });
persist();
return;
}
// Keep focus pointing at a real leaf.
if (!tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null;
}
if (isMobile) {
renderMobile(tab);
persist();
return;
}
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();
persist();
}
// Mobile: a pill strip of the active tab's leaves + one full-screen pane.
function renderMobile(tab: { root: Node | null; focusedLeafId: string | null }): void {
const ls = leaves(tab.root);
const focused = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || ls[0];
const wrap = document.createElement("div");
wrap.className = "mv-mobile";
if (ls.length > 1) {
const bar = document.createElement("div");
bar.className = "mv-panebar";
for (const l of ls) {
const pill = document.createElement("button");
pill.type = "button";
pill.className = "mv-pill" + (l.id === focused.id ? " is-active" : "");
pill.textContent = l.sessionId ? (infoFor(l.sessionId)?.name ?? l.sessionId.slice(0, 8)) : "empty";
if (l.sessionId) pill.style.setProperty("--mv-color", sessionColor(l.sessionId));
pill.addEventListener("click", () => {
tab.focusedLeafId = l.id;
render();
});
bar.appendChild(pill);
}
wrap.appendChild(bar);
}
const pane = buildPane(focused);
pane.el.classList.add("is-focused"); // the only pane — never dimmed
wrap.appendChild(pane.el);
canvas.appendChild(wrap);
// Only the focused leaf's session is live on mobile; the rest park.
pool.setVisible(
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
);
pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null, colors: colorMap() });
}
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];
[dragged.draft, target.draft] = [target.draft, dragged.draft];
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;
t2.draft = null;
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,
openSessions: () => openSessions(layout),
beginSessionDrag,
render,
focusedSessionId: focusedLeafSession,
};
}