Make the sidebar resize handle usable by touch (#2953)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Ivan Malison
2026-08-08 22:32:24 +02:00
committed by GitHub
co-authored by Claude Opus 5 Mohamed Boudra
parent b9b3a24d58
commit 1e0ce69c54
7 changed files with 276 additions and 48 deletions
@@ -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({
<SidebarResizeHandle
edge="left"
gesture={resizeGesture}
pressed={resizePressed}
testID="explorer-sidebar-resize-handle"
/>
+30 -3
View File
@@ -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({
<SidebarResizeHandle
edge="right"
gesture={resizeGesture}
pressed={resizePressed}
testID="left-sidebar-resize-handle"
/>
</View>
@@ -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();
});
});
@@ -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,
};
}
@@ -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 <PointerResizeHandle edge={edge} gesture={gesture} pressed={pressed} testID={testID} />;
}
return <TouchResizeHandle edge={edge} gesture={gesture} pressed={pressed} testID={testID} />;
}
function PointerResizeHandle({ edge, gesture, testID }: SidebarResizeHandleProps) {
const [highlighted, setHighlighted] = useState(false);
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<View pointerEvents="box-none" style={layerStyle}>
<GestureDetector gesture={gesture}>
<View
testID={testID}
role="separator"
aria-orientation="vertical"
collapsable={false}
style={targetStyle}
>
<View pointerEvents="none" testID={`${testID}-grip`} style={gripStyle} />
</View>
</GestureDetector>
</View>
);
}
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],
},
}));
@@ -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;
}
+2 -28
View File
@@ -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<TextInput | null>(null);
const focus = useWorkspaceFocusRestoration();
const canShowKeyboardHints = useCanShowReviewKeyboardHints();
const canShowKeyboardHints = useHasFinePointer();
const [body, setBody] = useState(initialBody);
const [isFocused, setIsFocused] = useState(false);
const trimmedBody = body.trim();