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