diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index bd45f39ad2bdf08bc85d13af5caa4e3c821484be..f6509289d45ae4d35c933ae0847ab932a790b06d 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -104,9 +104,8 @@ function maxDrawerHeight(): number { return Math.max(MIN_DRAWER_HEIGHT, Math.floor(window.innerHeight * MAX_DRAWER_HEIGHT_RATIO)); } -function clampDrawerHeight(height: number): number { +function clampDrawerHeight(height: number, maxHeight = maxDrawerHeight()): number { const safeHeight = Number.isFinite(height) ? height : DEFAULT_THREAD_TERMINAL_HEIGHT; - const maxHeight = maxDrawerHeight(); return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight); } @@ -1181,13 +1180,16 @@ export default function ThreadTerminalDrawer({ false, Schema.Boolean, ); - const controlledDrawerHeight = clampDrawerHeight(height); + // The stored height is the user's preference; the window only clamps what renders. + const controlledDrawerHeight = height; + const [windowMaxDrawerHeight, setWindowMaxDrawerHeight] = useState(maxDrawerHeight); const [drawerHeightState, setDrawerHeightState] = useState(() => ({ threadId, height: controlledDrawerHeight, })); - const drawerHeight = + const preferredDrawerHeight = drawerHeightState.threadId === threadId ? drawerHeightState.height : controlledDrawerHeight; + const drawerHeight = clampDrawerHeight(preferredDrawerHeight, windowMaxDrawerHeight); const setDrawerHeight = useCallback( (update: SetStateAction) => { setDrawerHeightState((current) => { @@ -1201,11 +1203,9 @@ export default function ThreadTerminalDrawer({ }, [controlledDrawerHeight, threadId], ); - const setDrawerHeightFromWindowResize = useEffectEvent((nextHeight: number) => { - setDrawerHeight(nextHeight); - }); const [resizeEpoch, setResizeEpoch] = useState(0); const drawerHeightRef = useRef(drawerHeight); + const preferredDrawerHeightRef = useRef(preferredDrawerHeight); const lastSyncedHeightRef = useRef(controlledDrawerHeight); const onHeightChangeRef = useRef(onHeightChange); const resizeStateRef = useRef<{ @@ -1395,15 +1395,24 @@ export default function ThreadTerminalDrawer({ onHeightChangeRef.current = onHeightChange; }, [onHeightChange]); - useEffect(() => { + useLayoutEffect(() => { + // A render mid-drag (the window cap moved) must not replace the live height. + const resizeState = resizeStateRef.current; + if (resizeState) { + resizeState.drawer?.style.setProperty("height", `${drawerHeightRef.current}px`); + return; + } drawerHeightRef.current = drawerHeight; }, [drawerHeight]); + useEffect(() => { + preferredDrawerHeightRef.current = preferredDrawerHeight; + }, [preferredDrawerHeight]); + const syncHeight = useCallback((nextHeight: number) => { - const clampedHeight = clampDrawerHeight(nextHeight); - if (lastSyncedHeightRef.current === clampedHeight) return; - lastSyncedHeightRef.current = clampedHeight; - onHeightChangeRef.current(clampedHeight); + if (lastSyncedHeightRef.current === nextHeight) return; + lastSyncedHeightRef.current = nextHeight; + onHeightChangeRef.current(nextHeight); }, []); useEffect(() => { @@ -1448,7 +1457,14 @@ export default function ThreadTerminalDrawer({ if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } - if (!didResizeDuringDragRef.current) { + // A drag that ends where it started keeps the stored preference, which may + // be taller than the window-capped height the drag started from. + if (!didResizeDuringDragRef.current || drawerHeightRef.current === resizeState.startHeight) { + // React skips the write when the rendered height did not change, so put + // back the preference as the current window caps it. + const renderedHeight = clampDrawerHeight(preferredDrawerHeightRef.current); + drawerHeightRef.current = renderedHeight; + resizeState.drawer?.style.setProperty("height", `${renderedHeight}px`); return; } setDrawerHeight(drawerHeightRef.current); @@ -1464,33 +1480,33 @@ export default function ThreadTerminalDrawer({ } const onWindowResize = () => { - const clampedHeight = clampDrawerHeight(drawerHeightRef.current); - const changed = clampedHeight !== drawerHeightRef.current; - if (changed) { - setDrawerHeightFromWindowResize(clampedHeight); - drawerHeightRef.current = clampedHeight; - } - if (!resizeStateRef.current) { - syncHeight(clampedHeight); + // React only renders the stored preference, so clamp a live drag height here. + const resizeState = resizeStateRef.current; + const liveHeight = clampDrawerHeight(drawerHeightRef.current); + if (resizeState && liveHeight !== drawerHeightRef.current) { + drawerHeightRef.current = liveHeight; + resizeState.drawer?.style.setProperty("height", `${liveHeight}px`); } + setWindowMaxDrawerHeight(maxDrawerHeight()); setResizeEpoch((value) => value + 1); }; window.addEventListener("resize", onWindowResize); return () => { window.removeEventListener("resize", onWindowResize); }; - }, [syncHeight, visible]); + }, [visible]); useEffect(() => { if (!visible) { return; } + setWindowMaxDrawerHeight(maxDrawerHeight()); setResizeEpoch((value) => value + 1); }, [visible]); useEffect(() => { return () => { - syncHeight(drawerHeightRef.current); + syncHeight(preferredDrawerHeightRef.current); }; }, [syncHeight]);