From 4bb7748f9e4a860f1ee9a8d6acc7e194729c86c0 Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:47:21 +0100 Subject: [PATCH 1/7] Add workspace-aware terminal panel layout --- apps/web/src/components/ChatView.tsx | 196 +++++++--- apps/web/src/components/DiffPanelShell.tsx | 4 +- ...er.test.ts => ThreadTerminalPanel.test.ts} | 41 +- ...inalDrawer.tsx => ThreadTerminalPanel.tsx} | 340 ++++++++++++---- .../src/components/WorkspaceRightSidebar.tsx | 104 +++++ apps/web/src/components/chat/ChatHeader.tsx | 6 +- .../components/settings/SettingsPanels.tsx | 162 +++++++- apps/web/src/components/ui/sidebar.tsx | 18 +- apps/web/src/index.css | 6 +- apps/web/src/lib/terminalFocus.test.ts | 2 +- apps/web/src/lib/terminalFocus.ts | 2 +- apps/web/src/routes/_chat.$threadId.tsx | 362 ++++++++++++------ apps/web/src/terminal-links.test.ts | 14 +- apps/web/src/workspacePanels.test.ts | 153 ++++++++ apps/web/src/workspacePanels.ts | 77 ++++ apps/web/src/workspaceTerminalPortal.ts | 19 + packages/contracts/src/settings.test.ts | 58 +++ packages/contracts/src/settings.ts | 32 ++ 18 files changed, 1332 insertions(+), 264 deletions(-) rename apps/web/src/components/{ThreadTerminalDrawer.test.ts => ThreadTerminalPanel.test.ts} (78%) rename apps/web/src/components/{ThreadTerminalDrawer.tsx => ThreadTerminalPanel.tsx} (81%) create mode 100644 apps/web/src/components/WorkspaceRightSidebar.tsx create mode 100644 apps/web/src/workspacePanels.test.ts create mode 100644 apps/web/src/workspacePanels.ts create mode 100644 apps/web/src/workspaceTerminalPortal.ts create mode 100644 packages/contracts/src/settings.test.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f995bb4ce7..e3cca53ce3 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 { gitStatusQueryOptions } from "~/lib/gitReactQuery"; 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, @@ -124,6 +126,7 @@ import { } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelection } from "../modelSelection"; +import type { TerminalDockTarget } from "../workspacePanels"; import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, @@ -198,6 +201,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 +333,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 +416,7 @@ function useLocalDispatchState(input: { }; } -interface PersistentThreadTerminalDrawerProps { +interface PersistentThreadTerminalPanelProps { threadId: ThreadId; visible: boolean; launchContext: PersistentTerminalLaunchContext | null; @@ -416,9 +425,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 +440,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 +563,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({ @@ -1570,17 +1602,60 @@ export default function ChatView({ threadId }: ChatViewProps) { () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), [keybindings, nonTerminalShortcutLabelOptions], ); - const onToggleDiff = useCallback(() => { + const terminalToggleActive = layoutState.terminalToggleActive; + const diffToggleActive = layoutState.diffToggleActive; + const closeDiffPanel = useCallback(() => { + void navigate({ + to: "/$threadId", + params: { threadId }, + replace: true, + search: (previous) => ({ + ...stripDiffSearchParams(previous), + diff: undefined, + }), + }); + }, [navigate, threadId]); + const openDiffPanel = useCallback(() => { void navigate({ to: "/$threadId", params: { threadId }, replace: true, search: (previous) => { const rest = stripDiffSearchParams(previous); - return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; + return { ...rest, diff: "1" }; }, }); - }, [diffOpen, navigate, threadId]); + }, [navigate, threadId]); + const setTerminalOpen = useCallback( + (open: boolean) => { + if (!activeThreadId) return; + storeSetTerminalOpen(activeThreadId, open); + }, + [activeThreadId, storeSetTerminalOpen], + ); + const onToggleDiff = useCallback(() => { + if (terminalPosition === "right") { + if (diffToggleActive) { + closeDiffPanel(); + return; + } + setTerminalOpen(false); + openDiffPanel(); + return; + } + if (diffOpen) { + closeDiffPanel(); + return; + } + openDiffPanel(); + }, [ + closeDiffPanel, + diffOpen, + diffToggleActive, + openDiffPanel, + setTerminalOpen, + terminalPosition, + ]); const envLocked = Boolean( activeThread && @@ -1668,17 +1743,33 @@ 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; + if (terminalPosition === "right") { + if (terminalToggleActive) { + setTerminalOpen(false); + return; + } + if (diffOpen) { + setTerminalOpen(true); + closeDiffPanel(); + return; + } + if (!terminalState.terminalOpen) { + setTerminalOpen(true); + } + return; + } setTerminalOpen(!terminalState.terminalOpen); - }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]); + }, [ + activeThreadId, + closeDiffPanel, + diffOpen, + setTerminalOpen, + terminalToggleActive, + terminalPosition, + terminalState.terminalOpen, + ]); const splitTerminal = useCallback(() => { if (!activeThreadId || hasReachedSplitLimit) return; const terminalId = `terminal-${randomUUID()}`; @@ -3917,6 +4008,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 */} @@ -3939,11 +4043,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); }} @@ -4454,7 +4558,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("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 81% rename from apps/web/src/components/ThreadTerminalDrawer.tsx rename to apps/web/src/components/ThreadTerminalPanel.tsx index ffb7c1e4d0..878c96fae6 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,16 +37,45 @@ 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); } @@ -74,6 +104,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 +262,7 @@ interface TerminalViewportProps { autoFocus: boolean; resizeEpoch: number; drawerHeight: number; + layout: "bottom" | "side"; } function TerminalViewport({ @@ -235,6 +278,7 @@ function TerminalViewport({ autoFocus, resizeEpoch, drawerHeight, + layout, }: TerminalViewportProps) { const containerRef = useRef(null); const terminalRef = useRef(null); @@ -254,6 +298,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; @@ -493,6 +544,8 @@ function TerminalViewport({ }); const applyTerminalEvent = (event: TerminalEvent) => { + const unsubscribe = api?.terminal.onEvent((event) => { + if (event.threadId !== threadId || event.terminalId !== terminalId) return; const activeTerminal = terminalRef.current; if (!activeTerminal) { return; @@ -509,6 +562,7 @@ function TerminalViewport({ } if (event.type === "started" || event.type === "restarted") { + sessionOpenRef.current = true; hasHandledExitRef.current = false; clearSelectionAction(); writeTerminalSnapshot(activeTerminal, event.snapshot); @@ -527,26 +581,29 @@ 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 applyPendingTerminalEvents = ( terminalEventEntries: ReadonlyArray<{ id: number; event: TerminalEvent }>, @@ -590,21 +647,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 +698,8 @@ function TerminalViewport({ terminal, err instanceof Error ? err.message : "Failed to open terminal", ); + } finally { + openingSessionRef.current = false; } }; @@ -641,15 +710,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 +736,7 @@ function TerminalViewport({ disposed = true; terminalHydratedRef.current = false; lastAppliedTerminalEventIdRef.current = 0; + unsubscribe?.(); unsubscribeTerminalEvents(); window.clearTimeout(fitTimer); inputDisposable.dispose(); @@ -670,6 +748,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 +771,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 +859,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 +906,7 @@ interface ThreadTerminalDrawerProps { onCloseTerminal: (terminalId: string) => void; onHeightChange: (height: number) => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; + layout?: "bottom" | "side"; } interface TerminalActionButtonProps { @@ -772,13 +938,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 +960,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; @@ -933,19 +1119,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 +1150,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 +1186,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 +1205,7 @@ export default function ThreadTerminalDrawer({ return () => { window.removeEventListener("resize", onWindowResize); }; - }, [syncHeight, visible]); + }, [clampPanelHeight, isSideLayout, syncHeight, visible]); useEffect(() => { if (!visible) { @@ -1029,16 +1222,21 @@ export default function ThreadTerminalDrawer({ return (