import { useEffect, useRef, useState } from "react"; import { Maximize2, Minimize2, RotateCw, GripVertical, Play, X } from "lucide-react"; import { Terminal } from "@xterm/xterm"; import { TerminalView } from "./TerminalView"; import { SearchBar } from "./SearchBar"; import { StatusRing } from "./StatusRing"; import { COLORS, FONT, STATE_COLOR } from "./theme"; import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes"; import { setRatios, restartSurface, setZoom, moveSurface, attachSurface, detachSurface, closeSurfaceCmd } from "./socketBridge"; interface Props { workspaceId: string; layout: LayoutNode | null; /** surface_id -> running flag, from the latest status/events. */ running: Record; states: Record; surfaces: Record; focusedId: string | null; onFocus: (id: string) => void; zoomed: string | null; /** The surface whose scrollback search bar is open, or null. Anchored to the * panel it was opened on — it does NOT follow focus. */ searchSurfaceId: string | null; searchNonce: number; onCloseSearch: () => void; font: { family: string; size: number } | null; palette: Record | null; } type Edge = "left" | "right" | "top" | "bottom"; interface DropTarget { id: string; edge: Edge } function edgeAt(clientX: number, clientY: number, r: DOMRect): Edge { const px = (clientX - r.left) / r.width; const py = (clientY - r.top) / r.height; const d: Record = { left: px, right: 1 - px, top: py, bottom: 1 - py }; return (Object.keys(d) as Edge[]).reduce((a, b) => (d[b] < d[a] ? b : a), "left"); } /** Collapse an absolute cwd into a ~/ style label for the panel header. */ function shortPath(cwd: string): string { const leaf = cwd.split("/").filter(Boolean).pop(); return leaf ? `~/${leaf}` : cwd; } export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Props) { // Panel drag-to-reorder. Implemented with raw pointer events rather than the // HTML5 drag API, which is unreliable in the macOS WKWebView Tauri uses. const [drop, setDrop] = useState(null); const dropRef = useRef(null); dropRef.current = drop; const startPanelDrag = (srcId: string, e: React.MouseEvent) => { e.preventDefault(); const startX = e.clientX, startY = e.clientY; let active = false; const prevUserSelect = document.body.style.userSelect; const move = (ev: MouseEvent) => { if (!active) { if (Math.abs(ev.clientX - startX) + Math.abs(ev.clientY - startY) < 5) return; active = true; document.body.style.userSelect = "none"; } const el = (document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null)?.closest("[data-surface-id]") as HTMLElement | null; const tid = el?.getAttribute("data-surface-id"); if (!el || !tid || tid === srcId) { setDrop(null); return; } setDrop({ id: tid, edge: edgeAt(ev.clientX, ev.clientY, el.getBoundingClientRect()) }); }; const up = () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); document.body.style.userSelect = prevUserSelect; const d = dropRef.current; setDrop(null); if (active && d && d.id !== srcId) void moveSurface(srcId, d.id, d.edge); }; window.addEventListener("mousemove", move); window.addEventListener("mouseup", up); }; if (!layout) { return
Empty workspace — apply a preset to add panels.
; } const shared = { workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette }; if (zoomed) { return (
); } return (
); } interface NodeProps { workspaceId: string; node: LayoutNode; path: number[]; running: Record; states: Record; surfaces: Record; focusedId: string | null; onFocus: (id: string) => void; zoomed: string | null; drop: DropTarget | null; onStartPanelDrag: (srcId: string, e: React.MouseEvent) => void; searchSurfaceId: string | null; searchNonce: number; onCloseSearch: () => void; font: { family: string; size: number } | null; palette: Record | null; } function Node({ node, path, ...rest }: NodeProps) { if ("leaf" in node) { return ; } return ; } const NERD_FALLBACK_LE = "'Symbols Nerd Font Mono'"; const fontStackLE = (family: string | null) => family ? `'${family}', ${NERD_FALLBACK_LE}, monospace` : `'JetBrains Mono Variable', 'JetBrains Mono', ${NERD_FALLBACK_LE}, monospace`; function xtermThemeLE(p: Record) { return { background: p["term-bg"] ?? p["bg-panel"], foreground: p["text-primary"], cursor: p["text-primary"], selectionBackground: p["search-match"], }; } function StoppedSnapshot({ surfaceId, font, palette }: { surfaceId: string; font: { family: string; size: number } | null; palette: Record | null }) { const hostRef = useRef(null); useEffect(() => { const host = hostRef.current; if (!host) return; const term = new Terminal({ fontFamily: fontStackLE(font?.family ?? null), fontSize: font?.size ?? 13, theme: palette ? xtermThemeLE(palette) : undefined, allowTransparency: true, // term-bg may be transparent under a background theme cursorBlink: false, disableStdin: true, scrollback: 0, }); term.open(host); let disposed = false; void attachSurface(surfaceId, () => {}).then((res) => { if (!disposed && res.snapshot) term.write(res.snapshot); }); return () => { disposed = true; term.dispose(); void detachSurface(surfaceId); }; }, [surfaceId, font, palette]); // eslint-disable-line react-hooks/exhaustive-deps return
; } function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag, searchSurfaceId, searchNonce, onCloseSearch, font, palette }: Omit & { id: string }) { const focused = focusedId === id; const dropEdge = drop && drop.id === id ? drop.edge : null; const card = (inner: React.ReactNode) => (
onFocus(id)} style={{ position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%", background: "transparent", borderRadius: 8, overflow: "hidden", // Constant 2px border, color-only on focus. A width change (1px<->2px) // would resize the inner content box, fire ResizeObserver -> fit -> PTY // SIGWINCH, making zsh/powerlevel10k reprint its prompt on every focus // switch (the "stacked prompts" bug). border: `2px solid ${focused ? COLORS.accent : COLORS.borderSubtle}`, boxSizing: "border-box", }} > {/* Glass fill + blur as a layer BEHIND the content. The terminal's transparent cells show this through. Crucially the terminal canvas is NOT a descendant of a backdrop-filter element — under WKWebView that clips/smears the WebGL canvas (first-glyph clip at column 0, smearing on scroll). With "none" the glass is the solid bg-panel so the classic look is unchanged. */}
{inner}
{dropEdge && }
); if (running[id] === false) { return card(
Stopped
{zoomed === id && ( )}
); } const spec = surfaces[id]?.spec; const agent = spec?.agent_label ?? "shell"; const state = states[id] ?? "idle"; return card( <>
{ onFocus(id); onStartPanelDrag(id, e); }} title="Drag to move this panel" style={{ display: "flex", alignItems: "center", gap: 8, height: 30, flex: "0 0 30px", padding: "0 10px", background: COLORS.elevatedGlass, borderBottom: `1px solid ${COLORS.borderSubtle}`, cursor: "grab" }} > {agent} {spec?.cwd && {shortPath(spec.cwd)}} {state} {zoomed === id ? { e.stopPropagation(); void setZoom(workspaceId, null); }} /> : { e.stopPropagation(); onFocus(id); void setZoom(workspaceId, id); }} />} { e.stopPropagation(); void closeSurfaceCmd(id); }} onMouseEnter={(e) => { e.currentTarget.style.color = COLORS.stError; }} onMouseLeave={(e) => { e.currentTarget.style.color = COLORS.textMuted; }} />
{searchSurfaceId === id && ( )} ); } function DropIndicator({ edge }: { edge: Edge }) { const base: React.CSSProperties = { position: "absolute", background: `${COLORS.accent}55`, border: `2px solid ${COLORS.accent}`, pointerEvents: "none", boxSizing: "border-box", zIndex: 5 }; const map: Record = { left: { ...base, top: 0, bottom: 0, left: 0, width: "50%" }, right: { ...base, top: 0, bottom: 0, right: 0, width: "50%" }, top: { ...base, left: 0, right: 0, top: 0, height: "50%" }, bottom: { ...base, left: 0, right: 0, bottom: 0, height: "50%" }, }; return
; } function SplitView({ split, path, ...rest }: Omit & { split: Extract["split"] }) { const { orient, ratios, children } = split; const { workspaceId } = rest; const dir = orient === "h" ? "row" : "column"; const containerRef = useRef(null); const [live, setLive] = useState(null); // Drop any local override once the authoritative ratios arrive from the daemon. useEffect(() => { setLive(null); }, [ratios.join(",")]); const effective = live ?? ratios; const startDrag = (i: number, e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); const container = containerRef.current; if (!container) return; const total = orient === "h" ? container.clientWidth : container.clientHeight; if (total <= 0) return; const start = orient === "h" ? e.clientX : e.clientY; const base = [...ratios]; const sum = base.reduce((a, b) => a + b, 0) || 1; const move = (ev: MouseEvent) => { const cur = orient === "h" ? ev.clientX : ev.clientY; // Accumulated delta from drag start — not an incremental step — so the // panel tracks the pointer 1:1 instead of crawling one echo at a time. const deltaFrac = ((cur - start) / total) * sum; const next = [...base]; next[i] = Math.max(0.05, base[i] + deltaFrac); next[i + 1] = Math.max(0.05, (base[i + 1] ?? 1) - deltaFrac); setLive(next); }; const up = () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); setLive((cur) => { if (cur) void setRatios(workspaceId, path, cur); return cur; }); }; window.addEventListener("mousemove", move); window.addEventListener("mouseup", up); }; return (
{children.map((child, i) => (
{i < children.length - 1 && (
startDrag(i, e)} style={{ position: "absolute", zIndex: 4, ...(orient === "h" ? { top: 0, bottom: 0, right: -5, width: 10, cursor: "col-resize" } : { left: 0, right: 0, bottom: -5, height: 10, cursor: "row-resize" }), }} /> )}
))}
); }