From 287a951943b02b36604742e000b260897d4552aa Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 16 Jul 2026 23:48:36 -0700 Subject: [PATCH 1/4] improvement(workflow): zero-render drag-resize for panel, terminal, and output panel Port the sidebar's pointer-capture + rAF + CSS-variable drag pattern to use-panel-resize, use-terminal-resize, and use-output-panel-resize so a drag writes only --panel-width/--terminal-height/--output-panel-width per frame and commits to Zustand (one re-render + one localStorage write) on pointerup. Previously every mousemove dispatched a store set, re-rendering the whole always-mounted Panel tree (Chat/Editor/Toolbar) and the terminal, plus a persist localStorage write per move. Also drop the Panel's unused panelWidth subscription and drive the output panel width via a CSS variable instead of React state. --- apps/sim/app/_styles/globals.css | 1 + .../panel/hooks/use-panel-resize.ts | 119 +++++++++------- .../w/[workflowId]/components/panel/panel.tsx | 7 +- .../components/output-panel/output-panel.tsx | 11 +- .../terminal/hooks/use-output-panel-resize.ts | 118 ++++++++++------ .../terminal/hooks/use-terminal-resize.ts | 127 ++++++++++++------ .../components/terminal/terminal.tsx | 10 +- apps/sim/stores/terminal/store.ts | 14 +- 8 files changed, 262 insertions(+), 145 deletions(-) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index bbb8eca1745..94b055e163d 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -14,6 +14,7 @@ --panel-width: 320px; /* PANEL_WIDTH.DEFAULT */ --editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */ --terminal-height: 206px; /* TERMINAL_HEIGHT.DEFAULT */ + --output-panel-width: 560px; /* OUTPUT_PANEL_WIDTH.DEFAULT */ --auth-primary-btn-bg: #ffffff; --auth-primary-btn-border: #ffffff; --auth-primary-btn-text: #000000; diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts index 4f442fe053b..ad7998683c6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts @@ -1,5 +1,4 @@ -import { useCallback, useEffect } from 'react' -import { useShallow } from 'zustand/react/shallow' +import { useCallback, useEffect, useRef } from 'react' import { PANEL_WIDTH } from '@/stores/constants' import { usePanelStore } from '@/stores/panel' @@ -7,63 +6,85 @@ import { usePanelStore } from '@/stores/panel' const CONTENT_WINDOW_GAP = 8 /** - * Custom hook to handle panel resize functionality. - * Manages mouse events for resizing and enforces min/max width constraints. - * Maximum width is capped at 40% of the viewport width for optimal layout. + * Handles panel drag-resize with zero React renders during the drag. * - * @returns Resize state and handlers + * Mirrors the sidebar resize architecture (`use-sidebar-resize.ts`): + * + * pointerdown → capture the pointer on the handle (so move/up keep arriving + * even when the cursor leaves the window) + * pointermove → write to --panel-width inside a requestAnimationFrame + * callback (the CSS variable alone sizes `.panel-container`, + * so no React work happens per frame) + * pointerup → cancel any pending RAF, tear down, persist the final width + * to Zustand once (one re-render + one localStorage write) + * + * The drag is torn down by `pointerup`, `pointercancel`, or window `blur`, so + * an interrupted gesture can never leave the drag listeners or body cursor + * stuck. A single-flight guard prevents stacking listeners across rapid + * presses, and an unmount cleanup tears down a drag still in flight. */ export function usePanelResize() { - const { setPanelWidth, isResizing, setIsResizing } = usePanelStore( - useShallow((s) => ({ - setPanelWidth: s.setPanelWidth, - isResizing: s.isResizing, - setIsResizing: s.setIsResizing, - })) - ) + const setPanelWidth = usePanelStore((s) => s.setPanelWidth) + const setIsResizing = usePanelStore((s) => s.setIsResizing) + const cleanupRef = useRef<(() => void) | null>(null) - /** - * Handles mouse down on resize handle - */ - const handleMouseDown = useCallback(() => { - setIsResizing(true) - }, [setIsResizing]) + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if (cleanupRef.current) return - /** - * Setup resize event listeners and body styles when resizing - * Cleanup is handled automatically by the effect's return function - */ - useEffect(() => { - if (!isResizing) return + const handle = e.currentTarget + const pointerId = e.pointerId + setIsResizing(true) + document.body.style.cursor = 'ew-resize' + document.body.style.userSelect = 'none' + handle.setPointerCapture?.(pointerId) - const handleMouseMove = (e: MouseEvent) => { - const newWidth = window.innerWidth - CONTENT_WINDOW_GAP - e.clientX - const maxWidth = window.innerWidth * PANEL_WIDTH.MAX_PERCENTAGE + let rafId: number | null = null - if (newWidth >= PANEL_WIDTH.MIN && newWidth <= maxWidth) { - setPanelWidth(newWidth) + const onPointerMove = (ev: PointerEvent) => { + if (rafId !== null) cancelAnimationFrame(rafId) + rafId = requestAnimationFrame(() => { + const maxWidth = window.innerWidth * PANEL_WIDTH.MAX_PERCENTAGE + const newWidth = window.innerWidth - CONTENT_WINDOW_GAP - ev.clientX + const clamped = Math.min(Math.max(newWidth, PANEL_WIDTH.MIN), maxWidth) + document.documentElement.style.setProperty('--panel-width', `${clamped}px`) + rafId = null + }) } - } - const handleMouseUp = () => { - setIsResizing(false) - } + const cleanup = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId) + rafId = null + } + document.body.style.cursor = '' + document.body.style.userSelect = '' + if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId) + document.removeEventListener('pointermove', onPointerMove) + document.removeEventListener('pointerup', endDrag) + document.removeEventListener('pointercancel', endDrag) + window.removeEventListener('blur', endDrag) + cleanupRef.current = null + } - document.addEventListener('mousemove', handleMouseMove) - document.addEventListener('mouseup', handleMouseUp) - document.body.style.cursor = 'ew-resize' - document.body.style.userSelect = 'none' + function endDrag() { + cleanup() + const raw = document.documentElement.style.getPropertyValue('--panel-width') + const finalWidth = Number.parseFloat(raw) + if (!Number.isNaN(finalWidth)) setPanelWidth(finalWidth) + setIsResizing(false) + } + + cleanupRef.current = cleanup + document.addEventListener('pointermove', onPointerMove) + document.addEventListener('pointerup', endDrag) + document.addEventListener('pointercancel', endDrag) + window.addEventListener('blur', endDrag) + }, + [setPanelWidth, setIsResizing] + ) - return () => { - document.removeEventListener('mousemove', handleMouseMove) - document.removeEventListener('mouseup', handleMouseUp) - document.body.style.cursor = '' - document.body.style.userSelect = '' - } - }, [isResizing, setPanelWidth, setIsResizing]) + useEffect(() => () => cleanupRef.current?.(), []) - return { - isResizing, - handleMouseDown, - } + return { handlePointerDown } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 0831daffffb..5fa00fb44ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -122,11 +122,10 @@ export const Panel = memo(function Panel() { const panelRef = useRef(null) const fileInputRef = useRef(null) - const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore( + const { activeTab, setActiveTab, _hasHydrated, setHasHydrated } = usePanelStore( useShallow((state) => ({ activeTab: state.activeTab, setActiveTab: state.setActiveTab, - panelWidth: state.panelWidth, _hasHydrated: state._hasHydrated, setHasHydrated: state.setHasHydrated, })) @@ -189,7 +188,7 @@ export const Panel = memo(function Panel() { const { handleRunWorkflow, handleCancelExecution, isExecuting } = useWorkflowExecution() // Panel resize hook - const { handleMouseDown } = usePanelResize() + const { handlePointerDown } = usePanelResize() /** * Opens subscription settings modal @@ -932,7 +931,7 @@ export const Panel = memo(function Panel() { {/* Resize Handle */}
void + handleOutputPanelResizePointerDown: (e: React.PointerEvent) => void handleHeaderClick: () => void isExpanded: boolean expandToLastHeight: () => void @@ -109,7 +109,7 @@ export interface OutputPanelProps { */ export const OutputPanel = React.memo(function OutputPanel({ selectedEntry, - handleOutputPanelResizeMouseDown, + handleOutputPanelResizePointerDown, handleHeaderClick, isExpanded, expandToLastHeight, @@ -130,7 +130,6 @@ export const OutputPanel = React.memo(function OutputPanel({ handleClearConsoleFromMenu, }: OutputPanelProps) { // Access store-backed settings directly to reduce prop drilling - const outputPanelWidth = useTerminalStore((state) => state.outputPanelWidth) const wrapText = useTerminalStore((state) => state.wrapText) const setWrapText = useTerminalStore((state) => state.setWrapText) const openOnRun = useTerminalStore((state) => state.openOnRun) @@ -293,12 +292,12 @@ export const OutputPanel = React.memo(function OutputPanel({ <>
{/* Horizontal Resize Handle */}
state.setOutputPanelWidth) - const [isResizing, setIsResizing] = useState(false) + const setOutputPanelWidth = useTerminalStore((s) => s.setOutputPanelWidth) + const cleanupRef = useRef<(() => void) | null>(null) - const handleMouseDown = useCallback(() => { - setIsResizing(true) - }, []) + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if (cleanupRef.current) return - useEffect(() => { - if (!isResizing) return - - const handleMouseMove = (e: MouseEvent) => { const terminalEl = document.querySelector('[aria-label="Terminal"]') if (!terminalEl) return - const terminalRect = terminalEl.getBoundingClientRect() - const newWidth = terminalRect.right - e.clientX - const maxWidth = terminalRect.width - TERMINAL_BLOCK_COLUMN_WIDTH - const clampedWidth = Math.max(OUTPUT_PANEL_WIDTH.MIN, Math.min(newWidth, maxWidth)) - - setOutputPanelWidth(clampedWidth) - } - - const handleMouseUp = () => { - setIsResizing(false) - } - - document.addEventListener('mousemove', handleMouseMove) - document.addEventListener('mouseup', handleMouseUp) - document.body.style.cursor = 'ew-resize' - document.body.style.userSelect = 'none' - - return () => { - document.removeEventListener('mousemove', handleMouseMove) - document.removeEventListener('mouseup', handleMouseUp) - document.body.style.cursor = '' - document.body.style.userSelect = '' - } - }, [isResizing, setOutputPanelWidth]) - - return { - isResizing, - handleMouseDown, - } + const handle = e.currentTarget + const pointerId = e.pointerId + document.body.style.cursor = 'ew-resize' + document.body.style.userSelect = 'none' + handle.setPointerCapture?.(pointerId) + + let rafId: number | null = null + + const onPointerMove = (ev: PointerEvent) => { + if (rafId !== null) cancelAnimationFrame(rafId) + rafId = requestAnimationFrame(() => { + const terminalRect = terminalEl.getBoundingClientRect() + const newWidth = terminalRect.right - ev.clientX + const maxWidth = terminalRect.width - TERMINAL_BLOCK_COLUMN_WIDTH + const clamped = Math.max(OUTPUT_PANEL_WIDTH.MIN, Math.min(newWidth, maxWidth)) + document.documentElement.style.setProperty('--output-panel-width', `${clamped}px`) + rafId = null + }) + } + + const cleanup = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId) + rafId = null + } + document.body.style.cursor = '' + document.body.style.userSelect = '' + if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId) + document.removeEventListener('pointermove', onPointerMove) + document.removeEventListener('pointerup', endDrag) + document.removeEventListener('pointercancel', endDrag) + window.removeEventListener('blur', endDrag) + cleanupRef.current = null + } + + function endDrag() { + cleanup() + const raw = document.documentElement.style.getPropertyValue('--output-panel-width') + const finalWidth = Number.parseFloat(raw) + if (!Number.isNaN(finalWidth)) setOutputPanelWidth(finalWidth) + } + + cleanupRef.current = cleanup + document.addEventListener('pointermove', onPointerMove) + document.addEventListener('pointerup', endDrag) + document.addEventListener('pointercancel', endDrag) + window.addEventListener('blur', endDrag) + }, + [setOutputPanelWidth] + ) + + useEffect(() => () => cleanupRef.current?.(), []) + + return { handlePointerDown } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts index 6279aeb24e6..660151bdff0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts @@ -1,52 +1,99 @@ -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useRef } from 'react' +import { TERMINAL_CONFIG } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils' +import { TERMINAL_HEIGHT } from '@/stores/constants' import { useTerminalStore } from '@/stores/terminal' -const MIN_HEIGHT = 30 -const MAX_HEIGHT_PERCENTAGE = 0.7 - /** Inset gap between the viewport edge and the content window */ const CONTENT_WINDOW_GAP = 8 +/** + * Handles terminal drag-resize with (almost) zero React renders during the drag. + * + * Mirrors the sidebar resize architecture (`use-sidebar-resize.ts`): + * + * pointerdown → capture the pointer on the handle (so move/up keep arriving + * even when the cursor leaves the window) + * pointermove → write to --terminal-height inside a requestAnimationFrame + * callback (the CSS variable alone sizes `.terminal-container`). + * The store is committed mid-drag only when the height crosses + * the expanded threshold, so `isExpanded` subscribers (header + * chevron, auto-open logic) still flip live. + * pointerup → cancel any pending RAF, tear down, persist the final height + * to Zustand once (one re-render + one localStorage write) + * + * The drag is torn down by `pointerup`, `pointercancel`, or window `blur`, so + * an interrupted gesture can never leave the drag listeners or body cursor + * stuck. A single-flight guard prevents stacking listeners across rapid + * presses, and an unmount cleanup tears down a drag still in flight. + */ export function useTerminalResize() { - const setTerminalHeight = useTerminalStore((state) => state.setTerminalHeight) - const isResizing = useTerminalStore((state) => state.isResizing) - const setIsResizing = useTerminalStore((state) => state.setIsResizing) + const setTerminalHeight = useTerminalStore((s) => s.setTerminalHeight) + const setIsResizing = useTerminalStore((s) => s.setIsResizing) + const cleanupRef = useRef<(() => void) | null>(null) + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if (cleanupRef.current) return + + const handle = e.currentTarget + const pointerId = e.pointerId + setIsResizing(true) + document.body.style.cursor = 'ns-resize' + document.body.style.userSelect = 'none' + handle.setPointerCapture?.(pointerId) + + let rafId: number | null = null + + const onPointerMove = (ev: PointerEvent) => { + if (rafId !== null) cancelAnimationFrame(rafId) + rafId = requestAnimationFrame(() => { + const maxHeight = window.innerHeight * TERMINAL_HEIGHT.MAX_PERCENTAGE + const newHeight = window.innerHeight - CONTENT_WINDOW_GAP - ev.clientY + const clamped = Math.min(Math.max(newHeight, TERMINAL_HEIGHT.MIN), maxHeight) + document.documentElement.style.setProperty('--terminal-height', `${clamped}px`) - const handleMouseDown = useCallback(() => { - setIsResizing(true) - }, [setIsResizing]) + const wasExpanded = + useTerminalStore.getState().terminalHeight > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD + const nowExpanded = clamped > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD + if (wasExpanded !== nowExpanded) setTerminalHeight(clamped) - useEffect(() => { - if (!isResizing) return + rafId = null + }) + } - const handleMouseMove = (e: MouseEvent) => { - const newHeight = window.innerHeight - CONTENT_WINDOW_GAP - e.clientY - const maxHeight = window.innerHeight * MAX_HEIGHT_PERCENTAGE + const cleanup = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId) + rafId = null + } + document.body.style.cursor = '' + document.body.style.userSelect = '' + if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId) + document.removeEventListener('pointermove', onPointerMove) + document.removeEventListener('pointerup', endDrag) + document.removeEventListener('pointercancel', endDrag) + window.removeEventListener('blur', endDrag) + cleanupRef.current = null + } - if (newHeight >= MIN_HEIGHT && newHeight <= maxHeight) { - setTerminalHeight(newHeight) + function endDrag() { + cleanup() + const raw = document.documentElement.style.getPropertyValue('--terminal-height') + const finalHeight = Number.parseFloat(raw) + if (!Number.isNaN(finalHeight)) setTerminalHeight(finalHeight) + setIsResizing(false) } - } - - const handleMouseUp = () => { - setIsResizing(false) - } - - document.addEventListener('mousemove', handleMouseMove) - document.addEventListener('mouseup', handleMouseUp) - document.body.style.cursor = 'ns-resize' - document.body.style.userSelect = 'none' - - return () => { - document.removeEventListener('mousemove', handleMouseMove) - document.removeEventListener('mouseup', handleMouseUp) - document.body.style.cursor = '' - document.body.style.userSelect = '' - } - }, [isResizing, setTerminalHeight, setIsResizing]) - - return { - isResizing, - handleMouseDown, - } + + cleanupRef.current = cleanup + document.addEventListener('pointermove', onPointerMove) + document.addEventListener('pointerup', endDrag) + document.addEventListener('pointercancel', endDrag) + window.addEventListener('blur', endDrag) + }, + [setTerminalHeight, setIsResizing] + ) + + useEffect(() => () => cleanupRef.current?.(), []) + + return { handlePointerDown } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index c96aac2f30a..c668936f40a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -720,8 +720,8 @@ export const Terminal = memo(function Terminal() { const [isPlaygroundEnabled] = useState(() => isTruthy(getEnv('NEXT_PUBLIC_ENABLE_PLAYGROUND'))) - const { handleMouseDown } = useTerminalResize() - const { handleMouseDown: handleOutputPanelResizeMouseDown } = useOutputPanelResize() + const { handlePointerDown } = useTerminalResize() + const { handlePointerDown: handleOutputPanelResizePointerDown } = useOutputPanelResize() const { filters, @@ -1301,7 +1301,7 @@ export const Terminal = memo(function Terminal() { {/* Resize Handle */}
{/* Header */}
()( setOutputPanelWidth: (width) => { const clampedWidth = Math.max(OUTPUT_PANEL_WIDTH.MIN, width) set({ outputPanelWidth: clampedWidth }) + + // Update CSS variable for immediate visual feedback + if (typeof window !== 'undefined') { + document.documentElement.style.setProperty('--output-panel-width', `${clampedWidth}px`) + } }, openOnRun: true, /** @@ -107,8 +112,9 @@ export const useTerminalStore = create()( structuredView: state.structuredView, }), /** - * Synchronizes the `--terminal-height` CSS custom property with the - * persisted store value after client-side rehydration. + * Synchronizes the `--terminal-height` and `--output-panel-width` CSS + * custom properties with the persisted store values after client-side + * rehydration. */ onRehydrateStorage: () => (state) => { if (state && typeof window !== 'undefined') { @@ -116,6 +122,10 @@ export const useTerminalStore = create()( '--terminal-height', `${state.terminalHeight}px` ) + document.documentElement.style.setProperty( + '--output-panel-width', + `${state.outputPanelWidth}px` + ) } }, } From f54604c7e52a055faacce87e73ffec5ad562e8ac Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 00:18:32 -0700 Subject: [PATCH 2/4] improvement(workflow): shared useDragResize hook + review fixes Extract the drag mechanism into a shared useDragResize hook (pointer capture, rAF-aligned apply, commit-on-release) consumed by the panel, terminal, and output-panel resize hooks. Fixes from adversarial review: commit the last computed value instead of reading the CSS var back (a fast single-frame flick could be lost to a cancelled rAF, and a pre-rehydration read returned '' -> NaN), floor the panel/terminal max clamp at the minimum so narrow viewports can't invert the clamp, guard pointerup/pointercancel by pointerId so a second touch pointer can't kill the drag, and capture the terminal rect once on drag start instead of per-frame getBoundingClientRect. Remove the now-dead isResizing store state and centralize CONTENT_WINDOW_GAP in stores/constants. --- .../panel/hooks/use-panel-resize.ts | 108 ++++----------- .../terminal/hooks/use-output-panel-resize.ts | 119 ++++++----------- .../terminal/hooks/use-terminal-resize.ts | 125 ++++++------------ apps/sim/hooks/use-drag-resize.ts | 121 +++++++++++++++++ apps/sim/stores/constants.ts | 3 + apps/sim/stores/panel/store.ts | 8 +- apps/sim/stores/panel/types.ts | 4 - apps/sim/stores/terminal/store.ts | 18 +-- apps/sim/stores/terminal/types.ts | 15 --- 9 files changed, 236 insertions(+), 285 deletions(-) create mode 100644 apps/sim/hooks/use-drag-resize.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts index ad7998683c6..c46b23f8880 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts @@ -1,90 +1,40 @@ -import { useCallback, useEffect, useRef } from 'react' -import { PANEL_WIDTH } from '@/stores/constants' +import { useDragResize } from '@/hooks/use-drag-resize' +import { CONTENT_WINDOW_GAP, PANEL_WIDTH } from '@/stores/constants' import { usePanelStore } from '@/stores/panel' -/** Inset gap between the viewport edge and the content window */ -const CONTENT_WINDOW_GAP = 8 +/** + * Computes the clamped panel width for a pointer position. The maximum is + * floored at the minimum so a narrow viewport can never invert the clamp + * and force the panel below {@link PANEL_WIDTH.MIN}. + */ +function computePanelWidth(ev: PointerEvent): number { + const maxWidth = Math.max(PANEL_WIDTH.MIN, window.innerWidth * PANEL_WIDTH.MAX_PERCENTAGE) + const newWidth = window.innerWidth - CONTENT_WINDOW_GAP - ev.clientX + return Math.min(Math.max(newWidth, PANEL_WIDTH.MIN), maxWidth) +} + +/** + * Applies the panel width per frame. The `--panel-width` CSS variable alone + * sizes `.panel-container`, so no React work happens during the drag. + */ +function applyPanelWidth(width: number): void { + document.documentElement.style.setProperty('--panel-width', `${width}px`) +} /** * Handles panel drag-resize with zero React renders during the drag. + * The final width is committed to the store (one re-render + one + * localStorage write) when the drag ends. * - * Mirrors the sidebar resize architecture (`use-sidebar-resize.ts`): - * - * pointerdown → capture the pointer on the handle (so move/up keep arriving - * even when the cursor leaves the window) - * pointermove → write to --panel-width inside a requestAnimationFrame - * callback (the CSS variable alone sizes `.panel-container`, - * so no React work happens per frame) - * pointerup → cancel any pending RAF, tear down, persist the final width - * to Zustand once (one re-render + one localStorage write) - * - * The drag is torn down by `pointerup`, `pointercancel`, or window `blur`, so - * an interrupted gesture can never leave the drag listeners or body cursor - * stuck. A single-flight guard prevents stacking listeners across rapid - * presses, and an unmount cleanup tears down a drag still in flight. + * @returns Pointer-down handler for the resize handle */ export function usePanelResize() { const setPanelWidth = usePanelStore((s) => s.setPanelWidth) - const setIsResizing = usePanelStore((s) => s.setIsResizing) - const cleanupRef = useRef<(() => void) | null>(null) - - const handlePointerDown = useCallback( - (e: React.PointerEvent) => { - if (cleanupRef.current) return - - const handle = e.currentTarget - const pointerId = e.pointerId - setIsResizing(true) - document.body.style.cursor = 'ew-resize' - document.body.style.userSelect = 'none' - handle.setPointerCapture?.(pointerId) - - let rafId: number | null = null - - const onPointerMove = (ev: PointerEvent) => { - if (rafId !== null) cancelAnimationFrame(rafId) - rafId = requestAnimationFrame(() => { - const maxWidth = window.innerWidth * PANEL_WIDTH.MAX_PERCENTAGE - const newWidth = window.innerWidth - CONTENT_WINDOW_GAP - ev.clientX - const clamped = Math.min(Math.max(newWidth, PANEL_WIDTH.MIN), maxWidth) - document.documentElement.style.setProperty('--panel-width', `${clamped}px`) - rafId = null - }) - } - - const cleanup = () => { - if (rafId !== null) { - cancelAnimationFrame(rafId) - rafId = null - } - document.body.style.cursor = '' - document.body.style.userSelect = '' - if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId) - document.removeEventListener('pointermove', onPointerMove) - document.removeEventListener('pointerup', endDrag) - document.removeEventListener('pointercancel', endDrag) - window.removeEventListener('blur', endDrag) - cleanupRef.current = null - } - - function endDrag() { - cleanup() - const raw = document.documentElement.style.getPropertyValue('--panel-width') - const finalWidth = Number.parseFloat(raw) - if (!Number.isNaN(finalWidth)) setPanelWidth(finalWidth) - setIsResizing(false) - } - - cleanupRef.current = cleanup - document.addEventListener('pointermove', onPointerMove) - document.addEventListener('pointerup', endDrag) - document.addEventListener('pointercancel', endDrag) - window.addEventListener('blur', endDrag) - }, - [setPanelWidth, setIsResizing] - ) - - useEffect(() => () => cleanupRef.current?.(), []) - return { handlePointerDown } + return useDragResize({ + cursor: 'ew-resize', + compute: computePanelWidth, + apply: applyPanelWidth, + commit: setPanelWidth, + }) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts index 6ad640640b1..397a61ef185 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts @@ -1,89 +1,50 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useRef } from 'react' +import { useDragResize } from '@/hooks/use-drag-resize' import { OUTPUT_PANEL_WIDTH, TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants' import { useTerminalStore } from '@/stores/terminal' +/** + * Applies the output panel width per frame. The `--output-panel-width` CSS + * variable alone sizes the output panel and its sibling logs column, so no + * React work happens during the drag. + */ +function applyOutputPanelWidth(width: number): void { + document.documentElement.style.setProperty('--output-panel-width', `${width}px`) +} + /** * Handles the terminal output panel drag-resize with zero React renders - * during the drag. + * during the drag. The terminal rect is captured once on drag start (its + * size cannot change mid-drag, and this keeps forced layout reads off the + * per-move path). The final width is committed to the store (one re-render + * + one localStorage write) when the drag ends. * - * Mirrors the sidebar resize architecture (`use-sidebar-resize.ts`): - * - * pointerdown → capture the pointer on the handle (so move/up keep arriving - * even when the cursor leaves the window) - * pointermove → write to --output-panel-width inside a requestAnimationFrame - * callback (the CSS variable alone sizes the logs column via - * `calc(100% - var(--output-panel-width))`) - * pointerup → cancel any pending RAF, tear down, persist the final width - * to Zustand once (one re-render + one localStorage write) - * - * The drag is torn down by `pointerup`, `pointercancel`, or window `blur`, so - * an interrupted gesture can never leave the drag listeners or body cursor - * stuck. A single-flight guard prevents stacking listeners across rapid - * presses, and an unmount cleanup tears down a drag still in flight. + * @returns Pointer-down handler for the resize handle */ export function useOutputPanelResize() { const setOutputPanelWidth = useTerminalStore((s) => s.setOutputPanelWidth) - const cleanupRef = useRef<(() => void) | null>(null) - - const handlePointerDown = useCallback( - (e: React.PointerEvent) => { - if (cleanupRef.current) return - - const terminalEl = document.querySelector('[aria-label="Terminal"]') - if (!terminalEl) return - - const handle = e.currentTarget - const pointerId = e.pointerId - document.body.style.cursor = 'ew-resize' - document.body.style.userSelect = 'none' - handle.setPointerCapture?.(pointerId) - - let rafId: number | null = null - - const onPointerMove = (ev: PointerEvent) => { - if (rafId !== null) cancelAnimationFrame(rafId) - rafId = requestAnimationFrame(() => { - const terminalRect = terminalEl.getBoundingClientRect() - const newWidth = terminalRect.right - ev.clientX - const maxWidth = terminalRect.width - TERMINAL_BLOCK_COLUMN_WIDTH - const clamped = Math.max(OUTPUT_PANEL_WIDTH.MIN, Math.min(newWidth, maxWidth)) - document.documentElement.style.setProperty('--output-panel-width', `${clamped}px`) - rafId = null - }) - } - - const cleanup = () => { - if (rafId !== null) { - cancelAnimationFrame(rafId) - rafId = null - } - document.body.style.cursor = '' - document.body.style.userSelect = '' - if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId) - document.removeEventListener('pointermove', onPointerMove) - document.removeEventListener('pointerup', endDrag) - document.removeEventListener('pointercancel', endDrag) - window.removeEventListener('blur', endDrag) - cleanupRef.current = null - } - - function endDrag() { - cleanup() - const raw = document.documentElement.style.getPropertyValue('--output-panel-width') - const finalWidth = Number.parseFloat(raw) - if (!Number.isNaN(finalWidth)) setOutputPanelWidth(finalWidth) - } - - cleanupRef.current = cleanup - document.addEventListener('pointermove', onPointerMove) - document.addEventListener('pointerup', endDrag) - document.addEventListener('pointercancel', endDrag) - window.addEventListener('blur', endDrag) - }, - [setOutputPanelWidth] - ) - - useEffect(() => () => cleanupRef.current?.(), []) - - return { handlePointerDown } + const terminalRectRef = useRef(null) + + const captureTerminalRect = useCallback(() => { + const terminalEl = document.querySelector('[aria-label="Terminal"]') + if (!terminalEl) return false + terminalRectRef.current = terminalEl.getBoundingClientRect() + return true + }, []) + + const computeOutputPanelWidth = useCallback((ev: PointerEvent) => { + const terminalRect = terminalRectRef.current + if (!terminalRect) return null + const newWidth = terminalRect.right - ev.clientX + const maxWidth = terminalRect.width - TERMINAL_BLOCK_COLUMN_WIDTH + return Math.max(OUTPUT_PANEL_WIDTH.MIN, Math.min(newWidth, maxWidth)) + }, []) + + return useDragResize({ + cursor: 'ew-resize', + compute: computeOutputPanelWidth, + apply: applyOutputPanelWidth, + commit: setOutputPanelWidth, + onStart: captureTerminalRect, + }) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts index 660151bdff0..f68403e901a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts @@ -1,99 +1,48 @@ -import { useCallback, useEffect, useRef } from 'react' import { TERMINAL_CONFIG } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils' -import { TERMINAL_HEIGHT } from '@/stores/constants' +import { useDragResize } from '@/hooks/use-drag-resize' +import { CONTENT_WINDOW_GAP, TERMINAL_HEIGHT } from '@/stores/constants' import { useTerminalStore } from '@/stores/terminal' -/** Inset gap between the viewport edge and the content window */ -const CONTENT_WINDOW_GAP = 8 +/** Computes the clamped terminal height for a pointer position */ +function computeTerminalHeight(ev: PointerEvent): number { + const maxHeight = Math.max( + TERMINAL_HEIGHT.MIN, + window.innerHeight * TERMINAL_HEIGHT.MAX_PERCENTAGE + ) + const newHeight = window.innerHeight - CONTENT_WINDOW_GAP - ev.clientY + return Math.min(Math.max(newHeight, TERMINAL_HEIGHT.MIN), maxHeight) +} /** - * Handles terminal drag-resize with (almost) zero React renders during the drag. - * - * Mirrors the sidebar resize architecture (`use-sidebar-resize.ts`): - * - * pointerdown → capture the pointer on the handle (so move/up keep arriving - * even when the cursor leaves the window) - * pointermove → write to --terminal-height inside a requestAnimationFrame - * callback (the CSS variable alone sizes `.terminal-container`). - * The store is committed mid-drag only when the height crosses - * the expanded threshold, so `isExpanded` subscribers (header - * chevron, auto-open logic) still flip live. - * pointerup → cancel any pending RAF, tear down, persist the final height - * to Zustand once (one re-render + one localStorage write) + * Applies the terminal height per frame. The `--terminal-height` CSS + * variable alone sizes `.terminal-container`, so no React work happens on + * ordinary frames. The store is committed mid-drag only when the height + * crosses the expanded threshold, so `isExpanded` subscribers (header + * chevron, auto-open logic) still flip live during the drag. + */ +function applyTerminalHeight(height: number): void { + document.documentElement.style.setProperty('--terminal-height', `${height}px`) + + const store = useTerminalStore.getState() + const wasExpanded = store.terminalHeight > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD + const nowExpanded = height > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD + if (wasExpanded !== nowExpanded) store.setTerminalHeight(height) +} + +/** + * Handles terminal drag-resize with zero React renders during the drag + * (except at expanded-threshold crossings). The final height is committed + * to the store (one re-render + one localStorage write) when the drag ends. * - * The drag is torn down by `pointerup`, `pointercancel`, or window `blur`, so - * an interrupted gesture can never leave the drag listeners or body cursor - * stuck. A single-flight guard prevents stacking listeners across rapid - * presses, and an unmount cleanup tears down a drag still in flight. + * @returns Pointer-down handler for the resize handle */ export function useTerminalResize() { const setTerminalHeight = useTerminalStore((s) => s.setTerminalHeight) - const setIsResizing = useTerminalStore((s) => s.setIsResizing) - const cleanupRef = useRef<(() => void) | null>(null) - - const handlePointerDown = useCallback( - (e: React.PointerEvent) => { - if (cleanupRef.current) return - - const handle = e.currentTarget - const pointerId = e.pointerId - setIsResizing(true) - document.body.style.cursor = 'ns-resize' - document.body.style.userSelect = 'none' - handle.setPointerCapture?.(pointerId) - - let rafId: number | null = null - - const onPointerMove = (ev: PointerEvent) => { - if (rafId !== null) cancelAnimationFrame(rafId) - rafId = requestAnimationFrame(() => { - const maxHeight = window.innerHeight * TERMINAL_HEIGHT.MAX_PERCENTAGE - const newHeight = window.innerHeight - CONTENT_WINDOW_GAP - ev.clientY - const clamped = Math.min(Math.max(newHeight, TERMINAL_HEIGHT.MIN), maxHeight) - document.documentElement.style.setProperty('--terminal-height', `${clamped}px`) - - const wasExpanded = - useTerminalStore.getState().terminalHeight > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD - const nowExpanded = clamped > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD - if (wasExpanded !== nowExpanded) setTerminalHeight(clamped) - - rafId = null - }) - } - - const cleanup = () => { - if (rafId !== null) { - cancelAnimationFrame(rafId) - rafId = null - } - document.body.style.cursor = '' - document.body.style.userSelect = '' - if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId) - document.removeEventListener('pointermove', onPointerMove) - document.removeEventListener('pointerup', endDrag) - document.removeEventListener('pointercancel', endDrag) - window.removeEventListener('blur', endDrag) - cleanupRef.current = null - } - - function endDrag() { - cleanup() - const raw = document.documentElement.style.getPropertyValue('--terminal-height') - const finalHeight = Number.parseFloat(raw) - if (!Number.isNaN(finalHeight)) setTerminalHeight(finalHeight) - setIsResizing(false) - } - - cleanupRef.current = cleanup - document.addEventListener('pointermove', onPointerMove) - document.addEventListener('pointerup', endDrag) - document.addEventListener('pointercancel', endDrag) - window.addEventListener('blur', endDrag) - }, - [setTerminalHeight, setIsResizing] - ) - - useEffect(() => () => cleanupRef.current?.(), []) - return { handlePointerDown } + return useDragResize({ + cursor: 'ns-resize', + compute: computeTerminalHeight, + apply: applyTerminalHeight, + commit: setTerminalHeight, + }) } diff --git a/apps/sim/hooks/use-drag-resize.ts b/apps/sim/hooks/use-drag-resize.ts new file mode 100644 index 00000000000..e59ac2dbde4 --- /dev/null +++ b/apps/sim/hooks/use-drag-resize.ts @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useRef } from 'react' + +interface UseDragResizeOptions { + /** Cursor applied to the document body for the duration of the drag */ + cursor: 'ew-resize' | 'ns-resize' + /** + * Maps a pointer position to the clamped target dimension, or `null` to + * ignore the move. Runs on every pointermove, so it must stay cheap (no + * layout reads — capture rects in `onStart` instead). + */ + compute: (ev: PointerEvent) => number | null + /** + * Applies per-frame visual feedback (typically a CSS variable write). + * Invoked inside requestAnimationFrame, at most once per frame. + */ + apply: (value: number) => void + /** + * Persists the final value once when the drag ends. Not called when the + * pointer never moved (a plain click on the handle). + */ + commit: (value: number) => void + /** + * Optional drag-start hook (e.g. capture an anchor rect, set a store flag). + * Return `false` to abort the drag before any listeners are attached. + */ + onStart?: () => boolean | undefined + /** Optional drag-end hook, invoked after teardown and commit */ + onEnd?: () => void +} + +/** + * Shared drag-resize mechanism with zero React renders during the drag. + * + * Architecture (mirrors the sidebar's `use-sidebar-resize.ts`): + * + * pointerdown → capture the pointer on the handle (so move/up keep arriving + * even when the cursor leaves the window or crosses an iframe) + * pointermove → `compute` the clamped value immediately (cheap math), then + * `apply` it inside a requestAnimationFrame callback so DOM + * writes align with the browser paint cycle + * pointerup → cancel any pending RAF, tear down, and `commit` the last + * computed value once — committing the tracked value (rather + * than reading it back out of the DOM) means a fast + * single-frame flick is never lost to a cancelled RAF + * + * The drag is torn down by `pointerup`/`pointercancel` of the captured + * pointer (other pointers are ignored, so a second touch cannot kill the + * gesture) or window `blur`, so an interrupted gesture can never leave the + * listeners or body cursor stuck. A single-flight guard prevents stacking + * listeners across rapid presses, and an unmount cleanup tears down a drag + * still in flight. + */ +export function useDragResize(options: UseDragResizeOptions) { + const cleanupRef = useRef<(() => void) | null>(null) + const optionsRef = useRef(options) + + useEffect(() => { + optionsRef.current = options + }, [options]) + + const handlePointerDown = useCallback((e: React.PointerEvent) => { + if (cleanupRef.current) return + if (optionsRef.current.onStart?.() === false) return + + const handle = e.currentTarget + const pointerId = e.pointerId + document.body.style.cursor = optionsRef.current.cursor + document.body.style.userSelect = 'none' + handle.setPointerCapture?.(pointerId) + + let rafId: number | null = null + let pendingValue: number | null = null + + const onPointerMove = (ev: PointerEvent) => { + if (ev.pointerId !== pointerId) return + const value = optionsRef.current.compute(ev) + if (value === null) return + pendingValue = value + rafId ??= requestAnimationFrame(() => { + if (pendingValue !== null) optionsRef.current.apply(pendingValue) + rafId = null + }) + } + + const cleanup = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId) + rafId = null + } + document.body.style.cursor = '' + document.body.style.userSelect = '' + if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId) + document.removeEventListener('pointermove', onPointerMove) + document.removeEventListener('pointerup', onPointerEnd) + document.removeEventListener('pointercancel', onPointerEnd) + window.removeEventListener('blur', endDrag) + cleanupRef.current = null + } + + function endDrag() { + cleanup() + if (pendingValue !== null) optionsRef.current.commit(pendingValue) + optionsRef.current.onEnd?.() + } + + function onPointerEnd(ev: PointerEvent) { + if (ev.pointerId !== pointerId) return + endDrag() + } + + cleanupRef.current = cleanup + document.addEventListener('pointermove', onPointerMove) + document.addEventListener('pointerup', onPointerEnd) + document.addEventListener('pointercancel', onPointerEnd) + window.addEventListener('blur', endDrag) + }, []) + + useEffect(() => () => cleanupRef.current?.(), []) + + return { handlePointerDown } +} diff --git a/apps/sim/stores/constants.ts b/apps/sim/stores/constants.ts index a4848017e98..e905109dcd8 100644 --- a/apps/sim/stores/constants.ts +++ b/apps/sim/stores/constants.ts @@ -18,6 +18,9 @@ const API_ENDPOINTS = { * @see layout.tsx for pre-hydration script that reads localStorage */ +/** Inset gap in pixels between the viewport edge and the content window */ +export const CONTENT_WINDOW_GAP = 8 + /** Sidebar width constraints */ export const SIDEBAR_WIDTH = { DEFAULT: 248, diff --git a/apps/sim/stores/panel/store.ts b/apps/sim/stores/panel/store.ts index 1082ab64071..c8257f355c7 100644 --- a/apps/sim/stores/panel/store.ts +++ b/apps/sim/stores/panel/store.ts @@ -29,10 +29,6 @@ export const usePanelStore = create()( document.documentElement.removeAttribute('data-panel-active-tab') } }, - isResizing: false, - setIsResizing: (isResizing) => { - set({ isResizing }) - }, _hasHydrated: false, setHasHydrated: (hasHydrated) => { set({ _hasHydrated: hasHydrated }) @@ -44,8 +40,8 @@ export const usePanelStore = create()( * Persist only the durable panel preferences. `activeTab` MUST be kept: * the blocking script in `app/layout.tsx` reads it from this persisted * `panel-state` entry to set `data-panel-active-tab` before hydration, - * preventing a tab flash. The transient `isResizing` drag flag and the - * `_hasHydrated` hydration marker are excluded. + * preventing a tab flash. The `_hasHydrated` hydration marker is + * excluded. */ partialize: (state) => ({ panelWidth: state.panelWidth, diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts index d26dd609e9e..a46bc93adc1 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -11,10 +11,6 @@ export interface PanelState { setPanelWidth: (width: number) => void activeTab: PanelTab setActiveTab: (tab: PanelTab) => void - /** Whether the panel is currently being resized */ - isResizing: boolean - /** Updates the panel resize state */ - setIsResizing: (isResizing: boolean) => void _hasHydrated: boolean setHasHydrated: (hasHydrated: boolean) => void } diff --git a/apps/sim/stores/terminal/store.ts b/apps/sim/stores/terminal/store.ts index 3c0825898ee..d8f276c72f6 100644 --- a/apps/sim/stores/terminal/store.ts +++ b/apps/sim/stores/terminal/store.ts @@ -8,7 +8,6 @@ export const useTerminalStore = create()( (set) => ({ terminalHeight: TERMINAL_HEIGHT.DEFAULT, lastExpandedHeight: TERMINAL_HEIGHT.DEFAULT, - isResizing: false, /** * Updates the terminal height and synchronizes the CSS custom property. * @@ -33,17 +32,10 @@ export const useTerminalStore = create()( document.documentElement.style.setProperty('--terminal-height', `${clampedHeight}px`) } }, - /** - * Updates the terminal resize state used to coordinate layout transitions. - * - * @param isResizing - True while the terminal is being resized via mouse drag. - */ - setIsResizing: (isResizing) => { - set({ isResizing }) - }, outputPanelWidth: OUTPUT_PANEL_WIDTH.DEFAULT, /** - * Updates the output panel width, enforcing the minimum constraint. + * Updates the output panel width, enforcing the minimum constraint and + * synchronizing the `--output-panel-width` CSS custom property. * * @param width - Desired width in pixels for the output panel. */ @@ -51,7 +43,6 @@ export const useTerminalStore = create()( const clampedWidth = Math.max(OUTPUT_PANEL_WIDTH.MIN, width) set({ outputPanelWidth: clampedWidth }) - // Update CSS variable for immediate visual feedback if (typeof window !== 'undefined') { document.documentElement.style.setProperty('--output-panel-width', `${clampedWidth}px`) } @@ -99,9 +90,8 @@ export const useTerminalStore = create()( { name: 'terminal-state', /** - * Persist only the durable terminal UI preferences. The transient - * `isResizing` drag flag and the `_hasHydrated` hydration marker are - * excluded so they always start fresh on load. + * Persist only the durable terminal UI preferences. The `_hasHydrated` + * hydration marker is excluded so it always starts fresh on load. */ partialize: (state) => ({ terminalHeight: state.terminalHeight, diff --git a/apps/sim/stores/terminal/types.ts b/apps/sim/stores/terminal/types.ts index a50196102f8..b8eba62e6e1 100644 --- a/apps/sim/stores/terminal/types.ts +++ b/apps/sim/stores/terminal/types.ts @@ -21,21 +21,6 @@ export interface TerminalState { setWrapText: (wrap: boolean) => void structuredView: boolean setStructuredView: (structured: boolean) => void - /** - * Indicates whether the terminal is currently being resized via mouse drag. - * - * @remarks - * This flag is used by other workspace UI elements (e.g. notifications, - * diff controls) to temporarily disable position transitions while the - * terminal height is actively changing, avoiding janky animations. - */ - isResizing: boolean - /** - * Updates the {@link TerminalState.isResizing} flag. - * - * @param isResizing - True while the terminal is being resized. - */ - setIsResizing: (isResizing: boolean) => void _hasHydrated: boolean setHasHydrated: (hasHydrated: boolean) => void } From ac8902599a6398f77a6cfe37ca0e1e8dc5bd8471 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 00:31:52 -0700 Subject: [PATCH 3/4] fix(workflow): compute drag value rAF-aligned from the latest pointer event Run compute inside the rAF (before the CSS-var write, so any layout read hits clean layout at most once per frame) and derive the final value from the latest pointer event on release. The output-panel hook now captures the terminal element on drag start and re-reads its rect per frame, so the clamp stays correct when the terminal resizes mid-drag and the live width can never exceed the current max. --- .../terminal/hooks/use-output-panel-resize.ts | 26 +++++------ apps/sim/hooks/use-drag-resize.ts | 44 ++++++++++++------- 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts index 397a61ef185..9684457616f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts @@ -14,27 +14,27 @@ function applyOutputPanelWidth(width: number): void { /** * Handles the terminal output panel drag-resize with zero React renders - * during the drag. The terminal rect is captured once on drag start (its - * size cannot change mid-drag, and this keeps forced layout reads off the - * per-move path). The final width is committed to the store (one re-render - * + one localStorage write) when the drag ends. + * during the drag. The terminal element is captured once on drag start and + * its rect is re-read per frame (rAF-aligned, before the CSS-var write), so + * the clamp stays correct even if the terminal resizes mid-drag. The final + * width is committed to the store (one re-render + one localStorage write) + * when the drag ends. * * @returns Pointer-down handler for the resize handle */ export function useOutputPanelResize() { const setOutputPanelWidth = useTerminalStore((s) => s.setOutputPanelWidth) - const terminalRectRef = useRef(null) + const terminalElRef = useRef(null) - const captureTerminalRect = useCallback(() => { - const terminalEl = document.querySelector('[aria-label="Terminal"]') - if (!terminalEl) return false - terminalRectRef.current = terminalEl.getBoundingClientRect() - return true + const captureTerminalElement = useCallback(() => { + terminalElRef.current = document.querySelector('[aria-label="Terminal"]') + return terminalElRef.current !== null }, []) const computeOutputPanelWidth = useCallback((ev: PointerEvent) => { - const terminalRect = terminalRectRef.current - if (!terminalRect) return null + const terminalEl = terminalElRef.current + if (!terminalEl) return null + const terminalRect = terminalEl.getBoundingClientRect() const newWidth = terminalRect.right - ev.clientX const maxWidth = terminalRect.width - TERMINAL_BLOCK_COLUMN_WIDTH return Math.max(OUTPUT_PANEL_WIDTH.MIN, Math.min(newWidth, maxWidth)) @@ -45,6 +45,6 @@ export function useOutputPanelResize() { compute: computeOutputPanelWidth, apply: applyOutputPanelWidth, commit: setOutputPanelWidth, - onStart: captureTerminalRect, + onStart: captureTerminalElement, }) } diff --git a/apps/sim/hooks/use-drag-resize.ts b/apps/sim/hooks/use-drag-resize.ts index e59ac2dbde4..d21ebba16e7 100644 --- a/apps/sim/hooks/use-drag-resize.ts +++ b/apps/sim/hooks/use-drag-resize.ts @@ -5,8 +5,9 @@ interface UseDragResizeOptions { cursor: 'ew-resize' | 'ns-resize' /** * Maps a pointer position to the clamped target dimension, or `null` to - * ignore the move. Runs on every pointermove, so it must stay cheap (no - * layout reads — capture rects in `onStart` instead). + * ignore the move. Runs at most once per animation frame (before `apply`, + * so a layout read here happens against clean layout) and once more on + * release, so it may read layout but must stay cheap. */ compute: (ev: PointerEvent) => number | null /** @@ -20,8 +21,9 @@ interface UseDragResizeOptions { */ commit: (value: number) => void /** - * Optional drag-start hook (e.g. capture an anchor rect, set a store flag). - * Return `false` to abort the drag before any listeners are attached. + * Optional drag-start hook (e.g. capture an anchor element, set a store + * flag). Return `false` to abort the drag before any listeners are + * attached. */ onStart?: () => boolean | undefined /** Optional drag-end hook, invoked after teardown and commit */ @@ -35,13 +37,15 @@ interface UseDragResizeOptions { * * pointerdown → capture the pointer on the handle (so move/up keep arriving * even when the cursor leaves the window or crosses an iframe) - * pointermove → `compute` the clamped value immediately (cheap math), then - * `apply` it inside a requestAnimationFrame callback so DOM - * writes align with the browser paint cycle - * pointerup → cancel any pending RAF, tear down, and `commit` the last - * computed value once — committing the tracked value (rather - * than reading it back out of the DOM) means a fast - * single-frame flick is never lost to a cancelled RAF + * pointermove → remember the latest pointer event and schedule a + * requestAnimationFrame callback that `compute`s the clamped + * value from it and `apply`s it, so both any layout read and + * the DOM write align with the browser paint cycle + * pointerup → tear down, `compute` the final value from the latest + * pointer event, `apply` and `commit` it once — deriving the + * final value from the event (rather than reading state back + * out of the DOM) means a fast single-frame flick is never + * lost to a cancelled RAF * * The drag is torn down by `pointerup`/`pointercancel` of the captured * pointer (other pointers are ignored, so a second touch cannot kill the @@ -69,16 +73,16 @@ export function useDragResize(options: UseDragResizeOptions) { handle.setPointerCapture?.(pointerId) let rafId: number | null = null - let pendingValue: number | null = null + let lastEvent: PointerEvent | null = null const onPointerMove = (ev: PointerEvent) => { if (ev.pointerId !== pointerId) return - const value = optionsRef.current.compute(ev) - if (value === null) return - pendingValue = value + lastEvent = ev rafId ??= requestAnimationFrame(() => { - if (pendingValue !== null) optionsRef.current.apply(pendingValue) rafId = null + if (lastEvent === null) return + const value = optionsRef.current.compute(lastEvent) + if (value !== null) optionsRef.current.apply(value) }) } @@ -99,7 +103,13 @@ export function useDragResize(options: UseDragResizeOptions) { function endDrag() { cleanup() - if (pendingValue !== null) optionsRef.current.commit(pendingValue) + if (lastEvent !== null) { + const value = optionsRef.current.compute(lastEvent) + if (value !== null) { + optionsRef.current.apply(value) + optionsRef.current.commit(value) + } + } optionsRef.current.onEnd?.() } From 3ea23338abc361330000d5b9c32017f67055811c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 00:42:27 -0700 Subject: [PATCH 4/4] fix(terminal): clamp output panel against the live CSS-var width The ResizeObserver clamp compared the persisted store width, which is intentionally stale during a drag; a terminal shrink mid-drag could overwrite the live width with a stale store value. Compare against the live --output-panel-width variable (store as pre-write fallback) so the clamp converges with the drag instead of fighting it. --- .../w/[workflowId]/components/terminal/terminal.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index c668936f40a..4d6c086ffdf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -1255,6 +1255,10 @@ export const Terminal = memo(function Terminal() { /** * Adjust output panel width on resize. * Closes the output panel if there's not enough space for the minimum width. + * The clamp compares against the live `--output-panel-width` CSS variable + * (the visual width — during a drag it runs ahead of the store, which only + * commits on release) so a resize mid-drag can never stomp the drag with a + * stale store value; the store value is the fallback before any write. */ useEffect(() => { const el = terminalRef.current @@ -1271,7 +1275,11 @@ export const Terminal = memo(function Terminal() { return } - if (outputPanelWidth > maxWidth) { + const liveWidth = Number.parseFloat( + document.documentElement.style.getPropertyValue('--output-panel-width') + ) + const currentWidth = Number.isNaN(liveWidth) ? outputPanelWidth : liveWidth + if (currentWidth > maxWidth) { setOutputPanelWidth(Math.max(maxWidth, MIN_OUTPUT_PANEL_WIDTH_PX)) } }