From ebf80aaa3e8f656c02bb3505ecae916d09d048c3 Mon Sep 17 00:00:00 2001 From: Atmik Shetty Date: Mon, 6 Apr 2026 12:33:49 +0530 Subject: [PATCH] feat: added terminal docking to right --- apps/desktop/scripts/electron-launcher.mjs | 38 ++- apps/web/src/components/ChatView.browser.tsx | 2 + apps/web/src/components/ChatView.tsx | 76 ++++-- .../components/ThreadTerminalDrawer.test.ts | 17 ++ .../src/components/ThreadTerminalDrawer.tsx | 228 ++++++++++++++---- apps/web/src/terminalStateStore.test.ts | 33 +++ apps/web/src/terminalStateStore.ts | 67 ++++- apps/web/src/types.ts | 3 + package.json | 1 + 9 files changed, 399 insertions(+), 66 deletions(-) diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 9d7c522781..4296ad27fe 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -24,6 +24,31 @@ const LAUNCHER_VERSION = 1; const __dirname = dirname(fileURLToPath(import.meta.url)); export const desktopDir = resolve(__dirname, ".."); +function ensureElectronInstalled(require) { + const electronPackageJsonPath = require.resolve("electron/package.json"); + const electronPackageDir = dirname(electronPackageJsonPath); + const electronPathFile = join(electronPackageDir, "path.txt"); + + if (existsSync(electronPathFile)) { + return; + } + + const installScriptPath = join(electronPackageDir, "install.js"); + const installResult = spawnSync("node", [installScriptPath], { + stdio: "inherit", + env: process.env, + }); + + if (installResult.status !== 0 || !existsSync(electronPathFile)) { + const detail = installResult.error?.message + ? ` ${installResult.error.message}` + : installResult.status === null + ? "" + : ` (exit ${installResult.status})`; + throw new Error(`Failed to install Electron runtime automatically.${detail}`.trim()); + } +} + function setPlistString(plistPath, key, value) { const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], { encoding: "utf8", @@ -134,7 +159,18 @@ function buildMacLauncher(electronBinaryPath) { export function resolveElectronPath() { const require = createRequire(import.meta.url); - const electronBinaryPath = require("electron"); + + let electronBinaryPath; + try { + electronBinaryPath = require("electron"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("Electron failed to install correctly")) { + throw error; + } + ensureElectronInstalled(require); + electronBinaryPath = require("electron"); + } if (process.platform !== "darwin") { return electronBinaryPath; diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index a727b89ea3..324feaa2f0 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1404,6 +1404,8 @@ describe("ChatView timeline estimator parity (full app)", () => { [THREAD_ID]: { terminalOpen: true, terminalHeight: 280, + terminalWidth: 420, + terminalDock: "bottom", terminalIds: ["default"], runningTerminalIds: [], activeTerminalId: "default", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index aeab2d083a..7223e8b885 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -77,6 +77,7 @@ import { DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, + type ThreadTerminalDock, type ChatMessage, type SessionPhase, type Thread, @@ -437,6 +438,8 @@ function PersistentThreadTerminalDrawer({ selectThreadTerminalState(state.terminalStateByThreadId, threadId), ); const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight); + const storeSetTerminalWidth = useTerminalStateStore((state) => state.setTerminalWidth); + const storeSetTerminalDock = useTerminalStateStore((state) => state.setTerminalDock); const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal); const storeNewTerminal = useTerminalStateStore((state) => state.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((state) => state.setActiveTerminal); @@ -484,6 +487,18 @@ function PersistentThreadTerminalDrawer({ }, [storeSetTerminalHeight, threadId], ); + const setTerminalWidth = useCallback( + (width: number) => { + storeSetTerminalWidth(threadId, width); + }, + [storeSetTerminalWidth, threadId], + ); + const setTerminalDock = useCallback( + (dock: ThreadTerminalDock) => { + storeSetTerminalDock(threadId, dock); + }, + [storeSetTerminalDock, threadId], + ); const splitTerminal = useCallback(() => { storeSplitTerminal(threadId, `terminal-${randomUUID()}`); @@ -554,7 +569,9 @@ function PersistentThreadTerminalDrawer({ worktreePath={effectiveWorktreePath} runtimeEnv={runtimeEnv} visible={visible} + dock={terminalState.terminalDock} height={terminalState.terminalHeight} + width={terminalState.terminalWidth} terminalIds={terminalState.terminalIds} activeTerminalId={terminalState.activeTerminalId} terminalGroups={terminalState.terminalGroups} @@ -567,7 +584,9 @@ function PersistentThreadTerminalDrawer({ closeShortcutLabel={visible ? closeShortcutLabel : undefined} onActiveTerminalChange={activateTerminal} onCloseTerminal={closeTerminal} + onDockChange={setTerminalDock} onHeightChange={setTerminalHeight} + onWidthChange={setTerminalWidth} onAddTerminalContext={handleAddTerminalContext} /> @@ -740,8 +759,10 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const openTerminalThreadIds = useMemo( () => - Object.entries(terminalStateByThreadId).flatMap(([nextThreadId, nextTerminalState]) => - nextTerminalState.terminalOpen ? [nextThreadId as ThreadId] : [], + Object.keys(terminalStateByThreadId).flatMap((nextThreadId) => + selectThreadTerminalState(terminalStateByThreadId, nextThreadId as ThreadId).terminalOpen + ? [nextThreadId as ThreadId] + : [], ), [terminalStateByThreadId], ); @@ -764,6 +785,24 @@ export default function ChatView({ threadId }: ChatViewProps) { [draftThreadsByThreadId], ); const [mountedTerminalThreadIds, setMountedTerminalThreadIds] = useState([]); + const rightDockedMountedTerminalThreadIds = useMemo( + () => + mountedTerminalThreadIds.filter( + (mountedThreadId) => + selectThreadTerminalState(terminalStateByThreadId, mountedThreadId).terminalDock === + "right", + ), + [mountedTerminalThreadIds, terminalStateByThreadId], + ); + const bottomDockedMountedTerminalThreadIds = useMemo( + () => + mountedTerminalThreadIds.filter( + (mountedThreadId) => + selectThreadTerminalState(terminalStateByThreadId, mountedThreadId).terminalDock !== + "right", + ), + [mountedTerminalThreadIds, terminalStateByThreadId], + ); const setPrompt = useCallback( (nextPrompt: string) => { @@ -3891,6 +3930,21 @@ export default function ChatView({ threadId }: ChatViewProps) { } void onRevertToTurnCount(targetTurnCount); }; + const renderPersistentTerminalDrawer = (mountedThreadId: ThreadId) => ( + + ); // Empty state: no active thread if (!activeThread) { @@ -4451,24 +4505,12 @@ export default function ChatView({ threadId }: ChatViewProps) { }} /> ) : null} + + {rightDockedMountedTerminalThreadIds.map(renderPersistentTerminalDrawer)} {/* end horizontal flex container */} - {mountedTerminalThreadIds.map((mountedThreadId) => ( - - ))} + {bottomDockedMountedTerminalThreadIds.map(renderPersistentTerminalDrawer)} {expandedImage && expandedImageItem && (
{ expect(terminalSelectionActionDelayForClickCount(3)).toBe(260); }); + it("clamps right-docked terminal widths to the supported range", () => { + expect(clampTerminalDockWidth(120, 1_000)).toBe(320); + expect(clampTerminalDockWidth(640, 1_000)).toBe(600); + expect(clampTerminalDockWidth(480, 1_000)).toBe(480); + }); + + it("switches split terminals to rows when docked on the right", () => { + expect(resolveTerminalSplitGridStyle("bottom", 3)).toEqual({ + gridTemplateColumns: "repeat(3, minmax(0, 1fr))", + }); + expect(resolveTerminalSplitGridStyle("right", 3)).toEqual({ + gridTemplateRows: "repeat(3, minmax(0, 1fr))", + }); + }); + it("only handles mouseup when the selection gesture started in the terminal", () => { expect(shouldHandleTerminalSelectionMouseUp(true, 0)).toBe(true); expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index ffb7c1e4d0..4922b1e50d 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,5 +1,13 @@ import { FitAddon } from "@xterm/addon-fit"; -import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; +import { + PanelBottomOpenIcon, + PanelRightOpenIcon, + Plus, + SquareSplitHorizontal, + TerminalSquare, + Trash2, + XIcon, +} from "lucide-react"; import { type TerminalEvent, type TerminalSessionSnapshot, @@ -26,9 +34,12 @@ import { } from "../terminal-links"; import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings"; import { + DEFAULT_THREAD_TERMINAL_DOCK, DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, + DEFAULT_THREAD_TERMINAL_WIDTH, MAX_TERMINALS_PER_GROUP, + type ThreadTerminalDock, type ThreadTerminalGroup, } from "../types"; import { readNativeApi } from "~/nativeApi"; @@ -36,6 +47,8 @@ import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalSt const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; +const MIN_DRAWER_WIDTH = 320; +const MAX_DRAWER_WIDTH_RATIO = 0.6; const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; function maxDrawerHeight(): number { @@ -49,6 +62,19 @@ function clampDrawerHeight(height: number): number { return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight); } +function maxDrawerWidth(viewportWidth?: number): number { + const resolvedViewportWidth = + viewportWidth ?? + (typeof window === "undefined" ? DEFAULT_THREAD_TERMINAL_WIDTH : window.innerWidth); + return Math.max(MIN_DRAWER_WIDTH, Math.floor(resolvedViewportWidth * MAX_DRAWER_WIDTH_RATIO)); +} + +export function clampTerminalDockWidth(width: number, viewportWidth?: number): number { + const safeWidth = Number.isFinite(width) ? width : DEFAULT_THREAD_TERMINAL_WIDTH; + const maxWidth = maxDrawerWidth(viewportWidth); + return Math.min(Math.max(Math.round(safeWidth), MIN_DRAWER_WIDTH), maxWidth); +} + function writeSystemMessage(terminal: Terminal, message: string): void { terminal.write(`\r\n[terminal] ${message}\r\n`); } @@ -74,6 +100,16 @@ export function selectPendingTerminalEventEntries( return entries.filter((entry) => entry.id > lastAppliedTerminalEventId); } +export function resolveTerminalSplitGridStyle( + dock: ThreadTerminalDock, + visibleTerminalCount: number, +): { gridTemplateColumns?: string; gridTemplateRows?: string } { + const count = Math.max(1, Math.round(visibleTerminalCount)); + return dock === "right" + ? { gridTemplateRows: `repeat(${count}, minmax(0, 1fr))` } + : { gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))` }; +} + function terminalThemeFromApp(): ITheme { const isDark = document.documentElement.classList.contains("dark"); const bodyStyles = getComputedStyle(document.body); @@ -219,7 +255,8 @@ interface TerminalViewportProps { focusRequestId: number; autoFocus: boolean; resizeEpoch: number; - drawerHeight: number; + dock: ThreadTerminalDock; + resizeMeasure: number; } function TerminalViewport({ @@ -234,7 +271,8 @@ function TerminalViewport({ focusRequestId, autoFocus, resizeEpoch, - drawerHeight, + dock, + resizeMeasure, }: TerminalViewportProps) { const containerRef = useRef(null); const terminalRef = useRef(null); @@ -714,7 +752,7 @@ function TerminalViewport({ return () => { window.cancelAnimationFrame(frame); }; - }, [drawerHeight, resizeEpoch, terminalId, threadId]); + }, [dock, resizeEpoch, resizeMeasure, terminalId, threadId]); return (
); @@ -726,7 +764,9 @@ interface ThreadTerminalDrawerProps { worktreePath?: string | null; runtimeEnv?: Record; visible?: boolean; + dock: ThreadTerminalDock; height: number; + width: number; terminalIds: string[]; activeTerminalId: string; terminalGroups: ThreadTerminalGroup[]; @@ -739,7 +779,9 @@ interface ThreadTerminalDrawerProps { closeShortcutLabel?: string | undefined; onActiveTerminalChange: (terminalId: string) => void; onCloseTerminal: (terminalId: string) => void; + onDockChange: (dock: ThreadTerminalDock) => void; onHeightChange: (height: number) => void; + onWidthChange: (width: number) => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; } @@ -778,7 +820,9 @@ export default function ThreadTerminalDrawer({ worktreePath, runtimeEnv, visible = true, + dock = DEFAULT_THREAD_TERMINAL_DOCK, height, + width, terminalIds, activeTerminalId, terminalGroups, @@ -791,18 +835,25 @@ export default function ThreadTerminalDrawer({ closeShortcutLabel, onActiveTerminalChange, onCloseTerminal, + onDockChange, onHeightChange, + onWidthChange, onAddTerminalContext, }: ThreadTerminalDrawerProps) { const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); + const [drawerWidth, setDrawerWidth] = useState(() => clampTerminalDockWidth(width)); const [resizeEpoch, setResizeEpoch] = useState(0); const drawerHeightRef = useRef(drawerHeight); + const drawerWidthRef = useRef(drawerWidth); const lastSyncedHeightRef = useRef(clampDrawerHeight(height)); + const lastSyncedWidthRef = useRef(clampTerminalDockWidth(width)); const onHeightChangeRef = useRef(onHeightChange); + const onWidthChangeRef = useRef(onWidthChange); const resizeStateRef = useRef<{ pointerId: number; + startX: number; startY: number; - startHeight: number; + startSize: number; } | null>(null); const didResizeDuringDragRef = useRef(false); @@ -911,16 +962,27 @@ export default function ThreadTerminalDrawer({ : splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal"; + const dockTerminalActionLabel = dock === "right" ? "Dock Terminal Below" : "Dock Terminal Right"; + const DockTerminalActionIcon = dock === "right" ? PanelBottomOpenIcon : PanelRightOpenIcon; const newTerminalActionLabel = newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"; const closeTerminalActionLabel = closeShortcutLabel ? `Close Terminal (${closeShortcutLabel})` : "Close Terminal"; + const resizeMeasure = dock === "right" ? drawerWidth : drawerHeight; + const splitGridStyle = resolveTerminalSplitGridStyle(dock, visibleTerminalIds.length); + const splitPaneClassName = + dock === "right" + ? "min-h-0 min-w-0 border-t first:border-t-0" + : "min-h-0 min-w-0 border-l first:border-l-0"; const onSplitTerminalAction = useCallback(() => { if (hasReachedSplitLimit) return; onSplitTerminal(); }, [hasReachedSplitLimit, onSplitTerminal]); + const onToggleDock = useCallback(() => { + onDockChange(dock === "right" ? "bottom" : "right"); + }, [dock, onDockChange]); const onNewTerminalAction = useCallback(() => { onNewTerminal(); }, [onNewTerminal]); @@ -929,10 +991,18 @@ export default function ThreadTerminalDrawer({ onHeightChangeRef.current = onHeightChange; }, [onHeightChange]); + useEffect(() => { + onWidthChangeRef.current = onWidthChange; + }, [onWidthChange]); + useEffect(() => { drawerHeightRef.current = drawerHeight; }, [drawerHeight]); + useEffect(() => { + drawerWidthRef.current = drawerWidth; + }, [drawerWidth]); + const syncHeight = useCallback((nextHeight: number) => { const clampedHeight = clampDrawerHeight(nextHeight); if (lastSyncedHeightRef.current === clampedHeight) return; @@ -940,6 +1010,13 @@ export default function ThreadTerminalDrawer({ onHeightChangeRef.current(clampedHeight); }, []); + const syncWidth = useCallback((nextWidth: number) => { + const clampedWidth = clampTerminalDockWidth(nextWidth); + if (lastSyncedWidthRef.current === clampedWidth) return; + lastSyncedWidthRef.current = clampedWidth; + onWidthChangeRef.current(clampedWidth); + }, []); + useEffect(() => { const clampedHeight = clampDrawerHeight(height); setDrawerHeight(clampedHeight); @@ -947,32 +1024,60 @@ export default function ThreadTerminalDrawer({ lastSyncedHeightRef.current = clampedHeight; }, [height, threadId]); - const handleResizePointerDown = useCallback((event: ReactPointerEvent) => { - if (event.button !== 0) return; - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - didResizeDuringDragRef.current = false; - resizeStateRef.current = { - pointerId: event.pointerId, - startY: event.clientY, - startHeight: drawerHeightRef.current, - }; - }, []); + useEffect(() => { + const clampedWidth = clampTerminalDockWidth(width); + setDrawerWidth(clampedWidth); + drawerWidthRef.current = clampedWidth; + lastSyncedWidthRef.current = clampedWidth; + }, [threadId, width]); - const handleResizePointerMove = useCallback((event: ReactPointerEvent) => { - const resizeState = resizeStateRef.current; - if (!resizeState || resizeState.pointerId !== event.pointerId) return; - event.preventDefault(); - const clampedHeight = clampDrawerHeight( - resizeState.startHeight + (resizeState.startY - event.clientY), - ); - if (clampedHeight === drawerHeightRef.current) { - return; - } - didResizeDuringDragRef.current = true; - drawerHeightRef.current = clampedHeight; - setDrawerHeight(clampedHeight); - }, []); + const handleResizePointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + didResizeDuringDragRef.current = false; + resizeStateRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + startSize: dock === "right" ? drawerWidthRef.current : drawerHeightRef.current, + }; + }, + [dock], + ); + + const handleResizePointerMove = useCallback( + (event: ReactPointerEvent) => { + const resizeState = resizeStateRef.current; + if (!resizeState || resizeState.pointerId !== event.pointerId) return; + event.preventDefault(); + + if (dock === "right") { + const clampedWidth = clampTerminalDockWidth( + resizeState.startSize + (resizeState.startX - event.clientX), + ); + if (clampedWidth === drawerWidthRef.current) { + return; + } + didResizeDuringDragRef.current = true; + drawerWidthRef.current = clampedWidth; + setDrawerWidth(clampedWidth); + return; + } + + const clampedHeight = clampDrawerHeight( + resizeState.startSize + (resizeState.startY - event.clientY), + ); + if (clampedHeight === drawerHeightRef.current) { + return; + } + didResizeDuringDragRef.current = true; + drawerHeightRef.current = clampedHeight; + setDrawerHeight(clampedHeight); + }, + [dock], + ); const handleResizePointerEnd = useCallback( (event: ReactPointerEvent) => { @@ -985,10 +1090,14 @@ export default function ThreadTerminalDrawer({ if (!didResizeDuringDragRef.current) { return; } - syncHeight(drawerHeightRef.current); + if (dock === "right") { + syncWidth(drawerWidthRef.current); + } else { + syncHeight(drawerHeightRef.current); + } setResizeEpoch((value) => value + 1); }, - [syncHeight], + [dock, syncHeight, syncWidth], ); useEffect(() => { @@ -998,13 +1107,18 @@ export default function ThreadTerminalDrawer({ const onWindowResize = () => { const clampedHeight = clampDrawerHeight(drawerHeightRef.current); - const changed = clampedHeight !== drawerHeightRef.current; - if (changed) { + const clampedWidth = clampTerminalDockWidth(drawerWidthRef.current); + if (clampedHeight !== drawerHeightRef.current) { setDrawerHeight(clampedHeight); drawerHeightRef.current = clampedHeight; } + if (clampedWidth !== drawerWidthRef.current) { + setDrawerWidth(clampedWidth); + drawerWidthRef.current = clampedWidth; + } if (!resizeStateRef.current) { syncHeight(clampedHeight); + syncWidth(clampedWidth); } setResizeEpoch((value) => value + 1); }; @@ -1012,28 +1126,35 @@ export default function ThreadTerminalDrawer({ return () => { window.removeEventListener("resize", onWindowResize); }; - }, [syncHeight, visible]); + }, [syncHeight, syncWidth, visible]); useEffect(() => { if (!visible) { return; } setResizeEpoch((value) => value + 1); - }, [visible]); + }, [dock, visible]); useEffect(() => { return () => { syncHeight(drawerHeightRef.current); + syncWidth(drawerWidthRef.current); }; - }, [syncHeight]); + }, [syncHeight, syncWidth]); return (