From e05b6a353e4701d919165a7a32ee30b2ec61dce0 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Tue, 13 Oct 2026 10:14:26 -0700 Subject: [PATCH] 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 --- .../browser/BrowserViewportResizeHandles.tsx | 2 +- .../src/browser/browserViewportLayout.test.ts | 16 +- apps/web/src/browser/browserViewportLayout.ts | 2 +- .../browser/useBrowserViewportResize.test.tsx | 124 ++++++++++++ .../src/browser/useBrowserViewportResize.ts | 28 ++- apps/web/src/components/AppSidebarLayout.tsx | 2 +- .../src/components/ThreadTerminalDrawer.tsx | 176 ++++++++---------- .../components/preview/PreviewPanelShell.tsx | 12 +- .../preview/RightPanelResizeHandle.tsx | 14 +- .../preview/ThreadPreviewMiniPlayer.tsx | 91 +++++++-- apps/web/src/components/ui/sidebar.tsx | 68 +++++-- apps/web/src/hooks/useResizableWidth.test.tsx | 112 ++++++++++- apps/web/src/hooks/useResizableWidth.ts | 36 +++- apps/web/src/hooks/useResizeDrag.ts | 48 +++-- 14 files changed, 556 insertions(+), 175 deletions(-) create mode 100644 apps/web/src/browser/useBrowserViewportResize.test.tsx diff --git a/apps/web/src/browser/BrowserViewportResizeHandles.tsx b/apps/web/src/browser/BrowserViewportResizeHandles.tsx index 08e69c359a158b6d646e4c1eb1853d7b4dbbb6a7..8c16061155c669fd9dafd5c084bc52cb3a400996 100644 --- a/apps/web/src/browser/BrowserViewportResizeHandles.tsx +++ b/apps/web/src/browser/BrowserViewportResizeHandles.tsx @@ -30,7 +30,7 @@ interface Props { type HandleKind = "horizontal" | "vertical" | "corner"; const EDGE_BUTTON_CLASS = - "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]"; + "group absolute z-20 touch-none border-0 bg-transparent p-0 outline-none focus-visible:bg-foreground/[0.04]"; const EDGE_GRIP_CLASS = "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"; diff --git a/apps/web/src/browser/browserViewportLayout.test.ts b/apps/web/src/browser/browserViewportLayout.test.ts index ca2832bea6ce175495c51677fb4c1d90d88a7bf0..2b72ff7d1538ded043e3f0b22505b21be9625253 100644 --- a/apps/web/src/browser/browserViewportLayout.test.ts +++ b/apps/web/src/browser/browserViewportLayout.test.ts @@ -123,15 +123,15 @@ describe("resolveBrowserViewportLayout", () => { expect( resolveBrowserDeviceViewportLayout( { width: 1200, height: 900 }, - { _tag: "freeform", width: 1180, height: 852 }, + { _tag: "freeform", width: 1168, height: 846 }, ), ).toEqual({ canvasWidth: 1200, canvasHeight: 900, - viewportX: 10, + viewportX: 16, viewportY: 38, - viewportWidth: 1180, - viewportHeight: 852, + viewportWidth: 1168, + viewportHeight: 846, viewportScale: 1, fillsPanel: false, }); @@ -139,12 +139,12 @@ describe("resolveBrowserViewportLayout", () => { it("captures the available framed area when responsive mode is enabled", () => { expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 })).toEqual({ - width: 1180, - height: 852, + width: 1168, + height: 846, }); expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 }, 2)).toEqual({ - width: 590, - height: 426, + width: 584, + height: 423, }); }); diff --git a/apps/web/src/browser/browserViewportLayout.ts b/apps/web/src/browser/browserViewportLayout.ts index 489a8f16f3913064103b51659706340770582f29..d46a706589207e683173c5f9a346f823b8cdbd8e 100644 --- a/apps/web/src/browser/browserViewportLayout.ts +++ b/apps/web/src/browser/browserViewportLayout.ts @@ -23,7 +23,7 @@ export interface BrowserViewportLayout { // on both sides, inside the toolbar's 1px bottom border. The bar scrolls // horizontally, which also clips anything that overflows vertically. export const BROWSER_DEVICE_TOOLBAR_HEIGHT = 38; -export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 10; +export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 16; export type BrowserViewportResizeDirection = | "north" diff --git a/apps/web/src/browser/useBrowserViewportResize.test.tsx b/apps/web/src/browser/useBrowserViewportResize.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8bca7a01ae9a05f4e7610f766576bd1283c65e23 --- /dev/null +++ b/apps/web/src/browser/useBrowserViewportResize.test.tsx @@ -0,0 +1,124 @@ +import { act, type PointerEvent as ReactPointerEvent, useLayoutEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { commitBrowserViewportChange } from "./browserViewportActions"; +import { useBrowserViewportResize } from "./useBrowserViewportResize"; + +vi.mock("./browserViewportActions", () => ({ commitBrowserViewportChange: vi.fn(async () => {}) })); + +let renderer: ReactTestRenderer; +let result: ReturnType; +let events: EventTarget; +let captured = false; +const target = Object.assign(new EventTarget(), { + setPointerCapture: () => { + captured = true; + }, + hasPointerCapture: () => captured, + releasePointerCapture: () => { + captured = false; + }, +}); + +function pointer(clientX: number, options: { button?: number; pointerId?: number } = {}) { + return { + button: options.button ?? 0, + pointerId: options.pointerId ?? 1, + clientX, + clientY: 100, + target, + currentTarget: target, + preventDefault() {}, + stopPropagation() {}, + } as unknown as ReactPointerEvent; +} + +function dispatch(type: string, clientX = 100, destination = events) { + destination.dispatchEvent( + Object.assign(new Event(type, { cancelable: true }), { pointerId: 1, clientX, clientY: 100 }), + ); +} + +function Viewport({ tabId = "one" }: { tabId?: string }) { + const resize = useBrowserViewportResize({ + tabId, + viewport: { _tag: "freeform", width: 800, height: 600 }, + zoomFactor: 1, + containerSize: { width: 1200, height: 900 }, + deviceToolbarVisible: true, + aspectRatio: null, + }); + useLayoutEffect(() => { + result = resize; + }); + return null; +} + +beforeEach(async () => { + events = new EventTarget(); + captured = false; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + addEventListener: events.addEventListener.bind(events), + removeEventListener: events.removeEventListener.bind(events), + }); + await act(() => { + renderer = create(); + }); +}); + +afterEach(async () => { + await act(() => renderer.unmount()); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +it("commits the final release position even without a move frame", async () => { + await act(async () => { + result.handleResizePointerDown("east", pointer(100)); + dispatch("pointerup", 225); + }); + expect(commitBrowserViewportChange).toHaveBeenCalledExactlyOnceWith("one", { + _tag: "freeform", + width: 1050, + height: 600, + }); + expect(captured).toBe(false); +}); + +it.each(["cancel", "lost capture", "blur", "unmount", "tab switch"])( + "abandons the resize after %s", + async (reason) => { + await act(() => { + result.handleResizePointerDown("east", pointer(100)); + dispatch("pointermove", 175); + }); + expect(result.effectiveViewport).toEqual({ _tag: "freeform", width: 950, height: 600 }); + await act(() => { + if (reason === "cancel") dispatch("pointercancel"); + if (reason === "lost capture") dispatch("lostpointercapture", 100, target); + if (reason === "blur") dispatch("blur"); + if (reason === "unmount") renderer.unmount(); + if (reason === "tab switch") renderer.update(); + }); + await act(() => dispatch("pointerup", 300)); + expect(commitBrowserViewportChange).not.toHaveBeenCalled(); + expect(captured).toBe(false); + if (reason !== "unmount") + expect(result.effectiveViewport).toEqual({ _tag: "freeform", width: 800, height: 600 }); + }, +); + +it("ignores secondary buttons and selectable descendants", async () => { + await act(() => { + result.handleResizePointerDown("east", pointer(100, { button: 2 })); + result.handleResizePointerDown("east", { + ...pointer(100), + target: {}, + } as ReactPointerEvent); + dispatch("pointerup", 225); + }); + expect(commitBrowserViewportChange).not.toHaveBeenCalled(); + expect(captured).toBe(false); +}); diff --git a/apps/web/src/browser/useBrowserViewportResize.ts b/apps/web/src/browser/useBrowserViewportResize.ts index 4f05f6dd27d9f5c726295556d67014e4012e0a0c..fe2e63d9b5e7b23b40102440def13de0dd04887c 100644 --- a/apps/web/src/browser/useBrowserViewportResize.ts +++ b/apps/web/src/browser/useBrowserViewportResize.ts @@ -4,6 +4,7 @@ import type { PreviewViewportSetting, PreviewViewportSize } from "@t3tools/contr import { useCallback, useEffect, + useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, @@ -42,7 +43,7 @@ export function useBrowserViewportResize(options: { const keyboardCommitTimerRef = useRef | null>(null); const keyboardViewportRef = useRef(null); const [dragViewport, setDragViewport] = useState(null); - const sourceViewportKey = browserViewportSettingKey(viewport); + const sourceViewportKey = `${tabId}:${browserViewportSettingKey(viewport)}`; const sourceViewportKeyRef = useRef(sourceViewportKey); sourceViewportKeyRef.current = sourceViewportKey; const activeDrag = dragViewport?.sourceKey === sourceViewportKey ? dragViewport : null; @@ -75,6 +76,14 @@ export function useBrowserViewportResize(options: { [], ); + useLayoutEffect( + () => () => { + dragVersionRef.current += 1; + dragCleanupRef.current?.(); + }, + [tabId, sourceViewportKey], + ); + useEffect(() => { const pending = keyboardViewportRef.current; if (!pending || pending.sourceKey === sourceViewportKey) return; @@ -159,7 +168,12 @@ export function useBrowserViewportResize(options: { direction: BrowserViewportResizeDirection, event: ReactPointerEvent, ) => { - if (effectiveViewport._tag === "fill") return; + if ( + effectiveViewport._tag === "fill" || + event.button !== 0 || + event.target !== event.currentTarget + ) + return; event.preventDefault(); event.stopPropagation(); if (keyboardCommitTimerRef.current !== null) { @@ -217,6 +231,8 @@ export function useBrowserViewportResize(options: { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", finish); window.removeEventListener("pointercancel", cancel); + window.removeEventListener("blur", interrupt); + target.removeEventListener("lostpointercapture", cancel); dragCleanupRef.current = null; try { target.releasePointerCapture(pointerId); @@ -226,6 +242,7 @@ export function useBrowserViewportResize(options: { } function finish(upEvent: PointerEvent) { if (upEvent.pointerId !== pointerId) return; + if (!sourceChanged()) move(upEvent); cleanup(); if (sourceChanged() || (latest.width === startWidth && latest.height === startHeight)) { clearDrag(); @@ -243,10 +260,17 @@ export function useBrowserViewportResize(options: { dragVersionRef.current += 1; clearDrag(); } + function interrupt() { + cleanup(); + dragVersionRef.current += 1; + clearDrag(); + } dragCleanupRef.current = cleanup; window.addEventListener("pointermove", move, { passive: false }); window.addEventListener("pointerup", finish); window.addEventListener("pointercancel", cancel); + window.addEventListener("blur", interrupt); + target.addEventListener("lostpointercapture", cancel); }; return { diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index edcc4e7bf5fcd3978af51fb2f128020d26bff46d..9472500ea78f5b0fc766e2aee89ce1bbcdebf0db 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -338,7 +338,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { ) : ( )} - + {children} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 4654f759e83250dd5a146ce723b8710ec0d1ad76..4499704635358dfd5977cf7439beea2f263ad5a6 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,3 +1,4 @@ +import { useResizeDrag } from "~/hooks/useResizeDrag"; import { useAtomValue } from "@effect/atom-react"; import { isAtomCommandInterrupted, @@ -31,7 +32,6 @@ import { import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as Schema from "effect/Schema"; import { - type PointerEvent as ReactPointerEvent, type ReactNode, type SetStateAction, useCallback, @@ -1250,17 +1250,9 @@ export default function ThreadTerminalDrawer({ [controlledDrawerHeight, threadId], ); const [resizeEpoch, setResizeEpoch] = useState(0); - const drawerHeightRef = useRef(drawerHeight); const preferredDrawerHeightRef = useRef(preferredDrawerHeight); const lastSyncedHeightRef = useRef(controlledDrawerHeight); const onHeightChangeRef = useRef(onHeightChange); - const resizeStateRef = useRef<{ - pointerId: number; - startY: number; - startHeight: number; - drawer: HTMLElement | null; - } | null>(null); - const didResizeDuringDragRef = useRef(false); const normalizedTerminalIds = useMemo(() => { const normalizedIds: string[] = []; @@ -1442,16 +1434,6 @@ export default function ThreadTerminalDrawer({ }, [onHeightChange]); useLayoutEffect(() => { - // A render mid-drag (the window cap moved) must not replace the live height. - const resizeState = resizeStateRef.current; - if (resizeState) { - resizeState.drawer?.style.setProperty("height", `${drawerHeightRef.current}px`); - return; - } - drawerHeightRef.current = drawerHeight; - }, [drawerHeight]); - - useEffect(() => { preferredDrawerHeightRef.current = preferredDrawerHeight; }, [preferredDrawerHeight]); @@ -1465,59 +1447,79 @@ export default function ThreadTerminalDrawer({ lastSyncedHeightRef.current = controlledDrawerHeight; }, [controlledDrawerHeight, threadId]); - const handleResizePointerDown = useCallback((event: ReactPointerEvent) => { - if (event.button !== 0) return; - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - didResizeDuringDragRef.current = false; - resizeStateRef.current = { - pointerId: event.pointerId, - startY: event.clientY, - startHeight: drawerHeightRef.current, - drawer: event.currentTarget.parentElement, - }; - }, []); - - // Writes the height straight to the drawer while dragging so the edge lands in - // the pointer's frame; React state commits once on release. - const handleResizePointerMove = useCallback((event: ReactPointerEvent) => { - const resizeState = resizeStateRef.current; - if (!resizeState || resizeState.pointerId !== event.pointerId) return; - event.preventDefault(); - const clampedHeight = clampDrawerHeight( - resizeState.startHeight + (resizeState.startY - event.clientY), - ); - if (clampedHeight === drawerHeightRef.current) { - return; - } - didResizeDuringDragRef.current = true; - drawerHeightRef.current = clampedHeight; - resizeState.drawer?.style.setProperty("height", `${clampedHeight}px`); - }, []); - - const handleResizePointerEnd = useCallback( - (event: ReactPointerEvent) => { - const resizeState = resizeStateRef.current; - if (!resizeState || resizeState.pointerId !== event.pointerId) return; - resizeStateRef.current = null; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - // A drag that ends where it started keeps the stored preference, which may - // be taller than the window-capped height the drag started from. - if (!didResizeDuringDragRef.current || drawerHeightRef.current === resizeState.startHeight) { - // React skips the write when the rendered height did not change, so put - // back the preference as the current window caps it. - const renderedHeight = clampDrawerHeight(preferredDrawerHeightRef.current); - drawerHeightRef.current = renderedHeight; - resizeState.drawer?.style.setProperty("height", `${renderedHeight}px`); - return; - } - setDrawerHeight(drawerHeightRef.current); - syncHeight(drawerHeightRef.current); - setResizeEpoch((value) => value + 1); + const { refresh: refreshDrawerResize, ...drawerResizeHandlers } = useResizeDrag( + (event) => { + const drawer = event.currentTarget.parentElement; + return { + size: drawerHeight, + edge: "top", + resize(value) { + const height = clampDrawerHeight(value); + drawer?.style.setProperty("height", `${height}px`); + return height; + }, + finish(height, moved) { + if (!moved || height === drawerHeight) { + drawer?.style.setProperty( + "height", + `${clampDrawerHeight(preferredDrawerHeightRef.current)}px`, + ); + return; + } + setDrawerHeight(height); + syncHeight(height); + setResizeEpoch((value) => value + 1); + }, + cleanup(committed) { + if (!committed) + drawer?.style.setProperty( + "height", + `${clampDrawerHeight(preferredDrawerHeightRef.current)}px`, + ); + }, + }; }, - [setDrawerHeight, syncHeight], + `${threadId}:${visible}:${isPanel}`, + ); + + useLayoutEffect(refreshDrawerResize, [drawerHeight, refreshDrawerResize]); + + const drawerResizeHandle = ( +
{ + const step = event.shiftKey ? 40 : 10; + const next = + event.key === "ArrowUp" + ? drawerHeight + step + : event.key === "ArrowDown" + ? drawerHeight - step + : event.key === "Home" + ? MIN_DRAWER_HEIGHT + : event.key === "End" + ? windowMaxDrawerHeight + : null; + if (next === null) return; + event.preventDefault(); + const height = clampDrawerHeight(next); + setDrawerHeight(height); + syncHeight(height); + setResizeEpoch((value) => value + 1); + }} + > + +
); useEffect(() => { @@ -1526,13 +1528,7 @@ export default function ThreadTerminalDrawer({ } const onWindowResize = () => { - // React only renders the stored preference, so clamp a live drag height here. - const resizeState = resizeStateRef.current; - const liveHeight = clampDrawerHeight(drawerHeightRef.current); - if (resizeState && liveHeight !== drawerHeightRef.current) { - drawerHeightRef.current = liveHeight; - resizeState.drawer?.style.setProperty("height", `${liveHeight}px`); - } + refreshDrawerResize(); setWindowMaxDrawerHeight(maxDrawerHeight()); setResizeEpoch((value) => value + 1); }; @@ -1540,7 +1536,7 @@ export default function ThreadTerminalDrawer({ return () => { window.removeEventListener("resize", onWindowResize); }; - }, [visible]); + }, [visible, refreshDrawerResize]); useEffect(() => { if (!visible) { @@ -1567,16 +1563,7 @@ export default function ThreadTerminalDrawer({ )} style={isPanel ? undefined : { height: `${drawerHeight}px` }} > - {!isPanel ? ( -
- ) : null} + {!isPanel ? drawerResizeHandle : null}

No terminal sessions for this thread yet.