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({
12121212 pointerId: number;
12131213 startY: number;
12141214 startHeight: number;
1215 drawer: HTMLElement | null;
12151216 } | null>(null);
12161217 const didResizeDuringDragRef = useRef(false);
12171218
......@@ -1418,26 +1419,26 @@ export default function ThreadTerminalDrawer({
14181419 pointerId: event.pointerId,
14191420 startY: event.clientY,
14201421 startHeight: drawerHeightRef.current,
1422 drawer: event.currentTarget.parentElement,
14211423 };
14221424 }, []);
14231425
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 }, []);
14411442
14421443 const handleResizePointerEnd = useCallback(
14431444 (event: ReactPointerEvent<HTMLDivElement>) => {
......@@ -1450,10 +1451,11 @@ export default function ThreadTerminalDrawer({
14501451 if (!didResizeDuringDragRef.current) {
14511452 return;
14521453 }
1454 setDrawerHeight(drawerHeightRef.current);
14531455 syncHeight(drawerHeightRef.current);
14541456 setResizeEpoch((value) => value + 1);
14551457 },
1456 [syncHeight],
1458 [setDrawerHeight, syncHeight],
14571459 );
14581460
14591461 useEffect(() => {
......@@ -1510,6 +1512,7 @@ export default function ThreadTerminalDrawer({
15101512 onPointerMove={handleResizePointerMove}
15111513 onPointerUp={handleResizePointerEnd}
15121514 onPointerCancel={handleResizePointerEnd}
1515 onLostPointerCapture={handleResizePointerEnd}
15131516 />
15141517 ) : null}
15151518 <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({
15461549 onPointerMove={handleResizePointerMove}
15471550 onPointerUp={handleResizePointerEnd}
15481551 onPointerCancel={handleResizePointerEnd}
1552 onLostPointerCapture={handleResizePointerEnd}
15491553 />
15501554 ) : null}
15511555
apps/web/src/components/preview/PreviewPanelShell.tsx+22-5
......@@ -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
310import { isElectron } from "~/env";
411import {
......@@ -6,6 +13,7 @@ import {
613 type PreviewPanelInlineSize,
714 usePreviewPanelInlineSize,
815} from "~/hooks/usePreviewPanelInlineSize";
16import { RESIZABLE_WIDTH_PROPERTY } from "~/hooks/useResizableWidth";
917
1018export { getPreviewPanelMaxWidth };
1119import { cn } from "~/lib/utils";
......@@ -122,10 +130,15 @@ function PreviewPanelShellFrame(
122130 )}
123131 style={
124132 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})`,
127140 transitionDuration: suppressWidthTransition ? "0ms" : undefined,
128 }
141 } as CSSProperties)
129142 : undefined
130143 }
131144 data-preview-panel-mode={props.mode}
......@@ -135,7 +148,11 @@ function PreviewPanelShellFrame(
135148 <div className={cn("h-full min-h-0 w-full", collapsible && "overflow-clip")}>
136149 <div
137150 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 }
139156 >
140157 {props.children}
141158 </div>
apps/web/src/hooks/useResizableWidth.test.tsx+17-5
......@@ -2,12 +2,19 @@ import { act, useLayoutEffect, type PointerEvent } from "react";
22import { create, type ReactTestRenderer } from "react-test-renderer";
33import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
44
5import { useResizableWidth } from "./useResizableWidth";
5import { RESIZABLE_WIDTH_PROPERTY, useResizableWidth } from "./useResizableWidth";
66
77let renderer: ReactTestRenderer;
88let result: ReturnType<typeof useResizableWidth>;
99let captured = false;
10const hostStyle = new Map<string, string>();
1011const target = {
12 parentElement: {
13 style: {
14 setProperty: (property: string, value: string) => hostStyle.set(property, value),
15 removeProperty: (property: string) => hostStyle.delete(property),
16 },
17 },
1118 setPointerCapture: () => {
1219 captured = true;
1320 },
......@@ -65,6 +72,7 @@ function Panel({
6572
6673beforeEach(async () => {
6774 savedWidths.clear();
75 hostStyle.clear();
6876 captured = false;
6977 frame = undefined;
7078 style.cursor = "";
......@@ -102,7 +110,9 @@ describe("panel resize cleanup", () => {
102110 result.handlers.onPointerMove(pointer(50));
103111 });
104112 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);
106116 // Queue another move to check that interruption cancels pending work too.
107117 await act(() => result.handlers.onPointerMove(pointer(25)));
108118 expect(style.cursor).toBe("col-resize");
......@@ -116,6 +126,7 @@ describe("panel resize cleanup", () => {
116126 expect(style.cursor).toBe("");
117127 expect(style.userSelect).toBe("");
118128 expect(captured).toBe(false);
129 expect(hostStyle.has("transition-property")).toBe(false);
119130 expect(cancelAnimationFrame).toHaveBeenCalledWith(42);
120131 if (reason === "unmount") {
121132 expect(setItem).not.toHaveBeenCalled();
......@@ -151,11 +162,11 @@ describe("panel resize cleanup", () => {
151162 result.handlers.onPointerMove(pointer(-200));
152163 });
153164 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`);
155166 await act(() => renderer.update(<Panel maxWidth={nextMax} />));
156167 await act(() => result.handlers.onPointerMove(pointer(-250)));
157168 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`);
159170 await act(() => result.handlers.onPointerUp(pointer(-300)));
160171 expect(result.width).toBe(nextMax);
161172 expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", String(nextMax));
......@@ -225,10 +236,11 @@ describe("panel width storage changes", () => {
225236 result.handlers.onPointerMove(pointer(50));
226237 });
227238 await act(() => frame?.(0));
228 expect(result.width).toBe(450);
239 expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe("450px");
229240 await act(() => result.handlers.onPointerMove(pointer(25)));
230241 await act(() => renderer.update(<Panel storageKey="thread-b" />));
231242 expect(result.width).toBe(650);
243 expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe("650px");
232244 expect(captured).toBe(false);
233245 expect(style.cursor).toBe("");
234246 await act(() => {
apps/web/src/hooks/useResizableWidth.ts+34-12
......@@ -12,6 +12,12 @@ import { useResizeDrag } from "./useResizeDrag";
1212
1313const 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
1521export interface UseResizableWidthOptions {
1622 /** localStorage key the persisted width is stored under. */
1723 readonly storageKey: string;
......@@ -39,9 +45,10 @@ export interface ResizableWidthHandlers {
3945 * specified edge. Width is read on mount or storage-key changes and persisted on
4046 * drag-end (not on every rAF tick — would otherwise be ~60 writes/sec).
4147 *
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.
4552 */
4653export function useResizableWidth(options: UseResizableWidthOptions): {
4754 readonly width: number;
......@@ -75,21 +82,26 @@ export function useResizableWidth(options: UseResizableWidthOptions): {
7582 }
7683
7784 const clampedWidth = clamp(widthState.width);
78 const latestOptions = useRef({ clamp, storageKey });
85 const latestOptions = useRef({ clamp, storageKey, width: clampedWidth });
7986 useLayoutEffect(() => {
80 latestOptions.current = { clamp, storageKey };
81 }, [clamp, storageKey]);
87 latestOptions.current = { clamp, storageKey, width: clampedWidth };
88 }, [clamp, clampedWidth, storageKey]);
8289
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 {
8596 width: clampedWidth,
8697 edge,
8798 resize(value) {
8899 const nextWidth = latestOptions.current.clamp(value);
89 setWidthState({ storageKey, width: nextWidth });
100 host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${nextWidth}px`);
90101 return nextWidth;
91102 },
92103 finish(finalWidth) {
104 setWidthState({ storageKey, width: finalWidth });
93105 // Commit once at drag-end to avoid 60Hz localStorage writes.
94106 try {
95107 setLocalStorageItem(latestOptions.current.storageKey, finalWidth, WidthSchema);
......@@ -97,9 +109,19 @@ export function useResizableWidth(options: UseResizableWidthOptions): {
97109 console.error("Could not persist panel width.", error);
98110 }
99111 },
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]);
103125
104126 return { width: clampedWidth, handlers };
105127}
apps/web/src/hooks/useResizeDrag.ts+6-3
......@@ -5,7 +5,8 @@ interface ResizeSession {
55 edge: "left" | "right";
66 resize: (width: number) => number;
77 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;
910}
1011
1112/** Shared pointer lifecycle for side panels, including interrupted and sub-frame drags. */
......@@ -49,7 +50,7 @@ export function useResizeDrag<T extends HTMLElement>(
4950 }
5051 document.body.style.removeProperty("cursor");
5152 document.body.style.removeProperty("user-select");
52 active.session.cleanup?.();
53 active.session.cleanup?.(commit);
5354 if (commit) active.session.finish(active.width, active.moved);
5455 },
5556 [flush],
......@@ -81,6 +82,8 @@ export function useResizeDrag<T extends HTMLElement>(
8182 };
8283
8384 return {
85 /** Re-applies the pointer position to an active drag, e.g. after its bounds change. */
86 refresh: flush,
8487 onPointerDown(event: PointerEvent<T>) {
8588 if (event.button !== 0 || drag.current) return;
8689 const session = start(event);
......@@ -88,7 +91,7 @@ export function useResizeDrag<T extends HTMLElement>(
8891 try {
8992 event.currentTarget.setPointerCapture(event.pointerId);
9093 } catch {
91 session.cleanup?.();
94 session.cleanup?.(false);
9295 return;
9396 }
9497 event.preventDefault();