import { type SandboxProcessRecord, useInterest } from "@sandbox-agent/foundry-client"; import { ProcessTerminal } from "@sandbox-agent/react"; import { useQuery } from "@tanstack/react-query"; import { useStyletron } from "baseui"; import { useFoundryTokens } from "../../app/theme"; import { ChevronDown, ChevronUp, Plus, SquareTerminal, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { SandboxAgent } from "sandbox-agent"; import { backendClient } from "../../lib/backend"; import { interestManager } from "../../lib/interest"; interface TerminalPaneProps { workspaceId: string; taskId: string | null; isExpanded?: boolean; onExpand?: () => void; onCollapse?: () => void; onStartResize?: (e: React.PointerEvent) => void; } interface ProcessTab { id: string; processId: string; title: string; } const MIN_TERMINAL_HEIGHT = 220; function defaultShellRequest(cwd?: string | null) { return { command: "/bin/bash", args: ["-lc", 'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec "$SHELL" -l; fi; if [ -x /bin/zsh ]; then exec /bin/zsh -l; fi; exec /bin/bash -l'], cwd: cwd ?? undefined, interactive: true, tty: true, }; } function formatProcessTabTitle(process: Pick, fallbackIndex: number): string { const label = process.command.split("/").pop()?.trim(); return label && label.length > 0 ? label : `Terminal ${fallbackIndex}`; } function formatCommandSummary(process: Pick): string { return [process.command, ...process.args].join(" ").trim(); } function HeaderIconButton({ css, t, label, disabled, onClick, children, }: { css: ReturnType[0]; t: ReturnType; label: string; disabled?: boolean; onClick?: () => void; children: React.ReactNode; }) { return (
{ if (!disabled) onClick?.(); }} onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && !disabled) onClick?.(); }} className={css({ width: "26px", height: "26px", borderRadius: "6px", display: "flex", alignItems: "center", justifyContent: "center", color: t.textTertiary, cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.4 : 1, transition: "background 200ms ease, color 200ms ease", ":hover": disabled ? undefined : { backgroundColor: t.interactiveHover, color: t.textSecondary, }, })} > {children}
); } export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onCollapse, onStartResize }: TerminalPaneProps) { const [css] = useStyletron(); const t = useFoundryTokens(); const [activeTabId, setActiveTabId] = useState(null); const [processTabs, setProcessTabs] = useState([]); const [creatingProcess, setCreatingProcess] = useState(false); const [hoveredTabId, setHoveredTabId] = useState(null); const [terminalClient, setTerminalClient] = useState(null); const [customTabNames, setCustomTabNames] = useState>({}); const [editingTabId, setEditingTabId] = useState(null); const editInputRef = useRef(null); // Drag-to-reorder state const [tabDrag, setTabDrag] = useState<{ fromIdx: number; overIdx: number | null } | null>(null); const tabDragRef = useRef<{ fromIdx: number; overIdx: number | null } | null>(null); const tabDragStartY = useRef(0); const didTabDrag = useRef(false); useEffect(() => { if (!tabDrag) return; const onMove = (e: MouseEvent) => { const el = document.elementFromPoint(e.clientX, e.clientY); if (!el) return; const tabEl = (el as HTMLElement).closest?.("[data-terminal-idx]") as HTMLElement | null; if (tabEl) { const overIdx = Number(tabEl.dataset.terminalIdx); if (overIdx !== tabDrag.overIdx) { setTabDrag({ ...tabDrag, overIdx }); tabDragRef.current = { ...tabDrag, overIdx }; } } if (Math.abs(e.clientY - tabDragStartY.current) > 4) { didTabDrag.current = true; } }; const onUp = () => { const d = tabDragRef.current; if (d && didTabDrag.current && d.overIdx !== null && d.fromIdx !== d.overIdx) { setProcessTabs((prev) => { const next = [...prev]; const [moved] = next.splice(d.fromIdx, 1); if (!moved) { return prev; } next.splice(d.overIdx!, 0, moved); return next; }); } tabDragRef.current = null; didTabDrag.current = false; setTabDrag(null); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); return () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; }, [tabDrag]); // Horizontal splitter for terminal list width const DEFAULT_LIST_WIDTH = 180; const MIN_LIST_WIDTH = 40; const MAX_LIST_WIDTH = 360; const [listWidth, setListWidth] = useState(DEFAULT_LIST_WIDTH); const splitterRef = useRef<{ startX: number; startWidth: number } | null>(null); const onSplitterPointerDown = useCallback( (e: React.PointerEvent) => { e.preventDefault(); splitterRef.current = { startX: e.clientX, startWidth: listWidth }; const onMove = (ev: PointerEvent) => { if (!splitterRef.current) return; // Dragging left = increase list width, dragging right = decrease const delta = splitterRef.current.startX - ev.clientX; const next = Math.min(MAX_LIST_WIDTH, Math.max(MIN_LIST_WIDTH, splitterRef.current.startWidth + delta)); setListWidth(next); }; const onUp = () => { splitterRef.current = null; document.removeEventListener("pointermove", onMove); document.removeEventListener("pointerup", onUp); }; document.addEventListener("pointermove", onMove); document.addEventListener("pointerup", onUp); }, [listWidth], ); const workspaceState = useInterest(interestManager, "workspace", { workspaceId }); const taskSummary = useMemo( () => (taskId ? (workspaceState.data?.taskSummaries.find((task) => task.id === taskId) ?? null) : null), [taskId, workspaceState.data?.taskSummaries], ); const taskState = useInterest( interestManager, "task", taskSummary ? { workspaceId, repoId: taskSummary.repoId, taskId: taskSummary.id, } : null, ); const activeSandbox = useMemo(() => { const task = taskState.data; if (!task?.activeSandboxId) { return null; } return task.sandboxes.find((sandbox) => sandbox.sandboxId === task.activeSandboxId) ?? null; }, [taskState.data]); const connectionQuery = useQuery({ queryKey: ["mock-layout", "sandbox-agent-connection", workspaceId, activeSandbox?.providerId ?? "", activeSandbox?.sandboxId ?? ""], enabled: Boolean(activeSandbox?.sandboxId), staleTime: 30_000, refetchOnWindowFocus: false, queryFn: async () => { if (!activeSandbox) { throw new Error("Cannot load a sandbox connection without an active sandbox."); } return await backendClient.getSandboxAgentConnection(workspaceId, activeSandbox.providerId, activeSandbox.sandboxId); }, }); const processesState = useInterest( interestManager, "sandboxProcesses", activeSandbox ? { workspaceId, providerId: activeSandbox.providerId, sandboxId: activeSandbox.sandboxId, } : null, ); useEffect(() => { if (!connectionQuery.data) { setTerminalClient((current) => { if (current) { void current.dispose(); } return null; }); return; } if (connectionQuery.data.endpoint.startsWith("mock://")) { setTerminalClient((current) => { if (current) { void current.dispose(); } return null; }); return; } let cancelled = false; void SandboxAgent.connect({ baseUrl: connectionQuery.data.endpoint, token: connectionQuery.data.token, waitForHealth: false, }) .then((client) => { if (cancelled) { void client.dispose(); return; } setTerminalClient((current) => { if (current) { void current.dispose(); } return client; }); }) .catch(() => { if (!cancelled) { setTerminalClient((current) => { if (current) { void current.dispose(); } return null; }); } }); return () => { cancelled = true; }; }, [connectionQuery.data]); useEffect(() => { return () => { if (terminalClient) { void terminalClient.dispose(); } }; }, [terminalClient]); useEffect(() => { setActiveTabId(null); setProcessTabs([]); }, [taskId]); const processes = processesState.data ?? []; const openTerminalTab = useCallback((process: SandboxProcessRecord) => { setProcessTabs((current) => { const existing = current.find((tab) => tab.processId === process.id); if (existing) { setActiveTabId(existing.id); return current; } const nextTab: ProcessTab = { id: `terminal:${process.id}`, processId: process.id, title: formatProcessTabTitle(process, current.length + 1), }; setActiveTabId(nextTab.id); return [...current, nextTab]; }); }, []); const closeTerminalTab = useCallback((tabId: string) => { setProcessTabs((current) => { const next = current.filter((tab) => tab.id !== tabId); setActiveTabId((currentActive) => { if (currentActive === tabId) { return next.length > 0 ? next[next.length - 1]!.id : null; } return currentActive; }); return next; }); }, []); const spawnTerminal = useCallback(async () => { if (!activeSandbox?.sandboxId) { return; } setCreatingProcess(true); try { const created = await backendClient.createSandboxProcess({ workspaceId, providerId: activeSandbox.providerId, sandboxId: activeSandbox.sandboxId, request: defaultShellRequest(activeSandbox.cwd), }); openTerminalTab(created); } finally { setCreatingProcess(false); } }, [activeSandbox, openTerminalTab, workspaceId]); const processTabsById = useMemo(() => new Map(processTabs.map((tab) => [tab.id, tab])), [processTabs]); const activeProcessTab = activeTabId ? (processTabsById.get(activeTabId) ?? null) : null; const activeTerminalProcess = useMemo( () => (activeProcessTab ? (processes.find((process) => process.id === activeProcessTab.processId) ?? null) : null), [activeProcessTab, processes], ); const emptyBodyClassName = css({ flex: 1, minHeight: `${MIN_TERMINAL_HEIGHT}px`, display: "flex", alignItems: "center", justifyContent: "center", padding: "24px", backgroundColor: t.surfacePrimary, }); const emptyCopyClassName = css({ maxWidth: "340px", display: "flex", flexDirection: "column", gap: "10px", color: t.textSecondary, fontSize: "12px", lineHeight: 1.6, textAlign: "center", }); const renderTerminalView = () => { if (!activeProcessTab) { return (
No terminal open. Click + to open a new terminal session.
); } if (!activeTerminalProcess) { return (
Process not found. This terminal tab points at a process that no longer exists.
); } if (!terminalClient) { return (
Interactive terminal transport is unavailable. Mock mode does not open a live terminal transport.
); } return (
{formatCommandSummary(activeTerminalProcess)} {activeTerminalProcess.id.slice(0, 8)}
); }; const renderBody = () => { if (!taskId) { return (
Select a task to open a terminal.
); } if (taskState.status === "loading") { return (
Loading sandbox state...
); } if (taskState.error) { return (
Could not load task state. {taskState.error.message}
); } if (!activeSandbox?.sandboxId) { return (
Waiting for sandbox... The active task does not have a sandbox runtime yet.
); } return renderTerminalView(); }; return (
{/* Resize handle */}
{/* Full-width header bar */}
Terminal
{ if (activeSandbox?.sandboxId && !creatingProcess) void spawnTerminal(); }} > { if (activeTabId) closeTerminalTab(activeTabId); }} > {isExpanded ? : }
{/* Two-column body: terminal left, list right — hidden when no tabs */} {processTabs.length > 0 && (
{/* Left: terminal content */}
{renderBody()}
{/* Splitter */}
{/* Right: vertical terminal list */}
{processTabs.map((tab, tabIndex) => { const isActive = activeTabId === tab.id; const isHovered = hoveredTabId === tab.id; const isDropTarget = tabDrag !== null && tabDrag.overIdx === tabIndex && tabDrag.fromIdx !== tabIndex; const isBeingDragged = tabDrag !== null && tabDrag.fromIdx === tabIndex && didTabDrag.current; return (
setHoveredTabId(tab.id)} onMouseLeave={() => setHoveredTabId((cur) => (cur === tab.id ? null : cur))} onMouseDown={(e) => { if (e.button !== 0 || editingTabId === tab.id) return; tabDragStartY.current = e.clientY; didTabDrag.current = false; const state = { fromIdx: tabIndex, overIdx: null }; tabDragRef.current = state; setTabDrag(state); }} onClick={() => { if (!didTabDrag.current) setActiveTabId(tab.id); }} className={css({ display: "flex", alignItems: "center", justifyContent: listWidth < 80 ? "center" : "flex-start", gap: "8px", padding: listWidth < 80 ? "8px 0" : "8px 12px", margin: "2px 4px", borderRadius: "8px", cursor: "pointer", overflow: "hidden", position: "relative", "::before": { content: '""', position: "absolute", top: "-2px", left: 0, right: 0, height: "2px", backgroundColor: isDropTarget ? t.textPrimary : "transparent", transition: "background-color 100ms ease", }, backgroundColor: isActive ? t.interactiveHover : "transparent", opacity: isBeingDragged ? 0.4 : 1, color: isActive ? t.textPrimary : t.textTertiary, fontWeight: isActive ? 600 : 400, fontSize: "12px", transition: "all 150ms ease", ":hover": { backgroundColor: t.interactiveHover, }, })} > {listWidth >= 80 && (editingTabId === tab.id ? ( e.stopPropagation()} onBlur={(e) => { const val = e.currentTarget.value.trim(); if (val) { setCustomTabNames((prev) => ({ ...prev, [tab.id]: val })); } setEditingTabId(null); }} onKeyDown={(e) => { if (e.key === "Enter") { e.currentTarget.blur(); } else if (e.key === "Escape") { setEditingTabId(null); } }} className={css({ flex: 1, minWidth: 0, background: "transparent", border: "none", outline: "none", color: "inherit", font: "inherit", fontSize: "12px", padding: 0, margin: 0, })} /> ) : ( { e.stopPropagation(); setEditingTabId(tab.id); }} className={css({ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })} > {customTabNames[tab.id] ?? tab.title} ))} {listWidth >= 80 && (
{ e.stopPropagation(); closeTerminalTab(tab.id); }} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") closeTerminalTab(tab.id); }} className={css({ width: "18px", height: "18px", borderRadius: "4px", display: "flex", alignItems: "center", justifyContent: "center", color: t.textMuted, flexShrink: 0, opacity: isHovered ? 1 : 0, pointerEvents: isHovered ? "auto" : "none", transition: "opacity 150ms ease, background 200ms ease, color 200ms ease", ":hover": { backgroundColor: "rgba(255, 255, 255, 0.20)", color: t.textSecondary, }, })} >
)}
); })} {/* Bottom drop zone for dragging to end of list */}
)}
); }