Skip to content

Commit a19fce1

Browse files
authored
improvement(workflow): zero-render drag-resize for panel, terminal, and output panel (#5730)
* 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. * 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. * 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. * 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.
1 parent 93ba3c4 commit a19fce1

13 files changed

Lines changed: 289 additions & 203 deletions

File tree

apps/sim/app/_styles/globals.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
--panel-width: 320px; /* PANEL_WIDTH.DEFAULT */
1515
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */
1616
--terminal-height: 206px; /* TERMINAL_HEIGHT.DEFAULT */
17+
--output-panel-width: 560px; /* OUTPUT_PANEL_WIDTH.DEFAULT */
1718
--auth-primary-btn-bg: #ffffff;
1819
--auth-primary-btn-border: #ffffff;
1920
--auth-primary-btn-text: #000000;
Lines changed: 32 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,40 @@
1-
import { useCallback, useEffect } from 'react'
2-
import { useShallow } from 'zustand/react/shallow'
3-
import { PANEL_WIDTH } from '@/stores/constants'
1+
import { useDragResize } from '@/hooks/use-drag-resize'
2+
import { CONTENT_WINDOW_GAP, PANEL_WIDTH } from '@/stores/constants'
43
import { usePanelStore } from '@/stores/panel'
54

6-
/** Inset gap between the viewport edge and the content window */
7-
const CONTENT_WINDOW_GAP = 8
5+
/**
6+
* Computes the clamped panel width for a pointer position. The maximum is
7+
* floored at the minimum so a narrow viewport can never invert the clamp
8+
* and force the panel below {@link PANEL_WIDTH.MIN}.
9+
*/
10+
function computePanelWidth(ev: PointerEvent): number {
11+
const maxWidth = Math.max(PANEL_WIDTH.MIN, window.innerWidth * PANEL_WIDTH.MAX_PERCENTAGE)
12+
const newWidth = window.innerWidth - CONTENT_WINDOW_GAP - ev.clientX
13+
return Math.min(Math.max(newWidth, PANEL_WIDTH.MIN), maxWidth)
14+
}
15+
16+
/**
17+
* Applies the panel width per frame. The `--panel-width` CSS variable alone
18+
* sizes `.panel-container`, so no React work happens during the drag.
19+
*/
20+
function applyPanelWidth(width: number): void {
21+
document.documentElement.style.setProperty('--panel-width', `${width}px`)
22+
}
823

924
/**
10-
* Custom hook to handle panel resize functionality.
11-
* Manages mouse events for resizing and enforces min/max width constraints.
12-
* Maximum width is capped at 40% of the viewport width for optimal layout.
25+
* Handles panel drag-resize with zero React renders during the drag.
26+
* The final width is committed to the store (one re-render + one
27+
* localStorage write) when the drag ends.
1328
*
14-
* @returns Resize state and handlers
29+
* @returns Pointer-down handler for the resize handle
1530
*/
1631
export function usePanelResize() {
17-
const { setPanelWidth, isResizing, setIsResizing } = usePanelStore(
18-
useShallow((s) => ({
19-
setPanelWidth: s.setPanelWidth,
20-
isResizing: s.isResizing,
21-
setIsResizing: s.setIsResizing,
22-
}))
23-
)
24-
25-
/**
26-
* Handles mouse down on resize handle
27-
*/
28-
const handleMouseDown = useCallback(() => {
29-
setIsResizing(true)
30-
}, [setIsResizing])
31-
32-
/**
33-
* Setup resize event listeners and body styles when resizing
34-
* Cleanup is handled automatically by the effect's return function
35-
*/
36-
useEffect(() => {
37-
if (!isResizing) return
38-
39-
const handleMouseMove = (e: MouseEvent) => {
40-
const newWidth = window.innerWidth - CONTENT_WINDOW_GAP - e.clientX
41-
const maxWidth = window.innerWidth * PANEL_WIDTH.MAX_PERCENTAGE
42-
43-
if (newWidth >= PANEL_WIDTH.MIN && newWidth <= maxWidth) {
44-
setPanelWidth(newWidth)
45-
}
46-
}
47-
48-
const handleMouseUp = () => {
49-
setIsResizing(false)
50-
}
51-
52-
document.addEventListener('mousemove', handleMouseMove)
53-
document.addEventListener('mouseup', handleMouseUp)
54-
document.body.style.cursor = 'ew-resize'
55-
document.body.style.userSelect = 'none'
56-
57-
return () => {
58-
document.removeEventListener('mousemove', handleMouseMove)
59-
document.removeEventListener('mouseup', handleMouseUp)
60-
document.body.style.cursor = ''
61-
document.body.style.userSelect = ''
62-
}
63-
}, [isResizing, setPanelWidth, setIsResizing])
64-
65-
return {
66-
isResizing,
67-
handleMouseDown,
68-
}
32+
const setPanelWidth = usePanelStore((s) => s.setPanelWidth)
33+
34+
return useDragResize({
35+
cursor: 'ew-resize',
36+
compute: computePanelWidth,
37+
apply: applyPanelWidth,
38+
commit: setPanelWidth,
39+
})
6940
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,10 @@ export const Panel = memo(function Panel() {
122122

123123
const panelRef = useRef<HTMLElement>(null)
124124
const fileInputRef = useRef<HTMLInputElement>(null)
125-
const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore(
125+
const { activeTab, setActiveTab, _hasHydrated, setHasHydrated } = usePanelStore(
126126
useShallow((state) => ({
127127
activeTab: state.activeTab,
128128
setActiveTab: state.setActiveTab,
129-
panelWidth: state.panelWidth,
130129
_hasHydrated: state._hasHydrated,
131130
setHasHydrated: state.setHasHydrated,
132131
}))
@@ -189,7 +188,7 @@ export const Panel = memo(function Panel() {
189188
const { handleRunWorkflow, handleCancelExecution, isExecuting } = useWorkflowExecution()
190189

191190
// Panel resize hook
192-
const { handleMouseDown } = usePanelResize()
191+
const { handlePointerDown } = usePanelResize()
193192

194193
/**
195194
* Opens subscription settings modal
@@ -932,7 +931,7 @@ export const Panel = memo(function Panel() {
932931
{/* Resize Handle */}
933932
<div
934933
className='absolute top-0 bottom-0 left-[-4px] z-20 w-[8px] cursor-ew-resize'
935-
onMouseDown={handleMouseDown}
934+
onPointerDown={handlePointerDown}
936935
role='separator'
937936
aria-orientation='vertical'
938937
aria-label='Resize panel'

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,12 @@ const OutputCodeContent = React.memo(function OutputCodeContent({
7777

7878
/**
7979
* Props for the OutputPanel component
80-
* Store-backed settings (wrapText, openOnRun, structuredView, outputPanelWidth)
80+
* Store-backed settings (wrapText, openOnRun, structuredView)
8181
* are accessed directly from useTerminalStore to reduce prop drilling.
8282
*/
8383
export interface OutputPanelProps {
8484
selectedEntry: ConsoleEntry
85-
handleOutputPanelResizeMouseDown: (e: React.MouseEvent) => void
85+
handleOutputPanelResizePointerDown: (e: React.PointerEvent<HTMLElement>) => void
8686
handleHeaderClick: () => void
8787
isExpanded: boolean
8888
expandToLastHeight: () => void
@@ -109,7 +109,7 @@ export interface OutputPanelProps {
109109
*/
110110
export const OutputPanel = React.memo(function OutputPanel({
111111
selectedEntry,
112-
handleOutputPanelResizeMouseDown,
112+
handleOutputPanelResizePointerDown,
113113
handleHeaderClick,
114114
isExpanded,
115115
expandToLastHeight,
@@ -130,7 +130,6 @@ export const OutputPanel = React.memo(function OutputPanel({
130130
handleClearConsoleFromMenu,
131131
}: OutputPanelProps) {
132132
// Access store-backed settings directly to reduce prop drilling
133-
const outputPanelWidth = useTerminalStore((state) => state.outputPanelWidth)
134133
const wrapText = useTerminalStore((state) => state.wrapText)
135134
const setWrapText = useTerminalStore((state) => state.setWrapText)
136135
const openOnRun = useTerminalStore((state) => state.openOnRun)
@@ -293,12 +292,12 @@ export const OutputPanel = React.memo(function OutputPanel({
293292
<>
294293
<div
295294
className='absolute top-0 right-0 bottom-0 flex flex-col border-[var(--border)] border-l bg-[var(--bg)]'
296-
style={{ width: `${outputPanelWidth}px` }}
295+
style={{ width: 'var(--output-panel-width)' }}
297296
>
298297
{/* Horizontal Resize Handle */}
299298
<div
300299
className='-ml-1 absolute top-0 bottom-0 left-0 z-20 w-[8px] cursor-ew-resize'
301-
onMouseDown={handleOutputPanelResizeMouseDown}
300+
onPointerDown={handleOutputPanelResizePointerDown}
302301
role='separator'
303302
aria-label='Resize output panel'
304303
aria-orientation='vertical'
Lines changed: 41 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,50 @@
1-
import { useCallback, useEffect, useState } from 'react'
1+
import { useCallback, useRef } from 'react'
2+
import { useDragResize } from '@/hooks/use-drag-resize'
23
import { OUTPUT_PANEL_WIDTH, TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants'
34
import { useTerminalStore } from '@/stores/terminal'
45

6+
/**
7+
* Applies the output panel width per frame. The `--output-panel-width` CSS
8+
* variable alone sizes the output panel and its sibling logs column, so no
9+
* React work happens during the drag.
10+
*/
11+
function applyOutputPanelWidth(width: number): void {
12+
document.documentElement.style.setProperty('--output-panel-width', `${width}px`)
13+
}
14+
15+
/**
16+
* Handles the terminal output panel drag-resize with zero React renders
17+
* during the drag. The terminal element is captured once on drag start and
18+
* its rect is re-read per frame (rAF-aligned, before the CSS-var write), so
19+
* the clamp stays correct even if the terminal resizes mid-drag. The final
20+
* width is committed to the store (one re-render + one localStorage write)
21+
* when the drag ends.
22+
*
23+
* @returns Pointer-down handler for the resize handle
24+
*/
525
export function useOutputPanelResize() {
6-
const setOutputPanelWidth = useTerminalStore((state) => state.setOutputPanelWidth)
7-
const [isResizing, setIsResizing] = useState(false)
26+
const setOutputPanelWidth = useTerminalStore((s) => s.setOutputPanelWidth)
27+
const terminalElRef = useRef<Element | null>(null)
828

9-
const handleMouseDown = useCallback(() => {
10-
setIsResizing(true)
29+
const captureTerminalElement = useCallback(() => {
30+
terminalElRef.current = document.querySelector('[aria-label="Terminal"]')
31+
return terminalElRef.current !== null
1132
}, [])
1233

13-
useEffect(() => {
14-
if (!isResizing) return
15-
16-
const handleMouseMove = (e: MouseEvent) => {
17-
const terminalEl = document.querySelector('[aria-label="Terminal"]')
18-
if (!terminalEl) return
19-
20-
const terminalRect = terminalEl.getBoundingClientRect()
21-
const newWidth = terminalRect.right - e.clientX
22-
const maxWidth = terminalRect.width - TERMINAL_BLOCK_COLUMN_WIDTH
23-
const clampedWidth = Math.max(OUTPUT_PANEL_WIDTH.MIN, Math.min(newWidth, maxWidth))
24-
25-
setOutputPanelWidth(clampedWidth)
26-
}
27-
28-
const handleMouseUp = () => {
29-
setIsResizing(false)
30-
}
31-
32-
document.addEventListener('mousemove', handleMouseMove)
33-
document.addEventListener('mouseup', handleMouseUp)
34-
document.body.style.cursor = 'ew-resize'
35-
document.body.style.userSelect = 'none'
36-
37-
return () => {
38-
document.removeEventListener('mousemove', handleMouseMove)
39-
document.removeEventListener('mouseup', handleMouseUp)
40-
document.body.style.cursor = ''
41-
document.body.style.userSelect = ''
42-
}
43-
}, [isResizing, setOutputPanelWidth])
34+
const computeOutputPanelWidth = useCallback((ev: PointerEvent) => {
35+
const terminalEl = terminalElRef.current
36+
if (!terminalEl) return null
37+
const terminalRect = terminalEl.getBoundingClientRect()
38+
const newWidth = terminalRect.right - ev.clientX
39+
const maxWidth = terminalRect.width - TERMINAL_BLOCK_COLUMN_WIDTH
40+
return Math.max(OUTPUT_PANEL_WIDTH.MIN, Math.min(newWidth, maxWidth))
41+
}, [])
4442

45-
return {
46-
isResizing,
47-
handleMouseDown,
48-
}
43+
return useDragResize({
44+
cursor: 'ew-resize',
45+
compute: computeOutputPanelWidth,
46+
apply: applyOutputPanelWidth,
47+
commit: setOutputPanelWidth,
48+
onStart: captureTerminalElement,
49+
})
4950
}
Lines changed: 42 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,48 @@
1-
import { useCallback, useEffect } from 'react'
1+
import { TERMINAL_CONFIG } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils'
2+
import { useDragResize } from '@/hooks/use-drag-resize'
3+
import { CONTENT_WINDOW_GAP, TERMINAL_HEIGHT } from '@/stores/constants'
24
import { useTerminalStore } from '@/stores/terminal'
35

4-
const MIN_HEIGHT = 30
5-
const MAX_HEIGHT_PERCENTAGE = 0.7
6+
/** Computes the clamped terminal height for a pointer position */
7+
function computeTerminalHeight(ev: PointerEvent): number {
8+
const maxHeight = Math.max(
9+
TERMINAL_HEIGHT.MIN,
10+
window.innerHeight * TERMINAL_HEIGHT.MAX_PERCENTAGE
11+
)
12+
const newHeight = window.innerHeight - CONTENT_WINDOW_GAP - ev.clientY
13+
return Math.min(Math.max(newHeight, TERMINAL_HEIGHT.MIN), maxHeight)
14+
}
615

7-
/** Inset gap between the viewport edge and the content window */
8-
const CONTENT_WINDOW_GAP = 8
16+
/**
17+
* Applies the terminal height per frame. The `--terminal-height` CSS
18+
* variable alone sizes `.terminal-container`, so no React work happens on
19+
* ordinary frames. The store is committed mid-drag only when the height
20+
* crosses the expanded threshold, so `isExpanded` subscribers (header
21+
* chevron, auto-open logic) still flip live during the drag.
22+
*/
23+
function applyTerminalHeight(height: number): void {
24+
document.documentElement.style.setProperty('--terminal-height', `${height}px`)
25+
26+
const store = useTerminalStore.getState()
27+
const wasExpanded = store.terminalHeight > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD
28+
const nowExpanded = height > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD
29+
if (wasExpanded !== nowExpanded) store.setTerminalHeight(height)
30+
}
931

32+
/**
33+
* Handles terminal drag-resize with zero React renders during the drag
34+
* (except at expanded-threshold crossings). The final height is committed
35+
* to the store (one re-render + one localStorage write) when the drag ends.
36+
*
37+
* @returns Pointer-down handler for the resize handle
38+
*/
1039
export function useTerminalResize() {
11-
const setTerminalHeight = useTerminalStore((state) => state.setTerminalHeight)
12-
const isResizing = useTerminalStore((state) => state.isResizing)
13-
const setIsResizing = useTerminalStore((state) => state.setIsResizing)
14-
15-
const handleMouseDown = useCallback(() => {
16-
setIsResizing(true)
17-
}, [setIsResizing])
18-
19-
useEffect(() => {
20-
if (!isResizing) return
21-
22-
const handleMouseMove = (e: MouseEvent) => {
23-
const newHeight = window.innerHeight - CONTENT_WINDOW_GAP - e.clientY
24-
const maxHeight = window.innerHeight * MAX_HEIGHT_PERCENTAGE
25-
26-
if (newHeight >= MIN_HEIGHT && newHeight <= maxHeight) {
27-
setTerminalHeight(newHeight)
28-
}
29-
}
30-
31-
const handleMouseUp = () => {
32-
setIsResizing(false)
33-
}
34-
35-
document.addEventListener('mousemove', handleMouseMove)
36-
document.addEventListener('mouseup', handleMouseUp)
37-
document.body.style.cursor = 'ns-resize'
38-
document.body.style.userSelect = 'none'
39-
40-
return () => {
41-
document.removeEventListener('mousemove', handleMouseMove)
42-
document.removeEventListener('mouseup', handleMouseUp)
43-
document.body.style.cursor = ''
44-
document.body.style.userSelect = ''
45-
}
46-
}, [isResizing, setTerminalHeight, setIsResizing])
47-
48-
return {
49-
isResizing,
50-
handleMouseDown,
51-
}
40+
const setTerminalHeight = useTerminalStore((s) => s.setTerminalHeight)
41+
42+
return useDragResize({
43+
cursor: 'ns-resize',
44+
compute: computeTerminalHeight,
45+
apply: applyTerminalHeight,
46+
commit: setTerminalHeight,
47+
})
5248
}

0 commit comments

Comments
 (0)