diff --git a/docs/mobile-panels.md b/docs/mobile-panels.md index 3301b4b60a..21849788d2 100644 --- a/docs/mobile-panels.md +++ b/docs/mobile-panels.md @@ -9,6 +9,10 @@ Compact layouts have three mutually exclusive destinations: They are one interaction, not two independent drawers. The implementation lives in `packages/app/src/mobile-panels/`. +Wide layouts reuse `agent-list` for the navigation overlay. When the left dock is collapsed or +yields its width, a touch swipe reveals the same sidebar over the workspace at the resolved sidebar +width. The workspace does not move, and the saved dock preference does not change. + ## Ownership React/Zustand owns the durable intent: @@ -23,6 +27,12 @@ interface MobilePanelSelection { Every semantic target change increments `revision`. Repeating the current target is idempotent. Compact panel selection is not persisted; a cold start begins at `agent`. +Only an overlay sidebar holds `agent-list`, and only a compact layout holds `file-explorer`. App +chrome publishes the layout (compact or wide, and the left sidebar presentation `overlay`, `dock`, or +`hidden`) to `panel-store`, which drops any target the layout does not present. A stale +`agent-list` would reopen the overlay on the next collapse; a stale `file-explorer` after widening +would block the navigation swipe, which begins only from `agent`. + The UI worklet owns transient motion: - one normalized position (`-1` left, `0` center, `1` right) @@ -32,8 +42,10 @@ The UI worklet owns transient motion: React publishes the active panel only when the canonical target and the UI-thread position agree at the final anchor. Retained content never observes gesture previews, progress, or an unsettled target. -The gesture hosts stay mounted. The Gesture Handler web patch keeps initially disabled hosts -from capturing paragraph pointers; that capture confines browser selection to its starting paragraph. +The gesture hosts stay mounted. A pointer capture on a paragraph confines browser selection to that +paragraph, so the Gesture Handler web patch keeps it off: initially disabled hosts register no +pointer listeners, and an enabled host captures only a pointer its handler still tracks after +pointer-down. The touch-only navigation pans fail a mouse at pointer-down and never capture it. Native worklets own overlay opacity and pointer events while React owns settled panel activity and accessibility. Keep hidden native overlays laid out: collapsing their scroll ranges makes Android spring retained offsets back to zero during touch cancellation or @@ -43,8 +55,11 @@ appearance keys; see [Unistyles appearance boundaries](unistyles.md#runtime-them ## Why one position -Both transforms and both backdrop opacities are derived from the same normalized position. Window -width is only a projection input. Rotation changes the projection, not the panel state. +Both transforms and both backdrop opacities are derived from the same normalized position. Panel +width is only a projection input: the window width on the right, and on the left the width +`MobilePanelsProvider` resolves (the full window on compact layouts, the saved sidebar width +clamped to the viewport on wide ones). The dock, the overlay, and their gestures read that one +value. Rotation changes the projection, not the panel state. This makes these invalid states unrepresentable: @@ -62,7 +77,9 @@ A gesture captures the current revision when it becomes active. Per-frame update while that revision still owns the gesture. When a React command arrives during a drag, its newer revision clears gesture ownership and starts -motion toward the new target. The older gesture's remaining updates and finish callback are ignored. +motion toward its target, even an unchanged one: docking mid-swipe keeps `agent` and still returns +the dragged position to its anchor. The older gesture's remaining updates and finish callback are +ignored. Canceled gestures return to the latest canonical target. A successful gesture starts its finishing motion immediately; the matching React command adopts that motion instead of restarting it. Position settlement and command acceptance may arrive in either order. Activity changes after both agree on @@ -80,6 +97,12 @@ definition, no longer eligible to begin. - Callers request semantic targets through `panel-store`; they never write shared values. - Gesture behavior comes from the four explicit hooks in `mobile-panels/gestures.ts`. +- Navigation gestures accept touch pointers only. Mouse and pen fail at touch-down, so dragging a + mouse to select text on a hybrid laptop never moves a panel. Browsers without a touch screen + (`hooks/pointer-capabilities`) never enable them. +- Phone web opens a drawer only from the 32px edge it slides in from, so interior swipes stay with + terminals, code blocks, and diff scrollers. Native drawers and the wide overlay open from anywhere. + Horizontal-scroll ownership and the open-gesture blocker still take precedence. - A focused interactive surface may block panel-opening gestures through `useBlockMobilePanelOpenGestures`. Register only while the conflicting interaction is active and unregister when the surface is hidden or unfocused. The blocker never disables gestures that close @@ -87,11 +110,17 @@ definition, no longer eligible to begin. - Keep `SidebarModelProvider` outside `MobileGestureWrapper`. The provider shares sidebar derivation across consumers, while Gesture Handler requires the wrapper's direct child to be a native `View` so its injected `collapsable={false}` reaches Android/Fabric. -- Mobile sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion - styles in sidebar components. -- The desktop left sidebar is retained too. App chrome owns separate mounted and visible decisions: - closing it or yielding its width marks it inactive and applies `display: none` without conditionally - removing the sidebar tree. +- Sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion styles + in sidebar components. The wide left sidebar keeps the same content and gesture hosts when switching + between dock and overlay. App chrome owns mounting and presentation; settled panel activity owns + overlay work. A hidden dock is inactive with `display: none`; the sidebar tree stays mounted. Keep + the native overlay laid out while hidden, as on compact layouts. Give the switching host no + Reanimated layout props: a width keyed on the presentation reaches Android after React commits the + switch, and a lost update left the workspace at the dock width. The dock sizes to its content and + the overlay host fills the window. +- The overlay's backdrop covers the whole window; safe-area padding applies to the sidebar and + workspace content only. It closes by swiping back, tapping the backdrop, or picking a destination: + every sidebar destination closes the overlay before it navigates. - Animated panel nodes use React Native static styles plus inline theme values. Do not attach Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node has caused native crashes. diff --git a/packages/app/e2e/browser/assistant-selection-copy.spec.ts b/packages/app/e2e/browser/assistant-selection-copy.spec.ts index 7daa11c7d6..81f89290c7 100644 --- a/packages/app/e2e/browser/assistant-selection-copy.spec.ts +++ b/packages/app/e2e/browser/assistant-selection-copy.spec.ts @@ -723,3 +723,46 @@ test("copying a selection across an assistant image keeps its Markdown source an await agent.cleanup(); } }); + +test.describe("on a touch-capable desktop browser", () => { + // A touch screen registers the navigation pan's pointer listeners over the whole workspace. + // The pan accepts touch only, and a mouse drag must still extend the browser's selection. + test.use({ hasTouch: true }); + + test("a mouse drag selects across assistant paragraphs", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "assistant-selection-drag-", + title: "Assistant selection drag", + initialPrompt: "Render three paragraphs.", + featureValues: { + mockAssistantResponse: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.", + }, + }); + + try { + await agent.client.waitForAgentUpsert( + agent.agentId, + (snapshot) => snapshot.status === "idle", + 30_000, + ); + await openAgentRoute(page, agent); + const blocks = assistantMessageBlocks(page); + const first = await blocks.getByText("First paragraph.").boundingBox(); + const third = await blocks.getByText("Third paragraph.").boundingBox(); + if (!first || !third) { + throw new Error("Expected rendered paragraphs"); + } + + await page.mouse.move(first.x + 1, first.y + first.height / 2); + await page.mouse.down(); + await page.mouse.move(third.x + third.width - 1, third.y + third.height / 2, { steps: 20 }); + await page.mouse.up(); + + const selected = await page.evaluate(() => window.getSelection()?.toString() ?? ""); + expect(selected).toContain("First paragraph."); + expect(selected).toContain("Third paragraph."); + } finally { + await agent.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/browser/composer-focus.spec.ts b/packages/app/e2e/browser/composer-focus.spec.ts index 5557e9e9ac..11fe0bf50f 100644 --- a/packages/app/e2e/browser/composer-focus.spec.ts +++ b/packages/app/e2e/browser/composer-focus.spec.ts @@ -25,7 +25,7 @@ import { expectMobileAgentSidebarVisible, expectMobileExplorerHidden, expectMobileExplorerVisible, - swipeOpenMobileAgentSidebar, + openMobileAgentSidebarWithKeyboard, swipeOpenMobileExplorer, } from "../support/helpers/sidebar"; import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor"; @@ -249,7 +249,7 @@ test.describe("touch screen wide enough for the desktop layout", () => { const COMPACT_PANELS = [ { name: "agent list", - open: swipeOpenMobileAgentSidebar, + open: openMobileAgentSidebarWithKeyboard, expectOpen: expectMobileAgentSidebarVisible, close: closeMobileAgentSidebar, expectClosed: expectMobileAgentSidebarHidden, diff --git a/packages/app/e2e/browser/composer-whitespace.spec.ts b/packages/app/e2e/browser/composer-whitespace.spec.ts index 8e1a527f0f..9ad03112db 100644 --- a/packages/app/e2e/browser/composer-whitespace.spec.ts +++ b/packages/app/e2e/browser/composer-whitespace.spec.ts @@ -37,7 +37,7 @@ import { closeMobileAgentSidebar, expectMobileAgentSidebarHidden, expectMobileAgentSidebarVisible, - swipeOpenMobileAgentSidebar, + openMobileAgentSidebarWithKeyboard, } from "../support/helpers/sidebar"; async function composerHeight(page: Page): Promise { @@ -541,8 +541,8 @@ test("the compact sidebar over a fullscreen editor leaves the exit action alone" await writeCompactDraft(page, draft); await enterComposerFullscreen(page); await expectWorkspaceHeaderHidden(page); - // Fullscreen hides the header's menu button; the edge swipe still opens the list over it. - await swipeOpenMobileAgentSidebar(page); + // Fullscreen hides the header's menu button; the keyboard shortcut opens the list over it. + await openMobileAgentSidebarWithKeyboard(page); await expectMobileAgentSidebarVisible(page); // The list covers the pane, so the editor's exit action is not armed. await page.keyboard.press("Escape"); diff --git a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts index b557b73ccb..618d44729c 100644 --- a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts +++ b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts @@ -97,6 +97,7 @@ test("fixed footer line keeps its five icons, Help and Settings at the end", asy await setFooterItemVisible(page, "usage", true); await leaveSettings(page); await page.setViewportSize({ width: 390, height: 844 }); + await openCompactSidebar(page); await expectCompactSidebarOpen(page); await expectFooterSeparator(page, true); await expectFooterIconRow(page); diff --git a/packages/app/e2e/support/helpers/sidebar.ts b/packages/app/e2e/support/helpers/sidebar.ts index 0943eba0b4..9fd61a8451 100644 --- a/packages/app/e2e/support/helpers/sidebar.ts +++ b/packages/app/e2e/support/helpers/sidebar.ts @@ -261,16 +261,9 @@ export async function expectMobileAgentSidebarHidden(page: Page): Promise await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 }); } -/** Drag from the left edge: the only way to open the list while fullscreen hides the header. */ -export async function swipeOpenMobileAgentSidebar(page: Page): Promise { - const viewport = page.viewportSize(); - if (!viewport) throw new Error("viewport size is required"); - const y = Math.round(viewport.height / 2); - await page.mouse.move(8, y); - await page.mouse.down(); - await page.mouse.move(40, y, { steps: 4 }); - await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 }); - await page.mouse.up(); +/** Opens navigation without moving focus to the header, including over a fullscreen editor. */ +export async function openMobileAgentSidebarWithKeyboard(page: Page): Promise { + await page.keyboard.press("ControlOrMeta+b"); } /** Drag from the right edge to open the compact Explorer over the agent. */ diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 29d63a6a05..d5b6794c03 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -25,6 +25,7 @@ import { useCallback, useContext, useEffect, + useLayoutEffect, useMemo, useState, useSyncExternalStore, @@ -68,6 +69,7 @@ import { resolveDesktopAppContentMinimum, resolveDesktopSidebarVisibility, resolveSidebarActivity, + resolveLeftSidebarPresentation, } from "@/components/desktop-sidebar-layout"; import { isNative, isWeb } from "@/constants/platform"; import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context"; @@ -121,7 +123,7 @@ import { useHosts, } from "@/runtime/host-runtime"; import { getDaemonStartService } from "@/runtime/daemon-start-service"; -import { usePanelStore } from "@/stores/panel-store"; +import { usePanelStore, type LeftSidebarPresentation } from "@/stores/panel-store"; import { flushDraftPersistStorage } from "@/stores/draft-store"; import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme"; import { useSessionStore } from "@/stores/session-store"; @@ -441,6 +443,17 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon viewportWidth, }), }); + const sidebarPresentation = resolveLeftSidebarPresentation({ + chromeEnabled, + isCompactLayout, + isFocusModeEnabled: isWorkspaceFocusModeEnabled, + desktopSidebarVisible, + }); + const applyPanelLayout = usePanelStore((state) => state.applyPanelLayout); + // Publishes the layout to the store, which owns the panel targets each layout allows. + useLayoutEffect(() => { + applyPanelLayout({ isCompact: isCompactLayout, leftSidebar: sidebarPresentation }); + }, [applyPanelLayout, isCompactLayout, sidebarPresentation]); const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left"); const appChromeLayout = resolveDesktopAppChromeLayout({ desktopSidebarRendered: desktopSidebarVisible, @@ -450,7 +463,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon const sidebarChrome = ( ); @@ -531,7 +544,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon ); return ( - + {surface} ); @@ -539,15 +552,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon function AppContainerLifetime({ children, - compactGesturesEnabled, + sidebarGesturesEnabled, }: { children: ReactNode; - compactGesturesEnabled: boolean; + sidebarGesturesEnabled: boolean; }) { return ( - + {children} @@ -557,23 +570,26 @@ function AppContainerLifetime({ function SidebarChrome({ mounted, - visible, + presentation, keyboardShortcutsEnabled, }: { mounted: boolean; - visible: boolean; + presentation: LeftSidebarPresentation; keyboardShortcutsEnabled: boolean; }) { const isCompactLayout = useIsCompactFormFactor(); const isMobileActive = useIsMobilePanelActive("agent-list"); - const active = resolveSidebarActivity(visible, isCompactLayout, isMobileActive); + const overlay = presentation === "overlay"; + const active = resolveSidebarActivity(presentation !== "hidden", overlay, isMobileActive); const hardwareKeyboardPresent = useHardwareKeyboardPresent(); return ( - {mounted ? : null} + {mounted ? ( + + ) : null} ); @@ -586,8 +602,8 @@ function MobileGestureWrapper({ children: ReactNode; chromeEnabled: boolean; }) { - const openGesture = useOpenAgentListGesture(chromeEnabled); const isCompactLayout = useIsCompactFormFactor(); + const openGesture = useOpenAgentListGesture(chromeEnabled); return ( diff --git a/packages/app/src/components/desktop-sidebar-layout.test.ts b/packages/app/src/components/desktop-sidebar-layout.test.ts index f4ccec7e98..41a663a9e3 100644 --- a/packages/app/src/components/desktop-sidebar-layout.test.ts +++ b/packages/app/src/components/desktop-sidebar-layout.test.ts @@ -9,9 +9,11 @@ import { canDesktopAppSidebarShare, resolveDesktopAppChromeLayout, resolveDesktopAppContentMinimum, + resolveDesktopSidebarInsetLeft, resolveDesktopSidebarVisibility, resolveSidebarActivity, resolveDesktopSidebarWidth, + resolveLeftSidebarPresentation, } from "@/components/desktop-sidebar-layout"; describe("desktop sidebar layout", () => { @@ -104,6 +106,40 @@ describe("desktop sidebar layout", () => { ).toBe(false); }); + it("offers an overlay only after wide navigation yields its dock", () => { + const input = { + chromeEnabled: true, + isCompactLayout: false, + isFocusModeEnabled: false, + }; + expect(resolveLeftSidebarPresentation({ ...input, desktopSidebarVisible: true })).toBe("dock"); + expect(resolveLeftSidebarPresentation({ ...input, desktopSidebarVisible: false })).toBe( + "overlay", + ); + }); + + it("does not reveal navigation over a focused workspace or suppressed chrome", () => { + const input = { + chromeEnabled: true, + isCompactLayout: false, + isFocusModeEnabled: false, + desktopSidebarVisible: false, + }; + expect(resolveLeftSidebarPresentation({ ...input, isFocusModeEnabled: true })).toBe("hidden"); + expect(resolveLeftSidebarPresentation({ ...input, chromeEnabled: false })).toBe("hidden"); + }); + + it("keeps phone navigation available independently of saved wide focus mode", () => { + expect( + resolveLeftSidebarPresentation({ + chromeEnabled: true, + isCompactLayout: true, + isFocusModeEnabled: true, + desktopSidebarVisible: false, + }), + ).toBe("overlay"); + }); + it("keeps the sidebar toggle window-owned beside left window controls", () => { expect( resolveDesktopAppChromeLayout({ @@ -207,3 +243,8 @@ it("reserves room for Explorer and the workspace before showing app navigation", }), ).toBe(true); }); + +it("insets an overlay sidebar's content by the left safe area and leaves the dock to app chrome", () => { + expect(resolveDesktopSidebarInsetLeft({ overlay: true, safeAreaLeft: 20 })).toBe(20); + expect(resolveDesktopSidebarInsetLeft({ overlay: false, safeAreaLeft: 20 })).toBe(0); +}); diff --git a/packages/app/src/components/desktop-sidebar-layout.ts b/packages/app/src/components/desktop-sidebar-layout.ts index 09e03bc02e..692934d73e 100644 --- a/packages/app/src/components/desktop-sidebar-layout.ts +++ b/packages/app/src/components/desktop-sidebar-layout.ts @@ -1,6 +1,10 @@ import { EXPLORER_WORKSPACE_MIN_WIDTH } from "@/components/explorer-sidebar-layout"; import { SETTINGS_DESKTOP_SPLIT_MIN_WIDTH } from "@/constants/layout"; -import { MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store"; +import { + MAX_SIDEBAR_WIDTH, + MIN_SIDEBAR_WIDTH, + type LeftSidebarPresentation, +} from "@/stores/panel-store"; import type { DesktopDivisionLayout } from "@/window-layout"; const MIN_DESKTOP_CENTER_WIDTH = 400; @@ -21,6 +25,33 @@ export function resolveDesktopSidebarVisibility(input: { ); } +/** + * Compact layouts always present navigation as a drawer overlay. Wide layouts dock it when it is + * visible and fall back to an overlay when the dock is collapsed or yields its width. + */ +export function resolveLeftSidebarPresentation(input: { + chromeEnabled: boolean; + isCompactLayout: boolean; + isFocusModeEnabled: boolean; + desktopSidebarVisible: boolean; +}): LeftSidebarPresentation { + if (!input.chromeEnabled) return "hidden"; + if (input.isCompactLayout) return "overlay"; + if (input.isFocusModeEnabled) return "hidden"; + return input.desktopSidebarVisible ? "dock" : "overlay"; +} + +/** + * The overlay sidebar starts at the window's left edge, over the workspace's safe-area padding, so + * its content takes the left inset itself. The dock sits inside app chrome, which already pads it. + */ +export function resolveDesktopSidebarInsetLeft(input: { + overlay: boolean; + safeAreaLeft: number; +}): number { + return input.overlay ? input.safeAreaLeft : 0; +} + export function resolveDesktopAppChromeLayout(input: { desktopSidebarRendered: boolean; hasTopLeftWindowControls: boolean; diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 73c1fadb7b..b4b54db3db 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -25,7 +25,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { SidebarCloseButton } from "@/components/headers/sidebar-close-button"; -import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout"; +import { + resolveDesktopSidebarInsetLeft, + resolveDesktopSidebarWidth, +} from "@/components/desktop-sidebar-layout"; import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout"; import { SIDEBAR_RESIZE_ACTIVATION_OFFSET, @@ -65,6 +68,7 @@ import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items"; import { usePanelStore } from "@/stores/panel-store"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; +import { useLeftPanelWidth } from "@/mobile-panels/provider"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { buildSettingsRoute } from "@/utils/host-routes"; import { openHostOverview } from "@/navigation/settings-navigation"; @@ -121,9 +125,16 @@ interface MobileSidebarProps extends SidebarSharedProps { interface DesktopSidebarProps extends SidebarSharedProps { insetsTop: number; active: boolean; + overlay: boolean; } -export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boolean }) { +export const LeftSidebar = memo(function LeftSidebar({ + active, + desktopOverlay, +}: { + active: boolean; + desktopOverlay: boolean; +}) { const { theme } = useUnistyles(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); @@ -164,27 +175,20 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole const openProjectPicker = useOpenAddProject(); const { open: openImportSession, sheet: importSessionSheet } = useImportSession(); - const handleOpenProjectMobile = useCallback(() => { + // Each destination closes the navigation overlay first. Closing is idempotent, so a docked + // sidebar runs the same handler. + const handleOpenProject = useCallback(() => { void openProjectPicker(); }, [openProjectPicker]); - const handleOpenProjectDesktop = useCallback(() => { - void openProjectPicker(); - }, [openProjectPicker]); - - const handleSettingsMobile = useCallback(() => { + const handleSettings = useCallback(() => { showMobileAgent(); router.push(buildSettingsRoute()); }, [showMobileAgent]); - const handleSettingsDesktop = useCallback(() => { - router.push(buildSettingsRoute()); - }, []); + const handleAddHost = useAddHost(); - const handleAddHostMobile = useAddHost(); - const handleAddHostDesktop = handleAddHostMobile; - - const handleOpenHostSettingsMobile = useCallback( + const handleOpenHostSettings = useCallback( (serverId: string) => { showMobileAgent(); openHostOverview(serverId); @@ -192,14 +196,6 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole [showMobileAgent], ); - const handleOpenHostSettingsDesktop = useCallback((serverId: string) => { - openHostOverview(serverId); - }, []); - - const handleImportSessionMobile = useCallback(() => { - openImportSession(); - }, [openImportSession]); - const labels = useMemo( (): SidebarLabels => ({ addProject: t("sidebar.actions.addProject"), @@ -232,41 +228,35 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole labels, }; - if (isCompactLayout) { - return ( - <> - - - - {importSessionSheet} - - ); - } + const handlers = { + handleOpenProject, + handleImportSession: openImportSession, + handleSettings, + handleAddHost, + handleOpenHostSettings, + }; return ( <> - + {isCompactLayout ? ( + + ) : ( + + )} {importSessionSheet} @@ -663,10 +653,10 @@ function DesktopSidebar({ handleOpenHostSettings, insetsTop, active, + overlay, }: DesktopSidebarProps) { const ownsTopLeft = useOwnsWindowChromeCorner("top-left"); const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); - const sidebarWidth = usePanelStore((state) => state.sidebarWidth); const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const insets = useSafeAreaInsets(); @@ -678,14 +668,12 @@ function DesktopSidebar({ leftInset: insets.left, rightInset: insets.right, }); - const visibleSidebarWidth = - divisionLayout?.sidebarWidth ?? - resolveDesktopSidebarWidth({ - requestedWidth: sidebarWidth, - viewportWidth, - }); + const visibleSidebarWidth = useLeftPanelWidth(); - const startWidthRef = useRef(visibleSidebarWidth); + const closeSidebar = usePanelStore((state) => state.showMobileAgent); + const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture(overlay); + + const startWidth = useSharedValue(visibleSidebarWidth); const resizeWidth = useSharedValue(visibleSidebarWidth); const [resizePressed, setResizePressed] = useState(false); const showResizeGrip = useCallback(() => setResizePressed(true), []); @@ -709,12 +697,12 @@ function DesktopSidebar({ .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; + startWidth.value = visibleSidebarWidth - event.translationX; resizeWidth.value = visibleSidebarWidth; }) .onUpdate((event) => { // Dragging right (positive translationX) increases width - const newWidth = startWidthRef.current + event.translationX; + const newWidth = startWidth.value + event.translationX; resizeWidth.value = resolveDesktopSidebarWidth({ requestedWidth: newWidth, viewportWidth, @@ -732,6 +720,7 @@ function DesktopSidebar({ resizeWidth, setSidebarWidth, showResizeGrip, + startWidth, viewportWidth, visibleSidebarWidth, ], @@ -740,101 +729,110 @@ function DesktopSidebar({ const resizeAnimatedStyle = useAnimatedStyle(() => ({ width: divisionLayout?.sidebarWidth ?? resizeWidth.value, })); - + // The panel host gets no width of its own: the dock sizes to this view and the overlay fills the + // window. A Reanimated width on the host reached Android after React had switched presentation, + // and a lost update left the workspace at the dock width after the dock collapsed. const desktopSidebarStyle = useMemo( - () => [ - staticStyles.desktopSidebar, - !active && staticStyles.desktopSidebarHidden, - resizeAnimatedStyle, - ], - [active, resizeAnimatedStyle], + () => [staticStyles.desktopSidebar, resizeAnimatedStyle], + [resizeAnimatedStyle], ); const desktopSidebarBorderStyle = useMemo( () => [ styles.desktopSidebarBorder(divisionLayout !== null), - { flex: 1, paddingTop: insetsTop, paddingBottom: insets.bottom }, + { + flex: 1, + paddingTop: insetsTop, + paddingBottom: insets.bottom, + paddingLeft: resolveDesktopSidebarInsetLeft({ overlay, safeAreaLeft: insets.left }), + }, ], - [insetsTop, insets.bottom, divisionLayout], + [insetsTop, insets.bottom, insets.left, overlay, divisionLayout], ); const sidebarHeaderGroupStyle = useMemo( () => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome], [ownsTopLeft], ); return ( - - - - {ownsTopLeft || DEV_BUILD_LABEL ? ( - + + + + {ownsTopLeft || DEV_BUILD_LABEL ? ( + + + {DEV_BUILD_LABEL ? ( + + + + {DEV_BUILD_LABEL} + + + ) : null} + + ) : ( - {DEV_BUILD_LABEL ? ( - - - - {DEV_BUILD_LABEL} - - - ) : null} - + )} + + + + {isInitialLoad && !hasActiveHostFilter ? ( + ) : ( - + )} - + + + + + + {!overlay && divisionLayout === null ? ( + + ) : null} - - {isInitialLoad && !hasActiveHostFilter ? ( - - ) : ( - - )} - - - - - - {divisionLayout === null ? ( - - ) : null} - - + + ); } @@ -865,9 +863,7 @@ const workspacesSectionHeaderElement = ; const staticStyles = RNStyleSheet.create({ desktopSidebar: { position: "relative" as const, - }, - desktopSidebarHidden: { - display: "none", + flex: 1, }, }); diff --git a/packages/app/src/components/resize-handle.tsx b/packages/app/src/components/resize-handle.tsx index c3930688c2..8bb2434ae8 100644 --- a/packages/app/src/components/resize-handle.tsx +++ b/packages/app/src/components/resize-handle.tsx @@ -3,7 +3,7 @@ import { View, type PointerEvent as RNPointerEvent } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { startResizeHandleDrag, type ResizeHandleDrag } from "@/components/resize-handle-drag"; -import { useHasFinePointer } from "@/hooks/use-fine-pointer"; +import { usePointerCapabilities } from "@/hooks/pointer-capabilities"; import { SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET, @@ -47,7 +47,7 @@ export function ResizeHandle({ onResizeSplit, }: ResizeHandleProps) { const { theme } = useUnistyles(); - const finePointer = useHasFinePointer(); + const { finePointer } = usePointerCapabilities(); const pointerStatesRef = useRef(new Map()); const touchStateRef = useRef(null); const cursorBeforeDragRef = useRef(null); diff --git a/packages/app/src/components/sidebar-resize-handle.tsx b/packages/app/src/components/sidebar-resize-handle.tsx index d842f0f008..37eef984b9 100644 --- a/packages/app/src/components/sidebar-resize-handle.tsx +++ b/packages/app/src/components/sidebar-resize-handle.tsx @@ -7,7 +7,7 @@ import { type SidebarResizeEdge, } from "@/components/sidebar-resize-handle-layout"; import { isWeb } from "@/constants/platform"; -import { useHasFinePointer } from "@/hooks/use-fine-pointer"; +import { usePointerCapabilities } from "@/hooks/pointer-capabilities"; interface SidebarResizeHandleProps { edge: SidebarResizeEdge; @@ -29,7 +29,7 @@ function edgeOffsetStyle(edge: SidebarResizeEdge, edgeOffset: number) { } export function SidebarResizeHandle({ edge, gesture, pressed, testID }: SidebarResizeHandleProps) { - const finePointer = useHasFinePointer(); + const { finePointer } = usePointerCapabilities(); if (finePointer) { return ; diff --git a/packages/app/src/components/ui/control-sizing.ts b/packages/app/src/components/ui/control-sizing.ts index d64fc3f000..6dc371af76 100644 --- a/packages/app/src/components/ui/control-sizing.ts +++ b/packages/app/src/components/ui/control-sizing.ts @@ -1,4 +1,4 @@ -import { hasTouchScreen } from "@/hooks/use-fine-pointer"; +import { hasTouchScreen } from "@/hooks/pointer-capabilities"; // The sizing gate, apart from the control sizes so that low-level modules such as // `constants/layout` can size by it without loading the theme. Import it through diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index af9a56b826..2ad3620313 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -37,7 +37,7 @@ import { TOOLBAR_CONTROL_HEIGHT, } from "@/components/ui/control-geometry"; import { usePanelStore } from "@/stores/panel-store"; -import { useHasFinePointer } from "@/hooks/use-fine-pointer"; +import { usePointerCapabilities } from "@/hooks/pointer-capabilities"; import { useShallow } from "zustand/shallow"; import { ArrowUp, @@ -1312,7 +1312,7 @@ function ComposerContentImpl({ const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor); const buttonIconSize = iconSizeFor("md")[controlSizingForLayout(isCompactFormFactor)]; const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor); - const hasFinePointer = useHasFinePointer(); + const { finePointer: hasFinePointer } = usePointerCapabilities(); const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout); const messagePlaceholder = resolveMessagePlaceholder(inputMode, isDesktopLayout, t, placeholder); const hasText = useSyncExternalStore( diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index c79c3e0082..a71469c626 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -37,7 +37,7 @@ import { import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; import * as Haptics from "expo-haptics"; import { useDictation } from "@/hooks/use-dictation"; -import { hasFinePointer } from "@/hooks/use-fine-pointer"; +import { hasFinePointer } from "@/hooks/pointer-capabilities"; import { DictationOverlay } from "@/components/dictation-controls"; import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; diff --git a/packages/app/src/hooks/pointer-capabilities.test.ts b/packages/app/src/hooks/pointer-capabilities.test.ts new file mode 100644 index 0000000000..167db7c553 --- /dev/null +++ b/packages/app/src/hooks/pointer-capabilities.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { createPointerCapabilitiesSource } from "./pointer-capabilities"; + +class FakeMediaQueryList { + private readonly listeners = new Set<() => void>(); + + constructor( + readonly media: string, + public matches: boolean, + ) {} + + addEventListener(_type: "change", listener: () => void) { + this.listeners.add(listener); + } + + removeEventListener(_type: "change", listener: () => void) { + this.listeners.delete(listener); + } + + setMatches(matches: boolean) { + this.matches = matches; + for (const listener of this.listeners) listener(); + } +} + +function createFakeDisplay(initial: { finePointer: boolean; touchScreen: boolean }) { + const queries = { + finePointer: new FakeMediaQueryList("(hover: hover) and (pointer: fine)", initial.finePointer), + touchScreen: new FakeMediaQueryList("(any-pointer: coarse)", initial.touchScreen), + }; + const matchMedia = (query: string) => { + const match = Object.values(queries).find((list) => list.media === query); + if (!match) throw new Error(`Unexpected media query ${query}`); + return match as unknown as MediaQueryList; + }; + return { queries, matchMedia }; +} + +describe("pointer capabilities", () => { + it("reports a hybrid touch laptop as both a fine pointer and a touch screen", () => { + const display = createFakeDisplay({ finePointer: true, touchScreen: true }); + const source = createPointerCapabilitiesSource(display.matchMedia); + + expect(source.getSnapshot()).toEqual({ finePointer: true, touchScreen: true }); + }); + + it("notifies and updates the touch answer when a touch screen appears or goes away", () => { + const display = createFakeDisplay({ finePointer: true, touchScreen: false }); + const source = createPointerCapabilitiesSource(display.matchMedia); + const seen: boolean[] = []; + const unsubscribe = source.subscribe(() => seen.push(source.getSnapshot().touchScreen)); + + display.queries.touchScreen.setMatches(true); + display.queries.touchScreen.setMatches(false); + unsubscribe(); + display.queries.touchScreen.setMatches(true); + + expect(seen).toEqual([true, false]); + }); + + it("keeps one snapshot until an answer changes", () => { + const display = createFakeDisplay({ finePointer: false, touchScreen: true }); + const source = createPointerCapabilitiesSource(display.matchMedia); + const first = source.getSnapshot(); + + expect(source.getSnapshot()).toBe(first); + display.queries.finePointer.setMatches(true); + expect(source.getSnapshot()).toEqual({ finePointer: true, touchScreen: true }); + }); + + it("reports neither capability without media queries", () => { + expect(createPointerCapabilitiesSource(null).getSnapshot()).toEqual({ + finePointer: false, + touchScreen: false, + }); + }); +}); diff --git a/packages/app/src/hooks/pointer-capabilities.ts b/packages/app/src/hooks/pointer-capabilities.ts new file mode 100644 index 0000000000..360ff88c24 --- /dev/null +++ b/packages/app/src/hooks/pointer-capabilities.ts @@ -0,0 +1,88 @@ +import { useSyncExternalStore } 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" or "can this user touch the screen". Only the +// media queries do, and the same queries notify when the answer changes +// (detachable keyboards, tablet mode, devtools touch emulation). +const FINE_POINTER_QUERY = "(hover: hover) and (pointer: fine)"; +const TOUCH_SCREEN_QUERY = "(any-pointer: coarse)"; + +export interface PointerCapabilities { + /** A mouse-like pointer drives the UI. */ + finePointer: boolean; + /** A touch screen is available, possibly next to a mouse on hybrid devices. */ + touchScreen: boolean; +} + +type MatchMedia = (query: string) => MediaQueryList; + +export interface PointerCapabilitiesSource { + getSnapshot: () => PointerCapabilities; + subscribe: (onChange: () => void) => () => void; +} + +export function createPointerCapabilitiesSource( + matchMedia: MatchMedia | null, +): PointerCapabilitiesSource { + const finePointerQuery = matchMedia?.(FINE_POINTER_QUERY) ?? null; + const touchScreenQuery = matchMedia?.(TOUCH_SCREEN_QUERY) ?? null; + let snapshot: PointerCapabilities = { finePointer: false, touchScreen: false }; + + return { + getSnapshot() { + const finePointer = finePointerQuery?.matches ?? false; + const touchScreen = touchScreenQuery?.matches ?? false; + if (snapshot.finePointer !== finePointer || snapshot.touchScreen !== touchScreen) { + snapshot = { finePointer, touchScreen }; + } + return snapshot; + }, + subscribe(onChange) { + finePointerQuery?.addEventListener("change", onChange); + touchScreenQuery?.addEventListener("change", onChange); + return () => { + finePointerQuery?.removeEventListener("change", onChange); + touchScreenQuery?.removeEventListener("change", onChange); + }; + }, + }; +} + +function browserMatchMedia(): MatchMedia | null { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return null; + return (query) => window.matchMedia(query); +} + +// Native builds run on iOS and Android touch screens. A paired mouse or pen is +// filtered per gesture event by its pointer type, not by this capability. +const NATIVE_POINTER_CAPABILITIES: PointerCapabilities = { finePointer: false, touchScreen: true }; +const nativeSource: PointerCapabilitiesSource = { + getSnapshot: () => NATIVE_POINTER_CAPABILITIES, + subscribe: () => () => {}, +}; + +const source = isWeb ? createPointerCapabilitiesSource(browserMatchMedia()) : nativeSource; +const SERVER_SNAPSHOT: PointerCapabilities = { finePointer: false, touchScreen: false }; + +function getServerSnapshot(): PointerCapabilities { + return SERVER_SNAPSHOT; +} + +export function usePointerCapabilities(): PointerCapabilities { + return useSyncExternalStore(source.subscribe, source.getSnapshot, getServerSnapshot); +} + +/** True when a mouse-like pointer drives the UI, read at call time. */ +export function hasFinePointer(): boolean { + return source.getSnapshot().finePointer; +} + +/** Touch sizing remains enabled on hybrid browsers that report touch points. */ +export function hasTouchScreen(): boolean { + return ( + source.getSnapshot().touchScreen || + (isWeb && typeof window !== "undefined" && window.navigator.maxTouchPoints > 0) + ); +} diff --git a/packages/app/src/hooks/use-fine-pointer.ts b/packages/app/src/hooks/use-fine-pointer.ts deleted file mode 100644 index dcd8fe0853..0000000000 --- a/packages/app/src/hooks/use-fine-pointer.ts +++ /dev/null @@ -1,50 +0,0 @@ -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; -} - -/** - * True when the device has a touch screen, even while a mouse or keyboard is attached to it: - * the primary pointer is coarse, or the screen reports touch points. Always true on native. - */ -export function hasTouchScreen(): boolean { - if (!isWeb) return true; - if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; - return window.matchMedia("(pointer: coarse)").matches || window.navigator.maxTouchPoints > 0; -} - -/** - * 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/mobile-panels/gesture-intent.test.ts b/packages/app/src/mobile-panels/gesture-intent.test.ts index 74035096fb..a85c25e81b 100644 --- a/packages/app/src/mobile-panels/gesture-intent.test.ts +++ b/packages/app/src/mobile-panels/gesture-intent.test.ts @@ -3,19 +3,36 @@ import { describe, expect, it } from "vitest"; import { resolveMobilePanelGestureIntent } from "./gesture-intent"; describe("mobile panel gesture intent", () => { + it("rejects a pending swipe when presentation changes before activation", () => { + expect( + resolveMobilePanelGestureIntent({ + enabled: false, + deltaX: 40, + deltaY: 2, + direction: 1, + openGesturesBlocked: false, + openEdge: null, + }), + ).toBe("fail"); + }); + it("blocks both panel-opening directions while the active surface owns horizontal dragging", () => { expect([ resolveMobilePanelGestureIntent({ + enabled: true, deltaX: 40, deltaY: 2, direction: 1, openGesturesBlocked: true, + openEdge: null, }), resolveMobilePanelGestureIntent({ + enabled: true, deltaX: -40, deltaY: 2, direction: -1, openGesturesBlocked: true, + openEdge: null, }), ]).toEqual(["fail", "fail"]); }); @@ -23,17 +40,68 @@ describe("mobile panel gesture intent", () => { it("keeps ordinary horizontal panel gestures available when unblocked", () => { expect([ resolveMobilePanelGestureIntent({ + enabled: true, deltaX: 40, deltaY: 2, direction: 1, openGesturesBlocked: false, + openEdge: null, }), resolveMobilePanelGestureIntent({ + enabled: true, deltaX: -40, deltaY: 2, direction: -1, openGesturesBlocked: false, + openEdge: null, }), ]).toEqual(["activate", "activate"]); }); + + it("opens phone web drawers only from the edge they slide in from", () => { + const swipe = { deltaY: 2, openGesturesBlocked: false }; + expect([ + resolveMobilePanelGestureIntent({ + enabled: true, + ...swipe, + deltaX: 40, + direction: 1, + openEdge: { startX: 200, windowWidth: 400 }, + }), + resolveMobilePanelGestureIntent({ + enabled: true, + ...swipe, + deltaX: 40, + direction: 1, + openEdge: { startX: 12, windowWidth: 400 }, + }), + resolveMobilePanelGestureIntent({ + enabled: true, + ...swipe, + deltaX: -40, + direction: -1, + openEdge: { startX: 200, windowWidth: 400 }, + }), + resolveMobilePanelGestureIntent({ + enabled: true, + ...swipe, + deltaX: -40, + direction: -1, + openEdge: { startX: 388, windowWidth: 400 }, + }), + ]).toEqual(["fail", "activate", "fail", "activate"]); + }); + + it("opens from anywhere when the layout does not require an edge start", () => { + expect( + resolveMobilePanelGestureIntent({ + enabled: true, + deltaX: 40, + deltaY: 2, + direction: 1, + openGesturesBlocked: false, + openEdge: null, + }), + ).toBe("activate"); + }); }); diff --git a/packages/app/src/mobile-panels/gesture-intent.ts b/packages/app/src/mobile-panels/gesture-intent.ts index 3399670924..6722ec0c0f 100644 --- a/packages/app/src/mobile-panels/gesture-intent.ts +++ b/packages/app/src/mobile-panels/gesture-intent.ts @@ -1,14 +1,39 @@ export type MobilePanelGestureDirection = -1 | 1; export type MobilePanelGestureIntent = "activate" | "fail" | "wait"; +/** How far from the window edge an edge-only open gesture may start. */ +export const MOBILE_PANEL_OPEN_EDGE_WIDTH = 32; + +/** + * Where an edge-only open gesture started. Phone web opens drawers only from the edge they slide in + * from: interior horizontal scrollers (terminals, code blocks, diffs) keep their swipes there. + */ +export interface MobilePanelOpenEdge { + startX: number; + windowWidth: number; +} + +function startsAtOpenEdge(edge: MobilePanelOpenEdge, direction: MobilePanelGestureDirection) { + "worklet"; + return direction === 1 + ? edge.startX <= MOBILE_PANEL_OPEN_EDGE_WIDTH + : edge.startX >= edge.windowWidth - MOBILE_PANEL_OPEN_EDGE_WIDTH; +} + export function resolveMobilePanelGestureIntent(input: { + enabled: boolean; deltaX: number; deltaY: number; direction: MobilePanelGestureDirection; openGesturesBlocked: boolean; + /** Required start edge for opening gestures, or `null` when they may start anywhere. */ + openEdge: MobilePanelOpenEdge | null; }): MobilePanelGestureIntent { "worklet"; - if (input.openGesturesBlocked) { + if (!input.enabled || input.openGesturesBlocked) { + return "fail"; + } + if (input.openEdge && !startsAtOpenEdge(input.openEdge, input.direction)) { return "fail"; } const directedDelta = input.deltaX * input.direction; diff --git a/packages/app/src/mobile-panels/gestures.ts b/packages/app/src/mobile-panels/gestures.ts index 2b3f3e11fe..c95c69aba4 100644 --- a/packages/app/src/mobile-panels/gestures.ts +++ b/packages/app/src/mobile-panels/gestures.ts @@ -1,16 +1,16 @@ import { createContext, useContext, useRef, type RefObject, useCallback, useMemo } from "react"; -import { Gesture, type GestureType } from "react-native-gesture-handler"; -import { useSharedValue } from "react-native-reanimated"; +import { Gesture, PointerType, type GestureType } from "react-native-gesture-handler"; +import { useDerivedValue, useSharedValue } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context"; +import { usePointerCapabilities } from "@/hooks/pointer-capabilities"; import { usePanelStore } from "@/stores/panel-store"; import { canBeginMobilePanelGesture, isMobilePanelGestureCurrent } from "./model"; import { useMobilePanelsRuntime } from "./provider"; import { resolveMobilePanelGestureIntent } from "./gesture-intent"; -const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32; - function isCurrentSelection(startedRevision: number): boolean { return usePanelStore.getState().mobilePanel.revision === startedRevision; } @@ -23,6 +23,15 @@ function useGestureState() { }; } +/** + * Gates a sidebar pan on the UI thread. The sidebar flips between the dock and the overlay at + * runtime, and on Android flipping the handler's `.enabled()` config from false back to true left + * it inert until the app reloaded (RNGH 2.32), so `.enabled()` only carries the device capability. + */ +function useGestureGate(enabled: boolean) { + return useDerivedValue(() => enabled, [enabled]); +} + function useRevisionCommit(action: () => void) { return useCallback( (revision: number) => { @@ -39,24 +48,35 @@ export function useOpenAgentListGesture(enabled: boolean) { beginGesture, finishGesture, leftOpenGestureRef, + leftPanelWidth: width, motionState, openGesturesBlocked, position, updateGesture, windowWidth, } = useMobilePanelsRuntime(); + const { touchScreen: hasTouchScreen } = usePointerCapabilities(); + // Phone web keeps interior swipes for the page's horizontal scrollers; the wide overlay opens + // from anywhere in the workspace. + const isCompact = useIsCompactFormFactor(); + const openFromEdgeOnly = isWeb && isCompact; const horizontalScroll = useHorizontalScrollOptional(); const { startedRevision, touchStartX, touchStartY } = useGestureState(); const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList); const commit = useRevisionCommit(showMobileAgentList); + const gate = useGestureGate(enabled); return useMemo( () => Gesture.Pan() .withRef(leftOpenGestureRef) - .enabled(enabled) + .enabled(hasTouchScreen) .manualActivation(true) - .onTouchesDown((event) => { + .onTouchesDown((event, stateManager) => { + if (!gate.value || event.pointerType !== PointerType.TOUCH) { + stateManager.fail(); + return; + } const touch = event.changedTouches[0]; if (touch) { touchStartX.value = touch.absoluteX; @@ -76,15 +96,16 @@ export function useOpenAgentListGesture(enabled: boolean) { } const panIntent = resolveMobilePanelGestureIntent({ + enabled: gate.value, deltaX, deltaY, direction: 1, openGesturesBlocked: openGesturesBlocked.value, + openEdge: openFromEdgeOnly ? { startX: touchStartX.value, windowWidth } : null, }); if ( !canBeginMobilePanelGesture(motionState.value, "agent", position.value) || horizontalScroll?.isAnyScrolledRight.value || - (isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) || panIntent === "fail" ) { stateManager.fail(); @@ -98,10 +119,10 @@ export function useOpenAgentListGesture(enabled: boolean) { startedRevision.value = beginGesture({ origin: "agent" }); }) .onUpdate((event) => { - updateGesture(startedRevision.value, -event.translationX / windowWidth); + updateGesture(startedRevision.value, -event.translationX / width); }) .onEnd((event, success) => { - const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500; + const shouldOpen = event.translationX > width / 3 || event.velocityX > 500; const result = finishGesture({ startedRevision: startedRevision.value, target: shouldOpen ? "agent-list" : "agent", @@ -113,43 +134,53 @@ export function useOpenAgentListGesture(enabled: boolean) { }), [ commit, - enabled, beginGesture, + gate, finishGesture, + hasTouchScreen, horizontalScroll?.isAnyScrolledRight, leftOpenGestureRef, motionState, + openFromEdgeOnly, openGesturesBlocked, position, startedRevision, touchStartX, touchStartY, updateGesture, + width, windowWidth, ], ); } -export function useCloseAgentListGesture() { +export function useCloseAgentListGesture(enabled = true) { const { beginGesture, finishGesture, leftCloseGestureRef, + leftPanelWidth: width, motionState, position, updateGesture, - windowWidth, } = useMobilePanelsRuntime(); + const { touchScreen: hasTouchScreen } = usePointerCapabilities(); const { startedRevision, touchStartX, touchStartY } = useGestureState(); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const commit = useRevisionCommit(showMobileAgent); + const gate = useGestureGate(enabled); const gesture = useMemo( () => Gesture.Pan() .withRef(leftCloseGestureRef) + .enabled(hasTouchScreen) .manualActivation(true) - .onTouchesDown((event) => { + .onTouchesDown((event, stateManager) => { + if (!gate.value || event.pointerType !== PointerType.TOUCH) { + stateManager.fail(); + return; + } const touch = event.changedTouches[0]; if (touch) { touchStartX.value = touch.absoluteX; @@ -169,10 +200,12 @@ export function useCloseAgentListGesture() { } const panIntent = resolveMobilePanelGestureIntent({ + enabled: gate.value, deltaX, deltaY, direction: -1, openGesturesBlocked: false, + openEdge: null, }); if ( !canBeginMobilePanelGesture(motionState.value, "agent-list", position.value) || @@ -191,10 +224,10 @@ export function useCloseAgentListGesture() { }); }) .onUpdate((event) => { - updateGesture(startedRevision.value, -1 - event.translationX / windowWidth); + updateGesture(startedRevision.value, -1 - event.translationX / width); }) .onEnd((event, success) => { - const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500; + const shouldClose = event.translationX < -width / 3 || event.velocityX < -500; const result = finishGesture({ startedRevision: startedRevision.value, target: shouldClose ? "agent" : "agent-list", @@ -207,6 +240,8 @@ export function useCloseAgentListGesture() { [ beginGesture, commit, + gate, + hasTouchScreen, finishGesture, leftCloseGestureRef, motionState, @@ -215,7 +250,7 @@ export function useCloseAgentListGesture() { touchStartX, touchStartY, updateGesture, - windowWidth, + width, ], ); @@ -269,14 +304,15 @@ export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorer } const panIntent = resolveMobilePanelGestureIntent({ + enabled: enabled, deltaX, deltaY, direction: -1, openGesturesBlocked: openGesturesBlocked.value, + openEdge: isWeb ? { startX: touchStartX.value, windowWidth } : null, }); if ( !canBeginMobilePanelGesture(motionState.value, "agent", position.value) || - (isWeb && touchStartX.value < windowWidth - MOBILE_WEB_EDGE_SWIPE_WIDTH) || panIntent === "fail" ) { stateManager.fail(); @@ -363,10 +399,12 @@ export function useCloseFileExplorerGesture(enabled = true) { } const panIntent = resolveMobilePanelGestureIntent({ + enabled: enabled, deltaX, deltaY, direction: 1, openGesturesBlocked: false, + openEdge: null, }); if ( !canBeginMobilePanelGesture(motionState.value, "file-explorer", position.value) || diff --git a/packages/app/src/mobile-panels/model.test.ts b/packages/app/src/mobile-panels/model.test.ts index 5e2d8e229c..ca4ef04a8a 100644 --- a/packages/app/src/mobile-panels/model.test.ts +++ b/packages/app/src/mobile-panels/model.test.ts @@ -17,6 +17,7 @@ class MobilePanelsScenario { private startedRevision = -1; private state: MobilePanelMotionState; readonly commits: MobilePanelCommit[] = []; + readonly animations: MobilePanelView[] = []; constructor(target: MobilePanelView = "agent") { this.state = createMobilePanelMotionState({ target, revision: this.nextRevision }); @@ -74,6 +75,9 @@ class MobilePanelsScenario { if (transition.commit) { this.commits.push(transition.commit); } + if (transition.animationTarget) { + this.animations.push(transition.animationTarget); + } } } @@ -237,6 +241,29 @@ describe("mobile panel ownership", () => { expect(panels.commits).toEqual([]); }); + it("returns an interrupted drag to the anchor when the command keeps its target", () => { + const panels = new MobilePanelsScenario(); + + panels.beginGesture("agent").command("agent").finishGesture("agent-list"); + + expect(panels.animations).toEqual(["agent"]); + expect(panels.snapshot()).toEqual({ + target: "agent", + motionTarget: "agent", + settledTarget: "agent", + revision: 1, + }); + expect(panels.commits).toEqual([]); + }); + + it("lets the command for a completed drag adopt the drag's finishing motion", () => { + const panels = new MobilePanelsScenario(); + + panels.beginGesture("agent").finishGesture("agent-list").command("agent-list"); + + expect(panels.animations).toEqual(["agent-list"]); + }); + it("keeps the latest rapid command when position reaches a stale anchor", () => { const panels = new MobilePanelsScenario(); diff --git a/packages/app/src/mobile-panels/model.ts b/packages/app/src/mobile-panels/model.ts index a7b821e82e..363bcfc450 100644 --- a/packages/app/src/mobile-panels/model.ts +++ b/packages/app/src/mobile-panels/model.ts @@ -78,7 +78,9 @@ export function transitionMobilePanel( if (event.selection.revision <= state.revision) { return { state }; } - const continuesCurrentMotion = state.motionTarget === event.selection.target; + // An interrupted drag left the position between anchors, so it moves back even when the + // target is unchanged. Without a drag, the command adopts motion already heading there. + const continuesCurrentMotion = !state.gesture && state.motionTarget === event.selection.target; return { animationTarget: continuesCurrentMotion ? undefined : event.selection.target, state: { diff --git a/packages/app/src/mobile-panels/presentation.tsx b/packages/app/src/mobile-panels/presentation.tsx index cb919e4595..7336e7c029 100644 --- a/packages/app/src/mobile-panels/presentation.tsx +++ b/packages/app/src/mobile-panels/presentation.tsx @@ -29,26 +29,27 @@ export function MobilePanelOverlay({ dockVisible = false, frameStyle, }: MobilePanelOverlayProps) { - const { position, windowWidth } = useMobilePanelsRuntime(); + const { leftPanelWidth, position, windowWidth } = useMobilePanelsRuntime(); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const overlayOpen = useIsMobilePanelActive(panel); const docked = presentation === "dock"; const isOpen = docked ? dockVisible : overlayOpen; const hostId = useId(); const isLeft = panel === "agent-list"; + const width = isLeft ? leftPanelWidth : windowWidth; const sidebarAnimatedStyle = useAnimatedStyle(() => { if (docked) return { transform: [{ translateX: 0 }] }; - const frame = getMobilePanelFrame(position.value, windowWidth); + const frame = getMobilePanelFrame(position.value, width); return { transform: [{ translateX: isLeft ? frame.leftTranslateX : frame.rightTranslateX }], }; - }, [docked, isLeft, windowWidth]); + }, [docked, isLeft, width]); const backdropAnimatedStyle = useAnimatedStyle(() => { - const frame = getMobilePanelFrame(position.value, windowWidth); + const frame = getMobilePanelFrame(position.value, width); return { opacity: isLeft ? frame.leftBackdropOpacity : frame.rightBackdropOpacity }; - }, [isLeft, windowWidth]); + }, [isLeft, width]); const overlayAnimatedStyle = useAnimatedStyle(() => { if (docked) return { opacity: 1, display: "flex" as const, pointerEvents: "box-none" as const }; @@ -73,11 +74,11 @@ export function MobilePanelOverlay({ () => [ docked ? styles.dockedPanel : styles.panel, !docked && positionedPanelStyle, - !docked && { width: windowWidth }, + !docked && { width }, panelStyle, sidebarAnimatedStyle, ], - [docked, panelStyle, positionedPanelStyle, sidebarAnimatedStyle, windowWidth], + [docked, panelStyle, positionedPanelStyle, sidebarAnimatedStyle, width], ); let overlayPointerEvents: "auto" | "box-none" | "none"; if (!isWeb) { diff --git a/packages/app/src/mobile-panels/provider.tsx b/packages/app/src/mobile-panels/provider.tsx index b2f97a2547..77c00558ce 100644 --- a/packages/app/src/mobile-panels/provider.tsx +++ b/packages/app/src/mobile-panels/provider.tsx @@ -1,3 +1,6 @@ +import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout"; +import { subscribeMobilePanelSelection } from "./selection"; + import { createContext, useCallback, @@ -20,7 +23,10 @@ import { withTiming, type SharedValue, } from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { scheduleOnRN, scheduleOnUI } from "react-native-worklets"; +import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative } from "@/constants/platform"; import { usePanelStore, @@ -45,6 +51,7 @@ interface MobilePanelsRuntime { beginGesture: (input: BeginGestureInput) => number; finishGesture: (input: FinishGestureInput) => MobilePanelCommit | null; leftCloseGestureRef: RefObject; + leftPanelWidth: number; leftOpenGestureRef: RefObject; motionState: SharedValue; openGesturesBlocked: SharedValue; @@ -68,8 +75,36 @@ interface FinishGestureInput { const MobilePanelsContext = createContext(null); const MobilePanelActiveContext = createContext("agent"); +/** + * The left sidebar's width: the full window for the compact drawer, the saved sidebar width + * clamped to the safe viewport on wide layouts. The wide dock, its overlay, and the gestures that + * drag it all read this one value. + */ +function useResolvedLeftPanelWidth(windowWidth: number): number { + const isCompact = useIsCompactFormFactor(); + const insets = useSafeAreaInsets(); + const sidebarWidth = usePanelStore((state) => state.sidebarWidth); + const { height: windowHeight } = useWindowDimensions(); + const divisionLayout = resolveDesktopDivisionLayout({ + geometry: useWindowLayout(), + windowWidth, + windowHeight, + leftInset: insets.left, + rightInset: insets.right, + }); + if (isCompact) return windowWidth; + return ( + divisionLayout?.sidebarWidth ?? + resolveDesktopSidebarWidth({ + requestedWidth: sidebarWidth, + viewportWidth: windowWidth - insets.left - insets.right, + }) + ); +} + export function MobilePanelsProvider({ children }: { children: ReactNode }) { const { width: windowWidth } = useWindowDimensions(); + const leftPanelWidth = useResolvedLeftPanelWidth(windowWidth); const initialSelection = useRef(usePanelStore.getState().mobilePanel).current; const position = useSharedValue(getMobilePanelAnchor(initialSelection.target)); const motionState = useSharedValue(createMobilePanelMotionState(initialSelection)); @@ -152,11 +187,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) { ); useEffect(() => { - return usePanelStore.subscribe((state, previousState) => { - const selection = state.mobilePanel; - if (selection === previousState.mobilePanel) { - return; - } + return subscribeMobilePanelSelection((selection) => { scheduleOnUI(applySelection, selection); }); }, [applySelection]); @@ -217,6 +248,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) { finishGesture, leftCloseGestureRef, leftOpenGestureRef, + leftPanelWidth, motionState, openGesturesBlocked, position, @@ -228,6 +260,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) { [ beginGesture, finishGesture, + leftPanelWidth, motionState, openGesturesBlocked, position, @@ -255,6 +288,11 @@ export function useMobilePanelsRuntime(): MobilePanelsRuntime { return context; } +/** The resolved left sidebar width shared by the dock, the overlay, and their gestures. */ +export function useLeftPanelWidth(): number { + return useMobilePanelsRuntime().leftPanelWidth; +} + export function useIsMobilePanelActive(panel: MobilePanelView): boolean { return useContext(MobilePanelActiveContext) === panel; } diff --git a/packages/app/src/mobile-panels/selection.ts b/packages/app/src/mobile-panels/selection.ts new file mode 100644 index 0000000000..f47d9e737c --- /dev/null +++ b/packages/app/src/mobile-panels/selection.ts @@ -0,0 +1,14 @@ +import { usePanelStore, type MobilePanelSelection } from "@/stores/panel-store"; + +/** Observe panel commands, including layout commands published before subscription. */ +export function subscribeMobilePanelSelection( + onSelection: (selection: MobilePanelSelection) => void, +) { + const unsubscribe = usePanelStore.subscribe((state, previousState) => { + if (state.mobilePanel !== previousState.mobilePanel) { + onSelection(state.mobilePanel); + } + }); + onSelection(usePanelStore.getState().mobilePanel); + return unsubscribe; +} diff --git a/packages/app/src/stores/navigation-active-workspace-store/index.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts index 16f3a23f0b..def8c73cc4 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/index.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -38,9 +38,7 @@ function navigateDeps(): NavigateToWorkspaceDeps { return { revealTab: revealWorkspaceTab, revealWorkspace: () => { - if (useWorkspaceLayoutStore.getState().layoutMode === "compact") { - usePanelStore.getState().showMobileAgent(); - } + usePanelStore.getState().showMobileAgent(); }, getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces, getSessionAgents: (serverId) => diff --git a/packages/app/src/stores/panel-store/index.ts b/packages/app/src/stores/panel-store/index.ts index 8af964f181..660f2d1b62 100644 --- a/packages/app/src/stores/panel-store/index.ts +++ b/packages/app/src/stores/panel-store/index.ts @@ -5,6 +5,7 @@ import { clampTreeRailWidth, clampSidebarWidth, DEFAULT_TREE_RAIL_WIDTH, + dropOverlayAgentList, DEFAULT_SIDEBAR_WIDTH, MAX_TREE_RAIL_WIDTH, MAX_SIDEBAR_WIDTH, @@ -15,9 +16,11 @@ import { selectIsAgentListOpen, selectIsCompactFileExplorerOpen, setMobilePanelTarget, + reconcileMobilePanel, type DesktopSidebarState, type MobilePanelView, type MobilePanelSelection, + type PanelLayout, type PanelLayoutInput, type SortOption, } from "./state"; @@ -25,8 +28,10 @@ import { isWeb } from "@/constants/platform"; import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; export type { DesktopSidebarState, + LeftSidebarPresentation, MobilePanelView, MobilePanelSelection, + PanelLayout, PanelLayoutInput, SortOption, } from "./state"; @@ -80,6 +85,7 @@ export interface PanelState { openAgentListForLayout: (input: PanelLayoutInput) => void; closeAgentListForLayout: (input: PanelLayoutInput) => void; toggleAgentListForLayout: (input: PanelLayoutInput) => void; + applyPanelLayout: (layout: PanelLayout) => void; // File explorer settings actions setExpandedPathsForWorkspace: (workspaceKey: string, paths: ExpandedPathsUpdate) => void; @@ -102,6 +108,13 @@ function setMobilePanelTargetPatch( return mobilePanel === state.mobilePanel ? state : { mobilePanel }; } +function dockAgentListPatch(state: PanelState): Pick { + return { + desktop: { ...state.desktop, agentListOpen: true }, + mobilePanel: dropOverlayAgentList(state.mobilePanel), + }; +} + export const usePanelStore = create()( persist( (set) => ({ @@ -151,12 +164,7 @@ export const usePanelStore = create()( ), openDesktopAgentList: () => - set((state) => { - if (state.desktop.agentListOpen) { - return state; - } - return { desktop: { ...state.desktop, agentListOpen: true } }; - }), + set((state) => (state.desktop.agentListOpen ? state : dockAgentListPatch(state))), closeDesktopAgentList: () => set((state) => { @@ -167,18 +175,18 @@ export const usePanelStore = create()( }), toggleDesktopAgentList: () => - set((state) => ({ - desktop: { ...state.desktop, agentListOpen: !state.desktop.agentListOpen }, - })), + set((state) => + state.desktop.agentListOpen + ? { desktop: { ...state.desktop, agentListOpen: false } } + : dockAgentListPatch(state), + ), openAgentListForLayout: ({ isCompact }) => set((state) => { if (isCompact) { return setMobilePanelTargetPatch(state, "agent-list"); } - return state.desktop.agentListOpen - ? state - : { desktop: { ...state.desktop, agentListOpen: true } }; + return state.desktop.agentListOpen ? state : dockAgentListPatch(state); }), closeAgentListForLayout: ({ isCompact }) => @@ -199,9 +207,15 @@ export const usePanelStore = create()( state.mobilePanel.target === "agent-list" ? "agent" : "agent-list", ); } - return { - desktop: { ...state.desktop, agentListOpen: !state.desktop.agentListOpen }, - }; + return state.desktop.agentListOpen + ? { desktop: { ...state.desktop, agentListOpen: false } } + : dockAgentListPatch(state); + }), + + applyPanelLayout: (layout) => + set((state) => { + const mobilePanel = reconcileMobilePanel(state.mobilePanel, layout); + return mobilePanel === state.mobilePanel ? state : { mobilePanel }; }), setExpandedPathsForWorkspace: (workspaceKey, paths) => diff --git a/packages/app/src/stores/panel-store/state.test.ts b/packages/app/src/stores/panel-store/state.test.ts index ad52b8a8c4..317d4fd9a8 100644 --- a/packages/app/src/stores/panel-store/state.test.ts +++ b/packages/app/src/stores/panel-store/state.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { subscribeMobilePanelSelection } from "@/mobile-panels/selection"; +import { beforeEach, describe, expect, it } from "vitest"; +import { usePanelStore } from "."; import { DEFAULT_TREE_RAIL_WIDTH, migratePanelState, @@ -122,3 +124,101 @@ it("discards obsolete checkout selection while preserving other panel preference expect(state).not.toHaveProperty("explorerTabByCheckout"); expect(state.sidebarWidth).toBe(400); }); + +describe("panel-store wide navigation overlay", () => { + it("subscribes with the current selection and observes future commands until unsubscribe", () => { + usePanelStore.getState().showMobileAgent(); + usePanelStore.getState().applyPanelLayout({ isCompact: false, leftSidebar: "hidden" }); + const published = usePanelStore.getState().mobilePanel; + const received: (typeof published)[] = []; + const unsubscribe = subscribeMobilePanelSelection((selection) => received.push(selection)); + try { + expect(received).toEqual([published]); + usePanelStore.getState().showMobileAgentList(); + expect(received).toEqual([published, usePanelStore.getState().mobilePanel]); + unsubscribe(); + const before = [...received]; + usePanelStore.getState().showMobileAgent(); + expect(received).toEqual(before); + } finally { + unsubscribe(); + } + }); + + beforeEach(() => { + const panels = usePanelStore.getState(); + panels.showMobileAgent(); + panels.closeDesktopAgentList(); + }); + + it.each([ + ["openDesktopAgentList", () => usePanelStore.getState().openDesktopAgentList()], + ["toggleDesktopAgentList", () => usePanelStore.getState().toggleDesktopAgentList()], + [ + "openAgentListForLayout", + () => usePanelStore.getState().openAgentListForLayout({ isCompact: false }), + ], + [ + "toggleAgentListForLayout", + () => usePanelStore.getState().toggleAgentListForLayout({ isCompact: false }), + ], + ])("drops the overlay target when %s docks the sidebar", (_name, dock) => { + usePanelStore.getState().showMobileAgentList(); + + dock(); + + expect(usePanelStore.getState().desktop.agentListOpen).toBe(true); + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + }); + + it("drops the overlay target once the sidebar is no longer presented as an overlay", () => { + usePanelStore.getState().showMobileAgentList(); + usePanelStore.getState().applyPanelLayout({ isCompact: false, leftSidebar: "overlay" }); + expect(usePanelStore.getState().mobilePanel.target).toBe("agent-list"); + + usePanelStore.getState().applyPanelLayout({ isCompact: false, leftSidebar: "dock" }); + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + + usePanelStore.getState().showMobileAgentList(); + usePanelStore.getState().applyPanelLayout({ isCompact: false, leftSidebar: "hidden" }); + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + }); + + it.each(["dock", "hidden"] as const)( + "invalidates an opening swipe when presentation becomes %s before its target commits", + (presentation) => { + usePanelStore.getState().applyPanelLayout({ isCompact: false, leftSidebar: "overlay" }); + const startedRevision = usePanelStore.getState().mobilePanel.revision; + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + + usePanelStore.getState().applyPanelLayout({ isCompact: false, leftSidebar: presentation }); + + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + expect(usePanelStore.getState().mobilePanel.revision).toBeGreaterThan(startedRevision); + }, + ); + + it.each(["overlay", "hidden"] as const)( + "keeps the compact explorer target when the compact sidebar becomes %s", + (leftSidebar) => { + usePanelStore.getState().showMobileFileExplorer(); + + usePanelStore.getState().applyPanelLayout({ isCompact: true, leftSidebar }); + + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); + }, + ); + + it.each(["overlay", "dock", "hidden"] as const)( + "drops the compact explorer target when the layout widens with a %s sidebar", + (leftSidebar) => { + usePanelStore.getState().showMobileFileExplorer(); + const openedRevision = usePanelStore.getState().mobilePanel.revision; + + usePanelStore.getState().applyPanelLayout({ isCompact: false, leftSidebar }); + + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + expect(usePanelStore.getState().mobilePanel.revision).toBeGreaterThan(openedRevision); + }, + ); +}); diff --git a/packages/app/src/stores/panel-store/state.ts b/packages/app/src/stores/panel-store/state.ts index cbbead4161..a9971fa458 100644 --- a/packages/app/src/stores/panel-store/state.ts +++ b/packages/app/src/stores/panel-store/state.ts @@ -26,6 +26,14 @@ export interface PanelLayoutInput { isCompact: boolean; } +/** How app chrome presents the left sidebar: over the workspace, beside it, or not at all. */ +export type LeftSidebarPresentation = "overlay" | "dock" | "hidden"; + +/** The panels app chrome presents, which decide the mobile panel targets that can stay selected. */ +export interface PanelLayout extends PanelLayoutInput { + leftSidebar: LeftSidebarPresentation; +} + export interface PanelCoreState { mobilePanel: MobilePanelSelection; desktop: DesktopSidebarState; @@ -68,6 +76,35 @@ export function setMobilePanelTarget( return { target, revision: selection.revision + 1 }; } +/** + * Only an overlay sidebar shows the `agent-list` target. A docked or hidden sidebar drops it, so + * a stale target cannot resurface the overlay on the next collapse. Always invalidate in-flight + * gestures, even when an opening swipe has not committed `agent-list` yet. + */ +export function dropOverlayAgentList(selection: MobilePanelSelection): MobilePanelSelection { + return { + target: selection.target === "agent-list" ? "agent" : selection.target, + revision: selection.revision + 1, + }; +} + +/** + * Keeps only the targets the layout presents. The `file-explorer` target exists on compact layouts + * only; a wide layout docks the Explorer instead, and a stale target there would block the + * navigation swipe, which begins only from `agent`. + */ +export function reconcileMobilePanel( + selection: MobilePanelSelection, + layout: PanelLayout, +): MobilePanelSelection { + const sidebarReconciled = + layout.leftSidebar === "overlay" ? selection : dropOverlayAgentList(selection); + if (!layout.isCompact && sidebarReconciled.target === "file-explorer") { + return setMobilePanelTarget(sidebarReconciled, "agent"); + } + return sidebarReconciled; +} + const ExplorerTabSchema = z.enum(["changes", "files", "pr"]); const DesktopSidebarStorageSchema = z.strictObject({ agentListOpen: z.boolean().optional(), diff --git a/packages/app/src/workspace-tabs/navigation.test.ts b/packages/app/src/workspace-tabs/navigation.test.ts index c32fc91cf9..de918e53d4 100644 --- a/packages/app/src/workspace-tabs/navigation.test.ts +++ b/packages/app/src/workspace-tabs/navigation.test.ts @@ -33,6 +33,28 @@ it("reveals an existing main file selected from compact Explorer", () => { expect(existing).toBe(tabId); expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); }); +it.each(["single-pane", "split-panes"] as const)( + "dismisses floating navigation when revealing a %s tab without pinning its dock", + (layoutMode) => { + const store = useWorkspaceLayoutStore.getState(); + store.setLayoutMode(layoutMode); + usePanelStore.getState().closeDesktopAgentList(); + usePanelStore.getState().showMobileAgentList(); + const tabId = store.openTab({ + workspaceKey, + target: { kind: "agent", agentId: "root" }, + intent: "reveal", + }); + revealWorkspaceTab(workspaceKey, tabId); + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + expect(usePanelStore.getState().desktop.agentListOpen).toBe(false); + expect( + useWorkspaceLayoutStore.getState().getTabDestination(workspaceKey, tabId!), + ).toMatchObject({ + host: "main", + }); + }, +); it("keeps Explorer beneath a new compact file sheet", () => { const store = useWorkspaceLayoutStore.getState(); store.setLayoutMode("compact"); diff --git a/packages/app/src/workspace-tabs/navigation.ts b/packages/app/src/workspace-tabs/navigation.ts index 5e36f65102..89e46994b8 100644 --- a/packages/app/src/workspace-tabs/navigation.ts +++ b/packages/app/src/workspace-tabs/navigation.ts @@ -10,10 +10,11 @@ export function revealWorkspaceTab(workspaceKey: string, tabId: string | null): if (!destination) return null; if (destination.kind === "sheet") return tabId; store.focusTab(workspaceKey, tabId); - if (store.layoutMode === "compact") { - const panels = usePanelStore.getState(); - if (destination.host === "explorer") panels.showMobileFileExplorer(); - else panels.showMobileAgent(); + const panels = usePanelStore.getState(); + if (store.layoutMode === "compact" && destination.host === "explorer") { + panels.showMobileFileExplorer(); + } else { + panels.showMobileAgent(); } return tabId; } diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 9c275d6cc9..f06860d862 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -1056,6 +1056,10 @@ async function runRegression({ await page.screenshot({ path: path.join(artifactDir, "local-page-screenshot-selector.png") }); await originalDeck.getByRole("button", { name: "Cancel element selector" }).click(); + // This journey selects tabs from both pane rails. Keep both columns above the compact-header + // breakpoint; narrow panes deliberately replace their rail with a tab dropdown. + await resizeDesktopWindow(inspectorPort, { width: 1920, height: 1080 }); + await page.waitForFunction(() => innerWidth >= 1920, null, { timeout: timeoutMs }); await originalDeck.getByTestId(`workspace-tab-agent_${callerAgentId}`).click(); await page.getByTestId("sidebar-search").click(); await page.getByTestId("command-center-input").fill("Split pane right"); diff --git a/patches/react-native-gesture-handler+2.32.0.patch b/patches/react-native-gesture-handler+2.32.0.patch index 7cdbc35d4a..b6e3a810f1 100644 --- a/patches/react-native-gesture-handler+2.32.0.patch +++ b/patches/react-native-gesture-handler+2.32.0.patch @@ -194,10 +194,37 @@ index 96c5227..cbf1a88 100644 // diff --git a/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js b/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js -index 76eb808..7c40526 100644 +index 76eb808..75a9d75 100644 --- a/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js +++ b/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js -@@ -58,7 +58,14 @@ class PointerEventManager extends _EventManager.default { +@@ -35,9 +35,6 @@ class PointerEventManager extends _EventManager.default { + } + const adaptedEvent = this.mapEvent(event, _interfaces.EventTypes.DOWN); + const target = event.target; +- if (!POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { +- target.setPointerCapture(adaptedEvent.pointerId); +- } + this.markAsInBounds(adaptedEvent.pointerId); + this.trackedPointers.add(adaptedEvent.pointerId); + if (++this.activePointersCounter > 1) { +@@ -46,6 +43,16 @@ class PointerEventManager extends _EventManager.default { + } else { + this.onPointerDown(adaptedEvent); + } ++ ++ // Capture only a pointer the handler still tracks after seeing it. A handler that fails ++ // the pointer on down (a touch-only pan rejecting the mouse) resets, and a capture it kept ++ // would confine the browser's text selection to the pressed element. ++ if ( ++ this.trackedPointers.has(adaptedEvent.pointerId) && ++ !POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName) ++ ) { ++ target.setPointerCapture(adaptedEvent.pointerId); ++ } + }; + pointerUpCallback = event => { + // When we call reset on gesture handlers, it also resets their event managers +@@ -58,7 +65,14 @@ class PointerEventManager extends _EventManager.default { const adaptedEvent = this.mapEvent(event, _interfaces.EventTypes.UP); const target = event.target; if (!POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { @@ -213,6 +240,15 @@ index 76eb808..7c40526 100644 } this.markAsOutOfBounds(adaptedEvent.pointerId); this.trackedPointers.delete(adaptedEvent.pointerId); +@@ -85,7 +99,7 @@ class PointerEventManager extends _EventManager.default { + // incorporating it here seems stupid), so we just call it again here, every time + // pointer moves until it succeeds. + // God, I do love web development. +- if (!target?.hasPointerCapture(event.pointerId) && !POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { ++ if (this.trackedPointers.has(event.pointerId) && !target?.hasPointerCapture(event.pointerId) && !POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { + target.setPointerCapture(event.pointerId); + } + const inBounds = (0, _utils.isPointerInBounds)(this.view, { diff --git a/node_modules/react-native-gesture-handler/lib/module/web/handlers/GestureHandler.js b/node_modules/react-native-gesture-handler/lib/module/web/handlers/GestureHandler.js index 0791c66..2f41c77 100644 --- a/node_modules/react-native-gesture-handler/lib/module/web/handlers/GestureHandler.js @@ -229,10 +265,37 @@ index 0791c66..2f41c77 100644 // diff --git a/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js b/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js -index 6ce3b8f..8fe28bc 100644 +index 6ce3b8f..65cc6c8 100644 --- a/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js +++ b/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js -@@ -53,7 +53,14 @@ export default class PointerEventManager extends EventManager { +@@ -30,9 +30,6 @@ export default class PointerEventManager extends EventManager { + } + const adaptedEvent = this.mapEvent(event, EventTypes.DOWN); + const target = event.target; +- if (!POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { +- target.setPointerCapture(adaptedEvent.pointerId); +- } + this.markAsInBounds(adaptedEvent.pointerId); + this.trackedPointers.add(adaptedEvent.pointerId); + if (++this.activePointersCounter > 1) { +@@ -41,6 +38,16 @@ export default class PointerEventManager extends EventManager { + } else { + this.onPointerDown(adaptedEvent); + } ++ ++ // Capture only a pointer the handler still tracks after seeing it. A handler that fails ++ // the pointer on down (a touch-only pan rejecting the mouse) resets, and a capture it kept ++ // would confine the browser's text selection to the pressed element. ++ if ( ++ this.trackedPointers.has(adaptedEvent.pointerId) && ++ !POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName) ++ ) { ++ target.setPointerCapture(adaptedEvent.pointerId); ++ } + }; + pointerUpCallback = event => { + // When we call reset on gesture handlers, it also resets their event managers +@@ -53,7 +60,14 @@ export default class PointerEventManager extends EventManager { const adaptedEvent = this.mapEvent(event, EventTypes.UP); const target = event.target; if (!POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { @@ -248,6 +311,15 @@ index 6ce3b8f..8fe28bc 100644 } this.markAsOutOfBounds(adaptedEvent.pointerId); this.trackedPointers.delete(adaptedEvent.pointerId); +@@ -80,7 +94,7 @@ export default class PointerEventManager extends EventManager { + // incorporating it here seems stupid), so we just call it again here, every time + // pointer moves until it succeeds. + // God, I do love web development. +- if (!target?.hasPointerCapture(event.pointerId) && !POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { ++ if (this.trackedPointers.has(event.pointerId) && !target?.hasPointerCapture(event.pointerId) && !POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { + target.setPointerCapture(event.pointerId); + } + const inBounds = isPointerInBounds(this.view, { diff --git a/node_modules/react-native-gesture-handler/src/web/handlers/GestureHandler.ts b/node_modules/react-native-gesture-handler/src/web/handlers/GestureHandler.ts index ddb79a3..4e1274c 100644 --- a/node_modules/react-native-gesture-handler/src/web/handlers/GestureHandler.ts @@ -264,10 +336,38 @@ index ddb79a3..4e1274c 100644 // diff --git a/node_modules/react-native-gesture-handler/src/web/tools/PointerEventManager.ts b/node_modules/react-native-gesture-handler/src/web/tools/PointerEventManager.ts -index 18d3627..769b0fe 100644 +index 18d3627..106b97c 100644 --- a/node_modules/react-native-gesture-handler/src/web/tools/PointerEventManager.ts +++ b/node_modules/react-native-gesture-handler/src/web/tools/PointerEventManager.ts -@@ -67,7 +67,14 @@ export default class PointerEventManager extends EventManager { +@@ -39,10 +39,6 @@ export default class PointerEventManager extends EventManager { + const adaptedEvent: AdaptedEvent = this.mapEvent(event, EventTypes.DOWN); + const target = event.target as HTMLElement; + +- if (!POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { +- target.setPointerCapture(adaptedEvent.pointerId); +- } +- + this.markAsInBounds(adaptedEvent.pointerId); + this.trackedPointers.add(adaptedEvent.pointerId); + +@@ -52,6 +48,16 @@ export default class PointerEventManager extends EventManager { + } else { + this.onPointerDown(adaptedEvent); + } ++ ++ // Capture only a pointer the handler still tracks after seeing it. A handler that fails ++ // the pointer on down (a touch-only pan rejecting the mouse) resets, and a capture it kept ++ // would confine the browser's text selection to the pressed element. ++ if ( ++ this.trackedPointers.has(adaptedEvent.pointerId) && ++ !POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName) ++ ) { ++ target.setPointerCapture(adaptedEvent.pointerId); ++ } + }; + + private pointerUpCallback = (event: PointerEvent) => { +@@ -67,7 +73,14 @@ export default class PointerEventManager extends EventManager { const target = event.target as HTMLElement; if (!POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { @@ -283,3 +383,11 @@ index 18d3627..769b0fe 100644 } this.markAsOutOfBounds(adaptedEvent.pointerId); +@@ -98,6 +111,7 @@ export default class PointerEventManager extends EventManager { + // pointer moves until it succeeds. + // God, I do love web development. + if ( ++ this.trackedPointers.has(event.pointerId) && + !target?.hasPointerCapture(event.pointerId) && + !POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName) + ) { diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index 0f4b96b19f..e6b9195a94 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -52,6 +52,9 @@ const patchedPackages = [ nodeModulesPath: "node_modules/react-native-draggable-flatlist", patchPrefix: "react-native-draggable-flatlist+", }, + // Web pointer capture: capture only pointers the handler still tracks after pointer-down, so a + // touch-only pan that fails the mouse leaves browser text selection alone. Keep until upstream + // stops capturing pointers a handler has rejected. { nodeModulesPath: "node_modules/react-native-gesture-handler", patchPrefix: "react-native-gesture-handler+",