/** * @file PaneGrid.tsx * @description The session view's split panes (layout model in * `lib/paneLayout.ts`). Every mounted slot is ONE absolutely placed child with * a stable key, positioned from `layoutRects`; a slot that is not in the * layout is mounted and hidden (CSS). So splitting, swapping, moving and * resizing only move boxes — a live terminal is never re-parented, which would * remount it and drop what it drew. With more than one pane each gets a slim * header: drag it to move the pane (drop on another pane's edge to split * there, on its middle to swap), pick another live session for it, split it * right or down, or close the pane (its session keeps running as a tab). * Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). Dividers * resize by pointer or by arrow keys. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Check, ChevronDown, Columns2, GripVertical, Plus, Rows2, SquareTerminal, X, } from "lucide-react"; import type { RunHandle } from "../../lib/api"; import { EMPTY_LAYOUT, type Edge, type PaneLayout, type Rect, type SplitDir, MAX_PANES, closeInLayout, layoutRects, leaves, loadLayout, moveInLayout, saveLayout, setRatio, swapInLayout, } from "../../lib/paneLayout"; import { AiToolIcon } from "../AiToolIcon"; export const PANE_DRAG_TYPE = "application/x-agenthub-pane"; /** The slot being dragged: `dataTransfer` data cannot be read during dragover. */ let dragSlot: number | null = null; /** Starts dragging a pane or a tab (`slot`) onto the grid. */ export function startPaneDrag(e: React.DragEvent, slot: number): void { dragSlot = slot; e.dataTransfer?.setData(PANE_DRAG_TYPE, String(slot)); if (e.dataTransfer) e.dataTransfer.effectAllowed = "move"; } /** A run's icon: a shell's terminal glyph, else its AI tool's mark. */ export function RunIcon({ run, label }: { run: RunHandle; label: string }) { return run.tool === "shell" ? ( ) : ( ); } export type LayoutUpdate = (fn: (l: PaneLayout) => PaneLayout) => void; /** A workspace's pane layout, loaded and saved per workspace. A layout update * that lands after a workspace switch never writes into the new one. */ export function usePaneLayout(laneId: number | null): [PaneLayout, LayoutUpdate] { const load = (id: number | null) => ({ laneId: id, layout: id === null ? EMPTY_LAYOUT : loadLayout(id), }); const [state, setState] = useState(() => load(laneId)); let current = state; if (state.laneId !== laneId) { current = load(laneId); setState(current); } useEffect(() => { if (state.laneId !== null) saveLayout(state.laneId, state.layout); }, [state]); const update = useCallback( (fn) => setState((s) => { if (s.laneId !== laneId) return s; const layout = fn(s.layout); return layout === s.layout ? s : { ...s, layout }; }), [laneId] ); return [current.layout, update]; } type Zone = Edge | "center"; const pct = (r: Rect) => ({ left: `${r.x * 100}%`, top: `${r.y * 100}%`, width: `${r.w * 100}%`, height: `${r.h * 100}%`, }); /** The half (or the whole) of a pane a drop zone would fill. */ function zoneRect(r: Rect, zone: Zone): Rect { if (zone === "left") return { ...r, w: r.w / 2 }; if (zone === "right") return { ...r, x: r.x + r.w / 2, w: r.w / 2 }; if (zone === "top") return { ...r, h: r.h / 2 }; if (zone === "bottom") return { ...r, y: r.y + r.h / 2, h: r.h / 2 }; return r; } /** A small anchored menu: a trigger button and a list that closes on a pick, * an outside click or Escape. */ function PaneMenu({ label, icon, disabled, testId, children, }: { label: string; icon: ReactNode; disabled?: boolean; testId: string; children: (close: () => void) => ReactNode; }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const onDown = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; }, [open]); return (
{open && (
{children(() => setOpen(false))}
)}
); } const ITEM = "flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary"; export function PaneGrid({ layout, mounted, runs, label, renderPane, onChange, canStart, onNewSplit, }: { layout: PaneLayout; /** Every slot to keep mounted; those not in the layout are hidden. */ mounted: number[]; /** The workspace's live runs (its tabs). */ runs: RunHandle[]; label: (r: RunHandle) => string; renderPane: (slot: number) => ReactNode; onChange: LayoutUpdate; /** A slot is free for a new terminal. */ canStart: boolean; /** Start a new terminal into a split of `target`. */ onNewSplit: (target: number, edge: Edge) => void; }) { const { t } = useTranslation("lanes"); const gridRef = useRef(null); const [drop, setDrop] = useState<{ slot: number; zone: Zone } | null>(null); const { panes, dividers } = layoutRects(layout.root); const shown = leaves(layout.root); const rectOf = new Map(panes.map((p) => [p.slot, p])); const multi = panes.length > 1; const full = shown.length >= MAX_PANES; const runOf = (slot: number) => runs.find((r) => (r.slot ?? 0) === slot); useEffect(() => { const end = () => { dragSlot = null; setDrop(null); }; window.addEventListener("dragend", end); window.addEventListener("drop", end); return () => { window.removeEventListener("dragend", end); window.removeEventListener("drop", end); }; }, []); const focus = (slot: number) => onChange((l) => (l.focus === slot ? l : { ...l, focus: slot })); // The keyboard follows the focused pane (a tab click, a close, a swap) — // unless the user is typing in a field outside the panes. useEffect(() => { if (layout.focus === null) return; const term = gridRef.current?.querySelector( `[data-testid="pane-${layout.focus}"] textarea.xterm-helper-textarea` ); const active = document.activeElement as HTMLElement | null; const typingElsewhere = !!active && !gridRef.current?.contains(active) && (active.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(active.tagName)); if (term && active !== term && !typingElsewhere) term.focus({ preventScroll: true }); }, [layout.focus]); /** Which pane and which part of it the pointer is over. */ const zoneAt = (e: React.DragEvent): { slot: number; zone: Zone } | null => { const g = gridRef.current?.getBoundingClientRect(); if (!g || !g.width || !g.height) return null; const fx = (e.clientX - g.left) / g.width; const fy = (e.clientY - g.top) / g.height; const p = panes.find((r) => fx >= r.x && fx <= r.x + r.w && fy >= r.y && fy <= r.y + r.h); if (!p) return null; const src = dragSlot; // A run not on screen cannot become a fifth pane: only a swap. if (src !== null && !shown.includes(src) && full) return { slot: p.slot, zone: "center" }; const dx = (fx - p.x) / p.w; const dy = (fy - p.y) / p.h; const edges: [Zone, number][] = [ ["left", dx], ["right", 1 - dx], ["top", dy], ["bottom", 1 - dy], ]; const [edge, dist] = edges.reduce((a, b) => (b[1] < a[1] ? b : a)); return { slot: p.slot, zone: dist < 0.25 ? edge : "center" }; }; const isPaneDrag = (e: React.DragEvent) => dragSlot !== null || !!e.dataTransfer?.types?.includes(PANE_DRAG_TYPE); return (
{ if (!isPaneDrag(e)) return; e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; const z = zoneAt(e); setDrop((d) => (d?.slot === z?.slot && d?.zone === z?.zone ? d : z)); }} onDragLeave={(e) => { if (!gridRef.current?.contains(e.relatedTarget as Node | null)) setDrop(null); }} onDrop={(e) => { if (!isPaneDrag(e)) return; e.preventDefault(); const src = dragSlot ?? Number(e.dataTransfer?.getData(PANE_DRAG_TYPE)); const z = zoneAt(e); dragSlot = null; setDrop(null); if (!z || !Number.isInteger(src) || src === z.slot) return; onChange((l) => z.zone === "center" ? swapInLayout(l, src, z.slot) : moveInLayout(l, src, z.slot, z.zone as Edge) ); }} > {mounted.map((slot) => { const r = rectOf.get(slot); const run = runOf(slot); const focused = layout.focus === slot; return (
r && focus(slot)} // Tabbing into a pane focuses it as a click does. onFocusCapture={() => r && focus(slot)} > {multi && r && (
startPaneDrag(e, slot)} title={t("panes.drag")} className={`relative flex h-8 shrink-0 cursor-grab items-center gap-1 border-b border-border bg-surface-1 pl-1.5 pr-1 text-xs ${ focused ? "text-fg-primary" : "text-fg-muted" }`} > {focused && } {run && } {run ? label(run) : t("panes.empty")} } > {(close) => runs.map((x) => { const s = x.slot ?? 0; return ( ); }) }
{(["right", "bottom"] as const).map((edge) => ( ) : ( ) } > {(close) => ( <> {runs .filter((x) => !shown.includes(x.slot ?? 0)) .map((x) => ( ))} {runs.some((x) => !shown.includes(x.slot ?? 0)) && (
)} )} ))}
)}
{renderPane(slot)}
); })} {dividers.map((d) => ( onChange((l) => (l.root ? { ...l, root: setRatio(l.root, d.path, r) } : l)) } /> ))} {drop && rectOf.has(drop.slot) && (
)}
); } /** A split's divider: a 1px line with a wider grab area. */ function Divider({ gridRef, dir, at, box, onRatio, }: { gridRef: React.RefObject; dir: SplitDir; at: number; box: Rect; onRatio: (ratio: number) => void; }) { const { t } = useTranslation("lanes"); const [dragging, setDragging] = useState(false); const row = dir === "row"; const ratioAt = (e: React.PointerEvent) => { const g = gridRef.current?.getBoundingClientRect(); if (!g || !g.width || !g.height) return null; return row ? ((e.clientX - g.left) / g.width - box.x) / box.w : ((e.clientY - g.top) / g.height - box.y) / box.h; }; const style = row ? { left: `${(box.x + box.w * at) * 100}%`, top: `${box.y * 100}%`, height: `${box.h * 100}%` } : { top: `${(box.y + box.h * at) * 100}%`, left: `${box.x * 100}%`, width: `${box.w * 100}%` }; return (
{ e.preventDefault(); e.currentTarget.setPointerCapture?.(e.pointerId); setDragging(true); }} onPointerMove={(e) => { if (!dragging) return; const r = ratioAt(e); if (r !== null) onRatio(r); }} onPointerUp={() => setDragging(false)} onPointerCancel={() => setDragging(false)} onKeyDown={(e) => { const back = row ? "ArrowLeft" : "ArrowUp"; const fwd = row ? "ArrowRight" : "ArrowDown"; if (e.key !== back && e.key !== fwd) return; e.preventDefault(); onRatio(at + (e.key === fwd ? 0.05 : -0.05)); }} >
); }