From c9ea5948f614f9a512c9bccd00fd00ed8bd449f6 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/3] Add workspace-aware terminal panel layout --- apps/web/src/components/ChatView.tsx | 165 +++++--- apps/web/src/components/DiffPanelShell.tsx | 4 +- ...er.test.ts => ThreadTerminalPanel.test.ts} | 41 +- ...inalDrawer.tsx => ThreadTerminalPanel.tsx} | 296 +++++++++++--- .../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, 1278 insertions(+), 243 deletions(-) rename apps/web/src/components/{ThreadTerminalDrawer.test.ts => ThreadTerminalPanel.test.ts} (68%) rename apps/web/src/components/{ThreadTerminalDrawer.tsx => ThreadTerminalPanel.tsx} (82%) 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 76133712d4..5296aac1c6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -26,6 +26,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; +import { createPortal } from "react-dom"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { isElectron } from "../env"; @@ -89,7 +90,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, @@ -126,6 +127,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, @@ -197,6 +199,7 @@ import { useServerConfig, useServerKeybindings, } from "~/rpc/serverState"; +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`; @@ -328,6 +331,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; } @@ -404,7 +412,7 @@ function useLocalDispatchState(input: { }; } -export default function ChatView({ threadId }: ChatViewProps) { +export default function ChatView({ layoutState, threadId }: ChatViewProps) { const serverThread = useThreadById(threadId); const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); @@ -412,10 +420,12 @@ export default function ChatView({ threadId }: ChatViewProps) { 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({ @@ -1377,17 +1387,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 && @@ -1474,13 +1527,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [activeThread, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext], ); - const setTerminalOpen = useCallback( - (open: boolean) => { - if (!activeThreadId) return; - storeSetTerminalOpen(activeThreadId, open); - }, - [activeThreadId, storeSetTerminalOpen], - ); const setTerminalHeight = useCallback( (height: number) => { if (!activeThreadId) return; @@ -1490,8 +1536,31 @@ export default function ChatView({ threadId }: ChatViewProps) { ); 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()}`; @@ -3749,6 +3818,40 @@ export default function ChatView({ threadId }: ChatViewProps) { ); } + const terminalPortalTarget = + layoutState.terminalDockTarget === "bottom-workspace" + ? workspaceTerminalPortalTargets.bottom + : layoutState.terminalDockTarget === "right" + ? workspaceTerminalPortalTargets.right + : null; + const terminalPanel = + terminalState.terminalOpen && activeProject ? ( + + ) : null; + const shouldRenderInlineBottomTerminal = layoutState.terminalDockTarget === "bottom-inline"; + return (
{/* Top bar */} @@ -3771,11 +3874,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); }} @@ -4284,34 +4387,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
{/* end horizontal flex container */} - {(() => { - if (!terminalState.terminalOpen || !activeProject) { - return null; - } - return ( - - ); - })()} + {terminalPortalTarget && terminalPanel + ? createPortal(terminalPanel, terminalPortalTarget) + : null} + {shouldRenderInlineBottomTerminal ? terminalPanel : null} {expandedImage && expandedImageItem && (
{ + 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 82% rename from apps/web/src/components/ThreadTerminalDrawer.tsx rename to apps/web/src/components/ThreadTerminalPanel.tsx index 1bdbfb6ad6..9bab6d47c9 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalPanel.tsx @@ -1,6 +1,7 @@ import { FitAddon } from "@xterm/addon-fit"; import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; import { type ThreadId } from "@t3tools/contracts"; +import type { TerminalBottomScope } from "@t3tools/contracts/settings"; import { Terminal, type ITheme } from "@xterm/xterm"; import { type PointerEvent as ReactPointerEvent, @@ -30,16 +31,45 @@ import { readNativeApi } from "~/nativeApi"; 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); } @@ -47,6 +77,18 @@ function writeSystemMessage(terminal: Terminal, message: string): void { terminal.write(`\r\n[terminal] ${message}\r\n`); } +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); @@ -192,6 +234,7 @@ interface TerminalViewportProps { autoFocus: boolean; resizeEpoch: number; drawerHeight: number; + layout: "bottom" | "side"; } function TerminalViewport({ @@ -206,6 +249,7 @@ function TerminalViewport({ autoFocus, resizeEpoch, drawerHeight, + layout, }: TerminalViewportProps) { const containerRef = useRef(null); const terminalRef = useRef(null); @@ -219,6 +263,8 @@ function TerminalViewport({ const selectionActionRequestIdRef = useRef(0); const selectionActionOpenRef = useRef(false); const selectionActionTimerRef = useRef(null); + const sessionOpenRef = useRef(false); + const openingSessionRef = useRef(false); useEffect(() => { onSessionExitedRef.current = onSessionExited; @@ -232,6 +278,11 @@ function TerminalViewport({ terminalLabelRef.current = terminalLabel; }, [terminalLabel]); + useEffect(() => { + sessionOpenRef.current = false; + openingSessionRef.current = false; + }, [cwd, runtimeEnv, terminalId, threadId]); + useEffect(() => { const mount = containerRef.current; if (!mount) return; @@ -470,20 +521,29 @@ 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, - cols: activeTerminal.cols, - rows: activeTerminal.rows, + cols: viewportSize.cols, + rows: viewportSize.rows, ...(runtimeEnv ? { env: runtimeEnv } : {}), }); if (disposed) return; + sessionOpenRef.current = true; activeTerminal.write("\u001bc"); if (snapshot.history.length > 0) { activeTerminal.write(snapshot.history); @@ -499,6 +559,8 @@ function TerminalViewport({ terminal, err instanceof Error ? err.message : "Failed to open terminal", ); + } finally { + openingSessionRef.current = false; } }; @@ -514,6 +576,7 @@ function TerminalViewport({ } if (event.type === "started" || event.type === "restarted") { + sessionOpenRef.current = true; hasHandledExitRef.current = false; clearSelectionAction(); activeTerminal.write("\u001bc"); @@ -536,6 +599,7 @@ function TerminalViewport({ } 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, @@ -566,15 +630,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); @@ -593,6 +665,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(); @@ -614,6 +688,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; @@ -622,32 +776,37 @@ 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; runtimeEnv?: Record; height: number; + bottomScope?: TerminalBottomScope; terminalIds: string[]; activeTerminalId: string; terminalGroups: ThreadTerminalGroup[]; @@ -662,6 +821,7 @@ interface ThreadTerminalDrawerProps { onCloseTerminal: (terminalId: string) => void; onHeightChange: (height: number) => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; + layout?: "bottom" | "side"; } interface TerminalActionButtonProps { @@ -693,11 +853,12 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA ); } -export default function ThreadTerminalDrawer({ +export default function ThreadTerminalPanel({ threadId, cwd, runtimeEnv, height, + bottomScope = "chat", terminalIds, activeTerminalId, terminalGroups, @@ -712,11 +873,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; @@ -852,19 +1032,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; @@ -878,20 +1063,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) => { @@ -911,8 +1099,9 @@ export default function ThreadTerminalDrawer({ ); useEffect(() => { + if (isSideLayout) return; const onWindowResize = () => { - const clampedHeight = clampDrawerHeight(drawerHeightRef.current); + const clampedHeight = clampPanelHeight(drawerHeightRef.current); const changed = clampedHeight !== drawerHeightRef.current; if (changed) { setDrawerHeight(clampedHeight); @@ -927,7 +1116,7 @@ export default function ThreadTerminalDrawer({ return () => { window.removeEventListener("resize", onWindowResize); }; - }, [syncHeight]); + }, [clampPanelHeight, isSideLayout, syncHeight]); useEffect(() => { return () => { @@ -937,16 +1126,21 @@ export default function ThreadTerminalDrawer({ return (