authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-09 22:51:36-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 22:51:36-03:00
logca40de081c3ab087cd6458ce9bef12e43c5a7233
tree5d5f5e323d63883822f1245ccb375302af324fd9
parentd97060709ca7741c0c45d2ffad4e241b992259f2
signature Signed by PGP key B5690EEEBB952194

fix(web): right panel and terminal drawer follow the pointer while dragging (#17657)


5 files changed, 101 insertions(+), 43 deletions(-)

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