Fix mobile web gestures, DnD activation, iOS zoom, and Enter behavior (#1048)

* fix(app): require long-press for sidebar dnd on mobile

* fix(app): right-edge open gesture for explorer sidebar

* fix(app): left-edge open gesture for workspace sidebar

* fix(app): allow gesture children to scroll on web

* fix(app): lock viewport zoom on compact web

* fix(app): don't submit on enter in compact web composer

---------

Co-authored-by: jon <nikuscs@gmail.com>
This commit is contained in:
Mohamed Boudra
2026-05-16 14:20:24 +08:00
committed by GitHub
co-authored by jon
parent 5762055213
commit 667f441cc0
7 changed files with 125 additions and 18 deletions
+27 -2
View File
@@ -56,6 +56,7 @@ import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-acti
import { useFaviconStatus } from "@/hooks/use-favicon-status"; import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useLatchedBoolean } from "@/hooks/use-latched-boolean"; import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project"; import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
@@ -375,6 +376,8 @@ function QueryProvider({ children }: { children: ReactNode }) {
const rowStyle = { flex: 1, flexDirection: "row" } as const; const rowStyle = { flex: 1, flexDirection: "row" } as const;
const flexStyle = { flex: 1 } as const; const flexStyle = { flex: 1 } as const;
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
const MOBILE_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
interface AppContainerProps { interface AppContainerProps {
children: ReactNode; children: ReactNode;
@@ -406,6 +409,7 @@ function AppContainer({
}, [settings.theme, updateSettings]); }, [settings.theme, updateSettings]);
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = useIsCompactFormFactor();
useCompactWebViewportZoomLock(isCompactLayout);
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
const pathname = usePathname(); const pathname = usePathname();
const activeServerId = useMemo( const activeServerId = useMemo(
@@ -498,6 +502,7 @@ function MobileGestureWrapper({
openGestureRef, openGestureRef,
} = useSidebarAnimation(); } = useSidebarAnimation();
const touchStartX = useSharedValue(0); const touchStartX = useSharedValue(0);
const touchStartY = useSharedValue(0);
const openGestureEnabled = chromeEnabled && mobileView === "agent"; const openGestureEnabled = chromeEnabled && mobileView === "agent";
const handleGestureOpen = useCallback(() => { const handleGestureOpen = useCallback(() => {
@@ -516,6 +521,7 @@ function MobileGestureWrapper({
const touch = event.changedTouches[0]; const touch = event.changedTouches[0];
if (touch) { if (touch) {
touchStartX.value = touch.absoluteX; touchStartX.value = touch.absoluteX;
touchStartY.value = touch.absoluteY;
} }
}) })
.onTouchesMove((event, stateManager) => { .onTouchesMove((event, stateManager) => {
@@ -523,13 +529,31 @@ function MobileGestureWrapper({
if (!touch || event.numberOfTouches !== 1) return; if (!touch || event.numberOfTouches !== 1) return;
const deltaX = touch.absoluteX - touchStartX.value; const deltaX = touch.absoluteX - touchStartX.value;
const deltaY = touch.absoluteY - touchStartY.value;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (horizontalScroll?.isAnyScrolledRight.value) { if (horizontalScroll?.isAnyScrolledRight.value) {
stateManager.fail(); stateManager.fail();
return; return;
} }
if (deltaX > 15) { if (isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) {
stateManager.fail();
return;
}
if (deltaX <= -10) {
stateManager.fail();
return;
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail();
return;
}
if (deltaX > 15 && absDeltaX > absDeltaY) {
stateManager.activate(); stateManager.activate();
} }
}) })
@@ -571,11 +595,12 @@ function MobileGestureWrapper({
openGestureRef, openGestureRef,
horizontalScroll?.isAnyScrolledRight, horizontalScroll?.isAnyScrolledRight,
touchStartX, touchStartX,
touchStartY,
], ],
); );
return ( return (
<GestureDetector gesture={openGesture} touchAction="pan-y"> <GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
{children} {children}
</GestureDetector> </GestureDetector>
); );
@@ -13,6 +13,9 @@ interface DndContextProps {
} }
let latestDndContextProps: DndContextProps | null = null; let latestDndContextProps: DndContextProps | null = null;
const dndKitMocks = vi.hoisted(() => ({
useSensor: vi.fn(() => ({})),
}));
vi.mock("@dnd-kit/core", () => ({ vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children, ...props }: React.PropsWithChildren<DndContextProps>) => { DndContext: ({ children, ...props }: React.PropsWithChildren<DndContextProps>) => {
@@ -22,7 +25,7 @@ vi.mock("@dnd-kit/core", () => ({
closestCenter: vi.fn(), closestCenter: vi.fn(),
KeyboardSensor: vi.fn(), KeyboardSensor: vi.fn(),
PointerSensor: vi.fn(), PointerSensor: vi.fn(),
useSensor: vi.fn(() => ({})), useSensor: dndKitMocks.useSensor,
useSensors: vi.fn(() => []), useSensors: vi.fn(() => []),
})); }));
@@ -104,7 +107,7 @@ function renderItem({ item, isActive }: { item: string; isActive: boolean }) {
); );
} }
function renderList(): void { function renderList({ useDragHandle = false }: { useDragHandle?: boolean } = {}): void {
act(() => { act(() => {
root?.render( root?.render(
<DraggableList <DraggableList
@@ -113,6 +116,7 @@ function renderList(): void {
onDragEnd={vi.fn()} onDragEnd={vi.fn()}
renderItem={renderItem} renderItem={renderItem}
scrollEnabled={false} scrollEnabled={false}
useDragHandle={useDragHandle}
/>, />,
); );
}); });
@@ -125,6 +129,28 @@ function getItemActiveState(item: string): string | null {
} }
describe("DraggableList web", () => { describe("DraggableList web", () => {
it("uses distance activation for default draggable rows", () => {
renderList();
expect(dndKitMocks.useSensor).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
activationConstraint: { distance: 6 },
}),
);
});
it("requires a held pointer before activating handle-based drags", () => {
renderList({ useDragHandle: true });
expect(dndKitMocks.useSensor).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
activationConstraint: { delay: 250, tolerance: 8 },
}),
);
});
it("clears active drag state when a drag is cancelled", () => { it("clears active drag state when a drag is cancelled", () => {
renderList(); renderList();
@@ -30,6 +30,8 @@ const restrictToVerticalAxis: Modifier = ({ transform }) => ({
}); });
const DND_MODIFIERS = [restrictToVerticalAxis]; const DND_MODIFIERS = [restrictToVerticalAxis];
const DEFAULT_POINTER_ACTIVATION_CONSTRAINT = { distance: 6 };
const HANDLE_POINTER_ACTIVATION_CONSTRAINT = { delay: 250, tolerance: 8 };
interface SortableItemProps<T> { interface SortableItemProps<T> {
id: string; id: string;
@@ -89,12 +91,6 @@ function SortableItem<T>({
}), }),
[combinedTransform, transition, isDragging], [combinedTransform, transition, isDragging],
); );
const {
role: _dragRole,
tabIndex: _dragTabIndex,
"aria-roledescription": _dragRoleDescription,
...dragHandleAttributes
} = attributes as unknown as Record<string, unknown>;
const info: DraggableRenderItemInfo<T> = { const info: DraggableRenderItemInfo<T> = {
item, item,
@@ -103,7 +99,7 @@ function SortableItem<T>({
isActive: activeId === id, isActive: activeId === id,
dragHandleProps: useDragHandle dragHandleProps: useDragHandle
? { ? {
attributes: dragHandleAttributes, attributes: attributes as unknown as Record<string, unknown>,
listeners: listeners as unknown as Record<string, unknown>, listeners: listeners as unknown as Record<string, unknown>,
setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void, setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void,
} }
@@ -149,12 +145,13 @@ export function DraggableList<T>({
const scrollbar = useWebScrollViewScrollbar(scrollViewRef, { const scrollbar = useWebScrollViewScrollbar(scrollViewRef, {
enabled: showCustomScrollbar, enabled: showCustomScrollbar,
}); });
const pointerActivationConstraint = useDragHandle
? HANDLE_POINTER_ACTIVATION_CONSTRAINT
: DEFAULT_POINTER_ACTIVATION_CONSTRAINT;
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { activationConstraint: pointerActivationConstraint,
distance: 6,
},
}), }),
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates, coordinateGetter: sortableKeyboardCoordinates,
@@ -53,6 +53,7 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerHeightMirror } from "./composer-height-mirror"; import { useComposerHeightMirror } from "./composer-height-mirror";
import { computeCanStartDictation } from "./message-input-state"; import { computeCanStartDictation } from "./message-input-state";
@@ -365,6 +366,7 @@ function resolveSendTooltipLabel(input: {
interface DesktopKeyPressContext { interface DesktopKeyPressContext {
onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined; onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined;
submitOnEnter: boolean;
isAgentRunning: boolean; isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined; onQueue: ((payload: MessagePayload) => void) | undefined;
isSubmitDisabled: boolean; isSubmitDisabled: boolean;
@@ -391,6 +393,7 @@ function handleDesktopKeyPressImpl(
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent; const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
if (event.nativeEvent.key !== "Enter") return; if (event.nativeEvent.key !== "Enter") return;
if (!ctx.submitOnEnter) return;
if (shiftKey) return; if (shiftKey) return;
if ((metaKey || ctrlKey) && ctx.isAgentRunning && ctx.onQueue) { if ((metaKey || ctrlKey) && ctx.isAgentRunning && ctx.onQueue) {
@@ -1187,6 +1190,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
inputWrapperStyle, inputWrapperStyle,
attachmentSlot, attachmentSlot,
} = resolveMessageInputProps(props); } = resolveMessageInputProps(props);
const isCompact = useIsCompactFormFactor();
const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg; const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
const toast = useToast(); const toast = useToast();
const voice = useVoiceOptional(); const voice = useVoiceOptional();
@@ -1567,12 +1571,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
[onSelectionChangeCallback], [onSelectionChangeCallback],
); );
const shouldHandleDesktopSubmit = isWeb; const shouldHandleWebKeyPress = isWeb;
const shouldSubmitOnEnter = isWeb && !isCompact;
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
if (!shouldHandleDesktopSubmit) return; if (!shouldHandleWebKeyPress) return;
handleDesktopKeyPressImpl(event, { handleDesktopKeyPressImpl(event, {
onKeyPressCallback, onKeyPressCallback,
submitOnEnter: shouldSubmitOnEnter,
isAgentRunning, isAgentRunning,
onQueue, onQueue,
isSubmitDisabled, isSubmitDisabled,
@@ -1724,7 +1730,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true} scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true}
onContentSizeChange={handleContentSizeChange} onContentSizeChange={handleContentSizeChange}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined} onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
autoFocus={isWeb && autoFocus} autoFocus={isWeb && autoFocus}
/> />
@@ -0,0 +1,40 @@
import { useEffect } from "react";
import { isWeb } from "@/constants/platform";
const COMPACT_WEB_VIEWPORT_CONTENT =
"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover";
const DEFAULT_WEB_VIEWPORT_CONTENT = "width=device-width, initial-scale=1, viewport-fit=cover";
export function useCompactWebViewportZoomLock(isCompactLayout: boolean) {
useEffect(() => {
if (!isWeb) {
return;
}
const viewportMeta =
document.querySelector<HTMLMetaElement>('meta[name="viewport"]') ??
document.createElement("meta");
const hadViewportMeta = viewportMeta.parentElement !== null;
const previousContent = viewportMeta.getAttribute("content");
if (!hadViewportMeta) {
viewportMeta.name = "viewport";
document.head.appendChild(viewportMeta);
}
viewportMeta.setAttribute(
"content",
isCompactLayout ? COMPACT_WEB_VIEWPORT_CONTENT : DEFAULT_WEB_VIEWPORT_CONTENT,
);
return () => {
if (!hadViewportMeta) {
viewportMeta.remove();
return;
}
if (previousContent !== null) {
viewportMeta.setAttribute("content", previousContent);
}
};
}, [isCompactLayout]);
}
@@ -3,12 +3,15 @@ import { Gesture } from "react-native-gesture-handler";
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated"; import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { isWeb } from "@/constants/platform";
interface UseExplorerOpenGestureParams { interface UseExplorerOpenGestureParams {
enabled: boolean; enabled: boolean;
onOpen: () => void; onOpen: () => void;
} }
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestureParams) { export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestureParams) {
const { const {
translateX, translateX,
@@ -56,6 +59,12 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
const absDeltaX = Math.abs(deltaX); const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY); const absDeltaY = Math.abs(deltaY);
// Browser back-swipe owns most of the viewport; keep this gesture on the right edge.
if (isWeb && touchStartX.value < windowWidth - MOBILE_WEB_EDGE_SWIPE_WIDTH) {
stateManager.fail();
return;
}
// Fail quickly on rightward or clearly vertical intent. // Fail quickly on rightward or clearly vertical intent.
if (deltaX >= 10) { if (deltaX >= 10) {
stateManager.fail(); stateManager.fail();
@@ -164,6 +164,7 @@ const EMPTY_UI_TABS: WorkspaceTab[] = [];
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = []; const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
const EMPTY_PINNED_AGENT_IDS = new Set<string>(); const EMPTY_PINNED_AGENT_IDS = new Set<string>();
const EMPTY_SET = new Set<string>(); const EMPTY_SET = new Set<string>();
const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedEllipsis = withUnistyles(Ellipsis); const ThemedEllipsis = withUnistyles(Ellipsis);
@@ -3213,7 +3214,10 @@ function WorkspaceScreenContent({
<View style={styles.centerContent}> <View style={styles.centerContent}>
{isMobile ? ( {isMobile ? (
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y"> <GestureDetector
gesture={explorerOpenGesture}
touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}
>
<View style={styles.content}>{content}</View> <View style={styles.content}>{content}</View>
</GestureDetector> </GestureDetector>
) : ( ) : (