diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 01fed3f36..efc5c2844 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -56,6 +56,7 @@ import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-acti import { useFaviconStatus } from "@/hooks/use-favicon-status"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; 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 { useAppSettings } from "@/hooks/use-settings"; 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 flexStyle = { flex: 1 } as const; +const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32; +const MOBILE_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y"; interface AppContainerProps { children: ReactNode; @@ -406,6 +409,7 @@ function AppContainer({ }, [settings.theme, updateSettings]); const isCompactLayout = useIsCompactFormFactor(); + useCompactWebViewportZoomLock(isCompactLayout); const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; const pathname = usePathname(); const activeServerId = useMemo( @@ -498,6 +502,7 @@ function MobileGestureWrapper({ openGestureRef, } = useSidebarAnimation(); const touchStartX = useSharedValue(0); + const touchStartY = useSharedValue(0); const openGestureEnabled = chromeEnabled && mobileView === "agent"; const handleGestureOpen = useCallback(() => { @@ -516,6 +521,7 @@ function MobileGestureWrapper({ const touch = event.changedTouches[0]; if (touch) { touchStartX.value = touch.absoluteX; + touchStartY.value = touch.absoluteY; } }) .onTouchesMove((event, stateManager) => { @@ -523,13 +529,31 @@ function MobileGestureWrapper({ if (!touch || event.numberOfTouches !== 1) return; 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) { stateManager.fail(); 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(); } }) @@ -571,11 +595,12 @@ function MobileGestureWrapper({ openGestureRef, horizontalScroll?.isAnyScrolledRight, touchStartX, + touchStartY, ], ); return ( - + {children} ); diff --git a/packages/app/src/components/draggable-list.web.test.tsx b/packages/app/src/components/draggable-list.web.test.tsx index 351079225..29fc391c4 100644 --- a/packages/app/src/components/draggable-list.web.test.tsx +++ b/packages/app/src/components/draggable-list.web.test.tsx @@ -13,6 +13,9 @@ interface DndContextProps { } let latestDndContextProps: DndContextProps | null = null; +const dndKitMocks = vi.hoisted(() => ({ + useSensor: vi.fn(() => ({})), +})); vi.mock("@dnd-kit/core", () => ({ DndContext: ({ children, ...props }: React.PropsWithChildren) => { @@ -22,7 +25,7 @@ vi.mock("@dnd-kit/core", () => ({ closestCenter: vi.fn(), KeyboardSensor: vi.fn(), PointerSensor: vi.fn(), - useSensor: vi.fn(() => ({})), + useSensor: dndKitMocks.useSensor, 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(() => { root?.render( , ); }); @@ -125,6 +129,28 @@ function getItemActiveState(item: string): string | null { } 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", () => { renderList(); diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx index f3601e629..a6dd57f7f 100644 --- a/packages/app/src/components/draggable-list.web.tsx +++ b/packages/app/src/components/draggable-list.web.tsx @@ -30,6 +30,8 @@ const restrictToVerticalAxis: Modifier = ({ transform }) => ({ }); const DND_MODIFIERS = [restrictToVerticalAxis]; +const DEFAULT_POINTER_ACTIVATION_CONSTRAINT = { distance: 6 }; +const HANDLE_POINTER_ACTIVATION_CONSTRAINT = { delay: 250, tolerance: 8 }; interface SortableItemProps { id: string; @@ -89,12 +91,6 @@ function SortableItem({ }), [combinedTransform, transition, isDragging], ); - const { - role: _dragRole, - tabIndex: _dragTabIndex, - "aria-roledescription": _dragRoleDescription, - ...dragHandleAttributes - } = attributes as unknown as Record; const info: DraggableRenderItemInfo = { item, @@ -103,7 +99,7 @@ function SortableItem({ isActive: activeId === id, dragHandleProps: useDragHandle ? { - attributes: dragHandleAttributes, + attributes: attributes as unknown as Record, listeners: listeners as unknown as Record, setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void, } @@ -149,12 +145,13 @@ export function DraggableList({ const scrollbar = useWebScrollViewScrollbar(scrollViewRef, { enabled: showCustomScrollbar, }); + const pointerActivationConstraint = useDragHandle + ? HANDLE_POINTER_ACTIVATION_CONSTRAINT + : DEFAULT_POINTER_ACTIVATION_CONSTRAINT; const sensors = useSensors( useSensor(PointerSensor, { - activationConstraint: { - distance: 6, - }, + activationConstraint: pointerActivationConstraint, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index d111b248c..b99d526a2 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -53,6 +53,7 @@ import { getShortcutOs } from "@/utils/shortcut-platform"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { isWeb } from "@/constants/platform"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { useComposerHeightMirror } from "./composer-height-mirror"; import { computeCanStartDictation } from "./message-input-state"; @@ -365,6 +366,7 @@ function resolveSendTooltipLabel(input: { interface DesktopKeyPressContext { onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined; + submitOnEnter: boolean; isAgentRunning: boolean; onQueue: ((payload: MessagePayload) => void) | undefined; isSubmitDisabled: boolean; @@ -391,6 +393,7 @@ function handleDesktopKeyPressImpl( const { shiftKey, metaKey, ctrlKey } = event.nativeEvent; if (event.nativeEvent.key !== "Enter") return; + if (!ctx.submitOnEnter) return; if (shiftKey) return; if ((metaKey || ctrlKey) && ctx.isAgentRunning && ctx.onQueue) { @@ -1187,6 +1190,7 @@ export const MessageInput = forwardRef( inputWrapperStyle, attachmentSlot, } = resolveMessageInputProps(props); + const isCompact = useIsCompactFormFactor(); const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg; const toast = useToast(); const voice = useVoiceOptional(); @@ -1567,12 +1571,14 @@ export const MessageInput = forwardRef( [onSelectionChangeCallback], ); - const shouldHandleDesktopSubmit = isWeb; + const shouldHandleWebKeyPress = isWeb; + const shouldSubmitOnEnter = isWeb && !isCompact; function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { - if (!shouldHandleDesktopSubmit) return; + if (!shouldHandleWebKeyPress) return; handleDesktopKeyPressImpl(event, { onKeyPressCallback, + submitOnEnter: shouldSubmitOnEnter, isAgentRunning, onQueue, isSubmitDisabled, @@ -1724,7 +1730,7 @@ export const MessageInput = forwardRef( scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true} onContentSizeChange={handleContentSizeChange} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} - onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined} + onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined} onSelectionChange={handleSelectionChange} autoFocus={isWeb && autoFocus} /> diff --git a/packages/app/src/hooks/use-compact-web-viewport-zoom-lock.ts b/packages/app/src/hooks/use-compact-web-viewport-zoom-lock.ts new file mode 100644 index 000000000..838b421d4 --- /dev/null +++ b/packages/app/src/hooks/use-compact-web-viewport-zoom-lock.ts @@ -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('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]); +} diff --git a/packages/app/src/hooks/use-explorer-open-gesture.ts b/packages/app/src/hooks/use-explorer-open-gesture.ts index 22b4eceac..7a978cb1c 100644 --- a/packages/app/src/hooks/use-explorer-open-gesture.ts +++ b/packages/app/src/hooks/use-explorer-open-gesture.ts @@ -3,12 +3,15 @@ import { Gesture } from "react-native-gesture-handler"; import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; +import { isWeb } from "@/constants/platform"; interface UseExplorerOpenGestureParams { enabled: boolean; onOpen: () => void; } +const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32; + export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestureParams) { const { translateX, @@ -56,6 +59,12 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu const absDeltaX = Math.abs(deltaX); 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. if (deltaX >= 10) { stateManager.fail(); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 80acadc0a..d055770f5 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -164,6 +164,7 @@ const EMPTY_UI_TABS: WorkspaceTab[] = []; const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = []; const EMPTY_PINNED_AGENT_IDS = new Set(); const EMPTY_SET = new Set(); +const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y"; const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedEllipsis = withUnistyles(Ellipsis); @@ -3213,7 +3214,10 @@ function WorkspaceScreenContent({ {isMobile ? ( - + {content} ) : (