diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5d160f4bda..815beb832d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -27,6 +27,8 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useQuery } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; +import { createPortal } from "react-dom"; +import type { TerminalBottomScope } from "@t3tools/contracts/settings"; import { useGitStatus } from "~/lib/gitStatusState"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { isElectron } from "../env"; @@ -90,7 +92,7 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import BranchToolbar from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; -import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; +import ThreadTerminalPanel from "./ThreadTerminalPanel"; import { BotIcon, ChevronDownIcon, @@ -123,7 +125,9 @@ import { resolveSelectableProvider, } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; +import { useWorkspacePanelController } from "../hooks/useWorkspacePanelController"; import { resolveAppModelSelection } from "../modelSelection"; +import type { TerminalDockTarget } from "../workspacePanels"; import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, @@ -198,6 +202,7 @@ import { useServerKeybindings, } from "~/rpc/serverState"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; +import { useWorkspaceTerminalPortalTargets } from "~/workspaceTerminalPortal"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -329,6 +334,11 @@ const terminalContextIdListsEqual = ( contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); interface ChatViewProps { + layoutState: { + diffToggleActive: boolean; + terminalDockTarget: TerminalDockTarget; + terminalToggleActive: boolean; + }; threadId: ThreadId; } @@ -407,7 +417,7 @@ function useLocalDispatchState(input: { }; } -interface PersistentThreadTerminalDrawerProps { +interface PersistentThreadTerminalPanelProps { threadId: ThreadId; visible: boolean; launchContext: PersistentTerminalLaunchContext | null; @@ -416,9 +426,13 @@ interface PersistentThreadTerminalDrawerProps { newShortcutLabel: string | undefined; closeShortcutLabel: string | undefined; onAddTerminalContext: (selection: TerminalContextSelection) => void; + bottomScope: TerminalBottomScope; + layout: "bottom" | "side"; + portalTarget: HTMLElement | null; + renderInline: boolean; } -function PersistentThreadTerminalDrawer({ +function PersistentThreadTerminalPanel({ threadId, visible, launchContext, @@ -427,7 +441,11 @@ function PersistentThreadTerminalDrawer({ newShortcutLabel, closeShortcutLabel, onAddTerminalContext, -}: PersistentThreadTerminalDrawerProps) { + bottomScope, + layout, + portalTarget, + renderInline, +}: PersistentThreadTerminalPanelProps) { const serverThread = useThreadById(threadId); const draftThread = useComposerDraftStore( (store) => store.draftThreadsByThreadId[threadId] ?? null, @@ -546,45 +564,60 @@ function PersistentThreadTerminalDrawer({ return null; } - return ( -
- -
+ const panel = ( + ); + + if (!visible) { + return
{panel}
; + } + + if (!renderInline) { + if (!portalTarget) { + return
{panel}
; + } + return createPortal(panel, portalTarget); + } + + return panel; } -export default function ChatView({ threadId }: ChatViewProps) { +export default function ChatView({ layoutState, threadId }: ChatViewProps) { const serverThread = useThreadById(threadId); const setStoreThreadError = useStore((store) => store.setError); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const activeThreadLastVisitedAt = useUiStateStore( (store) => store.threadLastVisitedAtById[threadId], ); + const workspaceTerminalPortalTargets = useWorkspaceTerminalPortalTargets(); const settings = useSettings(); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); + const terminalPosition = settings.terminalPosition; const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const rawSearch = useSearch({ @@ -1571,17 +1604,25 @@ export default function ChatView({ threadId }: ChatViewProps) { () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), [keybindings, nonTerminalShortcutLabelOptions], ); - const onToggleDiff = useCallback(() => { - void navigate({ - to: "/$threadId", - params: { threadId }, - replace: true, - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; - }, - }); - }, [diffOpen, navigate, threadId]); + const terminalToggleActive = layoutState.terminalToggleActive; + const diffToggleActive = layoutState.diffToggleActive; + const setTerminalOpen = useCallback( + (open: boolean) => { + storeSetTerminalOpen(threadId, open); + }, + [storeSetTerminalOpen, threadId], + ); + const panelController = useWorkspacePanelController({ + diffOpen, + diffToggleActive, + replaceHistory: true, + setTerminalOpen, + terminalOpen: terminalState.terminalOpen, + terminalPosition, + terminalToggleActive, + threadId, + }); + const onToggleDiff = panelController.toggleDiffPanel; const envLocked = Boolean( activeThread && @@ -1669,17 +1710,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [activeThread, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext], ); - const setTerminalOpen = useCallback( - (open: boolean) => { - if (!activeThreadId) return; - storeSetTerminalOpen(activeThreadId, open); - }, - [activeThreadId, storeSetTerminalOpen], - ); - const toggleTerminalVisibility = useCallback(() => { - if (!activeThreadId) return; - setTerminalOpen(!terminalState.terminalOpen); - }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]); + const toggleTerminalVisibility = panelController.toggleTerminalPanel; const splitTerminal = useCallback(() => { if (!activeThreadId || hasReachedSplitLimit) return; const terminalId = `terminal-${randomUUID()}`; @@ -3703,7 +3734,9 @@ export default function ChatView({ threadId }: ChatViewProps) { trigger.rangeStart, replacementRangeEnd, replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), + }, ); if (applied) { setComposerHighlightedItemId(null); @@ -3722,7 +3755,9 @@ export default function ChatView({ threadId }: ChatViewProps) { trigger.rangeStart, replacementRangeEnd, replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), + }, ); if (applied) { setComposerHighlightedItemId(null); @@ -3918,6 +3953,19 @@ export default function ChatView({ threadId }: ChatViewProps) { ); } + const activeTerminalPortalTarget = + layoutState.terminalDockTarget === "bottom-workspace" + ? workspaceTerminalPortalTargets.bottom + : layoutState.terminalDockTarget === "right" + ? workspaceTerminalPortalTargets.right + : null; + const activeTerminalLayout = layoutState.terminalDockTarget?.startsWith("bottom") + ? "bottom" + : "side"; + const activeTerminalBottomScope: TerminalBottomScope = + layoutState.terminalDockTarget === "bottom-workspace" ? "workspace" : "chat"; + const renderActiveTerminalInline = layoutState.terminalDockTarget === "bottom-inline"; + return (
{/* Top bar */} @@ -3940,11 +3988,11 @@ export default function ChatView({ threadId }: ChatViewProps) { keybindings={keybindings} availableEditors={availableEditors} terminalAvailable={activeProject !== undefined} - terminalOpen={terminalState.terminalOpen} + terminalOpen={terminalToggleActive} terminalToggleShortcutLabel={terminalToggleShortcutLabel} diffToggleShortcutLabel={diffPanelShortcutLabel} gitCwd={gitCwd} - diffOpen={diffOpen} + diffOpen={diffToggleActive} onRunProjectScript={(script) => { void runProjectScript(script); }} @@ -4455,7 +4503,7 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* end horizontal flex container */} {mountedTerminalThreadIds.map((mountedThreadId) => ( - ))} diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index c08c53325d..aac104e99e 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -10,7 +10,7 @@ export type DiffPanelMode = "inline" | "sheet" | "sidebar"; function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { const shouldUseDragRegion = isElectron && mode !== "sheet"; return cn( - "flex items-center justify-between gap-2 px-4", + "flex min-w-0 items-center gap-2 overflow-hidden px-4", shouldUseDragRegion ? "drag-region h-[52px] border-b border-border" : "h-12", ); } @@ -25,7 +25,7 @@ export function DiffPanelShell(props: { return (
{ + it("reserves room for the workspace row when the bottom terminal spans the workspace", () => { + expect( + resolveTerminalPanelMaxHeight({ + layout: "bottom", + bottomScope: "workspace", + parentHeight: 480, + viewportHeight: 1200, + }), + ).toBe(260); + }); + + it("uses the viewport ratio for chat-scoped bottom terminals", () => { + expect( + resolveTerminalPanelMaxHeight({ + layout: "bottom", + bottomScope: "chat", + parentHeight: 480, + viewportHeight: 1200, + }), + ).toBe(900); + }); +}); + +describe("clampTerminalPanelHeight", () => { + it("clamps workspace-spanning bottom terminals to the available workspace height", () => { + expect( + clampTerminalPanelHeight(420, { + layout: "bottom", + bottomScope: "workspace", + parentHeight: 480, + viewportHeight: 1200, + }), + ).toBe(260); + }); +}); + +describe("resolveTerminalSplitViewGridStyle", () => { + it("uses columns for bottom-docked terminal splits", () => { + expect(resolveTerminalSplitViewGridStyle("bottom", 3)).toEqual({ + gridTemplateColumns: "repeat(3, minmax(0, 1fr))", + }); + }); + + it("uses rows for right-docked terminal splits", () => { + expect(resolveTerminalSplitViewGridStyle("side", 3)).toEqual({ + gridTemplateRows: "repeat(3, minmax(0, 1fr))", + }); + }); +}); describe("resolveTerminalSelectionActionPosition", () => { it("prefers the selection rect over the last pointer position", () => { diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalPanel.tsx similarity index 80% rename from apps/web/src/components/ThreadTerminalDrawer.tsx rename to apps/web/src/components/ThreadTerminalPanel.tsx index ffb7c1e4d0..cf04ab3a1c 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalPanel.tsx @@ -5,6 +5,7 @@ import { type TerminalSessionSnapshot, type ThreadId, } from "@t3tools/contracts"; +import type { TerminalBottomScope } from "@t3tools/contracts/settings"; import { Terminal, type ITheme } from "@xterm/xterm"; import { type PointerEvent as ReactPointerEvent, @@ -36,19 +37,59 @@ import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalSt const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; +const MIN_BOTTOM_WORKSPACE_CONTENT_HEIGHT = 220; const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; +const MIN_TERMINAL_COLS = 20; +const MIN_TERMINAL_ROWS = 5; + +interface TerminalPanelHeightBoundsInput { + bottomScope: TerminalBottomScope; + layout: "bottom" | "side"; + parentHeight?: number | null; + viewportHeight?: number; +} + +export function resolveTerminalPanelMaxHeight(input: TerminalPanelHeightBoundsInput): number { + const viewportHeight = + input.viewportHeight ?? + (typeof window === "undefined" ? DEFAULT_THREAD_TERMINAL_HEIGHT : window.innerHeight); + let maxHeight = Math.floor(viewportHeight * MAX_DRAWER_HEIGHT_RATIO); + + if ( + input.layout === "bottom" && + input.bottomScope === "workspace" && + typeof input.parentHeight === "number" && + Number.isFinite(input.parentHeight) + ) { + maxHeight = Math.min( + maxHeight, + Math.floor(input.parentHeight - MIN_BOTTOM_WORKSPACE_CONTENT_HEIGHT), + ); + } -function maxDrawerHeight(): number { - if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; - return Math.max(MIN_DRAWER_HEIGHT, Math.floor(window.innerHeight * MAX_DRAWER_HEIGHT_RATIO)); + return Math.max(MIN_DRAWER_HEIGHT, maxHeight); } -function clampDrawerHeight(height: number): number { +export function clampTerminalPanelHeight( + height: number, + input: TerminalPanelHeightBoundsInput, +): number { const safeHeight = Number.isFinite(height) ? height : DEFAULT_THREAD_TERMINAL_HEIGHT; - const maxHeight = maxDrawerHeight(); + const maxHeight = resolveTerminalPanelMaxHeight(input); return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight); } +export function resolveTerminalSplitViewGridStyle( + layout: "bottom" | "side", + terminalCount: number, +): { + gridTemplateColumns?: string; + gridTemplateRows?: string; +} { + const template = `repeat(${terminalCount}, minmax(0, 1fr))`; + return layout === "side" ? { gridTemplateRows: template } : { gridTemplateColumns: template }; +} + function writeSystemMessage(terminal: Terminal, message: string): void { terminal.write(`\r\n[terminal] ${message}\r\n`); } @@ -74,6 +115,18 @@ export function selectPendingTerminalEventEntries( return entries.filter((entry) => entry.id > lastAppliedTerminalEventId); } +function resolveTerminalViewportSize(terminal: Terminal): { cols: number; rows: number } | null { + const cols = Math.round(terminal.cols); + const rows = Math.round(terminal.rows); + if (!Number.isFinite(cols) || !Number.isFinite(rows)) { + return null; + } + if (cols < MIN_TERMINAL_COLS || rows < MIN_TERMINAL_ROWS) { + return null; + } + return { cols, rows }; +} + function terminalThemeFromApp(): ITheme { const isDark = document.documentElement.classList.contains("dark"); const bodyStyles = getComputedStyle(document.body); @@ -220,6 +273,7 @@ interface TerminalViewportProps { autoFocus: boolean; resizeEpoch: number; drawerHeight: number; + layout: "bottom" | "side"; } function TerminalViewport({ @@ -235,6 +289,7 @@ function TerminalViewport({ autoFocus, resizeEpoch, drawerHeight, + layout, }: TerminalViewportProps) { const containerRef = useRef(null); const terminalRef = useRef(null); @@ -254,6 +309,13 @@ function TerminalViewport({ onAddTerminalContext(selection); }); const readTerminalLabel = useEffectEvent(() => terminalLabel); + const sessionOpenRef = useRef(false); + const openingSessionRef = useRef(false); + + useEffect(() => { + sessionOpenRef.current = false; + openingSessionRef.current = false; + }, [cwd, runtimeEnv, terminalId, threadId]); useEffect(() => { const mount = containerRef.current; @@ -509,6 +571,7 @@ function TerminalViewport({ } if (event.type === "started" || event.type === "restarted") { + sessionOpenRef.current = true; hasHandledExitRef.current = false; clearSelectionAction(); writeTerminalSnapshot(activeTerminal, event.snapshot); @@ -527,27 +590,34 @@ function TerminalViewport({ return; } - const details = [ - typeof event.exitCode === "number" ? `code ${event.exitCode}` : null, - typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null, - ] - .filter((value): value is string => value !== null) - .join(", "); - writeSystemMessage( - activeTerminal, - details.length > 0 ? `Process exited (${details})` : "Process exited", - ); - if (hasHandledExitRef.current) { - return; - } - hasHandledExitRef.current = true; - window.setTimeout(() => { - if (!hasHandledExitRef.current) { + if (event.type === "exited") { + sessionOpenRef.current = false; + const details = [ + typeof event.exitCode === "number" ? `code ${event.exitCode}` : null, + typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null, + ] + .filter((value): value is string => value !== null) + .join(", "); + writeSystemMessage( + activeTerminal, + details.length > 0 ? `Process exited (${details})` : "Process exited", + ); + if (hasHandledExitRef.current) { return; } - handleSessionExited(); - }, 0); + hasHandledExitRef.current = true; + window.setTimeout(() => { + if (!hasHandledExitRef.current) { + return; + } + handleSessionExited(); + }, 0); + } }; + const unsubscribe = api?.terminal.onEvent((event) => { + if (event.threadId !== threadId || event.terminalId !== terminalId) return; + applyTerminalEvent(event); + }); const applyPendingTerminalEvents = ( terminalEventEntries: ReadonlyArray<{ id: number; event: TerminalEvent }>, ) => { @@ -590,21 +660,31 @@ function TerminalViewport({ }); const openTerminal = async () => { + if (openingSessionRef.current || sessionOpenRef.current) { + return; + } try { const activeTerminal = terminalRef.current; const activeFitAddon = fitAddonRef.current; if (!activeTerminal || !activeFitAddon) return; activeFitAddon.fit(); + const viewportSize = resolveTerminalViewportSize(activeTerminal); + if (!viewportSize) { + return; + } + openingSessionRef.current = true; const snapshot = await api.terminal.open({ threadId, terminalId, cwd, ...(worktreePath !== undefined ? { worktreePath } : {}), - cols: activeTerminal.cols, - rows: activeTerminal.rows, + cols: viewportSize.cols, + rows: viewportSize.rows, ...(runtimeEnv ? { env: runtimeEnv } : {}), }); if (disposed) return; + sessionOpenRef.current = true; + hasHandledExitRef.current = false; writeTerminalSnapshot(activeTerminal, snapshot); const bufferedEntries = selectTerminalEventEntries( useTerminalStateStore.getState().terminalEventEntriesByKey, @@ -631,6 +711,8 @@ function TerminalViewport({ terminal, err instanceof Error ? err.message : "Failed to open terminal", ); + } finally { + openingSessionRef.current = false; } }; @@ -641,15 +723,23 @@ function TerminalViewport({ const wasAtBottom = activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; activeFitAddon.fit(); + const viewportSize = resolveTerminalViewportSize(activeTerminal); if (wasAtBottom) { activeTerminal.scrollToBottom(); } + if (!viewportSize) { + return; + } + if (!sessionOpenRef.current) { + void openTerminal(); + return; + } void api.terminal .resize({ threadId, terminalId, - cols: activeTerminal.cols, - rows: activeTerminal.rows, + cols: viewportSize.cols, + rows: viewportSize.rows, }) .catch(() => undefined); }, 30); @@ -659,6 +749,7 @@ function TerminalViewport({ disposed = true; terminalHydratedRef.current = false; lastAppliedTerminalEventIdRef.current = 0; + unsubscribe?.(); unsubscribeTerminalEvents(); window.clearTimeout(fitTimer); inputDisposable.dispose(); @@ -670,6 +761,8 @@ function TerminalViewport({ window.removeEventListener("mouseup", handleMouseUp); mount.removeEventListener("pointerdown", handlePointerDown); themeObserver.disconnect(); + sessionOpenRef.current = false; + openingSessionRef.current = false; terminalRef.current = null; fitAddonRef.current = null; terminal.dispose(); @@ -691,6 +784,86 @@ function TerminalViewport({ }; }, [autoFocus, focusRequestId]); + useEffect(() => { + const container = containerRef.current; + const api = readNativeApi(); + if (!container || !api || typeof ResizeObserver === "undefined") return; + + let frame: number | null = null; + + const fitToContainer = () => { + const terminal = terminalRef.current; + const fitAddon = fitAddonRef.current; + if (!terminal || !fitAddon) return; + const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; + fitAddon.fit(); + const viewportSize = resolveTerminalViewportSize(terminal); + if (wasAtBottom) { + terminal.scrollToBottom(); + } + if (!viewportSize) { + return; + } + if (!sessionOpenRef.current) { + void (async () => { + const activeTerminal = terminalRef.current; + const activeFitAddon = fitAddonRef.current; + if (!activeTerminal || !activeFitAddon) return; + activeFitAddon.fit(); + const nextViewportSize = resolveTerminalViewportSize(activeTerminal); + if (!nextViewportSize || openingSessionRef.current || sessionOpenRef.current) { + return; + } + openingSessionRef.current = true; + try { + const snapshot = await api.terminal.open({ + threadId, + terminalId, + cwd, + cols: nextViewportSize.cols, + rows: nextViewportSize.rows, + ...(runtimeEnv ? { env: runtimeEnv } : {}), + }); + sessionOpenRef.current = true; + activeTerminal.write("\u001bc"); + if (snapshot.history.length > 0) { + activeTerminal.write(snapshot.history); + } + } catch { + // Let the next valid resize retry opening the session. + } finally { + openingSessionRef.current = false; + } + })(); + return; + } + void api.terminal + .resize({ + threadId, + terminalId, + cols: viewportSize.cols, + rows: viewportSize.rows, + }) + .catch(() => undefined); + }; + + const observer = new ResizeObserver(() => { + if (frame !== null) return; + frame = window.requestAnimationFrame(() => { + frame = null; + fitToContainer(); + }); + }); + observer.observe(container); + + return () => { + observer.disconnect(); + if (frame !== null) { + window.cancelAnimationFrame(frame); + } + }; + }, [cwd, runtimeEnv, terminalId, threadId]); + useEffect(() => { const api = readNativeApi(); const terminal = terminalRef.current; @@ -699,34 +872,39 @@ function TerminalViewport({ const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; const frame = window.requestAnimationFrame(() => { fitAddon.fit(); + const viewportSize = resolveTerminalViewportSize(terminal); if (wasAtBottom) { terminal.scrollToBottom(); } + if (!viewportSize || !sessionOpenRef.current) { + return; + } void api.terminal .resize({ threadId, terminalId, - cols: terminal.cols, - rows: terminal.rows, + cols: viewportSize.cols, + rows: viewportSize.rows, }) .catch(() => undefined); }); return () => { window.cancelAnimationFrame(frame); }; - }, [drawerHeight, resizeEpoch, terminalId, threadId]); + }, [drawerHeight, layout, resizeEpoch, terminalId, threadId]); return (
); } -interface ThreadTerminalDrawerProps { +interface ThreadTerminalPanelProps { threadId: ThreadId; cwd: string; worktreePath?: string | null; runtimeEnv?: Record; visible?: boolean; height: number; + bottomScope?: TerminalBottomScope; terminalIds: string[]; activeTerminalId: string; terminalGroups: ThreadTerminalGroup[]; @@ -741,6 +919,7 @@ interface ThreadTerminalDrawerProps { onCloseTerminal: (terminalId: string) => void; onHeightChange: (height: number) => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; + layout?: "bottom" | "side"; } interface TerminalActionButtonProps { @@ -772,13 +951,14 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA ); } -export default function ThreadTerminalDrawer({ +export default function ThreadTerminalPanel({ threadId, cwd, worktreePath, runtimeEnv, visible = true, height, + bottomScope = "chat", terminalIds, activeTerminalId, terminalGroups, @@ -793,11 +973,30 @@ export default function ThreadTerminalDrawer({ onCloseTerminal, onHeightChange, onAddTerminalContext, -}: ThreadTerminalDrawerProps) { - const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); + layout = "bottom", +}: ThreadTerminalPanelProps) { + const isSideLayout = layout === "side"; + const panelRef = useRef(null); + const resolveParentHeight = useCallback( + () => + panelRef.current?.closest("[data-workspace-shell='true']")?.clientHeight ?? null, + [], + ); + const clampPanelHeight = useCallback( + (nextHeight: number) => + clampTerminalPanelHeight(nextHeight, { + bottomScope, + layout, + parentHeight: resolveParentHeight(), + }), + [bottomScope, layout, resolveParentHeight], + ); + const [drawerHeight, setDrawerHeight] = useState(() => + clampTerminalPanelHeight(height, { bottomScope, layout }), + ); const [resizeEpoch, setResizeEpoch] = useState(0); const drawerHeightRef = useRef(drawerHeight); - const lastSyncedHeightRef = useRef(clampDrawerHeight(height)); + const lastSyncedHeightRef = useRef(clampTerminalPanelHeight(height, { bottomScope, layout })); const onHeightChangeRef = useRef(onHeightChange); const resizeStateRef = useRef<{ pointerId: number; @@ -899,6 +1098,10 @@ export default function ThreadTerminalDrawer({ resolvedTerminalGroups.length > 1 || resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; + const splitViewGridStyle = useMemo( + () => resolveTerminalSplitViewGridStyle(layout, visibleTerminalIds.length), + [layout, visibleTerminalIds.length], + ); const terminalLabelById = useMemo( () => new Map( @@ -917,6 +1120,7 @@ export default function ThreadTerminalDrawer({ const closeTerminalActionLabel = closeShortcutLabel ? `Close Terminal (${closeShortcutLabel})` : "Close Terminal"; + const splitTerminalIconClassName = isSideLayout ? "size-3.25 rotate-90" : "size-3.25"; const onSplitTerminalAction = useCallback(() => { if (hasReachedSplitLimit) return; onSplitTerminal(); @@ -933,19 +1137,24 @@ export default function ThreadTerminalDrawer({ drawerHeightRef.current = drawerHeight; }, [drawerHeight]); - const syncHeight = useCallback((nextHeight: number) => { - const clampedHeight = clampDrawerHeight(nextHeight); - if (lastSyncedHeightRef.current === clampedHeight) return; - lastSyncedHeightRef.current = clampedHeight; - onHeightChangeRef.current(clampedHeight); - }, []); + const syncHeight = useCallback( + (nextHeight: number) => { + if (isSideLayout) return; + const clampedHeight = clampPanelHeight(nextHeight); + if (lastSyncedHeightRef.current === clampedHeight) return; + lastSyncedHeightRef.current = clampedHeight; + onHeightChangeRef.current(clampedHeight); + }, + [clampPanelHeight, isSideLayout], + ); useEffect(() => { - const clampedHeight = clampDrawerHeight(height); + if (isSideLayout) return; + const clampedHeight = clampPanelHeight(height); setDrawerHeight(clampedHeight); drawerHeightRef.current = clampedHeight; lastSyncedHeightRef.current = clampedHeight; - }, [height, threadId]); + }, [clampPanelHeight, height, isSideLayout, threadId]); const handleResizePointerDown = useCallback((event: ReactPointerEvent) => { if (event.button !== 0) return; @@ -959,20 +1168,23 @@ export default function ThreadTerminalDrawer({ }; }, []); - 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 handleResizePointerMove = useCallback( + (event: ReactPointerEvent) => { + const resizeState = resizeStateRef.current; + if (!resizeState || resizeState.pointerId !== event.pointerId) return; + event.preventDefault(); + const clampedHeight = clampPanelHeight( + resizeState.startHeight + (resizeState.startY - event.clientY), + ); + if (clampedHeight === drawerHeightRef.current) { + return; + } + didResizeDuringDragRef.current = true; + drawerHeightRef.current = clampedHeight; + setDrawerHeight(clampedHeight); + }, + [clampPanelHeight], + ); const handleResizePointerEnd = useCallback( (event: ReactPointerEvent) => { @@ -992,12 +1204,11 @@ export default function ThreadTerminalDrawer({ ); useEffect(() => { - if (!visible) { + if (!visible || isSideLayout) { return; } - const onWindowResize = () => { - const clampedHeight = clampDrawerHeight(drawerHeightRef.current); + const clampedHeight = clampPanelHeight(drawerHeightRef.current); const changed = clampedHeight !== drawerHeightRef.current; if (changed) { setDrawerHeight(clampedHeight); @@ -1012,7 +1223,7 @@ export default function ThreadTerminalDrawer({ return () => { window.removeEventListener("resize", onWindowResize); }; - }, [syncHeight, visible]); + }, [clampPanelHeight, isSideLayout, syncHeight, visible]); useEffect(() => { if (!visible) { @@ -1029,16 +1240,21 @@ export default function ThreadTerminalDrawer({ return (