/** * @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 take the pane OUT of this tab (its session keeps running * in a tab of its own — so its icon is not the tab's stop X), or stop its * session there (X, `onStop`) without taking it out first. The grid shows * one tab's panes; `onChange` applies through `updateActive`, so a session * dropped or swapped in from another tab leaves that tab. A header names the pane's session and * shows its state dot, as its tab does. * Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A * right-click on a header offers the swap, a split with a new terminal and * the close as one menu. Dividers resize by pointer or by arrow keys; a drag * moves only the line and commits the ratio on release, so the layout is * saved and the terminals refit once, not on every pointer move. * @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, Minimize2, Rows2, SquareTerminal, X, } from "lucide-react"; import type { RunHandle } from "../../lib/api"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { EMPTY_GROUPS, type Edge, type PaneGroups, type PaneLayout, type Rect, type SplitDir, MAX_PANES, closeInLayout, layoutRects, leaves, loadGroups, moveInLayout, saveGroups, setRatio, swapInLayout, } from "../../lib/paneLayout"; import { AiToolIcon } from "../AiToolIcon"; import { RunDot, type RunState } from "./ConsoleTabs"; 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; export type GroupsUpdate = (fn: (g: PaneGroups) => PaneGroups) => void; /** A workspace's tabs (groups of split panes), loaded and saved per * workspace. An update that lands after a workspace switch never writes into * the new one. */ export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate] { const load = (id: number | null) => ({ laneId: id, groups: id === null ? EMPTY_GROUPS : loadGroups(id), }); const [state, setState] = useState(() => load(laneId)); let current = state; if (state.laneId !== laneId) { current = load(laneId); setState(current); } useEffect(() => { if (state.laneId !== null) saveGroups(state.laneId, state.groups); }, [state]); const update = useCallback( (fn) => setState((s) => { if (s.laneId !== laneId) return s; const groups = fn(s.groups); return groups === s.groups ? s : { ...s, groups }; }), [laneId] ); return [current.groups, update]; } export type Zone = Edge | "center"; const pct = (r: Rect) => ({ left: `${r.x * 100}%`, top: `${r.y * 100}%`, width: `${r.w * 100}%`, height: `${r.h * 100}%`, }); /** What dropping `src` on a pane's zone does: the center swaps, an edge * splits. The drop applies it and the preview draws it, so they agree. */ export function applyDrop(l: PaneLayout, src: number, slot: number, zone: Zone): PaneLayout { return zone === "center" ? swapInLayout(l, src, slot) : moveInLayout(l, src, slot, zone); } /** Where `src` would land, or null for a drop that changes nothing. A pane * already shown leaves its place first, so its target grows before the * split — the target's own half is not where it lands. */ export function dropPreview(l: PaneLayout, src: number, slot: number, zone: Zone): Rect | null { if (src === slot) return null; const next = applyDrop(l, src, slot, zone); if (next === l) return null; return layoutRects(next.root).panes.find((p) => p.slot === src) ?? null; } /** 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"; /** A pane's split-right / split-down menus: move a hidden run beside * `slot`, or start a new terminal or AI tool there. In a pane header when the view is * split, beside Tools in the tab bar while one pane is shown. */ export function SplitMenus({ slot, shown, runs, label, onChange, canStart, tools, toolLabel, onNewSplit, }: { slot: number; /** The slots on screen. */ shown: number[]; runs: RunHandle[]; label: (r: RunHandle) => string; onChange: LayoutUpdate; canStart: boolean; /** AI tool ids a new split can start, beside the terminal. */ tools: string[]; toolLabel: (id: string) => string; /** `tool`: an AI tool id; none — a terminal. */ onNewSplit: (target: number, edge: Edge, tool?: string) => void; }) { const { t } = useTranslation("lanes"); const full = shown.length >= MAX_PANES; 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)) && (
)} {tools.map((id) => ( ))} )} ))} ); } export function PaneGrid({ layout, mounted, runs, label, renderPane, onChange, canStart, tools, toolLabel, onNewSplit, stateOf, onStop, }: { layout: PaneLayout; /** Stops the session in `slot` (the header's X). */ onStop?: (slot: number) => void; /** A live AI run's state for its header dot (none for a shell). */ stateOf?: (r: RunHandle) => RunState | null; /** 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; /** AI tool ids a new split can start, beside the terminal. */ tools: string[]; toolLabel: (id: string) => string; /** Start a new terminal (or AI tool) into a split of `target`. */ onNewSplit: (target: number, edge: Edge, tool?: string) => void; }) { const { t } = useTranslation("lanes"); const gridRef = useRef(null); const [drop, setDrop] = useState<{ src: number; 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); const paneCtx = useContextMenu(); // A header-menu item: close the menu, then act on the pane it was opened on. const pickPane = (fn: (slot: number) => void) => () => { const slot = paneCtx.menu?.item; paneCtx.close(); if (slot !== undefined) fn(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 preview = drop && dropPreview(layout, drop.src, drop.slot, drop.zone); 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); const next = z && dragSlot !== null ? { src: dragSlot, ...z } : null; setDrop((d) => d?.src === next?.src && d?.slot === next?.slot && d?.zone === next?.zone ? d : next ); }} 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) => applyDrop(l, src, z.slot, z.zone)); }} > {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)} onContextMenu={(e) => paneCtx.open(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")} {run && stateOf?.(run) && } } > {(close) => runs.map((x) => { const s = x.slot ?? 0; return ( ); }) }
{run && onStop && ( )}
)} {/* No padding here: the terminal paints its own gutter in its palette, so a dark pane never sits in a light-page frame. */}
{renderPane(slot)}
); })} {paneCtx.menu && ( {runs .filter((x) => (x.slot ?? 0) !== paneCtx.menu?.item) .map((x) => ( onChange((l) => swapInLayout(l, x.slot ?? 0, slot)))} > {t("panes.show", { name: label(x) })} ))} {runs.length > 1 && } {(["right", "bottom"] as const).map((edge) => ( onNewSplit(slot, edge))} > {t(`panes.splitNew.${edge}`)} ))} onChange((l) => closeInLayout(l, slot)))}> {t("panes.closeShort")} {onStop && runOf(paneCtx.menu.item) && ( {t("panes.stop")} )} )} {dividers.map((d) => ( onChange((l) => (l.root ? { ...l, root: setRatio(l.root, d.path, r) } : l)) } /> ))} {preview && (
)}
); } /** A split's divider: a 1px line with a wider grab area. Also the Changes * panel's splitter (Workspace.tsx), over the whole row (`box` = 0,0,1,1). */ export function Divider({ gridRef, dir, at, box, onRatio, label, }: { gridRef: React.RefObject; dir: SplitDir; at: number; box: Rect; onRatio: (ratio: number) => void; /** Accessible name; the panes' own by default. */ label?: string; }) { const { t } = useTranslation("lanes"); // The ratio under the pointer while dragging: only this line moves until // release, when the layout takes it once. const [live, setLive] = useState(null); const dragging = live !== null; const row = dir === "row"; const pos = live ?? at; 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 * pos) * 100}%`, top: `${box.y * 100}%`, height: `${box.h * 100}%` } : { top: `${(box.y + box.h * pos) * 100}%`, left: `${box.x * 100}%`, width: `${box.w * 100}%` }; const clamp = (r: number) => Math.min(0.9, Math.max(0.1, r)); const finish = () => { if (live !== null && live !== at) onRatio(live); setLive(null); }; return (
{ e.preventDefault(); e.currentTarget.setPointerCapture?.(e.pointerId); setLive(at); }} onPointerMove={(e) => { if (!dragging) return; const r = ratioAt(e); if (r !== null) setLive(clamp(r)); }} onPointerUp={finish} onPointerCancel={() => setLive(null)} 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)); }} >
); }