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 ad2e700391
ci / web (push) Successful in 31s
ci / rust (push) Successful in 2m22s
ci / flake (push) Successful in 6m43s
fix(maestro): spawn opens a new tab instead of splitting
Pressing Spawn while reading a session split that pane in half. It now
opens the draft in its own tab, leaving the current tab untouched.

Scoped to the explicit Spawn affordances (the header button and the
sidebar empty state, both via OPEN_SPAWN_EVENT). "Spawn similar" and the
fast-forward key deliberately keep splitting: both act on the session in
front of you, so a pane beside it is the point. Reusing an empty pane
(#72) is unchanged — that path never split anything.

Closes #100
2026-08-24 17:17:35 +01:00

764 lines
27 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 Draft,
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;
openSessionBeside: (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>;
/** Open a draft pane (issue #62): reuse the focused pane if it is already
* empty, otherwise split the focused pane. `seed` pre-fills the form
* ("Spawn similar" clones a session's cwd + command). */
openDraft: (seed?: Partial<Draft>, opts?: { newTab?: boolean }) => 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 persist = () => opts.onLayoutChange?.();
// Draft-pane forms (#57) carry global listeners (dir autocomplete), so forms
// whose leaves are gone are destroyed on each render. Forms for draft leaves
// that survive the render are reused in place (#68): rebuilding one mid-edit
// would drop focus, caret and selection, and re-fetch info/projects/names.
const draftForms = new Map<string, 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 = draftForms.get(leaf.id);
if (form) {
host.appendChild(form.el);
} else {
const created = 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 —
// searching every tab, since the operator may have switched tabs
// while the request was in flight.
const host = layout.tabs.find((t) => leaves(t.root).includes(leaf));
if (host) {
leaf.sessionId = session.id;
leaf.draft = null;
host.focusedLeafId = leaf.id;
}
render();
opts.onOpenSessionsChange?.();
},
});
draftForms.set(leaf.id, created);
host.appendChild(created.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();
}
/// 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. Follows `openDraft`'s rule instead — reuse an empty pane, else
/// split beside the focused one (#67 review).
function openSessionBeside(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 || !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)!;
if (!target.sessionId) {
target.sessionId = sessionId;
target.draft = null;
} else {
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
}
render();
opts.onOpenSessionsChange?.();
}
function openDraft(seed?: Partial<Draft>, opts?: { newTab?: boolean }): void {
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
if (opts?.newTab) {
// Spawn opens its own tab rather than halving whatever you were reading
// (#100). The empty-pane reuse below still applies within a tab.
const fresh = addTab(layout);
fresh.focusedLeafId = splitLeaf(fresh, "", null, "row", true);
findLeaf(fresh.root!, fresh.focusedLeafId)!.draft = draft();
render();
return;
}
const tab = activeTab(layout);
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
// Empty tab (or stale focus): the draft becomes the first pane.
tab.focusedLeafId = splitLeaf(tab, "", null, "row", true);
findLeaf(tab.root!, tab.focusedLeafId)!.draft = draft();
render();
return;
}
const target = findLeaf(tab.root, tab.focusedLeafId)!;
if (!target.sessionId) {
// Empty = no session, not `!draft` (#72): the mounted form auto-syncs
// picked defaults into `draft` once ready, so that's never reliably null.
target.draft = draft();
const stale = draftForms.get(target.id);
if (stale) {
stale.destroy();
draftForms.delete(target.id);
}
} else {
// Split a fresh draft beside the focused pane.
tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true);
findLeaf(tab.root, tab.focusedLeafId)!.draft = draft();
}
render();
}
// ---- top-level render ----
/** Destroy forms whose leaf is no longer a draft (leaf removed or filled by
* a session). Surviving draft leaves keep their form instance (#68). */
function pruneForms(tab: { root: Node | null }): void {
const alive = new Set(
tab.root
? leaves(tab.root)
.filter((l) => !l.sessionId)
.map((l) => l.id)
: [],
);
for (const [id, f] of draftForms) {
if (!alive.has(id)) {
f.destroy();
draftForms.delete(id);
}
}
}
function render(): void {
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) {
pruneForms(tab);
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);
pruneForms(tab);
persist();
return;
}
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
canvas.appendChild(buildTree(tab.root, hosts));
pruneForms(tab);
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];
// The leaf data moved but each pane's mounted form is still keyed to
// its old leaf (#73) — tear both down like openDraft's reuse case
// (#72) so render() remounts fresh forms seeded from the swapped data.
for (const id of [dragged.id, target.id]) {
const stale = draftForms.get(id);
if (stale) {
stale.destroy();
draftForms.delete(id);
}
}
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,
openSessionBeside,
openDraft,
closeSession,
openSessions: () => openSessions(layout),
beginSessionDrag,
render,
focusedSessionId: focusedLeafSession,
};
}