// 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 { 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; /** 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; /** 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 { 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; } /** 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(".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: focusedLeafSession(), }); // 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(); } // ---- pane + split DOM ---- function buildPane(leaf: Extract): { 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, 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" : "") + (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); // 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) }] : [], { focus: !preview && !quietRender }, ); if (!preview) pool.setFocus(focused.sessionId ?? null); sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null }); 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 = { 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: focusedLeafSession, previewSession, previewedSessionId: () => preview?.sessionId ?? null, }; }