mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 23:44:41 +00:00
Reveal collapsed navigation as a touch overlay on wide layouts (#8739)
* feat(app): swipe open collapsed tablet navigation Reuse the retained sidebar and mobile panel motion for a partial-width navigation overlay when the wide dock is hidden. Preserve the dock preference, project gestures through the sidebar width, and dismiss the overlay when revealing workspace content. Reset the animated frame width explicitly across dock transitions and keep the resize origin on the UI thread. Validated on Duo portrait and landscape, iPad portrait and landscape, iPhone full-width navigation, and browser sidebar interactions. Typecheck, lint, targeted formatting, and 31 relevant tests passed. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): refine touch navigation overlays Enable navigation swipes only on touch-capable runtimes and reject mouse and pen input per gesture event. Allow swiping from anywhere in the workspace instead of requiring an edge start. Cover the full application window with the backdrop, including safe-area padding, and remove the wide overlay's inline close button. Preserve swipe, backdrop, and destination-selection dismissal. Validated interior swipes on Duo and iPad, mouse exclusion in a browser, and full-window dimming and dismissal in Duo portrait and landscape. Typecheck, lint, and targeted formatting passed. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * refactor(app): let the panel store drop the overlay target on dock Docking the sidebar, or any layout that no longer presents it as an overlay, now drops the transient agent-list target inside the panel store. App chrome publishes one left sidebar presentation (overlay, dock, hidden) instead of an effect in SidebarChrome watching flags. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * refactor(app): answer fine pointer and touch screen from one module Merge use-fine-pointer and use-has-touch-screen into hooks/pointer-capabilities, which reads both answers from the media queries it subscribes to. The touch answer now comes from (any-pointer: coarse) instead of navigator.maxTouchPoints, which that query's change events never reported. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): keep the phone web drawer on its edge swipe Restore the 32px left-edge start for the compact web navigation drawer, so interior rightward swipes over terminals, code blocks, and diffs stay with their horizontal scrollers. The wide navigation overlay still opens from anywhere. The edge rule moves into resolveMobilePanelGestureIntent so both drawers share it and it has a test. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * refactor(app): one sidebar handler per destination Settings, host settings, add host, add project, and every navigation row now use one handler that closes the navigation overlay before it navigates. Closing is idempotent, so the docked sidebar shares it and the overlay ? mobile : desktop handler pairs go away. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * refactor(app): resolve the left panel width in one place MobilePanelsProvider now resolves the left sidebar width (full window on compact, the saved width clamped to the safe viewport on wide) and exposes it through the panel runtime. The open and close gestures, the overlay frame, and the docked sidebar all read that value, so gesture travel and panel width cannot disagree. MobileGestureWrapper no longer recomputes it, and the panelWidth/width parameters go away. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * docs: fold the wide navigation overlay into the mobile panel rules Move the facts the overlay PR appended into the sections that own them: the destinations intro, store ownership of the agent-list target, the projection widths, gesture pointer and edge rules, and the sidebar presentation bullets. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): keep the sidebar pans live across dock and overlay switches On an Android tablet, opening the dock with the header toggle and collapsing it again left the touch swipe unable to open the overlay until the app reloaded. Swipe-to-close from the sidebar also stopped working after the same toggle cycle. Both pans took the dock/overlay state through RNGH's `.enabled()`. On Android, RNGH 2.32 cancels the native handler when `isEnabled` changes and resets its config on every update. After a false -> true flip the handler stayed inert. `.enabled()` now carries only the device capability (touch screen present), which does not change at runtime. The dock/overlay state moves to a UI-thread derived value that fails the gesture in onTouchesDown, next to the existing non-touch pointer check. The wide sidebar's workspace list now always receives the close gesture ref, as the compact sidebar already does. Toggling it between undefined and the ref rewired the list's simultaneous handlers on every dock switch, and that also left the close pan dead after a toggle cycle. This is a native RNGH handler behavior, so there is no unit repro. It was verified on the Android fold emulator: toggle open, toggle closed, swipe opens the overlay, swipe on an empty sidebar strip closes it, no reload. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): keep the workspace full width after collapsing the dock On an Android tablet, opening the dock with the header toggle and collapsing it again could leave the workspace at the docked width, with a blank strip on the right, until the overlay was next opened. Elsa's original head showed the same. The sidebar's panel host flips between an in-flow dock and an absolute overlay. Its width came from a Reanimated style that read the React `overlay` flag, so the width reached the native view on the UI thread after React had already committed the new presentation. Layout logs on the fold emulator show the lag on every toggle-open: the workspace laid out at width 0 while the docked host still had the overlay's full window width, then a second pass laid out the 320dp dock. When the late update was lost on collapse, the workspace kept its dock width. The host no longer has a width of its own: - The dock sizes to the inner sidebar view, which already carries the animated resize width. - The overlay host is absoluteFill, which covers the whole row including the safe-area padding. With no animated layout prop on the host, a presentation switch is one React commit, and the host and the workspace lay out in the same pass. Device evidence on the fold emulator: - Toggle open and collapse cycles: the host and the workspace lay out together (320/521dp, then 841/841dp). - A swipe opens the 320dp overlay with a full-window backdrop, and a swipe on the sidebar closes it. - Dragging the dock edge still resizes the dock and the workspace together. There is no unit repro: the defect is the ordering of Reanimated's UI-thread layout updates against React commits. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): cancel pending sidebar swipes when docking An opening swipe still has the agent target until release. Leaving overlay presentation must advance its revision even then, so motion ownership and queued commits reject the obsolete swipe. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): keep mouse selection free of the touch-only pan's capture On a touch-capable browser the navigation pan registers pointer listeners over the whole workspace. Gesture Handler's web pointer manager captured the pressed element before the handler saw the pointer, and captured it again on every move, so a mouse drag that the pan rejects stayed captured and the browser's selection stopped at the starting paragraph. The patched manager now captures after pointer-down, and only a pointer the handler still tracks. The resize handle and touch swipes keep capturing. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): drop the compact Explorer target when the layout widens Opening the Explorer on a compact layout selects the `file-explorer` mobile panel target. Widening the window (unfolding a foldable, rotating a tablet) kept that target, because the store only heard the left sidebar presentation, which is `overlay` on both compact layouts and wide layouts with a collapsed dock. The navigation swipe begins only from `agent`, so on the wide layout every swipe was rejected until something else reset the target. App chrome now publishes the whole panel layout (compact or wide, plus the left sidebar presentation) and the store drops any target that layout does not present. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): keep wide overlay sidebar content inside the left safe area The overlay starts at the window's left edge, over the workspace's safe-area padding, so its content now takes the left inset itself. The dock already sits inside app chrome's padding and stays unchanged. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): return an interrupted swipe to its anchor when docking cancels it Docking mid-swipe bumps the panel revision while keeping the agent target. The command treated the unchanged target as motion already in progress and skipped the animation, leaving the dragged position stranded between anchors. A command that interrupts a drag now always animates to its target. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): synchronize startup panel selection and gate pending swipes A child layout effect can publish panel layout before the motion provider subscribes. Establish the current selection when subscribing, then observe later commands. Recheck presentation eligibility before gesture activation so docking also cancels touches that have not started dragging. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * test(app): cover panel subscription through its public contract Verify initial snapshot delivery, later commands, and unsubscription directly in the existing panel-store suite instead of mounting test-only React components. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * test(app): reopen compact navigation after leaving settings Destination navigation clears the transient overlay selection. Reopen the compact drawer before asserting its configured footer rather than relying on a stale open target surviving a wide layout. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * test(app): open navigation by keyboard in composer focus checks Navigation pans intentionally reject mouse drags. Use the keyboard sidebar command to exercise focus and fullscreen Escape behavior without relying on the former mouse swipe helper. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * fix(app): complete sidebar integration after main rebase Retain the resize width resolver and format the shared division-width and hybrid touch-sizing integration. Co-authored-by: Elsa Granger <zeyugao@outlook.com> * test(desktop): give split browser panes room for tab rails After #8740, narrow panes replace tab rails with compact dropdowns. The desktop browser journey counts and selects rail tabs, so widen the real Electron content window before splitting instead of treating missing narrow-pane rails as missing panes. Co-authored-by: Elsa Granger <zeyugao@outlook.com> --------- Co-authored-by: Elsa <zeyugao@outlook.com>
This commit is contained in:
co-authored by
Elsa Granger
parent
0e7f452c2f
commit
ac775a2904
+39
-10
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
swipeOpenMobileAgentSidebar,
|
||||
openMobileAgentSidebarWithKeyboard,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
async function composerHeight(page: Page): Promise<number> {
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -261,16 +261,9 @@ export async function expectMobileAgentSidebarHidden(page: Page): Promise<void>
|
||||
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<void> {
|
||||
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<void> {
|
||||
await page.keyboard.press("ControlOrMeta+b");
|
||||
}
|
||||
|
||||
/** Drag from the right edge to open the compact Explorer over the agent. */
|
||||
|
||||
@@ -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 = (
|
||||
<SidebarChrome
|
||||
mounted={isCompactLayout ? chromeEnabled : desktopSidebarMounted}
|
||||
visible={isCompactLayout ? chromeEnabled : desktopSidebarVisible}
|
||||
presentation={sidebarPresentation}
|
||||
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
|
||||
/>
|
||||
);
|
||||
@@ -531,7 +544,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
);
|
||||
|
||||
return (
|
||||
<AppContainerLifetime compactGesturesEnabled={chromeEnabled && isCompactLayout}>
|
||||
<AppContainerLifetime sidebarGesturesEnabled={sidebarPresentation === "overlay"}>
|
||||
<SheetHostScope enabled={isCompactLayout}>{surface}</SheetHostScope>
|
||||
</AppContainerLifetime>
|
||||
);
|
||||
@@ -539,15 +552,15 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
|
||||
function AppContainerLifetime({
|
||||
children,
|
||||
compactGesturesEnabled,
|
||||
sidebarGesturesEnabled,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
compactGesturesEnabled: boolean;
|
||||
sidebarGesturesEnabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ComposerEditingSessionsProvider>
|
||||
<CommandCenterProvider>
|
||||
<MobileGestureWrapper chromeEnabled={compactGesturesEnabled}>
|
||||
<MobileGestureWrapper chromeEnabled={sidebarGesturesEnabled}>
|
||||
{children}
|
||||
</MobileGestureWrapper>
|
||||
</CommandCenterProvider>
|
||||
@@ -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 (
|
||||
<SidebarModelProvider
|
||||
active={active}
|
||||
keyboardNavigationActive={keyboardShortcutsEnabled && hardwareKeyboardPresent}
|
||||
>
|
||||
{mounted ? <LeftSidebar active={active} /> : null}
|
||||
{mounted ? (
|
||||
<LeftSidebar active={active} desktopOverlay={overlay && !isCompactLayout} />
|
||||
) : null}
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
||||
</SidebarModelProvider>
|
||||
);
|
||||
@@ -586,8 +602,8 @@ function MobileGestureWrapper({
|
||||
children: ReactNode;
|
||||
chromeEnabled: boolean;
|
||||
}) {
|
||||
const openGesture = useOpenAgentListGesture(chromeEnabled);
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const openGesture = useOpenAgentListGesture(chromeEnabled);
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<RetainedPanelActivity active={active}>
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
active={active}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
closeSidebar={showMobileAgent}
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleImportSession={handleImportSessionMobile}
|
||||
handleSettings={handleSettingsMobile}
|
||||
handleAddHost={handleAddHostMobile}
|
||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||
/>
|
||||
</RetainedPanelActivity>
|
||||
{importSessionSheet}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const handlers = {
|
||||
handleOpenProject,
|
||||
handleImportSession: openImportSession,
|
||||
handleSettings,
|
||||
handleAddHost,
|
||||
handleOpenHostSettings,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<RetainedPanelActivity active={active}>
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
active={active}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleImportSession={openImportSession}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
handleAddHost={handleAddHostDesktop}
|
||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||
/>
|
||||
{isCompactLayout ? (
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
{...handlers}
|
||||
active={active}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
closeSidebar={showMobileAgent}
|
||||
/>
|
||||
) : (
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
{...handlers}
|
||||
insetsTop={insets.top}
|
||||
active={active}
|
||||
overlay={desktopOverlay}
|
||||
/>
|
||||
)}
|
||||
</RetainedPanelActivity>
|
||||
{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 (
|
||||
<Animated.View
|
||||
accessibilityElementsHidden={!active}
|
||||
importantForAccessibility={active ? "auto" : "no-hide-descendants"}
|
||||
pointerEvents={active ? "auto" : "none"}
|
||||
style={desktopSidebarStyle}
|
||||
<MobilePanelOverlay
|
||||
panel="agent-list"
|
||||
presentation={overlay ? "overlay" : "dock"}
|
||||
dockVisible={active}
|
||||
closeGesture={closeGesture}
|
||||
>
|
||||
<View style={desktopSidebarBorderStyle}>
|
||||
<View style={styles.sidebarDragArea}>
|
||||
{ownsTopLeft || DEV_BUILD_LABEL ? (
|
||||
<View style={styles.desktopChromeRow}>
|
||||
<Animated.View style={desktopSidebarStyle}>
|
||||
<View style={desktopSidebarBorderStyle}>
|
||||
<View style={styles.sidebarDragArea}>
|
||||
{ownsTopLeft || DEV_BUILD_LABEL ? (
|
||||
<View style={styles.desktopChromeRow}>
|
||||
<TitlebarDragRegion />
|
||||
{DEV_BUILD_LABEL ? (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={styles.devBuildBadge}
|
||||
testID="dev-build-label"
|
||||
accessibilityLabel={`Development build: ${DEV_BUILD_LABEL}`}
|
||||
>
|
||||
<GitBranch size={12} color={theme.colors.accentForeground} />
|
||||
<Text numberOfLines={1} ellipsizeMode="tail" style={styles.devBuildBadgeText}>
|
||||
{DEV_BUILD_LABEL}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<TitlebarDragRegion />
|
||||
{DEV_BUILD_LABEL ? (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={styles.devBuildBadge}
|
||||
testID="dev-build-label"
|
||||
accessibilityLabel={`Development build: ${DEV_BUILD_LABEL}`}
|
||||
>
|
||||
<GitBranch size={12} color={theme.colors.accentForeground} />
|
||||
<Text numberOfLines={1} ellipsizeMode="tail" style={styles.devBuildBadgeText}>
|
||||
{DEV_BUILD_LABEL}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
<SidebarNavRows style={sidebarHeaderGroupStyle} onBeforeNavigate={closeSidebar} />
|
||||
</View>
|
||||
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<TitlebarDragRegion />
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
onWorkspacePress={closeSidebar}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
<SidebarNavRows style={sidebarHeaderGroupStyle} />
|
||||
|
||||
<SidebarCalloutSlot />
|
||||
|
||||
<SidebarFooter
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
handleOpenHostSettings={handleOpenHostSettings}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
/>
|
||||
|
||||
{!overlay && divisionLayout === null ? (
|
||||
<SidebarResizeHandle
|
||||
edge="right"
|
||||
gesture={resizeGesture}
|
||||
pressed={resizePressed}
|
||||
testID="left-sidebar-resize-handle"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarCalloutSlot />
|
||||
|
||||
<SidebarFooter
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
handleOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
|
||||
{divisionLayout === null ? (
|
||||
<SidebarResizeHandle
|
||||
edge="right"
|
||||
gesture={resizeGesture}
|
||||
pressed={resizePressed}
|
||||
testID="left-sidebar-resize-handle"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</MobilePanelOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -865,9 +863,7 @@ const workspacesSectionHeaderElement = <WorkspacesSectionHeader />;
|
||||
const staticStyles = RNStyleSheet.create({
|
||||
desktopSidebar: {
|
||||
position: "relative" as const,
|
||||
},
|
||||
desktopSidebarHidden: {
|
||||
display: "none",
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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<number, PointerState>());
|
||||
const touchStateRef = useRef<PointerState | null>(null);
|
||||
const cursorBeforeDragRef = useRef<string | null>(null);
|
||||
|
||||
@@ -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 <PointerResizeHandle edge={edge} gesture={gesture} pressed={pressed} testID={testID} />;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<GestureType | undefined>;
|
||||
leftPanelWidth: number;
|
||||
leftOpenGestureRef: RefObject<GestureType | undefined>;
|
||||
motionState: SharedValue<MobilePanelMotionState>;
|
||||
openGesturesBlocked: SharedValue<boolean>;
|
||||
@@ -68,8 +75,36 @@ interface FinishGestureInput {
|
||||
const MobilePanelsContext = createContext<MobilePanelsRuntime | null>(null);
|
||||
const MobilePanelActiveContext = createContext<MobilePanelView>("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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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<PanelState, "desktop" | "mobilePanel"> {
|
||||
return {
|
||||
desktop: { ...state.desktop, agentListOpen: true },
|
||||
mobilePanel: dropOverlayAgentList(state.mobilePanel),
|
||||
};
|
||||
}
|
||||
|
||||
export const usePanelStore = create<PanelState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
@@ -151,12 +164,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
),
|
||||
|
||||
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<PanelState>()(
|
||||
}),
|
||||
|
||||
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<PanelState>()(
|
||||
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) =>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<HTMLElement> {
|
||||
@@ -39,10 +39,6 @@ export default class PointerEventManager extends EventManager<HTMLElement> {
|
||||
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<HTMLElement> {
|
||||
} 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<HTMLElement> {
|
||||
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<HTMLElement> {
|
||||
// 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)
|
||||
) {
|
||||
|
||||
@@ -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+",
|
||||
|
||||
Reference in New Issue
Block a user