Files
paseo/docs/mobile-panels.md
Mohamed Boudra 8dd4da6ee0 Keep mobile streaming and panel motion stable under JS stalls (#4190)
* fix(app): activate mobile panels after motion settles

Gesture previews were publishing React presentation state and activating retained content before the native motion finished. Keep transient motion on the UI thread and publish the active panel only from the accepted animation completion.

* perf(app): re-render only changed history rows

Every coalesced stream tick re-rendered all mounted history rows because the inverted FlatList recreates each cell with a new index and ref. Keep layout item identity for unchanged rows and memoize the row on it, and resolve tool-call groups through a stable event so the render callbacks stop churning.

* perf(app): count replica row bytes once

Budget accounting measured every stored row of a touched host on each persist through the Buffer polyfill. Cache the UTF-8 length per immutable row and count without materialising bytes.

* fix(app): settle mobile panels on position

Activity now follows the UI-thread position reaching the canonical anchor instead of an animation completion callback. A successful gesture keeps its finishing motion and the matching command adopts it; settlement and command acceptance may arrive in either order.

* fix(app): keep settled panel props across JS stalls

Turn off Reanimated's FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS. Its settled-props collector drops entries older than two seconds before React receives them, so a JS stall across a settle left the overlay display and backdrop opacity reverted on the next commit while the store held the new panel.

* fix(app): reconcile hidden keyboard end on iOS

Only a hidden end event resets the keyboard shift. A late visible end no longer resurrects a keyboard that already dismissed.

* fix(app): focus the replacement input through autoFocus after clearing

Clearing swaps the native input for a fresh instance. Focusing the doomed instance asked Android for the keyboard and then tore the focused view down, which cancelled the show; a JS focus command sent alongside the remount reached a view that was not attached yet and the IME ignored it. Focus and blur requested while the replacement is pending now decide the replacement's autoFocus, which native applies once the view is attached.

* fix(app): revise history rows in every viewport

The history row memo boundary bails out until its item identity changes, but only the native viewport cloned rows flagged by historyRowRevision. On web and desktop a history host whose tool-call group kept updating from the live head rendered stale. Move the revision into useRevisedHistoryRows and run both viewports' history through it. Regression test renders the web strategy with a memoized row and asserts only the revised host re-renders.
2026-09-02 00:52:44 +02:00

6.6 KiB

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/.

Ownership

React/Zustand owns the durable intent:

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.

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; worklets reveal their retained overlays through native styles while React owns pointer events and accessibility.

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.

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 the new target. 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.
  • 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.
  • 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.
  • 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.

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.