authorclover caruso <git@paperclover.net> 2026-10-13 10:14:26-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 16:52:04-07:00
loge05b6a353e4701d919165a7a32ee30b2ec61dce0
treeb38ec38b04481f03ee50881213551960bafff9f6
parent99e6ff629afe3d401757ea3b304d861b1b64f1e5
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

fix: make panel resize dividers easier to grab

Clover often missed drag handles and terminal text selection accidentally resized the sidebar. Reserve 12px sidebar and side-panel gutters and a 12px terminal divider outside selectable terminal content; expand browser viewport rails to 16px and floating preview targets to 12px edges and 20px corners. Add keyboard resizing and clean up pointer capture on interruption, route changes and unmount while preserving final release positions. Assisted-by: gpt-6.1-sol

14 files changed, 555 insertions(+), 174 deletions(-)

apps/web/src/browser/BrowserViewportResizeHandles.tsx+1-1
...@@ -30,7 +30,7 @@ interface Props {...@@ -30,7 +30,7 @@ interface Props {
30type HandleKind = "horizontal" | "vertical" | "corner";30type HandleKind = "horizontal" | "vertical" | "corner";
3131
32const EDGE_BUTTON_CLASS =32const EDGE_BUTTON_CLASS =
33 "group absolute z-20 touch-none border-0 bg-transparent p-0 outline-none before:absolute before:-inset-1 before:content-[''] focus-visible:bg-foreground/[0.04]";33 "group absolute z-20 touch-none border-0 bg-transparent p-0 outline-none focus-visible:bg-foreground/[0.04]";
34const EDGE_GRIP_CLASS =34const EDGE_GRIP_CLASS =
35 "pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center text-muted-foreground/55 transition-colors duration-150 group-hover:text-foreground/85 group-focus-visible:text-foreground group-active:text-foreground";35 "pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center text-muted-foreground/55 transition-colors duration-150 group-hover:text-foreground/85 group-focus-visible:text-foreground group-active:text-foreground";
3636
apps/web/src/browser/browserViewportLayout.test.ts+8-8
...@@ -123,15 +123,15 @@ describe("resolveBrowserViewportLayout", () => {...@@ -123,15 +123,15 @@ describe("resolveBrowserViewportLayout", () => {
123 expect(123 expect(
124 resolveBrowserDeviceViewportLayout(124 resolveBrowserDeviceViewportLayout(
125 { width: 1200, height: 900 },125 { width: 1200, height: 900 },
126 { _tag: "freeform", width: 1180, height: 852 },126 { _tag: "freeform", width: 1168, height: 846 },
127 ),127 ),
128 ).toEqual({128 ).toEqual({
129 canvasWidth: 1200,129 canvasWidth: 1200,
130 canvasHeight: 900,130 canvasHeight: 900,
131 viewportX: 10,131 viewportX: 16,
132 viewportY: 38,132 viewportY: 38,
133 viewportWidth: 1180,133 viewportWidth: 1168,
134 viewportHeight: 852,134 viewportHeight: 846,
135 viewportScale: 1,135 viewportScale: 1,
136 fillsPanel: false,136 fillsPanel: false,
137 });137 });
...@@ -139,12 +139,12 @@ describe("resolveBrowserViewportLayout", () => {...@@ -139,12 +139,12 @@ describe("resolveBrowserViewportLayout", () => {
139139
140 it("captures the available framed area when responsive mode is enabled", () => {140 it("captures the available framed area when responsive mode is enabled", () => {
141 expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 })).toEqual({141 expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 })).toEqual({
142 width: 1180,142 width: 1168,
143 height: 852,143 height: 846,
144 });144 });
145 expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 }, 2)).toEqual({145 expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 }, 2)).toEqual({
146 width: 590,146 width: 584,
147 height: 426,147 height: 423,
148 });148 });
149 });149 });
150150
apps/web/src/browser/browserViewportLayout.ts+1-1
...@@ -23,7 +23,7 @@ export interface BrowserViewportLayout {...@@ -23,7 +23,7 @@ export interface BrowserViewportLayout {
23// on both sides, inside the toolbar's 1px bottom border. The bar scrolls23// on both sides, inside the toolbar's 1px bottom border. The bar scrolls
24// horizontally, which also clips anything that overflows vertically.24// horizontally, which also clips anything that overflows vertically.
25export const BROWSER_DEVICE_TOOLBAR_HEIGHT = 38;25export const BROWSER_DEVICE_TOOLBAR_HEIGHT = 38;
26export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 10;26export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 16;
2727
28export type BrowserViewportResizeDirection =28export type BrowserViewportResizeDirection =
29 | "north"29 | "north"
apps/web/src/browser/useBrowserViewportResize.test.tsx created+124
...@@ -0,0 +1,124 @@
1import { act, type PointerEvent as ReactPointerEvent, useLayoutEffect } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
4
5import { commitBrowserViewportChange } from "./browserViewportActions";
6import { useBrowserViewportResize } from "./useBrowserViewportResize";
7
8vi.mock("./browserViewportActions", () => ({ commitBrowserViewportChange: vi.fn(async () => {}) }));
9
10let renderer: ReactTestRenderer;
11let result: ReturnType<typeof useBrowserViewportResize>;
12let events: EventTarget;
13let captured = false;
14const target = Object.assign(new EventTarget(), {
15 setPointerCapture: () => {
16 captured = true;
17 },
18 hasPointerCapture: () => captured,
19 releasePointerCapture: () => {
20 captured = false;
21 },
22});
23
24function pointer(clientX: number, options: { button?: number; pointerId?: number } = {}) {
25 return {
26 button: options.button ?? 0,
27 pointerId: options.pointerId ?? 1,
28 clientX,
29 clientY: 100,
30 target,
31 currentTarget: target,
32 preventDefault() {},
33 stopPropagation() {},
34 } as unknown as ReactPointerEvent<HTMLButtonElement>;
35}
36
37function dispatch(type: string, clientX = 100, destination = events) {
38 destination.dispatchEvent(
39 Object.assign(new Event(type, { cancelable: true }), { pointerId: 1, clientX, clientY: 100 }),
40 );
41}
42
43function Viewport({ tabId = "one" }: { tabId?: string }) {
44 const resize = useBrowserViewportResize({
45 tabId,
46 viewport: { _tag: "freeform", width: 800, height: 600 },
47 zoomFactor: 1,
48 containerSize: { width: 1200, height: 900 },
49 deviceToolbarVisible: true,
50 aspectRatio: null,
51 });
52 useLayoutEffect(() => {
53 result = resize;
54 });
55 return null;
56}
57
58beforeEach(async () => {
59 events = new EventTarget();
60 captured = false;
61 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
62 vi.stubGlobal("window", {
63 addEventListener: events.addEventListener.bind(events),
64 removeEventListener: events.removeEventListener.bind(events),
65 });
66 await act(() => {
67 renderer = create(<Viewport />);
68 });
69});
70
71afterEach(async () => {
72 await act(() => renderer.unmount());
73 vi.unstubAllGlobals();
74 vi.clearAllMocks();
75});
76
77it("commits the final release position even without a move frame", async () => {
78 await act(async () => {
79 result.handleResizePointerDown("east", pointer(100));
80 dispatch("pointerup", 225);
81 });
82 expect(commitBrowserViewportChange).toHaveBeenCalledExactlyOnceWith("one", {
83 _tag: "freeform",
84 width: 1050,
85 height: 600,
86 });
87 expect(captured).toBe(false);
88});
89
90it.each(["cancel", "lost capture", "blur", "unmount", "tab switch"])(
91 "abandons the resize after %s",
92 async (reason) => {
93 await act(() => {
94 result.handleResizePointerDown("east", pointer(100));
95 dispatch("pointermove", 175);
96 });
97 expect(result.effectiveViewport).toEqual({ _tag: "freeform", width: 950, height: 600 });
98 await act(() => {
99 if (reason === "cancel") dispatch("pointercancel");
100 if (reason === "lost capture") dispatch("lostpointercapture", 100, target);
101 if (reason === "blur") dispatch("blur");
102 if (reason === "unmount") renderer.unmount();
103 if (reason === "tab switch") renderer.update(<Viewport tabId="two" />);
104 });
105 await act(() => dispatch("pointerup", 300));
106 expect(commitBrowserViewportChange).not.toHaveBeenCalled();
107 expect(captured).toBe(false);
108 if (reason !== "unmount")
109 expect(result.effectiveViewport).toEqual({ _tag: "freeform", width: 800, height: 600 });
110 },
111);
112
113it("ignores secondary buttons and selectable descendants", async () => {
114 await act(() => {
115 result.handleResizePointerDown("east", pointer(100, { button: 2 }));
116 result.handleResizePointerDown("east", {
117 ...pointer(100),
118 target: {},
119 } as ReactPointerEvent<HTMLButtonElement>);
120 dispatch("pointerup", 225);
121 });
122 expect(commitBrowserViewportChange).not.toHaveBeenCalled();
123 expect(captured).toBe(false);
124});
apps/web/src/browser/useBrowserViewportResize.ts+26-2
...@@ -4,6 +4,7 @@ import type { PreviewViewportSetting, PreviewViewportSize } from "@t3tools/contr...@@ -4,6 +4,7 @@ import type { PreviewViewportSetting, PreviewViewportSize } from "@t3tools/contr
4import {4import {
5 useCallback,5 useCallback,
6 useEffect,6 useEffect,
7 useLayoutEffect,
7 useRef,8 useRef,
8 useState,9 useState,
9 type KeyboardEvent as ReactKeyboardEvent,10 type KeyboardEvent as ReactKeyboardEvent,
...@@ -42,7 +43,7 @@ export function useBrowserViewportResize(options: {...@@ -42,7 +43,7 @@ export function useBrowserViewportResize(options: {
42 const keyboardCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);43 const keyboardCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
43 const keyboardViewportRef = useRef<ViewportDrag | null>(null);44 const keyboardViewportRef = useRef<ViewportDrag | null>(null);
44 const [dragViewport, setDragViewport] = useState<ViewportDrag | null>(null);45 const [dragViewport, setDragViewport] = useState<ViewportDrag | null>(null);
45 const sourceViewportKey = browserViewportSettingKey(viewport);46 const sourceViewportKey = `${tabId}:${browserViewportSettingKey(viewport)}`;
46 const sourceViewportKeyRef = useRef(sourceViewportKey);47 const sourceViewportKeyRef = useRef(sourceViewportKey);
47 sourceViewportKeyRef.current = sourceViewportKey;48 sourceViewportKeyRef.current = sourceViewportKey;
48 const activeDrag = dragViewport?.sourceKey === sourceViewportKey ? dragViewport : null;49 const activeDrag = dragViewport?.sourceKey === sourceViewportKey ? dragViewport : null;
...@@ -75,6 +76,14 @@ export function useBrowserViewportResize(options: {...@@ -75,6 +76,14 @@ export function useBrowserViewportResize(options: {
75 [],76 [],
76 );77 );
7778
79 useLayoutEffect(
80 () => () => {
81 dragVersionRef.current += 1;
82 dragCleanupRef.current?.();
83 },
84 [tabId, sourceViewportKey],
85 );
86
78 useEffect(() => {87 useEffect(() => {
79 const pending = keyboardViewportRef.current;88 const pending = keyboardViewportRef.current;
80 if (!pending || pending.sourceKey === sourceViewportKey) return;89 if (!pending || pending.sourceKey === sourceViewportKey) return;
...@@ -159,7 +168,12 @@ export function useBrowserViewportResize(options: {...@@ -159,7 +168,12 @@ export function useBrowserViewportResize(options: {
159 direction: BrowserViewportResizeDirection,168 direction: BrowserViewportResizeDirection,
160 event: ReactPointerEvent<HTMLButtonElement>,169 event: ReactPointerEvent<HTMLButtonElement>,
161 ) => {170 ) => {
162 if (effectiveViewport._tag === "fill") return;171 if (
172 effectiveViewport._tag === "fill" ||
173 event.button !== 0 ||
174 event.target !== event.currentTarget
175 )
176 return;
163 event.preventDefault();177 event.preventDefault();
164 event.stopPropagation();178 event.stopPropagation();
165 if (keyboardCommitTimerRef.current !== null) {179 if (keyboardCommitTimerRef.current !== null) {
...@@ -217,6 +231,8 @@ export function useBrowserViewportResize(options: {...@@ -217,6 +231,8 @@ export function useBrowserViewportResize(options: {
217 window.removeEventListener("pointermove", move);231 window.removeEventListener("pointermove", move);
218 window.removeEventListener("pointerup", finish);232 window.removeEventListener("pointerup", finish);
219 window.removeEventListener("pointercancel", cancel);233 window.removeEventListener("pointercancel", cancel);
234 window.removeEventListener("blur", interrupt);
235 target.removeEventListener("lostpointercapture", cancel);
220 dragCleanupRef.current = null;236 dragCleanupRef.current = null;
221 try {237 try {
222 target.releasePointerCapture(pointerId);238 target.releasePointerCapture(pointerId);
...@@ -226,6 +242,7 @@ export function useBrowserViewportResize(options: {...@@ -226,6 +242,7 @@ export function useBrowserViewportResize(options: {
226 }242 }
227 function finish(upEvent: PointerEvent) {243 function finish(upEvent: PointerEvent) {
228 if (upEvent.pointerId !== pointerId) return;244 if (upEvent.pointerId !== pointerId) return;
245 if (!sourceChanged()) move(upEvent);
229 cleanup();246 cleanup();
230 if (sourceChanged() || (latest.width === startWidth && latest.height === startHeight)) {247 if (sourceChanged() || (latest.width === startWidth && latest.height === startHeight)) {
231 clearDrag();248 clearDrag();
...@@ -243,10 +260,17 @@ export function useBrowserViewportResize(options: {...@@ -243,10 +260,17 @@ export function useBrowserViewportResize(options: {
243 dragVersionRef.current += 1;260 dragVersionRef.current += 1;
244 clearDrag();261 clearDrag();
245 }262 }
263 function interrupt() {
264 cleanup();
265 dragVersionRef.current += 1;
266 clearDrag();
267 }
246 dragCleanupRef.current = cleanup;268 dragCleanupRef.current = cleanup;
247 window.addEventListener("pointermove", move, { passive: false });269 window.addEventListener("pointermove", move, { passive: false });
248 window.addEventListener("pointerup", finish);270 window.addEventListener("pointerup", finish);
249 window.addEventListener("pointercancel", cancel);271 window.addEventListener("pointercancel", cancel);
272 window.addEventListener("blur", interrupt);
273 target.addEventListener("lostpointercapture", cancel);
250 };274 };
251275
252 return {276 return {
apps/web/src/components/AppSidebarLayout.tsx+1-1
...@@ -338,7 +338,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -338,7 +338,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
338 ) : (338 ) : (
339 <ThreadSidebar />339 <ThreadSidebar />
340 )}340 )}
341 <SidebarRail onDoubleClick={resetSidebarWidth} />341 <SidebarRail aria-valuenow={sidebarWidth} onDoubleClick={resetSidebarWidth} />
342 </Sidebar>342 </Sidebar>
343 {children}343 {children}
344 <SidebarControl />344 <SidebarControl />
apps/web/src/components/ThreadTerminalDrawer.tsx+76-98
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { useResizeDrag } from "~/hooks/useResizeDrag";
1import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
2import {3import {
3 isAtomCommandInterrupted,4 isAtomCommandInterrupted,
...@@ -31,7 +32,6 @@ import {...@@ -31,7 +32,6 @@ import {
31import { getTerminalLabel } from "@t3tools/shared/terminalLabels";32import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
32import * as Schema from "effect/Schema";33import * as Schema from "effect/Schema";
33import {34import {
34 type PointerEvent as ReactPointerEvent,
35 type ReactNode,35 type ReactNode,
36 type SetStateAction,36 type SetStateAction,
37 useCallback,37 useCallback,
...@@ -1250,17 +1250,9 @@ export default function ThreadTerminalDrawer({...@@ -1250,17 +1250,9 @@ export default function ThreadTerminalDrawer({
1250 [controlledDrawerHeight, threadId],1250 [controlledDrawerHeight, threadId],
1251 );1251 );
1252 const [resizeEpoch, setResizeEpoch] = useState(0);1252 const [resizeEpoch, setResizeEpoch] = useState(0);
1253 const drawerHeightRef = useRef(drawerHeight);
1254 const preferredDrawerHeightRef = useRef(preferredDrawerHeight);1253 const preferredDrawerHeightRef = useRef(preferredDrawerHeight);
1255 const lastSyncedHeightRef = useRef(controlledDrawerHeight);1254 const lastSyncedHeightRef = useRef(controlledDrawerHeight);
1256 const onHeightChangeRef = useRef(onHeightChange);1255 const onHeightChangeRef = useRef(onHeightChange);
1257 const resizeStateRef = useRef<{
1258 pointerId: number;
1259 startY: number;
1260 startHeight: number;
1261 drawer: HTMLElement | null;
1262 } | null>(null);
1263 const didResizeDuringDragRef = useRef(false);
12641256
1265 const normalizedTerminalIds = useMemo(() => {1257 const normalizedTerminalIds = useMemo(() => {
1266 const normalizedIds: string[] = [];1258 const normalizedIds: string[] = [];
...@@ -1442,16 +1434,6 @@ export default function ThreadTerminalDrawer({...@@ -1442,16 +1434,6 @@ export default function ThreadTerminalDrawer({
1442 }, [onHeightChange]);1434 }, [onHeightChange]);
14431435
1444 useLayoutEffect(() => {1436 useLayoutEffect(() => {
1445 // A render mid-drag (the window cap moved) must not replace the live height.
1446 const resizeState = resizeStateRef.current;
1447 if (resizeState) {
1448 resizeState.drawer?.style.setProperty("height", `${drawerHeightRef.current}px`);
1449 return;
1450 }
1451 drawerHeightRef.current = drawerHeight;
1452 }, [drawerHeight]);
1453
1454 useEffect(() => {
1455 preferredDrawerHeightRef.current = preferredDrawerHeight;1437 preferredDrawerHeightRef.current = preferredDrawerHeight;
1456 }, [preferredDrawerHeight]);1438 }, [preferredDrawerHeight]);
14571439
...@@ -1465,59 +1447,79 @@ export default function ThreadTerminalDrawer({...@@ -1465,59 +1447,79 @@ export default function ThreadTerminalDrawer({
1465 lastSyncedHeightRef.current = controlledDrawerHeight;1447 lastSyncedHeightRef.current = controlledDrawerHeight;
1466 }, [controlledDrawerHeight, threadId]);1448 }, [controlledDrawerHeight, threadId]);
14671449
1468 const handleResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {1450 const { refresh: refreshDrawerResize, ...drawerResizeHandlers } = useResizeDrag<HTMLDivElement>(
1469 if (event.button !== 0) return;1451 (event) => {
1470 event.preventDefault();1452 const drawer = event.currentTarget.parentElement;
1471 event.currentTarget.setPointerCapture(event.pointerId);1453 return {
1472 didResizeDuringDragRef.current = false;1454 size: drawerHeight,
1473 resizeStateRef.current = {1455 edge: "top",
1474 pointerId: event.pointerId,1456 resize(value) {
1475 startY: event.clientY,1457 const height = clampDrawerHeight(value);
1476 startHeight: drawerHeightRef.current,1458 drawer?.style.setProperty("height", `${height}px`);
1477 drawer: event.currentTarget.parentElement,1459 return height;
1478 };1460 },
1479 }, []);1461 finish(height, moved) {
1462 if (!moved || height === drawerHeight) {
1463 drawer?.style.setProperty(
1464 "height",
1465 `${clampDrawerHeight(preferredDrawerHeightRef.current)}px`,
1466 );
1467 return;
1468 }
1469 setDrawerHeight(height);
1470 syncHeight(height);
1471 setResizeEpoch((value) => value + 1);
1472 },
1473 cleanup(committed) {
1474 if (!committed)
1475 drawer?.style.setProperty(
1476 "height",
1477 `${clampDrawerHeight(preferredDrawerHeightRef.current)}px`,
1478 );
1479 },
1480 };
1481 },
1482 `${threadId}:${visible}:${isPanel}`,
1483 );
14801484
1481 // Writes the height straight to the drawer while dragging so the edge lands in1485 useLayoutEffect(refreshDrawerResize, [drawerHeight, refreshDrawerResize]);
1482 // the pointer's frame; React state commits once on release.
1483 const handleResizePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
1484 const resizeState = resizeStateRef.current;
1485 if (!resizeState || resizeState.pointerId !== event.pointerId) return;
1486 event.preventDefault();
1487 const clampedHeight = clampDrawerHeight(
1488 resizeState.startHeight + (resizeState.startY - event.clientY),
1489 );
1490 if (clampedHeight === drawerHeightRef.current) {
1491 return;
1492 }
1493 didResizeDuringDragRef.current = true;
1494 drawerHeightRef.current = clampedHeight;
1495 resizeState.drawer?.style.setProperty("height", `${clampedHeight}px`);
1496 }, []);
14971486
1498 const handleResizePointerEnd = useCallback(1487 const drawerResizeHandle = (
1499 (event: ReactPointerEvent<HTMLDivElement>) => {1488 <div
1500 const resizeState = resizeStateRef.current;1489 role="separator"
1501 if (!resizeState || resizeState.pointerId !== event.pointerId) return;1490 aria-label="Resize terminal"
1502 resizeStateRef.current = null;1491 aria-orientation="horizontal"
1503 if (event.currentTarget.hasPointerCapture(event.pointerId)) {1492 aria-valuemin={MIN_DRAWER_HEIGHT}
1504 event.currentTarget.releasePointerCapture(event.pointerId);1493 aria-valuemax={windowMaxDrawerHeight}
1505 }1494 aria-valuenow={drawerHeight}
1506 // A drag that ends where it started keeps the stored preference, which may1495 tabIndex={0}
1507 // be taller than the window-capped height the drag started from.1496 className="group relative h-3 shrink-0 cursor-row-resize touch-none select-none outline-none hover:bg-primary/10 focus-visible:bg-primary/10 active:bg-primary/15"
1508 if (!didResizeDuringDragRef.current || drawerHeightRef.current === resizeState.startHeight) {1497 {...drawerResizeHandlers}
1509 // React skips the write when the rendered height did not change, so put1498 onKeyDown={(event) => {
1510 // back the preference as the current window caps it.1499 const step = event.shiftKey ? 40 : 10;
1511 const renderedHeight = clampDrawerHeight(preferredDrawerHeightRef.current);1500 const next =
1512 drawerHeightRef.current = renderedHeight;1501 event.key === "ArrowUp"
1513 resizeState.drawer?.style.setProperty("height", `${renderedHeight}px`);1502 ? drawerHeight + step
1514 return;1503 : event.key === "ArrowDown"
1515 }1504 ? drawerHeight - step
1516 setDrawerHeight(drawerHeightRef.current);1505 : event.key === "Home"
1517 syncHeight(drawerHeightRef.current);1506 ? MIN_DRAWER_HEIGHT
1518 setResizeEpoch((value) => value + 1);1507 : event.key === "End"
1519 },1508 ? windowMaxDrawerHeight
1520 [setDrawerHeight, syncHeight],1509 : null;
1510 if (next === null) return;
1511 event.preventDefault();
1512 const height = clampDrawerHeight(next);
1513 setDrawerHeight(height);
1514 syncHeight(height);
1515 setResizeEpoch((value) => value + 1);
1516 }}
1517 >
1518 <span
1519 aria-hidden
1520 className="pointer-events-none absolute left-1/2 top-1/2 h-0.5 w-8 -translate-x-1/2 -translate-y-1/2 rounded-full bg-border group-hover:bg-primary/60 group-focus-visible:bg-primary/60"
1521 />
1522 </div>
1521 );1523 );
15221524
1523 useEffect(() => {1525 useEffect(() => {
...@@ -1526,13 +1528,7 @@ export default function ThreadTerminalDrawer({...@@ -1526,13 +1528,7 @@ export default function ThreadTerminalDrawer({
1526 }1528 }
15271529
1528 const onWindowResize = () => {1530 const onWindowResize = () => {
1529 // React only renders the stored preference, so clamp a live drag height here.1531 refreshDrawerResize();
1530 const resizeState = resizeStateRef.current;
1531 const liveHeight = clampDrawerHeight(drawerHeightRef.current);
1532 if (resizeState && liveHeight !== drawerHeightRef.current) {
1533 drawerHeightRef.current = liveHeight;
1534 resizeState.drawer?.style.setProperty("height", `${liveHeight}px`);
1535 }
1536 setWindowMaxDrawerHeight(maxDrawerHeight());1532 setWindowMaxDrawerHeight(maxDrawerHeight());
1537 setResizeEpoch((value) => value + 1);1533 setResizeEpoch((value) => value + 1);
1538 };1534 };
...@@ -1540,7 +1536,7 @@ export default function ThreadTerminalDrawer({...@@ -1540,7 +1536,7 @@ export default function ThreadTerminalDrawer({
1540 return () => {1536 return () => {
1541 window.removeEventListener("resize", onWindowResize);1537 window.removeEventListener("resize", onWindowResize);
1542 };1538 };
1543 }, [visible]);1539 }, [visible, refreshDrawerResize]);
15441540
1545 useEffect(() => {1541 useEffect(() => {
1546 if (!visible) {1542 if (!visible) {
...@@ -1567,16 +1563,7 @@ export default function ThreadTerminalDrawer({...@@ -1567,16 +1563,7 @@ export default function ThreadTerminalDrawer({
1567 )}1563 )}
1568 style={isPanel ? undefined : { height: `${drawerHeight}px` }}1564 style={isPanel ? undefined : { height: `${drawerHeight}px` }}
1569 >1565 >
1570 {!isPanel ? (1566 {!isPanel ? drawerResizeHandle : null}
1571 <div
1572 className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize"
1573 onPointerDown={handleResizePointerDown}
1574 onPointerMove={handleResizePointerMove}
1575 onPointerUp={handleResizePointerEnd}
1576 onPointerCancel={handleResizePointerEnd}
1577 onLostPointerCapture={handleResizePointerEnd}
1578 />
1579 ) : null}
1580 <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">1567 <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">
1581 <p>No terminal sessions for this thread yet.</p>1568 <p>No terminal sessions for this thread yet.</p>
1582 <Button1569 <Button
...@@ -1604,16 +1591,7 @@ export default function ThreadTerminalDrawer({...@@ -1604,16 +1591,7 @@ export default function ThreadTerminalDrawer({
1604 )}1591 )}
1605 style={isPanel ? undefined : { height: `${drawerHeight}px` }}1592 style={isPanel ? undefined : { height: `${drawerHeight}px` }}
1606 >1593 >
1607 {!isPanel ? (1594 {!isPanel ? drawerResizeHandle : null}
1608 <div
1609 className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize"
1610 onPointerDown={handleResizePointerDown}
1611 onPointerMove={handleResizePointerMove}
1612 onPointerUp={handleResizePointerEnd}
1613 onPointerCancel={handleResizePointerEnd}
1614 onLostPointerCapture={handleResizePointerEnd}
1615 />
1616 ) : null}
16171595
1618 {!hasTerminalSidebar && (1596 {!hasTerminalSidebar && (
1619 <div className="pointer-events-none absolute right-2 top-2 z-20">1597 <div className="pointer-events-none absolute right-2 top-2 z-20">
apps/web/src/components/preview/PreviewPanelShell.tsx+8-4
...@@ -7,7 +7,6 @@ import {...@@ -7,7 +7,6 @@ import {
7 useState,7 useState,
8} from "react";8} from "react";
99
10import { isElectron } from "~/env";
11import {10import {
12 getPreviewPanelMaxWidth,11 getPreviewPanelMaxWidth,
13 type PreviewPanelInlineSize,12 type PreviewPanelInlineSize,
...@@ -68,7 +67,6 @@ function PreviewPanelShellFrame(...@@ -68,7 +67,6 @@ function PreviewPanelShellFrame(
68 hostRef?: RefObject<HTMLDivElement | null>;67 hostRef?: RefObject<HTMLDivElement | null>;
69 },68 },
70) {69) {
71 const useDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded";
72 const isInline = props.mode === "inline";70 const isInline = props.mode === "inline";
73 const collapsible = isInline && props.open !== undefined;71 const collapsible = isInline && props.open !== undefined;
74 const open = props.open ?? true;72 const open = props.open ?? true;
...@@ -145,12 +143,18 @@ function PreviewPanelShellFrame(...@@ -145,12 +143,18 @@ function PreviewPanelShellFrame(
145 data-preview-panel-maximized={maximized ? "true" : "false"}143 data-preview-panel-maximized={maximized ? "true" : "false"}
146 >144 >
147 {isInline && !maximized ? <RightPanelResizeHandle handlers={handlers} /> : null}145 {isInline && !maximized ? <RightPanelResizeHandle handlers={handlers} /> : null}
148 <div className={cn("h-full min-h-0 w-full", collapsible && "overflow-clip")}>146 <div
147 className={cn(
148 "h-full min-h-0 w-full",
149 isInline && !maximized && "pl-3",
150 collapsible && "overflow-clip",
151 )}
152 >
149 <div153 <div
150 className="flex h-full min-h-0 min-w-0 flex-col"154 className="flex h-full min-h-0 min-w-0 flex-col"
151 style={155 style={
152 collapsible && !maximized156 collapsible && !maximized
153 ? { width: `calc(var(${RESIZABLE_WIDTH_PROPERTY}) - 1px)` }157 ? { width: `calc(var(${RESIZABLE_WIDTH_PROPERTY}) - 13px)` }
154 : undefined158 : undefined
155 }159 }
156 >160 >
apps/web/src/components/preview/RightPanelResizeHandle.tsx+4-10
...@@ -6,28 +6,22 @@ interface Props {...@@ -6,28 +6,22 @@ interface Props {
6 className?: string;6 className?: string;
7}7}
88
9/**
10 * Hit target for resizing a right-anchored panel via its left edge.
11 *
12 * - Sits on top of the panel's border with a 4px overlap on each side so the
13 * user can grab a few pixels off the edge without aiming.
14 * - Visual indicator is a 1px line that lights up on hover/active to mirror
15 * VS Code / Cursor.
16 */
17export function RightPanelResizeHandle({ handlers, className }: Props) {9export function RightPanelResizeHandle({ handlers, className }: Props) {
18 return (10 return (
19 <div11 <div
20 role="separator"12 role="separator"
21 aria-orientation="vertical"13 aria-orientation="vertical"
14 aria-label="Resize side panel"
15 tabIndex={0}
22 className={cn(16 className={cn(
23 "group absolute inset-y-0 -left-1 z-20 w-2 cursor-col-resize select-none",17 "group absolute inset-y-0 left-0 z-20 w-3 cursor-col-resize touch-none select-none outline-none hover:bg-primary/10 focus-visible:bg-primary/10 active:bg-primary/15",
24 className,18 className,
25 )}19 )}
26 {...handlers}20 {...handlers}
27 >21 >
28 <span22 <span
29 aria-hidden23 aria-hidden
30 className="pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors duration-150 group-hover:bg-border group-active:bg-primary/60"24 className="pointer-events-none absolute inset-y-0 left-0 w-px bg-transparent transition-colors duration-150 group-hover:bg-border group-active:bg-primary/60"
31 />25 />
32 </div>26 </div>
33 );27 );
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+74-17
...@@ -63,6 +63,7 @@ import {...@@ -63,6 +63,7 @@ import {
63} from "./previewMiniPlayerLayout";63} from "./previewMiniPlayerLayout";
6464
65interface PointerGesture {65interface PointerGesture {
66 readonly target: HTMLElement;
66 readonly pointerId: number;67 readonly pointerId: number;
67 readonly pointerType: string;68 readonly pointerType: string;
68 readonly pointerX: number;69 readonly pointerX: number;
...@@ -82,19 +83,18 @@ interface Props {...@@ -82,19 +83,18 @@ interface Props {
8283
83const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS;84const frameCornerRadius = () => PREVIEW_MINI_PLAYER_CORNER_RADIUS;
8485
85// Invisible grab zones straddling each edge; the cursor is the only affordance.
86const RESIZE_HANDLES: ReadonlyArray<{86const RESIZE_HANDLES: ReadonlyArray<{
87 readonly direction: BrowserViewportResizeDirection;87 readonly direction: BrowserViewportResizeDirection;
88 readonly className: string;88 readonly className: string;
89}> = [89}> = [
90 { direction: "north", className: "inset-x-0 -top-1 h-2 cursor-ns-resize" },90 { direction: "north", className: "inset-x-0 -top-1.5 h-3 cursor-ns-resize" },
91 { direction: "south", className: "inset-x-0 -bottom-1 h-2 cursor-ns-resize" },91 { direction: "south", className: "inset-x-0 -bottom-1.5 h-3 cursor-ns-resize" },
92 { direction: "west", className: "inset-y-0 -left-1 w-2 cursor-ew-resize" },92 { direction: "west", className: "inset-y-0 -left-1.5 w-3 cursor-ew-resize" },
93 { direction: "east", className: "inset-y-0 -right-1 w-2 cursor-ew-resize" },93 { direction: "east", className: "inset-y-0 -right-1.5 w-3 cursor-ew-resize" },
94 { direction: "northwest", className: "-left-2 -top-2 size-4 cursor-nwse-resize" },94 { direction: "northwest", className: "-left-2.5 -top-2.5 size-5 cursor-nwse-resize" },
95 { direction: "northeast", className: "-right-2 -top-2 size-4 cursor-nesw-resize" },95 { direction: "northeast", className: "-right-2.5 -top-2.5 size-5 cursor-nesw-resize" },
96 { direction: "southwest", className: "-bottom-2 -left-2 size-4 cursor-nesw-resize" },96 { direction: "southwest", className: "-bottom-2.5 -left-2.5 size-5 cursor-nesw-resize" },
97 { direction: "southeast", className: "-bottom-2 -right-2 size-4 cursor-nwse-resize" },97 { direction: "southeast", className: "-bottom-2.5 -right-2.5 size-5 cursor-nwse-resize" },
98];98];
9999
100/** Floats the thread's browser tab or device stream over chat. */100/** Floats the thread's browser tab or device stream over chat. */
...@@ -383,6 +383,24 @@ function MiniPlayerShell({...@@ -383,6 +383,24 @@ function MiniPlayerShell({
383 ]);383 ]);
384 useLayoutEffect(() => () => clearPreview?.(sourceKey), [clearPreview, sourceKey]);384 useLayoutEffect(() => () => clearPreview?.(sourceKey), [clearPreview, sourceKey]);
385385
386 const releaseGesture = useCallback(() => {
387 const gesture = gestureRef.current;
388 gestureRef.current = null;
389 handleTappedRef.current = false;
390 if (gesture?.target.hasPointerCapture(gesture.pointerId)) {
391 gesture.target.releasePointerCapture(gesture.pointerId);
392 }
393 }, []);
394
395 useEffect(() => {
396 const onBlur = () => releaseGesture();
397 window.addEventListener("blur", onBlur);
398 return () => {
399 window.removeEventListener("blur", onBlur);
400 releaseGesture();
401 };
402 }, [releaseGesture, sourceKey, threadRef.environmentId, threadRef.threadId]);
403
386 const radius = frame ? cornerRadius(frame) : PREVIEW_MINI_PLAYER_CORNER_RADIUS;404 const radius = frame ? cornerRadius(frame) : PREVIEW_MINI_PLAYER_CORNER_RADIUS;
387 // Inside a wide curve the default 8px inset would land on the clipped-away corner.405 // Inside a wide curve the default 8px inset would land on the clipped-away corner.
388 const pillInset = Math.max(8, Math.round(radius * 0.55));406 const pillInset = Math.max(8, Math.round(radius * 0.55));
...@@ -395,8 +413,10 @@ function MiniPlayerShell({...@@ -395,8 +413,10 @@ function MiniPlayerShell({
395 event: ReactPointerEvent<HTMLElement>,413 event: ReactPointerEvent<HTMLElement>,
396 direction: BrowserViewportResizeDirection | null,414 direction: BrowserViewportResizeDirection | null,
397 ) => {415 ) => {
398 if (event.button !== 0 || !frame) return;416 if (event.button !== 0 || !frame || gestureRef.current || event.target !== event.currentTarget)
417 return;
399 gestureRef.current = {418 gestureRef.current = {
419 target: event.currentTarget,
400 pointerId: event.pointerId,420 pointerId: event.pointerId,
401 pointerType: event.pointerType,421 pointerType: event.pointerType,
402 pointerX: event.clientX,422 pointerX: event.clientX,
...@@ -405,7 +425,12 @@ function MiniPlayerShell({...@@ -405,7 +425,12 @@ function MiniPlayerShell({
405 direction,425 direction,
406 moved: false,426 moved: false,
407 };427 };
408 event.currentTarget.setPointerCapture(event.pointerId);428 try {
429 event.currentTarget.setPointerCapture(event.pointerId);
430 } catch {
431 gestureRef.current = null;
432 return;
433 }
409 event.preventDefault();434 event.preventDefault();
410 event.stopPropagation();435 event.stopPropagation();
411 };436 };
...@@ -444,15 +469,13 @@ function MiniPlayerShell({...@@ -444,15 +469,13 @@ function MiniPlayerShell({
444 const endGesture = (event: ReactPointerEvent<HTMLElement>) => {469 const endGesture = (event: ReactPointerEvent<HTMLElement>) => {
445 const gesture = gestureRef.current;470 const gesture = gestureRef.current;
446 if (gesture?.pointerId !== event.pointerId) return;471 if (gesture?.pointerId !== event.pointerId) return;
447 gestureRef.current = null;472 if (event.type === "pointerup") handlePointerMove(event);
473 releaseGesture();
448 handleTappedRef.current =474 handleTappedRef.current =
449 event.type === "pointerup" &&475 event.type === "pointerup" &&
450 gesture.direction === null &&476 gesture.direction === null &&
451 gesture.pointerType === "touch" &&477 gesture.pointerType === "touch" &&
452 !gesture.moved;478 !gesture.moved;
453 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
454 event.currentTarget.releasePointerCapture(event.pointerId);
455 }
456 };479 };
457480
458 return (481 return (
...@@ -479,6 +502,7 @@ function MiniPlayerShell({...@@ -479,6 +502,7 @@ function MiniPlayerShell({
479 onPointerMove={handlePointerMove}502 onPointerMove={handlePointerMove}
480 onPointerUp={endGesture}503 onPointerUp={endGesture}
481 onPointerCancel={endGesture}504 onPointerCancel={endGesture}
505 onLostPointerCapture={endGesture}
482 onClick={() => {506 onClick={() => {
483 if (!handleTappedRef.current) return;507 if (!handleTappedRef.current) return;
484 handleTappedRef.current = false;508 handleTappedRef.current = false;
...@@ -548,13 +572,46 @@ function MiniPlayerShell({...@@ -548,13 +572,46 @@ function MiniPlayerShell({
548 {RESIZE_HANDLES.map(({ direction, className }) => (572 {RESIZE_HANDLES.map(({ direction, className }) => (
549 <div573 <div
550 key={direction}574 key={direction}
551 role="presentation"575 role="button"
576 tabIndex={0}
577 aria-label={`Resize floating preview from ${direction}`}
552 data-preview-mini-player-resize={direction}578 data-preview-mini-player-resize={direction}
553 className={cn("pointer-events-auto absolute z-[49] touch-none", className)}579 className={cn(
580 "pointer-events-auto absolute z-[49] touch-none outline-none hover:bg-primary/15 focus-visible:bg-primary/20",
581 className,
582 )}
554 onPointerDown={(event) => beginGesture(event, direction)}583 onPointerDown={(event) => beginGesture(event, direction)}
555 onPointerMove={handlePointerMove}584 onPointerMove={handlePointerMove}
556 onPointerUp={endGesture}585 onPointerUp={endGesture}
557 onPointerCancel={endGesture}586 onPointerCancel={endGesture}
587 onLostPointerCapture={endGesture}
588 onKeyDown={(event) => {
589 if (!container) return;
590 const step = event.shiftKey ? 40 : 10;
591 const delta =
592 event.key === "ArrowLeft"
593 ? { x: -step, y: 0 }
594 : event.key === "ArrowRight"
595 ? { x: step, y: 0 }
596 : event.key === "ArrowUp"
597 ? { x: 0, y: -step }
598 : event.key === "ArrowDown"
599 ? { x: 0, y: step }
600 : null;
601 if (!delta) return;
602 event.preventDefault();
603 const next = resizePreviewMiniPlayer({
604 start: frame,
605 direction,
606 delta,
607 source: sourceSize,
608 container,
609 obstacles,
610 });
611 usePreviewMiniPlayerStore
612 .getState()
613 .resize(threadRef, sourceKey, next.width, { x: next.x, y: next.y });
614 }}
558 />615 />
559 ))}616 ))}
560 </section>617 </section>
apps/web/src/components/ui/sidebar.tsx+57-11
...@@ -331,7 +331,10 @@ function Sidebar({...@@ -331,7 +331,10 @@ function Sidebar({
331 {...props}331 {...props}
332 >332 >
333 <div333 <div
334 className="flex h-full w-full flex-col bg-sidebar surface-grain group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm/5"334 className={cn(
335 "flex h-full w-full flex-col bg-sidebar surface-grain group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm/5",
336 resolvedResizable && (side === "left" ? "pr-3" : "pl-3"),
337 )}
335 data-sidebar="sidebar"338 data-sidebar="sidebar"
336 data-slot="sidebar-inner"339 data-slot="sidebar-inner"
337 >340 >
...@@ -426,7 +429,7 @@ function SidebarRail({...@@ -426,7 +429,7 @@ function SidebarRail({
426 setTargetWidth(width);429 setTargetWidth(width);
427430
428 return {431 return {
429 width,432 size: width,
430 edge: side === "left" ? "right" : "left",433 edge: side === "left" ? "right" : "left",
431 resize(value) {434 resize(value) {
432 const options = latestResizable.current;435 const options = latestResizable.current;
...@@ -523,14 +526,9 @@ function SidebarRail({...@@ -523,14 +526,9 @@ function SidebarRail({
523 <button526 <button
524 aria-label={railLabel}527 aria-label={railLabel}
525 className={cn(528 className={cn(
526 /* disable pointer events only when offcanvas sidebar is collapsed, that's when the rail sits over the native scrollbar on windows and linux. icon mode stays fully clickable. */529 "group absolute inset-y-0 z-20 hidden w-3 touch-none select-none outline-none sm:flex hover:bg-primary/10 focus-visible:bg-primary/10 active:bg-primary/15 [[data-collapsible=offcanvas][data-state=collapsed]_&]:pointer-events-none",
527 "-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex [[data-collapsible=offcanvas][data-state=collapsed]_&]:pointer-events-none",530 "group-data-[side=left]:right-0 group-data-[side=right]:left-0 cursor-col-resize",
528 "[[data-panel-animations=true]_&]:transition-all [[data-panel-animations=true]_&]:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:ease-out",531 "after:pointer-events-none after:absolute after:inset-y-0 after:w-px after:bg-border/50 group-data-[side=left]:after:right-0 group-data-[side=right]:after:left-0",
529 "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
530 "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
531 "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",
532 "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
533 "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
534 className,532 className,
535 )}533 )}
536 data-sidebar="rail"534 data-sidebar="rail"
...@@ -557,7 +555,55 @@ function SidebarRail({...@@ -557,7 +555,55 @@ function SidebarRail({
557 if (!event.defaultPrevented) resize.onPointerUp(event);555 if (!event.defaultPrevented) resize.onPointerUp(event);
558 }}556 }}
559 ref={railRef}557 ref={railRef}
560 tabIndex={-1}558 role={canResize ? "separator" : undefined}
559 aria-orientation={canResize ? "vertical" : undefined}
560 aria-valuemin={canResize ? resolvedResizable.minWidth : undefined}
561 aria-valuemax={
562 canResize && Number.isFinite(resolvedResizable.maxWidth)
563 ? resolvedResizable.maxWidth
564 : undefined
565 }
566 onKeyDown={(event) => {
567 if (!canResize) return;
568 const rail = event.currentTarget;
569 const wrapper = rail.closest<HTMLElement>("[data-slot='sidebar-wrapper']");
570 const container = rail.closest<HTMLElement>("[data-slot='sidebar-container']");
571 if (!wrapper || !container) return;
572 const currentWidth = container.getBoundingClientRect().width;
573 const side = sidebarInstance?.side ?? "left";
574 const step = (event.shiftKey ? 40 : 10) * (side === "left" ? 1 : -1);
575 const next =
576 event.key === "ArrowRight"
577 ? currentWidth + step
578 : event.key === "ArrowLeft"
579 ? currentWidth - step
580 : event.key === "Home"
581 ? resolvedResizable.minWidth
582 : event.key === "End" && Number.isFinite(resolvedResizable.maxWidth)
583 ? resolvedResizable.maxWidth
584 : null;
585 if (next === null) return;
586 event.preventDefault();
587 const nextWidth = clampSidebarWidth(next, resolvedResizable);
588 const sidebarRoot = rail.closest<HTMLElement>("[data-slot='sidebar']");
589 if (
590 !sidebarRoot ||
591 resolvedResizable.shouldAcceptWidth?.({
592 currentWidth,
593 nextWidth,
594 wrapper,
595 rail,
596 side,
597 sidebarRoot,
598 }) === false
599 )
600 return;
601 wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`);
602 if (resolvedResizable.storageKey)
603 setLocalStorageItem(resolvedResizable.storageKey, nextWidth, Schema.Finite);
604 resolvedResizable.onResize?.(nextWidth);
605 }}
606 tabIndex={canResize ? 0 : -1}
561 type="button"607 type="button"
562 {...props}608 {...props}
563 />609 />
apps/web/src/hooks/useResizableWidth.test.tsx+111-1
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1import { act, useLayoutEffect, type PointerEvent } from "react";1import { act, useLayoutEffect, useState, type KeyboardEvent, 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 { useResizeDrag } from "./useResizeDrag";
5import { RESIZABLE_WIDTH_PROPERTY, useResizableWidth } from "./useResizableWidth";6import { RESIZABLE_WIDTH_PROPERTY, useResizableWidth } from "./useResizableWidth";
67
7let renderer: ReactTestRenderer;8let renderer: ReactTestRenderer;
...@@ -43,6 +44,7 @@ function pointer(clientX = 100) {...@@ -43,6 +44,7 @@ function pointer(clientX = 100) {
43 pointerId: 1,44 pointerId: 1,
44 clientX,45 clientX,
45 currentTarget: target,46 currentTarget: target,
47 target,
46 preventDefault() {},48 preventDefault() {},
47 stopPropagation() {},49 stopPropagation() {},
48 } as unknown as PointerEvent<HTMLElement>;50 } as unknown as PointerEvent<HTMLElement>;
...@@ -70,6 +72,33 @@ function Panel({...@@ -70,6 +72,33 @@ function Panel({
70 return null;72 return null;
71}73}
7274
75let verticalHandlers: ReturnType<typeof useResizeDrag<HTMLElement>>;
76let committedHeight = 400;
77
78function VerticalPanel() {
79 const [height, setHeight] = useState(400);
80 const handlers = useResizeDrag<HTMLElement>(() => ({
81 size: height,
82 edge: "top",
83 resize(size) {
84 const next = Math.max(180, Math.min(600, size));
85 hostStyle.set("height", `${next}px`);
86 return next;
87 },
88 finish(size) {
89 setHeight(size);
90 committedHeight = size;
91 },
92 cleanup(committed) {
93 if (!committed) hostStyle.set("height", `${height}px`);
94 },
95 }));
96 useLayoutEffect(() => {
97 verticalHandlers = handlers;
98 });
99 return null;
100}
101
73beforeEach(async () => {102beforeEach(async () => {
74 savedWidths.clear();103 savedWidths.clear();
75 hostStyle.clear();104 hostStyle.clear();
...@@ -204,6 +233,87 @@ describe("panel resize cleanup", () => {...@@ -204,6 +233,87 @@ describe("panel resize cleanup", () => {
204 });233 });
205});234});
206235
236describe("vertical drawer resize lifecycle", () => {
237 it("uses the final vertical release position and ignores horizontal movement", async () => {
238 committedHeight = 400;
239 await act(() => renderer.update(<VerticalPanel />));
240 await act(() => {
241 verticalHandlers.onPointerDown({ ...pointer(), clientY: 100 });
242 verticalHandlers.onPointerMove({ ...pointer(-1000), clientY: 50 });
243 verticalHandlers.onPointerUp({ ...pointer(1000), clientY: 25 });
244 });
245 expect(committedHeight).toBe(475);
246 expect(hostStyle.get("height")).toBe("475px");
247 expect(style.cursor).toBe("");
248 expect(captured).toBe(false);
249 });
250
251 it("abandons an unfinished vertical drag on unmount without committing", async () => {
252 committedHeight = 400;
253 await act(() => renderer.update(<VerticalPanel />));
254 await act(() => {
255 verticalHandlers.onPointerDown({ ...pointer(), clientY: 100 });
256 verticalHandlers.onPointerMove({ ...pointer(), clientY: -1000 });
257 });
258 await act(() => frame?.(0));
259 expect(hostStyle.get("height")).toBe("600px");
260 expect(style.cursor).toBe("row-resize");
261 await act(() => renderer.unmount());
262 expect(committedHeight).toBe(400);
263 expect(hostStyle.get("height")).toBe("400px");
264 expect(style.userSelect).toBe("");
265 expect(captured).toBe(false);
266 });
267});
268
269describe("resize acquisition and keyboard", () => {
270 it("does not acquire a drag that started in selectable descendant content", async () => {
271 const event = { ...pointer(), target: {} } as PointerEvent<HTMLElement>;
272 await act(() => {
273 result.handlers.onPointerDown(event);
274 result.handlers.onPointerMove(pointer(50));
275 result.handlers.onPointerUp(pointer(50));
276 });
277 expect(captured).toBe(false);
278 expect(result.width).toBe(400);
279 expect(style.userSelect).toBe("");
280 expect(setItem).not.toHaveBeenCalled();
281 });
282
283 it("ignores a secondary button and another pointer during an active drag", async () => {
284 await act(() => result.handlers.onPointerDown({ ...pointer(), button: 2 }));
285 expect(captured).toBe(false);
286 await act(() => {
287 result.handlers.onPointerDown(pointer());
288 result.handlers.onPointerMove({ ...pointer(-500), pointerId: 2 });
289 result.handlers.onPointerUp({ ...pointer(-500), pointerId: 2 });
290 });
291 expect(result.width).toBe(400);
292 expect(captured).toBe(true);
293 await act(() => result.handlers.onPointerUp(pointer(50)));
294 expect(result.width).toBe(450);
295 });
296
297 it("resizes from keyboard in edge direction, clamps and persists across reload", async () => {
298 const key = (key: string, shiftKey = false) =>
299 ({ key, shiftKey, preventDefault() {} }) as KeyboardEvent<HTMLElement>;
300 await act(() => result.handlers.onKeyDown(key("ArrowLeft")));
301 expect(result.width).toBe(410);
302 await act(() => result.handlers.onKeyDown(key("ArrowLeft", true)));
303 expect(result.width).toBe(450);
304 await act(() => result.handlers.onKeyDown(key("Home")));
305 expect(result.width).toBe(200);
306 await act(() => result.handlers.onKeyDown(key("ArrowRight")));
307 expect(result.width).toBe(200);
308 await act(() => renderer.unmount());
309 await act(() => {
310 renderer = create(<Panel />);
311 });
312 expect(result.width).toBe(200);
313 expect(captured).toBe(false);
314 });
315});
316
207describe("panel width storage changes", () => {317describe("panel width storage changes", () => {
208 it("restores separate thread widths without remounting and retains them after reload", async () => {318 it("restores separate thread widths without remounting and retains them after reload", async () => {
209 await act(() => {319 await act(() => {
apps/web/src/hooks/useResizableWidth.ts+34-2
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import {2import {
3 type PointerEvent as ReactPointerEvent,3 type PointerEvent as ReactPointerEvent,
4 type KeyboardEvent,
4 useCallback,5 useCallback,
5 useLayoutEffect,6 useLayoutEffect,
6 useRef,7 useRef,
...@@ -33,6 +34,10 @@ export interface UseResizableWidthOptions {...@@ -33,6 +34,10 @@ export interface UseResizableWidthOptions {
33}34}
3435
35export interface ResizableWidthHandlers {36export interface ResizableWidthHandlers {
37 readonly onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
38 readonly "aria-valuemin": number;
39 readonly "aria-valuemax": number;
40 readonly "aria-valuenow": number;
36 readonly onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;41 readonly onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;
37 readonly onPointerMove: (event: ReactPointerEvent<HTMLElement>) => void;42 readonly onPointerMove: (event: ReactPointerEvent<HTMLElement>) => void;
38 readonly onPointerUp: (event: ReactPointerEvent<HTMLElement>) => void;43 readonly onPointerUp: (event: ReactPointerEvent<HTMLElement>) => void;
...@@ -93,7 +98,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): {...@@ -93,7 +98,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): {
93 // renders its own transition-duration, so override a property it leaves alone.98 // renders its own transition-duration, so override a property it leaves alone.
94 host?.style.setProperty("transition-property", "none");99 host?.style.setProperty("transition-property", "none");
95 return {100 return {
96 width: clampedWidth,101 size: clampedWidth,
97 edge,102 edge,
98 resize(value) {103 resize(value) {
99 const nextWidth = latestOptions.current.clamp(value);104 const nextWidth = latestOptions.current.clamp(value);
...@@ -123,5 +128,32 @@ export function useResizableWidth(options: UseResizableWidthOptions): {...@@ -123,5 +128,32 @@ export function useResizableWidth(options: UseResizableWidthOptions): {
123 // rewrites the committed width, so re-apply the live pointer position.128 // rewrites the committed width, so re-apply the live pointer position.
124 useLayoutEffect(refresh, [clamp, clampedWidth, refresh]);129 useLayoutEffect(refresh, [clamp, clampedWidth, refresh]);
125130
126 return { width: clampedWidth, handlers };131 return {
132 width: clampedWidth,
133 handlers: {
134 ...handlers,
135 "aria-valuemin": minWidth,
136 "aria-valuemax": maxWidth,
137 "aria-valuenow": clampedWidth,
138 onKeyDown(event) {
139 const step = event.shiftKey ? 40 : 10;
140 const delta = edge === "left" ? -step : step;
141 const next =
142 event.key === "ArrowRight"
143 ? clampedWidth + delta
144 : event.key === "ArrowLeft"
145 ? clampedWidth - delta
146 : event.key === "Home"
147 ? minWidth
148 : event.key === "End"
149 ? maxWidth
150 : null;
151 if (next === null) return;
152 event.preventDefault();
153 const width = clamp(next);
154 setWidthState({ storageKey, width });
155 setLocalStorageItem(storageKey, width, WidthSchema);
156 },
157 },
158 };
127}159}
apps/web/src/hooks/useResizeDrag.ts+30-18
...@@ -1,10 +1,10 @@...@@ -1,10 +1,10 @@
1import { type PointerEvent, useCallback, useEffect, useLayoutEffect, useRef } from "react";1import { type PointerEvent, useCallback, useEffect, useLayoutEffect, useRef } from "react";
22
3interface ResizeSession {3interface ResizeSession {
4 width: number;4 size: number;
5 edge: "left" | "right";5 edge: "left" | "right" | "top" | "bottom";
6 resize: (width: number) => number;6 resize: (size: number) => number;
7 finish: (width: number, moved: boolean) => void;7 finish: (size: number, moved: boolean) => void;
8 /** Runs on every end; `committed` is false when the drag was abandoned without `finish`. */8 /** Runs on every end; `committed` is false when the drag was abandoned without `finish`. */
9 cleanup?: (committed: boolean) => void;9 cleanup?: (committed: boolean) => void;
10}10}
...@@ -18,9 +18,9 @@ export function useResizeDrag<T extends HTMLElement>(...@@ -18,9 +18,9 @@ export function useResizeDrag<T extends HTMLElement>(
18 session: ResizeSession;18 session: ResizeSession;
19 target: T;19 target: T;
20 pointerId: number;20 pointerId: number;
21 startX: number;21 startPosition: number;
22 pendingX: number;22 pendingPosition: number;
23 width: number;23 size: number;
24 moved: boolean;24 moved: boolean;
25 frame: number | null;25 frame: number | null;
26 } | null>(null);26 } | null>(null);
...@@ -28,9 +28,11 @@ export function useResizeDrag<T extends HTMLElement>(...@@ -28,9 +28,11 @@ export function useResizeDrag<T extends HTMLElement>(
28 const flush = useCallback(() => {28 const flush = useCallback(() => {
29 const active = drag.current;29 const active = drag.current;
30 if (!active) return;30 if (!active) return;
31 const delta = (active.pendingX - active.startX) * (active.session.edge === "left" ? -1 : 1);31 const delta =
32 (active.pendingPosition - active.startPosition) *
33 (active.session.edge === "left" || active.session.edge === "top" ? -1 : 1);
32 active.moved ||= Math.abs(delta) > 2;34 active.moved ||= Math.abs(delta) > 2;
33 active.width = active.session.resize(active.session.width + delta);35 active.size = active.session.resize(active.session.size + delta);
34 }, []);36 }, []);
3537
36 const finish = useCallback(38 const finish = useCallback(
...@@ -51,7 +53,7 @@ export function useResizeDrag<T extends HTMLElement>(...@@ -51,7 +53,7 @@ export function useResizeDrag<T extends HTMLElement>(
51 document.body.style.removeProperty("cursor");53 document.body.style.removeProperty("cursor");
52 document.body.style.removeProperty("user-select");54 document.body.style.removeProperty("user-select");
53 active.session.cleanup?.(commit);55 active.session.cleanup?.(commit);
54 if (commit) active.session.finish(active.width, active.moved);56 if (commit) active.session.finish(active.size, active.moved);
55 },57 },
56 [flush],58 [flush],
57 );59 );
...@@ -77,7 +79,11 @@ export function useResizeDrag<T extends HTMLElement>(...@@ -77,7 +79,11 @@ export function useResizeDrag<T extends HTMLElement>(
77 const active = drag.current;79 const active = drag.current;
78 if (!active || active.pointerId !== event.pointerId) return;80 if (!active || active.pointerId !== event.pointerId) return;
79 event.preventDefault();81 event.preventDefault();
80 if (usePosition) active.pendingX = event.clientX;82 if (usePosition)
83 active.pendingPosition =
84 active.session.edge === "top" || active.session.edge === "bottom"
85 ? event.clientY
86 : event.clientX;
81 finish();87 finish();
82 };88 };
8389
...@@ -85,7 +91,7 @@ export function useResizeDrag<T extends HTMLElement>(...@@ -85,7 +91,7 @@ export function useResizeDrag<T extends HTMLElement>(
85 /** Re-applies the pointer position to an active drag, e.g. after its bounds change. */91 /** Re-applies the pointer position to an active drag, e.g. after its bounds change. */
86 refresh: flush,92 refresh: flush,
87 onPointerDown(event: PointerEvent<T>) {93 onPointerDown(event: PointerEvent<T>) {
88 if (event.button !== 0 || drag.current) return;94 if (event.button !== 0 || drag.current || event.target !== event.currentTarget) return;
89 const session = start(event);95 const session = start(event);
90 if (!session) return;96 if (!session) return;
91 try {97 try {
...@@ -94,27 +100,33 @@ export function useResizeDrag<T extends HTMLElement>(...@@ -94,27 +100,33 @@ export function useResizeDrag<T extends HTMLElement>(
94 session.cleanup?.(false);100 session.cleanup?.(false);
95 return;101 return;
96 }102 }
103 const position =
104 session.edge === "top" || session.edge === "bottom" ? event.clientY : event.clientX;
97 event.preventDefault();105 event.preventDefault();
98 event.stopPropagation();106 event.stopPropagation();
99 drag.current = {107 drag.current = {
100 session,108 session,
101 target: event.currentTarget,109 target: event.currentTarget,
102 pointerId: event.pointerId,110 pointerId: event.pointerId,
103 startX: event.clientX,111 startPosition: position,
104 pendingX: event.clientX,112 pendingPosition: position,
105 width: session.width,113 size: session.size,
106 moved: false,114 moved: false,
107 frame: null,115 frame: null,
108 };116 };
109 document.body.style.cursor = "col-resize";117 document.body.style.cursor =
118 session.edge === "top" || session.edge === "bottom" ? "row-resize" : "col-resize";
110 document.body.style.userSelect = "none";119 document.body.style.userSelect = "none";
111 },120 },
112 onPointerMove(event: PointerEvent<T>) {121 onPointerMove(event: PointerEvent<T>) {
113 const active = drag.current;122 const active = drag.current;
114 if (!active || active.pointerId !== event.pointerId) return;123 if (!active || active.pointerId !== event.pointerId) return;
115 event.preventDefault();124 event.preventDefault();
116 active.pendingX = event.clientX;125 active.pendingPosition =
117 active.moved ||= Math.abs(event.clientX - active.startX) > 2;126 active.session.edge === "top" || active.session.edge === "bottom"
127 ? event.clientY
128 : event.clientX;
129 active.moved ||= Math.abs(active.pendingPosition - active.startPosition) > 2;
118 if (active.frame !== null) return;130 if (active.frame !== null) return;
119 active.frame = requestAnimationFrame(() => {131 active.frame = requestAnimationFrame(() => {
120 active.frame = null;132 active.frame = null;