mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 07:54:38 +00:00
* 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>
160 lines
9.7 KiB
Markdown
160 lines
9.7 KiB
Markdown
# Mobile panels
|
|
|
|
Compact layouts have three mutually exclusive destinations:
|
|
|
|
- `agent-list` on the left
|
|
- `agent` in the center
|
|
- `file-explorer` on the right
|
|
|
|
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:
|
|
|
|
```ts
|
|
interface MobilePanelSelection {
|
|
target: "agent-list" | "agent" | "file-explorer";
|
|
revision: number;
|
|
}
|
|
```
|
|
|
|
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)
|
|
- the current motion target
|
|
- the active gesture's starting revision
|
|
- the last settled target
|
|
|
|
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. 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
|
|
release. Native panel hosts and their dependent draggable lists
|
|
also retain identity across appearance hydration and settings changes. Do not wrap those hosts in
|
|
appearance keys; see [Unistyles appearance boundaries](unistyles.md#runtime-theme-patching-for-user-preferences).
|
|
|
|
## Why one position
|
|
|
|
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:
|
|
|
|
- a panel and its backdrop disagreeing
|
|
- left and right drawers both claiming to be open
|
|
- a width-sync effect resetting an active drag
|
|
- one animation context settling a transition owned by the other
|
|
|
|
Do not add another panel translate shared value, backdrop shared value, or width synchronization
|
|
effect.
|
|
|
|
## Ordering and interruption
|
|
|
|
A gesture captures the current revision when it becomes active. Per-frame updates are accepted only
|
|
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 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
|
|
the same target.
|
|
|
|
Manual gesture arbitration has two phases:
|
|
|
|
1. Before activation, determine whether horizontal intent may begin.
|
|
2. After activation, stop running begin checks and let the active revision own updates.
|
|
|
|
Re-running the begin gate after activation self-cancels the gesture because an active gesture is, by
|
|
definition, no longer eligible to begin.
|
|
|
|
## Integration rules
|
|
|
|
- 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
|
|
an already-open panel.
|
|
- 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.
|
|
- 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.
|
|
- `FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS` stays off in `packages/app/package.json`. It is a
|
|
compile-time flag; changing it needs a native rebuild. With it on, Reanimated 4.3 hands settled
|
|
animated props back to React through a collector that forgets entries older than two seconds. A JS
|
|
stall across a panel settle loses the final position, and the next React commit reverts the overlay
|
|
to its last synced props: a backdrop over the workspace while the store says closed. Upstream fixed
|
|
the collector in 4.4.3, which needs React Native 0.83.
|
|
- Gesture start and progress must not update React state. The retained overlay is already mounted and
|
|
offscreen; only the shared position and its derived native styles move during a drag.
|
|
- Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
|
|
pointer events, and the active signal consumed by `useRetainedPanelActive`.
|
|
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
|
|
same active signal without adding another layout root. Persistent animations, timers, polling, and
|
|
shared clocks must subscribe to that signal and stop when their final visible consumer leaves.
|
|
- Synchronized step animations use one wall-clock-aligned source. Register a local shared value only
|
|
while its retained panel is active so hidden animated styles remain mounted without receiving clock
|
|
updates. Do not give every instance its own loop or leave hidden styles subscribed to the source.
|
|
- Retention order and render order are separate concerns. LRU metadata may change on every switch;
|
|
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
|
|
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
|
|
- `useIsMobilePanelActive` follows the settled target, not the requested target. Position at the
|
|
canonical target's anchor is the settlement signal; animation duration and completion callbacks
|
|
do not own activity. Cancellation publishes nothing.
|
|
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
|
|
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
|
|
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
|
|
Hidden chat content is suspended on web only, through `retained-chat-content.web.tsx`, where DOM
|
|
nodes carry no Fabric ownership; the native file keeps the chat live.
|
|
|
|
## Tests
|
|
|
|
`packages/app/src/mobile-panels/model.test.ts` exercises command, drag, cancellation, interruption,
|
|
rapid-command, position-settlement, and width-projection sequences through the transition model. Add
|
|
a sequence there whenever ownership or ordering changes.
|