| author | |
| committer | |
| log | ca40de081c3ab087cd6458ce9bef12e43c5a7233 |
| tree | 5d5f5e323d63883822f1245ccb375302af324fd9 |
| parent | d97060709ca7741c0c45d2ffad4e241b992259f2 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 101 insertions(+), 43 deletions(-)
apps/web/src/components/ThreadTerminalDrawer.tsx+22-18| ... | ... | @@ -1212,6 +1212,7 @@ export default function ThreadTerminalDrawer({ |
| 1212 | 1212 | pointerId: number; |
| 1213 | 1213 | startY: number; |
| 1214 | 1214 | startHeight: number; |
| 1215 | drawer: HTMLElement | null; | |
| 1215 | 1216 | } | null>(null); |
| 1216 | 1217 | const didResizeDuringDragRef = useRef(false); |
| 1217 | 1218 | |
| ... | ... | @@ -1418,26 +1419,26 @@ export default function ThreadTerminalDrawer({ |
| 1418 | 1419 | pointerId: event.pointerId, |
| 1419 | 1420 | startY: event.clientY, |
| 1420 | 1421 | startHeight: drawerHeightRef.current, |
| 1422 | drawer: event.currentTarget.parentElement, | |
| 1421 | 1423 | }; |
| 1422 | 1424 | }, []); |
| 1423 | 1425 | |
| 1424 | const handleResizePointerMove = useCallback( | |
| 1425 | (event: ReactPointerEvent<HTMLDivElement>) => { | |
| 1426 | const resizeState = resizeStateRef.current; | |
| 1427 | if (!resizeState || resizeState.pointerId !== event.pointerId) return; | |
| 1428 | event.preventDefault(); | |
| 1429 | const clampedHeight = clampDrawerHeight( | |
| 1430 | resizeState.startHeight + (resizeState.startY - event.clientY), | |
| 1431 | ); | |
| 1432 | if (clampedHeight === drawerHeightRef.current) { | |
| 1433 | return; | |
| 1434 | } | |
| 1435 | didResizeDuringDragRef.current = true; | |
| 1436 | drawerHeightRef.current = clampedHeight; | |
| 1437 | setDrawerHeight(clampedHeight); | |
| 1438 | }, | |
| 1439 | [setDrawerHeight], | |
| 1440 | ); | |
| 1426 | // Writes the height straight to the drawer while dragging so the edge lands in | |
| 1427 | // the pointer's frame; React state commits once on release. | |
| 1428 | const handleResizePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { | |
| 1429 | const resizeState = resizeStateRef.current; | |
| 1430 | if (!resizeState || resizeState.pointerId !== event.pointerId) return; | |
| 1431 | event.preventDefault(); | |
| 1432 | const clampedHeight = clampDrawerHeight( | |
| 1433 | resizeState.startHeight + (resizeState.startY - event.clientY), | |
| 1434 | ); | |
| 1435 | if (clampedHeight === drawerHeightRef.current) { | |
| 1436 | return; | |
| 1437 | } | |
| 1438 | didResizeDuringDragRef.current = true; | |
| 1439 | drawerHeightRef.current = clampedHeight; | |
| 1440 | resizeState.drawer?.style.setProperty("height", `${clampedHeight}px`); | |
| 1441 | }, []); | |
| 1441 | 1442 | |
| 1442 | 1443 | const handleResizePointerEnd = useCallback( |
| 1443 | 1444 | (event: ReactPointerEvent<HTMLDivElement>) => { |
| ... | ... | @@ -1450,10 +1451,11 @@ export default function ThreadTerminalDrawer({ |
| 1450 | 1451 | if (!didResizeDuringDragRef.current) { |
| 1451 | 1452 | return; |
| 1452 | 1453 | } |
| 1454 | setDrawerHeight(drawerHeightRef.current); | |
| 1453 | 1455 | syncHeight(drawerHeightRef.current); |
| 1454 | 1456 | setResizeEpoch((value) => value + 1); |
| 1455 | 1457 | }, |
| 1456 | [syncHeight], | |
| 1458 | [setDrawerHeight, syncHeight], | |
| 1457 | 1459 | ); |
| 1458 | 1460 | |
| 1459 | 1461 | useEffect(() => { |
| ... | ... | @@ -1510,6 +1512,7 @@ export default function ThreadTerminalDrawer({ |
| 1510 | 1512 | onPointerMove={handleResizePointerMove} |
| 1511 | 1513 | onPointerUp={handleResizePointerEnd} |
| 1512 | 1514 | onPointerCancel={handleResizePointerEnd} |
| 1515 | onLostPointerCapture={handleResizePointerEnd} | |
| 1513 | 1516 | /> |
| 1514 | 1517 | ) : null} |
| 1515 | 1518 | <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center text-sm text-muted-foreground"> |
| ... | ... | @@ -1546,6 +1549,7 @@ export default function ThreadTerminalDrawer({ |
| 1546 | 1549 | onPointerMove={handleResizePointerMove} |
| 1547 | 1550 | onPointerUp={handleResizePointerEnd} |
| 1548 | 1551 | onPointerCancel={handleResizePointerEnd} |
| 1552 | onLostPointerCapture={handleResizePointerEnd} | |
| 1549 | 1553 | /> |
| 1550 | 1554 | ) : null} |
| 1551 | 1555 |
apps/web/src/components/preview/PreviewPanelShell.tsx+22-5| ... | ... | @@ -1,4 +1,11 @@ |
| 1 | import { type ReactNode, type RefObject, useLayoutEffect, useRef, useState } from "react"; | |
| 1 | import { | |
| 2 | type CSSProperties, | |
| 3 | type ReactNode, | |
| 4 | type RefObject, | |
| 5 | useLayoutEffect, | |
| 6 | useRef, | |
| 7 | useState, | |
| 8 | } from "react"; | |
| 2 | 9 | |
| 3 | 10 | import { isElectron } from "~/env"; |
| 4 | 11 | import { |
| ... | ... | @@ -6,6 +13,7 @@ import { |
| 6 | 13 | type PreviewPanelInlineSize, |
| 7 | 14 | usePreviewPanelInlineSize, |
| 8 | 15 | } from "~/hooks/usePreviewPanelInlineSize"; |
| 16 | import { RESIZABLE_WIDTH_PROPERTY } from "~/hooks/useResizableWidth"; | |
| 9 | 17 | |
| 10 | 18 | export { getPreviewPanelMaxWidth }; |
| 11 | 19 | import { cn } from "~/lib/utils"; |
| ... | ... | @@ -122,10 +130,15 @@ function PreviewPanelShellFrame( |
| 122 | 130 | )} |
| 123 | 131 | style={ |
| 124 | 132 | isInline |
| 125 | ? { | |
| 126 | width: maximized ? "100%" : collapsible && !open ? "0px" : `${width}px`, | |
| 133 | ? ({ | |
| 134 | [RESIZABLE_WIDTH_PROPERTY]: `${width}px`, | |
| 135 | width: maximized | |
| 136 | ? "100%" | |
| 137 | : collapsible && !open | |
| 138 | ? "0px" | |
| 139 | : `var(${RESIZABLE_WIDTH_PROPERTY})`, | |
| 127 | 140 | transitionDuration: suppressWidthTransition ? "0ms" : undefined, |
| 128 | } | |
| 141 | } as CSSProperties) | |
| 129 | 142 | : undefined |
| 130 | 143 | } |
| 131 | 144 | data-preview-panel-mode={props.mode} |
| ... | ... | @@ -135,7 +148,11 @@ function PreviewPanelShellFrame( |
| 135 | 148 | <div className={cn("h-full min-h-0 w-full", collapsible && "overflow-clip")}> |
| 136 | 149 | <div |
| 137 | 150 | className="flex h-full min-h-0 min-w-0 flex-col" |
| 138 | style={collapsible && !maximized ? { width: `calc(${width}px - 1px)` } : undefined} | |
| 151 | style={ | |
| 152 | collapsible && !maximized | |
| 153 | ? { width: `calc(var(${RESIZABLE_WIDTH_PROPERTY}) - 1px)` } | |
| 154 | : undefined | |
| 155 | } | |
| 139 | 156 | > |
| 140 | 157 | {props.children} |
| 141 | 158 | </div> |
apps/web/src/hooks/useResizableWidth.test.tsx+17-5| ... | ... | @@ -2,12 +2,19 @@ import { act, useLayoutEffect, type PointerEvent } from "react"; |
| 2 | 2 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 3 | 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 4 | |
| 5 | import { useResizableWidth } from "./useResizableWidth"; | |
| 5 | import { RESIZABLE_WIDTH_PROPERTY, useResizableWidth } from "./useResizableWidth"; | |
| 6 | 6 | |
| 7 | 7 | let renderer: ReactTestRenderer; |
| 8 | 8 | let result: ReturnType<typeof useResizableWidth>; |
| 9 | 9 | let captured = false; |
| 10 | const hostStyle = new Map<string, string>(); | |
| 10 | 11 | const target = { |
| 12 | parentElement: { | |
| 13 | style: { | |
| 14 | setProperty: (property: string, value: string) => hostStyle.set(property, value), | |
| 15 | removeProperty: (property: string) => hostStyle.delete(property), | |
| 16 | }, | |
| 17 | }, | |
| 11 | 18 | setPointerCapture: () => { |
| 12 | 19 | captured = true; |
| 13 | 20 | }, |
| ... | ... | @@ -65,6 +72,7 @@ function Panel({ |
| 65 | 72 | |
| 66 | 73 | beforeEach(async () => { |
| 67 | 74 | savedWidths.clear(); |
| 75 | hostStyle.clear(); | |
| 68 | 76 | captured = false; |
| 69 | 77 | frame = undefined; |
| 70 | 78 | style.cursor = ""; |
| ... | ... | @@ -102,7 +110,9 @@ describe("panel resize cleanup", () => { |
| 102 | 110 | result.handlers.onPointerMove(pointer(50)); |
| 103 | 111 | }); |
| 104 | 112 | await act(() => frame?.(0)); |
| 105 | expect(result.width).toBe(450); | |
| 113 | expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe("450px"); | |
| 114 | expect(hostStyle.get("transition-property")).toBe("none"); | |
| 115 | expect(result.width).toBe(400); | |
| 106 | 116 | // Queue another move to check that interruption cancels pending work too. |
| 107 | 117 | await act(() => result.handlers.onPointerMove(pointer(25))); |
| 108 | 118 | expect(style.cursor).toBe("col-resize"); |
| ... | ... | @@ -116,6 +126,7 @@ describe("panel resize cleanup", () => { |
| 116 | 126 | expect(style.cursor).toBe(""); |
| 117 | 127 | expect(style.userSelect).toBe(""); |
| 118 | 128 | expect(captured).toBe(false); |
| 129 | expect(hostStyle.has("transition-property")).toBe(false); | |
| 119 | 130 | expect(cancelAnimationFrame).toHaveBeenCalledWith(42); |
| 120 | 131 | if (reason === "unmount") { |
| 121 | 132 | expect(setItem).not.toHaveBeenCalled(); |
| ... | ... | @@ -151,11 +162,11 @@ describe("panel resize cleanup", () => { |
| 151 | 162 | result.handlers.onPointerMove(pointer(-200)); |
| 152 | 163 | }); |
| 153 | 164 | await act(() => frame?.(0)); |
| 154 | expect(result.width).toBe(Math.min(700, initialMax)); | |
| 165 | expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe(`${Math.min(700, initialMax)}px`); | |
| 155 | 166 | await act(() => renderer.update(<Panel maxWidth={nextMax} />)); |
| 156 | 167 | await act(() => result.handlers.onPointerMove(pointer(-250))); |
| 157 | 168 | await act(() => frame?.(0)); |
| 158 | expect(result.width).toBe(Math.min(750, nextMax)); | |
| 169 | expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe(`${Math.min(750, nextMax)}px`); | |
| 159 | 170 | await act(() => result.handlers.onPointerUp(pointer(-300))); |
| 160 | 171 | expect(result.width).toBe(nextMax); |
| 161 | 172 | expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", String(nextMax)); |
| ... | ... | @@ -225,10 +236,11 @@ describe("panel width storage changes", () => { |
| 225 | 236 | result.handlers.onPointerMove(pointer(50)); |
| 226 | 237 | }); |
| 227 | 238 | await act(() => frame?.(0)); |
| 228 | expect(result.width).toBe(450); | |
| 239 | expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe("450px"); | |
| 229 | 240 | await act(() => result.handlers.onPointerMove(pointer(25))); |
| 230 | 241 | await act(() => renderer.update(<Panel storageKey="thread-b" />)); |
| 231 | 242 | expect(result.width).toBe(650); |
| 243 | expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe("650px"); | |
| 232 | 244 | expect(captured).toBe(false); |
| 233 | 245 | expect(style.cursor).toBe(""); |
| 234 | 246 | await act(() => { |
apps/web/src/hooks/useResizableWidth.ts+34-12| ... | ... | @@ -12,6 +12,12 @@ import { useResizeDrag } from "./useResizeDrag"; |
| 12 | 12 | |
| 13 | 13 | const WidthSchema = Schema.Finite; |
| 14 | 14 | |
| 15 | /** | |
| 16 | * Custom property the host element must size itself from. The drag handle sits | |
| 17 | * directly inside the host, and a drag writes this property on it each frame. | |
| 18 | */ | |
| 19 | export const RESIZABLE_WIDTH_PROPERTY = "--resizable-width"; | |
| 20 | ||
| 15 | 21 | export interface UseResizableWidthOptions { |
| 16 | 22 | /** localStorage key the persisted width is stored under. */ |
| 17 | 23 | readonly storageKey: string; |
| ... | ... | @@ -39,9 +45,10 @@ export interface ResizableWidthHandlers { |
| 39 | 45 | * specified edge. Width is read on mount or storage-key changes and persisted on |
| 40 | 46 | * drag-end (not on every rAF tick — would otherwise be ~60 writes/sec). |
| 41 | 47 | * |
| 42 | * The hook updates an internal `width` state during drag (so the panel | |
| 43 | * follows the cursor live) and only commits to localStorage when the user | |
| 44 | * lifts the pointer or the drag is interrupted. | |
| 48 | * During a drag the hook writes `RESIZABLE_WIDTH_PROPERTY` on the handle's | |
| 49 | * parent, so the panel follows the cursor in the same frame without | |
| 50 | * re-rendering the owner. `width` state and localStorage commit once when the | |
| 51 | * user lifts the pointer or the drag is interrupted. | |
| 45 | 52 | */ |
| 46 | 53 | export function useResizableWidth(options: UseResizableWidthOptions): { |
| 47 | 54 | readonly width: number; |
| ... | ... | @@ -75,21 +82,26 @@ export function useResizableWidth(options: UseResizableWidthOptions): { |
| 75 | 82 | } |
| 76 | 83 | |
| 77 | 84 | const clampedWidth = clamp(widthState.width); |
| 78 | const latestOptions = useRef({ clamp, storageKey }); | |
| 85 | const latestOptions = useRef({ clamp, storageKey, width: clampedWidth }); | |
| 79 | 86 | useLayoutEffect(() => { |
| 80 | latestOptions.current = { clamp, storageKey }; | |
| 81 | }, [clamp, storageKey]); | |
| 87 | latestOptions.current = { clamp, storageKey, width: clampedWidth }; | |
| 88 | }, [clamp, clampedWidth, storageKey]); | |
| 82 | 89 | |
| 83 | const handlers = useResizeDrag<HTMLElement>( | |
| 84 | () => ({ | |
| 90 | const { refresh, ...handlers } = useResizeDrag<HTMLElement>((event) => { | |
| 91 | const host = event.currentTarget.parentElement; | |
| 92 | // A collapsible host animates width; live drag writes must not. The host | |
| 93 | // renders its own transition-duration, so override a property it leaves alone. | |
| 94 | host?.style.setProperty("transition-property", "none"); | |
| 95 | return { | |
| 85 | 96 | width: clampedWidth, |
| 86 | 97 | edge, |
| 87 | 98 | resize(value) { |
| 88 | 99 | const nextWidth = latestOptions.current.clamp(value); |
| 89 | setWidthState({ storageKey, width: nextWidth }); | |
| 100 | host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${nextWidth}px`); | |
| 90 | 101 | return nextWidth; |
| 91 | 102 | }, |
| 92 | 103 | finish(finalWidth) { |
| 104 | setWidthState({ storageKey, width: finalWidth }); | |
| 93 | 105 | // Commit once at drag-end to avoid 60Hz localStorage writes. |
| 94 | 106 | try { |
| 95 | 107 | setLocalStorageItem(latestOptions.current.storageKey, finalWidth, WidthSchema); |
| ... | ... | @@ -97,9 +109,19 @@ export function useResizableWidth(options: UseResizableWidthOptions): { |
| 97 | 109 | console.error("Could not persist panel width.", error); |
| 98 | 110 | } |
| 99 | 111 | }, |
| 100 | }), | |
| 101 | storageKey, | |
| 102 | ); | |
| 112 | cleanup(committed) { | |
| 113 | host?.style.removeProperty("transition-property"); | |
| 114 | // React skips the write when the rendered width did not change. | |
| 115 | if (!committed) { | |
| 116 | host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${latestOptions.current.width}px`); | |
| 117 | } | |
| 118 | }, | |
| 119 | }; | |
| 120 | }, storageKey); | |
| 121 | ||
| 122 | // Bounds can change mid-drag (sidebar opens, window narrows) and the render | |
| 123 | // rewrites the committed width, so re-apply the live pointer position. | |
| 124 | useLayoutEffect(refresh, [clamp, clampedWidth, refresh]); | |
| 103 | 125 | |
| 104 | 126 | return { width: clampedWidth, handlers }; |
| 105 | 127 | } |
apps/web/src/hooks/useResizeDrag.ts+6-3| ... | ... | @@ -5,7 +5,8 @@ interface ResizeSession { |
| 5 | 5 | edge: "left" | "right"; |
| 6 | 6 | resize: (width: number) => number; |
| 7 | 7 | finish: (width: number, moved: boolean) => void; |
| 8 | cleanup?: () => void; | |
| 8 | /** Runs on every end; `committed` is false when the drag was abandoned without `finish`. */ | |
| 9 | cleanup?: (committed: boolean) => void; | |
| 9 | 10 | } |
| 10 | 11 | |
| 11 | 12 | /** Shared pointer lifecycle for side panels, including interrupted and sub-frame drags. */ |
| ... | ... | @@ -49,7 +50,7 @@ export function useResizeDrag<T extends HTMLElement>( |
| 49 | 50 | } |
| 50 | 51 | document.body.style.removeProperty("cursor"); |
| 51 | 52 | document.body.style.removeProperty("user-select"); |
| 52 | active.session.cleanup?.(); | |
| 53 | active.session.cleanup?.(commit); | |
| 53 | 54 | if (commit) active.session.finish(active.width, active.moved); |
| 54 | 55 | }, |
| 55 | 56 | [flush], |
| ... | ... | @@ -81,6 +82,8 @@ export function useResizeDrag<T extends HTMLElement>( |
| 81 | 82 | }; |
| 82 | 83 | |
| 83 | 84 | return { |
| 85 | /** Re-applies the pointer position to an active drag, e.g. after its bounds change. */ | |
| 86 | refresh: flush, | |
| 84 | 87 | onPointerDown(event: PointerEvent<T>) { |
| 85 | 88 | if (event.button !== 0 || drag.current) return; |
| 86 | 89 | const session = start(event); |
| ... | ... | @@ -88,7 +91,7 @@ export function useResizeDrag<T extends HTMLElement>( |
| 88 | 91 | try { |
| 89 | 92 | event.currentTarget.setPointerCapture(event.pointerId); |
| 90 | 93 | } catch { |
| 91 | session.cleanup?.(); | |
| 94 | session.cleanup?.(false); | |
| 92 | 95 | return; |
| 93 | 96 | } |
| 94 | 97 | event.preventDefault(); |