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 cc1e7b5f98 fix(maestro): never open a live pane for a session that isn't running
Integration review (#168): the content pane opened a pane for
whatever the sidebar highlighted with no status check. After a host
reboot every session is dormant, so arrowing down the sidebar opened
a pane per session that immediately died with WS 1006 (the daemon
answers a bridgeless attach with 404/409, never a completed
handshake).

Gate every path that fills a pane (preview, open, mobile's single
pane) on the session's own status: a session that isn't Running gets
the same metadata-and-resume view its sidebar card shows
(docs/SIDEBAR.md, docs/PREVIEW.md) instead of an attach attempt. The
shell also reacts to onNeverAttached (previous commit) by dropping the
dead connection and re-rendering, and to an open session's
Running-ness flipping — most commonly a resume completing — so a
placeholder and a live pane swap in step with the catalog.
2026-09-04 09:19:19 +01:00

898 lines
33 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 (freshly split, not yet filled) show a
// placeholder. Drag-to-dock is issue #32; mobile projection is #35.
import type { SidebarHandle } from "./catalog.js";
import type { ConnectionPool } from "./connections.js";
import { activityDurationLabel, activityHeldMs } from "./activity.js";
import { escapeHtml } from "./html.js";
import { sessionName } from "./types.js";
import type { Session } from "./types.js";
import {
activeTab,
addTab,
closeTab,
findLeaf,
freshId,
type Dir,
type Layout,
leaves,
locateSession,
type Node,
openSessions,
removeLeaf,
splitLeaf,
type Tab,
} from "./layout.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;
/** The session behind a leaf, when known — used to gate a live attach on
* `status === "Running"` (#168 review) and to fill in the dormant pane's
* resume affordance. A leaf whose session this returns `undefined` for
* (unknown to the caller) attaches as before, so callers that don't wire
* this up see no change in behaviour. */
sessionFor?: (sessionId: string) => Session | undefined;
/** Resume a dormant session (#166/#168), from its pane's own affordance. */
onResume?: (sessionId: string) => Promise<unknown>;
/** Called when a spawn fills a pane with a new session, 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;
openSessionBeside: (sessionId: string, opts?: { newTab?: boolean }) => 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;
/** Show `sessionId` in the content pane without committing to it
* (docs/PREVIEW.md): the tab holding it is brought forward, or a throwaway
* tab carries it. `null` puts back whatever was on screen before. */
previewSession: (sessionId: string | null) => void;
/** The session being previewed, or null. */
previewedSessionId: () => string | null;
}
export function createMultiView(opts: MultiViewOptions): MultiView {
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
// A preview must never reach localStorage: it is undone the moment the
// highlight moves on, and the layout it would save is not the operator's.
const persist = () => {
if (preview) return;
opts.onLayoutChange?.();
};
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 layout collapses to exactly one visible
// pane and no tab bar — splits/drag/resize are desktop-only, and opening or
// spawning replaces the pane rather than adding a second thing to navigate.
// The sidebar drawer is the only navigation. The tree is untouched, so
// widening restores whatever splits and tabs a desktop session built.
const mq = window.matchMedia("(max-width: 859px)");
let isMobile = mq.matches;
mq.addEventListener("change", (e) => {
isMobile = e.matches;
render();
});
// ---- preview (docs/PREVIEW.md) ----
// A preview is a temporary *view* change, never a layout change: it records
// exactly what it moved so ending it puts the layout back byte for byte, and
// it neither persists nor reports pane focus to the daemon while it is up.
interface Preview {
sessionId: string;
/** Tab that was active before the preview. */
prevActiveTabId: string;
/** Focused leaf to restore, when the preview stole focus in a real tab. */
prevFocus: { tabId: string; leafId: string | null } | null;
/** Throwaway tab built for a session that no pane was showing. */
tempTabId: string | null;
}
let preview: Preview | null = null;
/// Set while a preview-driven render runs: ending a preview re-mounts the
/// pane that was there, and that mount must not pull the keyboard out of the
/// sidebar the operator is still walking.
let quietRender = false;
/** Render without any mount taking the keyboard. */
function renderQuietly(): void {
quietRender = true;
try {
render();
} finally {
quietRender = false;
}
}
const isTempTab = (tabId: string) => preview?.tempTabId === tabId;
/** Undo the preview's moves. Mutates only — the caller renders. */
function restorePreview(): boolean {
const p = preview;
if (!p) return false;
preview = null;
if (p.tempTabId) {
const i = layout.tabs.findIndex((t) => t.id === p.tempTabId);
if (i >= 0) layout.tabs.splice(i, 1);
} else if (p.prevFocus) {
const t = layout.tabs.find((x) => x.id === p.prevFocus!.tabId);
if (t) t.focusedLeafId = p.prevFocus.leafId;
}
layout.activeTabId = layout.tabs.some((t) => t.id === p.prevActiveTabId)
? p.prevActiveTabId
: layout.tabs[0].id;
return true;
}
/** Keep what the preview is showing: its throwaway tab becomes a real one,
* and the focus it moved stands. Returns true when `sessionId` was the
* session being previewed; any other preview is simply discarded. */
function settlePreview(sessionId: string): boolean {
const p = preview;
if (!p) return false;
if (p.sessionId !== sessionId) {
restorePreview();
return false;
}
preview = null;
if (p.tempTabId) {
const t = layout.tabs.find((x) => x.id === p.tempTabId);
if (t) t.title = `Tab ${layout.tabs.length}`;
}
return true;
}
function previewSession(sessionId: string | null): void {
if (preview?.sessionId === sessionId) return;
if (sessionId === null) {
if (restorePreview()) renderQuietly();
return;
}
restorePreview();
const prevActiveTabId = layout.activeTabId;
const loc = locateSession(layout, sessionId);
if (loc) {
preview = {
sessionId,
prevActiveTabId,
prevFocus: { tabId: loc.tab.id, leafId: loc.tab.focusedLeafId },
tempTabId: null,
};
layout.activeTabId = loc.tab.id;
loc.tab.focusedLeafId = loc.leaf.id;
} else {
// Not on screen anywhere: a throwaway tab carries it, so no existing
// pane is evicted and nothing the operator arranged moves.
const tab: Tab = { id: freshId("t"), title: "Preview", root: null, focusedLeafId: null };
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
layout.tabs.push(tab);
layout.activeTabId = tab.id;
preview = { sessionId, prevActiveTabId, prevFocus: null, tempTabId: tab.id };
}
renderQuietly();
}
/** Any user action that changes the layout for real ends the preview first,
* so it never bakes a throwaway tab into the arrangement. */
function dropPreview(): void {
restorePreview();
}
// ---- highlight helpers ----
/** Sessions the operator actually has open — the preview's throwaway tab
* does not count, or hovering the sidebar would flip the shell's route. */
function realOpenSessions(): Set<string> {
const out = openSessions(layout);
if (preview?.tempTabId) out.delete(preview.sessionId);
return out;
}
function focusedLeafSession(): string | null {
const tab = activeTab(layout);
const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null;
return fl?.sessionId ?? null;
}
/** The session the operator actually picked — what `focusedLeafSession()`
* would return with the preview undone. A preview moves the active tab and
* its focus, but it is a view: the accent stays on the row the operator
* chose until they choose another (docs/PREVIEW.md). */
function realFocusedSession(): string | null {
const p = preview;
if (!p) return focusedLeafSession();
const tab = layout.tabs.find((t) => t.id === p.prevActiveTabId) ?? activeTab(layout);
const leafId =
p.prevFocus && p.prevFocus.tabId === tab.id ? p.prevFocus.leafId : tab.focusedLeafId;
const fl = leafId ? findLeaf(tab.root, leafId) : 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);
// The previewed pane's titlebar wears the preview colour instead of the
// accent tint a focused pane gets (docs/PREVIEW.md).
pane.classList.toggle(
"is-preview",
preview !== null && pane.dataset.sessionId === preview.sessionId,
);
}
// A preview is not a focused pane: reporting it would clear the session's
// `AwaitingInput` glow just because the operator scrolled past it.
if (!preview) pool.setFocus(focusedLeafSession());
sidebar.setHighlights({
open: realOpenSessions(),
focused: realFocusedSession(),
});
// The row the preview came from wears the same colour, so the sidebar and
// the pane read as one thing rather than two unrelated highlights.
sidebar.setPreview(preview?.sessionId ?? null);
}
function focusLeaf(leafId: string): void {
const tab = activeTab(layout);
if (tab.focusedLeafId === leafId) return;
tab.focusedLeafId = leafId;
syncFocus();
persist();
}
// ---- dormant panes (#168 review) ----
/** The session behind `sessionId` when it is known and not `Running` —
* what gates a leaf away from a live attach and towards the resume
* affordance instead. */
function dormantSessionFor(sessionId: string): Session | undefined {
const s = opts.sessionFor?.(sessionId);
return s && s.status !== "Running" ? s : undefined;
}
/** docs/PREVIEW.md / docs/SIDEBAR.md: what the content pane can honestly
* show for a session with no pane behind it — its metadata and a resume
* affordance, never a live terminal that would just die with WS 1006. */
function renderDormantPane(host: HTMLElement, session: Session): void {
const heldMs = activityHeldMs(session.last_activity_at ?? session.created_at, Date.now());
const age = heldMs !== null ? activityDurationLabel(heldMs) : null;
const wrap = document.createElement("div");
wrap.className = "mv-pane-dormant";
wrap.innerHTML = `
<div class="mv-dormant-glyph" aria-hidden="true">◌</div>
<div class="mv-dormant-name">${escapeHtml(sessionName(session))}</div>
<div class="mv-dormant-cwd" title="${escapeHtml(session.cwd)}">${escapeHtml(session.cwd)}</div>
<div class="mv-dormant-detail">dormant${age ? ` for ${escapeHtml(age)}` : ""} — nothing running behind it</div>
<button type="button" class="btn btn-primary mv-dormant-resume">Resume</button>
`;
const btn = wrap.querySelector(".mv-dormant-resume") as HTMLButtonElement;
btn.addEventListener("click", () => {
btn.disabled = true;
btn.textContent = "Resuming…";
opts.onResume?.(session.id)?.catch((e: Error) => {
btn.disabled = false;
btn.textContent = "Resume";
alert(`Failed to resume: ${e.message}`);
});
});
host.replaceChildren(wrap);
}
// ---- 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;
const host = document.createElement("div");
host.className = "mv-pane-host";
if (!leaf.sessionId) {
const empty = document.createElement("div");
empty.className = "mv-pane-empty";
empty.textContent = "Empty pane — pick a session from the sidebar, or press n to spawn.";
host.appendChild(empty);
}
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) => {
// Clicking into a previewed pane is the operator choosing it.
if (preview && leaf.sessionId === preview.sessionId) {
settlePreview(leaf.sessionId);
persist();
}
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) {
const dormant = dormantSessionFor(node.sessionId);
if (dormant) {
renderDormantPane(pane.host, dormant);
} else {
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" : "") +
(isTempTab(t.id) ? " is-preview" : "");
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 (isTempTab(t.id)) return; // a preview tab is not a place to go
dropPreview();
if (layout.activeTabId !== t.id) {
layout.activeTabId = t.id;
}
render();
});
// A preview tab is not the operator's to close: it goes on its own.
if (layout.tabs.length > 1 && !isTempTab(t.id)) {
const close = document.createElement("span");
close.className = "mv-tab-close";
close.textContent = "✕";
close.title = "Close tab";
close.addEventListener("click", (e) => {
e.stopPropagation();
dropPreview();
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", () => {
dropPreview();
addTab(layout);
render();
});
tabBar.appendChild(add);
}
// ---- mutations ----
function splitFocused(leafId: string, dir: Dir): void {
dropPreview();
const tab = activeTab(layout);
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
render();
}
function closePane(leafId: string): void {
dropPreview();
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 {
// Opening what is already previewed keeps exactly what is on screen: the
// throwaway tab becomes a real one instead of the session moving again.
if (settlePreview(sessionId)) {
render();
return;
}
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();
}
/// Mount a session without evicting one.
///
/// `openSession` deliberately replaces the focused pane (VS Code style) for
/// a sidebar click, but the fast path must never do that: pressing the key
/// while attached would drop the running session out of the layout
/// entirely. Reuses an empty pane, else splits beside the focused one
/// (#67 review). `newTab` opens its own tab instead of halving whatever the
/// operator was reading (#100) — what the Spawn button asks for.
function openSessionBeside(sessionId: string, placement?: { newTab?: boolean }): void {
if (settlePreview(sessionId)) {
render();
opts.onOpenSessionsChange?.();
return;
}
const loc = locateSession(layout, sessionId);
if (loc) {
layout.activeTabId = loc.tab.id;
loc.tab.focusedLeafId = loc.leaf.id;
render();
return;
}
// Mobile keeps a single tab and a single pane, so a new tab is not an
// option there: the session takes over the pane below.
if (placement?.newTab && !isMobile) {
const fresh = addTab(layout);
fresh.focusedLeafId = splitLeaf(fresh, "", sessionId, "row", true);
render();
opts.onOpenSessionsChange?.();
return;
}
const tab = activeTab(layout);
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
render();
opts.onOpenSessionsChange?.();
return;
}
const target = findLeaf(tab.root, tab.focusedLeafId)!;
// Mobile shows one pane, so there is no "beside": take over the pane. The
// evicted session keeps running and stays a tap away in the sidebar.
if (!target.sessionId || isMobile) {
target.sessionId = sessionId;
} else {
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
}
render();
opts.onOpenSessionsChange?.();
}
// ---- top-level render ----
function render(): void {
renderTabs();
canvas.replaceChildren();
const tab = activeTab(layout);
// Mobile: the tab bar is a second navigation surface we don't want, so CSS
// collapses it; the pane header's ☰ keeps the sidebar drawer reachable.
el.classList.toggle("mv-mobile-solo", isMobile);
// A lone tab has nothing to switch between, so the bar is only a strip of
// chrome; CSS hides it until a second tab exists. Spawning still opens its
// own tab (#100), which brings the bar back with the "+" button on it.
// The preview's throwaway tab must not make the bar appear and vanish as
// the highlight moves, so it does not count towards "one tab only".
el.classList.toggle(
"mv-solo-tab",
layout.tabs.filter((t) => !isTempTab(t.id)).length === 1,
);
el.classList.toggle("mv-previewing", preview !== null);
if (!tab.root) {
pool.setVisible([]);
if (!preview) pool.setFocus(null);
canvas.appendChild(renderWelcome());
sidebar.setHighlights({ open: realOpenSessions(), focused: null });
sidebar.setPreview(preview?.sessionId ?? null);
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));
// A preview keeps the keyboard in the sidebar: mounting must not focus the
// terminal, or j/k would stop walking the list after the first step.
pool.setVisible(
hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })),
{ focus: !preview && !quietRender },
);
syncFocus();
persist();
}
// Mobile: the focused leaf, full screen, alone. Panes a desktop session left
// behind stay in the tree but are reached only by tapping the session in the
// sidebar drawer — mobile shows no pane switcher of its own.
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";
const pane = buildPane(focused);
pane.el.classList.add("is-focused"); // the only pane — never dimmed
if (preview && focused.sessionId === preview.sessionId) pane.el.classList.add("is-preview");
wrap.appendChild(pane.el);
canvas.appendChild(wrap);
const dormant = focused.sessionId ? dormantSessionFor(focused.sessionId) : undefined;
if (dormant) renderDormantPane(pane.host, dormant);
// Only the focused leaf's session is live on mobile; the rest park.
pool.setVisible(
focused.sessionId && !dormant
? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }]
: [],
{ focus: !preview && !quietRender },
);
if (!preview) pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: realOpenSessions(), focused: realFocusedSession() });
sidebar.setPreview(preview?.sessionId ?? null);
}
function closeSession(sessionId: string): void {
dropPreview();
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;
}
// A tab that held nothing but the dead session goes with it (#137): a killed
// session should not leave an empty tab behind for the operator to close by
// hand. `closeTab` keeps the last tab (emptied) so there is always one.
if (!loc.tab.root) closeTab(layout, loc.tab.id);
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 {
dropPreview();
runDrag({ kind: "session", sessionId }, e, { deferred: true });
}
return {
el,
openSession,
openSessionBeside,
closeSession,
openSessions: realOpenSessions,
beginSessionDrag,
render,
focusedSessionId: realFocusedSession,
previewSession,
previewedSessionId: () => preview?.sessionId ?? null,
};
}