From 1e0ce69c548ea0d266ed96fae58210816d5ff9fa Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sat, 8 Aug 2026 13:32:24 -0700 Subject: [PATCH] Make the sidebar resize handle usable by touch (#2953) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(app): make the sidebar resize handle usable by touch The drag handle was a 10px strip at `right: -5`, so half of it sat outside the sidebar's bounds. Web hit-testing does not clip to the parent box, but iOS `hitTest:` and Gesture Handler's Android traversal both do, leaving a ~5px target with no visible affordance — hover is the only cue, and hover never fires on native. Split the handle by input capability rather than by platform: a fine pointer keeps the hairline straddling the border, and touch gets a 24x88 grab target that sits fully inside the sidebar with a permanent grip. The pan gesture now requires horizontal intent and fails on vertical drift, so a finger on the grip still scrolls the list behind it. `useHasFinePointer` replaces the copy of the same media query that lived in the review surface. Co-Authored-By: Claude Opus 5 * refactor(app): simplify touch resize handle styles * fix(app): reveal touch resize grip while pressed Keep the touch grip out of sidebar content while idle, then expose it for the full resize gesture lifecycle so it remains visible during an active drag. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Mohamed Boudra --- .../app/src/components/explorer-sidebar.tsx | 34 +++++- packages/app/src/components/left-sidebar.tsx | 33 +++++- .../sidebar-resize-handle-layout.test.ts | 21 ++++ .../sidebar-resize-handle-layout.ts | 57 +++++++++ .../src/components/sidebar-resize-handle.tsx | 109 +++++++++++++++--- packages/app/src/hooks/use-fine-pointer.ts | 40 +++++++ packages/app/src/review/surface.tsx | 30 +---- 7 files changed, 276 insertions(+), 48 deletions(-) create mode 100644 packages/app/src/components/sidebar-resize-handle-layout.test.ts create mode 100644 packages/app/src/components/sidebar-resize-handle-layout.ts create mode 100644 packages/app/src/hooks/use-fine-pointer.ts diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index f4fd089fd..9788776a2 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { View, Text, @@ -8,6 +8,7 @@ import { } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; import { Gesture } from "react-native-gesture-handler"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { X } from "lucide-react-native"; @@ -36,6 +37,10 @@ import { RetainedPanelActivity } from "@/components/retained-panel"; import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout"; +import { + SIDEBAR_RESIZE_ACTIVATION_OFFSET, + SIDEBAR_RESIZE_FAIL_OFFSET, +} from "@/components/sidebar-resize-handle-layout"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { resolveFocusedChatTarget } from "@/composer/focused-chat-target"; @@ -167,6 +172,9 @@ export function ExplorerSidebar({ }); const startWidthRef = useRef(visibleExplorerWidth); const resizeWidth = useSharedValue(visibleExplorerWidth); + const [resizePressed, setResizePressed] = useState(false); + const showResizeGrip = useCallback(() => setResizePressed(true), []); + const hideResizeGrip = useCallback(() => setResizePressed(false), []); useEffect(() => { resizeWidth.value = visibleExplorerWidth; @@ -185,8 +193,15 @@ export function ExplorerSidebar({ Gesture.Pan() .enabled(true) .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) - .onStart(() => { - startWidthRef.current = visibleExplorerWidth; + .onBegin(() => { + scheduleOnRN(showResizeGrip); + }) + // See the left sidebar's gesture: horizontal intent only, with the start + // width anchored to the activation translation so the threshold is free. + .activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET]) + .failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET]) + .onStart((event) => { + startWidthRef.current = visibleExplorerWidth + event.translationX; resizeWidth.value = visibleExplorerWidth; }) .onUpdate((event) => { @@ -198,8 +213,18 @@ export function ExplorerSidebar({ }) .onEnd(() => { runOnJS(setExplorerWidth)(resizeWidth.value); + }) + .onFinalize(() => { + scheduleOnRN(hideResizeGrip); }), - [resizeWidth, setExplorerWidth, viewportWidth, visibleExplorerWidth], + [ + hideResizeGrip, + resizeWidth, + setExplorerWidth, + showResizeGrip, + viewportWidth, + visibleExplorerWidth, + ], ); const resizeAnimatedStyle = useAnimatedStyle(() => ({ @@ -220,6 +245,7 @@ export function ExplorerSidebar({ diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 7564469b9..bfb75a08e 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -22,10 +22,15 @@ import { } from "react-native"; import { Gesture } from "react-native-gesture-handler"; import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout"; +import { + SIDEBAR_RESIZE_ACTIVATION_OFFSET, + SIDEBAR_RESIZE_FAIL_OFFSET, +} from "@/components/sidebar-resize-handle-layout"; import { HostPicker } from "@/components/hosts/host-picker"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu"; @@ -771,6 +776,9 @@ function DesktopSidebar({ const startWidthRef = useRef(visibleSidebarWidth); const resizeWidth = useSharedValue(visibleSidebarWidth); + const [resizePressed, setResizePressed] = useState(false); + const showResizeGrip = useCallback(() => setResizePressed(true), []); + const hideResizeGrip = useCallback(() => setResizePressed(false), []); useEffect(() => { resizeWidth.value = visibleSidebarWidth; @@ -780,8 +788,16 @@ function DesktopSidebar({ () => Gesture.Pan() .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) - .onStart(() => { - startWidthRef.current = visibleSidebarWidth; + .onBegin(() => { + scheduleOnRN(showResizeGrip); + }) + // Horizontal intent only, so a finger dragging down the touch grip scrolls + // the workspace list instead of resizing. Anchoring the start width to the + // activation translation keeps the extra threshold from jumping the edge. + .activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET]) + .failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET]) + .onStart((event) => { + startWidthRef.current = visibleSidebarWidth - event.translationX; resizeWidth.value = visibleSidebarWidth; }) .onUpdate((event) => { @@ -794,8 +810,18 @@ function DesktopSidebar({ }) .onEnd(() => { runOnJS(setSidebarWidth)(resizeWidth.value); + }) + .onFinalize(() => { + scheduleOnRN(hideResizeGrip); }), - [resizeWidth, setSidebarWidth, viewportWidth, visibleSidebarWidth], + [ + hideResizeGrip, + resizeWidth, + setSidebarWidth, + showResizeGrip, + viewportWidth, + visibleSidebarWidth, + ], ); const resizeAnimatedStyle = useAnimatedStyle(() => ({ @@ -894,6 +920,7 @@ function DesktopSidebar({ diff --git a/packages/app/src/components/sidebar-resize-handle-layout.test.ts b/packages/app/src/components/sidebar-resize-handle-layout.test.ts new file mode 100644 index 000000000..909f82965 --- /dev/null +++ b/packages/app/src/components/sidebar-resize-handle-layout.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { resolveSidebarResizeHandleGeometry } from "./sidebar-resize-handle-layout"; + +const MIN_TOUCH_TARGET = 24; + +describe("resolveSidebarResizeHandleGeometry", () => { + it("keeps the touch hit area inside the sidebar so native hit-testing reaches it", () => { + const geometry = resolveSidebarResizeHandleGeometry(false); + + expect(geometry.edgeOffset).toBeGreaterThanOrEqual(0); + expect(geometry.width).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET); + expect(geometry.height ?? 0).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET); + }); + + it("straddles the border for a fine pointer", () => { + const geometry = resolveSidebarResizeHandleGeometry(true); + + expect(geometry.edgeOffset).toBe(-geometry.width / 2); + expect(geometry.height).toBeNull(); + }); +}); diff --git a/packages/app/src/components/sidebar-resize-handle-layout.ts b/packages/app/src/components/sidebar-resize-handle-layout.ts new file mode 100644 index 000000000..c4742beae --- /dev/null +++ b/packages/app/src/components/sidebar-resize-handle-layout.ts @@ -0,0 +1,57 @@ +/** + * Geometry for the sidebar resize handle. + * + * A mouse can grab a hairline, so the pointer variant straddles the sidebar + * border — half the strip hangs outside the sidebar's own bounds, which is + * fine because web hit-testing does not clip to the parent box. + * + * Native hit-testing does clip: iOS `hitTest:` returns nil for points outside a + * view's bounds and Gesture Handler's Android traversal only recurses into + * children whose ancestors contain the point. A strip hanging outside the + * sidebar is dead on touch, and what remains is far too small for a finger. + * So the touch variant sits fully inside the sidebar and is sized for a thumb. + */ + +export const POINTER_HANDLE_WIDTH = 10; +export const TOUCH_HANDLE_WIDTH = 24; +export const TOUCH_HANDLE_HEIGHT = 88; + +/** + * A finger resting on the grip drifts before it commits. Requiring horizontal + * intent before the pan activates, and failing it once the drift is mostly + * vertical, leaves the sidebar's own scrolling reachable through the grip. + * A mouse press moves less than either threshold, so pointer drags are + * unaffected. + */ +export const SIDEBAR_RESIZE_ACTIVATION_OFFSET = 6; +export const SIDEBAR_RESIZE_FAIL_OFFSET = 12; + +export type SidebarResizeEdge = "left" | "right"; + +export interface SidebarResizeHandleGeometry { + /** + * Distance from the sidebar edge to the near side of the hit area. Negative + * means the hit area overhangs the sidebar, which only touch forbids. + */ + edgeOffset: number; + width: number; + /** `null` stretches the hit area to the sidebar's full height. */ + height: number | null; +} + +export function resolveSidebarResizeHandleGeometry( + finePointer: boolean, +): SidebarResizeHandleGeometry { + if (finePointer) { + return { + edgeOffset: -POINTER_HANDLE_WIDTH / 2, + width: POINTER_HANDLE_WIDTH, + height: null, + }; + } + return { + edgeOffset: 0, + width: TOUCH_HANDLE_WIDTH, + height: TOUCH_HANDLE_HEIGHT, + }; +} diff --git a/packages/app/src/components/sidebar-resize-handle.tsx b/packages/app/src/components/sidebar-resize-handle.tsx index 617d9076b..d842f0f00 100644 --- a/packages/app/src/components/sidebar-resize-handle.tsx +++ b/packages/app/src/components/sidebar-resize-handle.tsx @@ -2,11 +2,17 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Pressable, View } from "react-native"; import { GestureDetector, type GestureType } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; +import { + resolveSidebarResizeHandleGeometry, + type SidebarResizeEdge, +} from "@/components/sidebar-resize-handle-layout"; import { isWeb } from "@/constants/platform"; +import { useHasFinePointer } from "@/hooks/use-fine-pointer"; interface SidebarResizeHandleProps { - edge: "left" | "right"; + edge: SidebarResizeEdge; gesture: GestureType; + pressed: boolean; testID: string; } @@ -18,13 +24,29 @@ const webResizeCursorStyle = isWeb } as object) : null; -export function SidebarResizeHandle({ edge, gesture, testID }: SidebarResizeHandleProps) { +function edgeOffsetStyle(edge: SidebarResizeEdge, edgeOffset: number) { + return edge === "left" ? { left: edgeOffset } : { right: edgeOffset }; +} + +export function SidebarResizeHandle({ edge, gesture, pressed, testID }: SidebarResizeHandleProps) { + const finePointer = useHasFinePointer(); + + if (finePointer) { + return ; + } + return ; +} + +function PointerResizeHandle({ edge, gesture, testID }: SidebarResizeHandleProps) { const [highlighted, setHighlighted] = useState(false); const highlightTimerRef = useRef | null>(null); - const hitAreaStyle = - edge === "left" - ? [styles.hitArea, styles.leftEdge, webResizeCursorStyle] - : [styles.hitArea, styles.rightEdge, webResizeCursorStyle]; + const geometry = resolveSidebarResizeHandleGeometry(true); + const hitAreaStyle = [ + styles.hitArea, + { width: geometry.width }, + edgeOffsetStyle(edge, geometry.edgeOffset), + webResizeCursorStyle, + ]; const cancelHighlightTimer = useCallback(() => { if (highlightTimerRef.current === null) return; @@ -63,20 +85,49 @@ export function SidebarResizeHandle({ edge, gesture, testID }: SidebarResizeHand ); } +function TouchResizeHandle({ edge, gesture, pressed, testID }: SidebarResizeHandleProps) { + const geometry = resolveSidebarResizeHandleGeometry(false); + // `box-none` keeps the full-height column out of hit-testing so only the + // grab target steals taps from the rows behind it. + const layerStyle = [ + styles.touchLayer, + { width: geometry.width }, + edgeOffsetStyle(edge, geometry.edgeOffset), + ]; + const targetStyle = [ + styles.touchTarget, + { width: geometry.width, height: geometry.height ?? undefined }, + ]; + const gripStyle = [ + styles.grip, + edge === "left" ? styles.leftEdgeGrip : styles.rightEdgeGrip, + pressed ? styles.visibleGrip : styles.hiddenGrip, + ]; + + return ( + + + + + + + + ); +} + const styles = StyleSheet.create((theme) => ({ hitArea: { position: "absolute", top: 0, bottom: 0, - width: 10, zIndex: 10, }, - leftEdge: { - left: -5, - }, - rightEdge: { - right: -5, - }, highlight: { position: "absolute", top: 0, @@ -86,4 +137,36 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.foreground, opacity: 0.25, }, + touchLayer: { + position: "absolute", + top: 0, + bottom: 0, + zIndex: 10, + alignItems: "center", + justifyContent: "center", + }, + touchTarget: { + alignItems: "center", + justifyContent: "center", + }, + grip: { + width: 4, + height: 36, + borderRadius: 2, + backgroundColor: theme.colors.foreground, + }, + hiddenGrip: { + opacity: 0, + }, + visibleGrip: { + opacity: 0.3, + }, + leftEdgeGrip: { + alignSelf: "flex-start", + marginLeft: theme.spacing[0.5], + }, + rightEdgeGrip: { + alignSelf: "flex-end", + marginRight: theme.spacing[0.5], + }, })); diff --git a/packages/app/src/hooks/use-fine-pointer.ts b/packages/app/src/hooks/use-fine-pointer.ts new file mode 100644 index 000000000..a0bc2489b --- /dev/null +++ b/packages/app/src/hooks/use-fine-pointer.ts @@ -0,0 +1,40 @@ +import { useEffect, useState } from "react"; +import { isWeb } from "@/constants/platform"; + +// A touch tablet running the desktop layout is still `isWeb === false || true` +// depending on the build, so neither platform gate answers "can this user +// hover and point precisely". Only the media query does. +const FINE_POINTER_QUERY = "(hover: hover) and (pointer: fine)"; + +function getFinePointerQueryList(): MediaQueryList | null { + if (!isWeb || typeof window === "undefined" || typeof window.matchMedia !== "function") { + return null; + } + return window.matchMedia(FINE_POINTER_QUERY); +} + +/** True when a mouse-like pointer drives the UI. Always false on native. */ +export function hasFinePointer(): boolean { + return getFinePointerQueryList()?.matches ?? false; +} + +/** + * Reactive `hasFinePointer()` — re-renders when the user switches between a + * mouse and touch (detachable keyboards, tablet mode). + */ +export function useHasFinePointer(): boolean { + const [finePointer, setFinePointer] = useState(hasFinePointer); + + useEffect(() => { + const mediaQuery = getFinePointerQueryList(); + if (!mediaQuery) return; + const handleChange = () => setFinePointer(mediaQuery.matches); + handleChange(); + mediaQuery.addEventListener?.("change", handleChange); + return () => { + mediaQuery.removeEventListener?.("change", handleChange); + }; + }, []); + + return finePointer; +} diff --git a/packages/app/src/review/surface.tsx b/packages/app/src/review/surface.tsx index 2b7a5298f..fd79290b5 100644 --- a/packages/app/src/review/surface.tsx +++ b/packages/app/src/review/surface.tsx @@ -16,6 +16,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Button } from "@/components/ui/button"; import { Shortcut } from "@/components/ui/shortcut"; import { isWeb } from "@/constants/platform"; +import { useHasFinePointer } from "@/hooks/use-fine-pointer"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import type { Theme } from "@/styles/theme"; import type { ShortcutKey } from "@/utils/format-shortcut"; @@ -46,33 +47,6 @@ function getWebTextInputElement(input: TextInput | null): HTMLElement | null { return element instanceof HTMLElement ? element : null; } -function getCanShowReviewKeyboardHints(): boolean { - if (!isWeb || typeof window === "undefined" || typeof window.matchMedia !== "function") { - return false; - } - return window.matchMedia("(hover: hover) and (pointer: fine)").matches; -} - -function useCanShowReviewKeyboardHints(): boolean { - const [canShowHints, setCanShowHints] = useState(getCanShowReviewKeyboardHints); - - useEffect(() => { - if (!isWeb || typeof window === "undefined" || typeof window.matchMedia !== "function") { - return; - } - - const mediaQuery = window.matchMedia("(hover: hover) and (pointer: fine)"); - const handleChange = () => setCanShowHints(mediaQuery.matches); - handleChange(); - mediaQuery.addEventListener?.("change", handleChange); - return () => { - mediaQuery.removeEventListener?.("change", handleChange); - }; - }, []); - - return canShowHints; -} - export const INLINE_REVIEW_COMMENT_HEIGHT = 72; export const INLINE_REVIEW_EDITOR_HEIGHT = 132; const INLINE_REVIEW_GAP = 6; @@ -533,7 +507,7 @@ export function InlineReviewEditor({ const { t } = useTranslation(); const inputRef = useRef(null); const focus = useWorkspaceFocusRestoration(); - const canShowKeyboardHints = useCanShowReviewKeyboardHints(); + const canShowKeyboardHints = useHasFinePointer(); const [body, setBody] = useState(initialBody); const [isFocused, setIsFocused] = useState(false); const trimmedBody = body.trim();