Skip to content

Commit 287a951

Browse files
committed
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.
1 parent 86e6e1d commit 287a951

8 files changed

Lines changed: 262 additions & 145 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: 70 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,90 @@
1-
import { useCallback, useEffect } from 'react'
2-
import { useShallow } from 'zustand/react/shallow'
1+
import { useCallback, useEffect, useRef } from 'react'
32
import { PANEL_WIDTH } from '@/stores/constants'
43
import { usePanelStore } from '@/stores/panel'
54

65
/** Inset gap between the viewport edge and the content window */
76
const CONTENT_WINDOW_GAP = 8
87

98
/**
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.
9+
* Handles panel drag-resize with zero React renders during the drag.
1310
*
14-
* @returns Resize state and handlers
11+
* Mirrors the sidebar resize architecture (`use-sidebar-resize.ts`):
12+
*
13+
* pointerdown → capture the pointer on the handle (so move/up keep arriving
14+
* even when the cursor leaves the window)
15+
* pointermove → write to --panel-width inside a requestAnimationFrame
16+
* callback (the CSS variable alone sizes `.panel-container`,
17+
* so no React work happens per frame)
18+
* pointerup → cancel any pending RAF, tear down, persist the final width
19+
* to Zustand once (one re-render + one localStorage write)
20+
*
21+
* The drag is torn down by `pointerup`, `pointercancel`, or window `blur`, so
22+
* an interrupted gesture can never leave the drag listeners or body cursor
23+
* stuck. A single-flight guard prevents stacking listeners across rapid
24+
* presses, and an unmount cleanup tears down a drag still in flight.
1525
*/
1626
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-
)
27+
const setPanelWidth = usePanelStore((s) => s.setPanelWidth)
28+
const setIsResizing = usePanelStore((s) => s.setIsResizing)
29+
const cleanupRef = useRef<(() => void) | null>(null)
2430

25-
/**
26-
* Handles mouse down on resize handle
27-
*/
28-
const handleMouseDown = useCallback(() => {
29-
setIsResizing(true)
30-
}, [setIsResizing])
31+
const handlePointerDown = useCallback(
32+
(e: React.PointerEvent<HTMLElement>) => {
33+
if (cleanupRef.current) return
3134

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
35+
const handle = e.currentTarget
36+
const pointerId = e.pointerId
37+
setIsResizing(true)
38+
document.body.style.cursor = 'ew-resize'
39+
document.body.style.userSelect = 'none'
40+
handle.setPointerCapture?.(pointerId)
3841

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+
let rafId: number | null = null
4243

43-
if (newWidth >= PANEL_WIDTH.MIN && newWidth <= maxWidth) {
44-
setPanelWidth(newWidth)
44+
const onPointerMove = (ev: PointerEvent) => {
45+
if (rafId !== null) cancelAnimationFrame(rafId)
46+
rafId = requestAnimationFrame(() => {
47+
const maxWidth = window.innerWidth * PANEL_WIDTH.MAX_PERCENTAGE
48+
const newWidth = window.innerWidth - CONTENT_WINDOW_GAP - ev.clientX
49+
const clamped = Math.min(Math.max(newWidth, PANEL_WIDTH.MIN), maxWidth)
50+
document.documentElement.style.setProperty('--panel-width', `${clamped}px`)
51+
rafId = null
52+
})
4553
}
46-
}
4754

48-
const handleMouseUp = () => {
49-
setIsResizing(false)
50-
}
55+
const cleanup = () => {
56+
if (rafId !== null) {
57+
cancelAnimationFrame(rafId)
58+
rafId = null
59+
}
60+
document.body.style.cursor = ''
61+
document.body.style.userSelect = ''
62+
if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId)
63+
document.removeEventListener('pointermove', onPointerMove)
64+
document.removeEventListener('pointerup', endDrag)
65+
document.removeEventListener('pointercancel', endDrag)
66+
window.removeEventListener('blur', endDrag)
67+
cleanupRef.current = null
68+
}
5169

52-
document.addEventListener('mousemove', handleMouseMove)
53-
document.addEventListener('mouseup', handleMouseUp)
54-
document.body.style.cursor = 'ew-resize'
55-
document.body.style.userSelect = 'none'
70+
function endDrag() {
71+
cleanup()
72+
const raw = document.documentElement.style.getPropertyValue('--panel-width')
73+
const finalWidth = Number.parseFloat(raw)
74+
if (!Number.isNaN(finalWidth)) setPanelWidth(finalWidth)
75+
setIsResizing(false)
76+
}
77+
78+
cleanupRef.current = cleanup
79+
document.addEventListener('pointermove', onPointerMove)
80+
document.addEventListener('pointerup', endDrag)
81+
document.addEventListener('pointercancel', endDrag)
82+
window.addEventListener('blur', endDrag)
83+
},
84+
[setPanelWidth, setIsResizing]
85+
)
5686

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])
87+
useEffect(() => () => cleanupRef.current?.(), [])
6488

65-
return {
66-
isResizing,
67-
handleMouseDown,
68-
}
89+
return { handlePointerDown }
6990
}

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: 79 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,89 @@
1-
import { useCallback, useEffect, useState } from 'react'
1+
import { useCallback, useEffect, useRef } from 'react'
22
import { OUTPUT_PANEL_WIDTH, TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants'
33
import { useTerminalStore } from '@/stores/terminal'
44

5+
/**
6+
* Handles the terminal output panel drag-resize with zero React renders
7+
* during the drag.
8+
*
9+
* Mirrors the sidebar resize architecture (`use-sidebar-resize.ts`):
10+
*
11+
* pointerdown → capture the pointer on the handle (so move/up keep arriving
12+
* even when the cursor leaves the window)
13+
* pointermove → write to --output-panel-width inside a requestAnimationFrame
14+
* callback (the CSS variable alone sizes the logs column via
15+
* `calc(100% - var(--output-panel-width))`)
16+
* pointerup → cancel any pending RAF, tear down, persist the final width
17+
* to Zustand once (one re-render + one localStorage write)
18+
*
19+
* The drag is torn down by `pointerup`, `pointercancel`, or window `blur`, so
20+
* an interrupted gesture can never leave the drag listeners or body cursor
21+
* stuck. A single-flight guard prevents stacking listeners across rapid
22+
* presses, and an unmount cleanup tears down a drag still in flight.
23+
*/
524
export function useOutputPanelResize() {
6-
const setOutputPanelWidth = useTerminalStore((state) => state.setOutputPanelWidth)
7-
const [isResizing, setIsResizing] = useState(false)
25+
const setOutputPanelWidth = useTerminalStore((s) => s.setOutputPanelWidth)
26+
const cleanupRef = useRef<(() => void) | null>(null)
827

9-
const handleMouseDown = useCallback(() => {
10-
setIsResizing(true)
11-
}, [])
28+
const handlePointerDown = useCallback(
29+
(e: React.PointerEvent<HTMLElement>) => {
30+
if (cleanupRef.current) return
1231

13-
useEffect(() => {
14-
if (!isResizing) return
15-
16-
const handleMouseMove = (e: MouseEvent) => {
1732
const terminalEl = document.querySelector('[aria-label="Terminal"]')
1833
if (!terminalEl) return
1934

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])
44-
45-
return {
46-
isResizing,
47-
handleMouseDown,
48-
}
35+
const handle = e.currentTarget
36+
const pointerId = e.pointerId
37+
document.body.style.cursor = 'ew-resize'
38+
document.body.style.userSelect = 'none'
39+
handle.setPointerCapture?.(pointerId)
40+
41+
let rafId: number | null = null
42+
43+
const onPointerMove = (ev: PointerEvent) => {
44+
if (rafId !== null) cancelAnimationFrame(rafId)
45+
rafId = requestAnimationFrame(() => {
46+
const terminalRect = terminalEl.getBoundingClientRect()
47+
const newWidth = terminalRect.right - ev.clientX
48+
const maxWidth = terminalRect.width - TERMINAL_BLOCK_COLUMN_WIDTH
49+
const clamped = Math.max(OUTPUT_PANEL_WIDTH.MIN, Math.min(newWidth, maxWidth))
50+
document.documentElement.style.setProperty('--output-panel-width', `${clamped}px`)
51+
rafId = null
52+
})
53+
}
54+
55+
const cleanup = () => {
56+
if (rafId !== null) {
57+
cancelAnimationFrame(rafId)
58+
rafId = null
59+
}
60+
document.body.style.cursor = ''
61+
document.body.style.userSelect = ''
62+
if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId)
63+
document.removeEventListener('pointermove', onPointerMove)
64+
document.removeEventListener('pointerup', endDrag)
65+
document.removeEventListener('pointercancel', endDrag)
66+
window.removeEventListener('blur', endDrag)
67+
cleanupRef.current = null
68+
}
69+
70+
function endDrag() {
71+
cleanup()
72+
const raw = document.documentElement.style.getPropertyValue('--output-panel-width')
73+
const finalWidth = Number.parseFloat(raw)
74+
if (!Number.isNaN(finalWidth)) setOutputPanelWidth(finalWidth)
75+
}
76+
77+
cleanupRef.current = cleanup
78+
document.addEventListener('pointermove', onPointerMove)
79+
document.addEventListener('pointerup', endDrag)
80+
document.addEventListener('pointercancel', endDrag)
81+
window.addEventListener('blur', endDrag)
82+
},
83+
[setOutputPanelWidth]
84+
)
85+
86+
useEffect(() => () => cleanupRef.current?.(), [])
87+
88+
return { handlePointerDown }
4989
}

0 commit comments

Comments
 (0)