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();