* fix(app): keep open chats current and show resume progress Keep open-chat delivery independent of mounted views, freeze hidden chat rendering, and catch up visible chats before hidden ones. Distinguish host reconnection from timeline freshness so cached messages do not silently appear current after resume. * fix(app): prevent sync status from overlapping action errors Share the existing panel toast slot so action errors remain readable during reconnect and catch-up. Verify the interaction and draft retention in the real browser, preserve the tested freeze transition, and refresh the Nix dependency hash for react-freeze. * test(hub): expose complete creation and message retry failures Partial response assertions hid the creation error and allowed duplicate responses with incomplete agent data. Check complete success envelopes, the shared agent snapshot, and null message errors so future failures print the actual error payload. * fix(app): dismiss retained chat menus before freezing An async fork navigates before its closing state commits, so freezing the source chat could leave its portal backdrop blocking the destination. Remove the shared menu surface in the inactive commit before suspension. Keep regression assertions aligned with open-chat subscriptions, per-agent incremental recovery, plugin contribution lifetimes, and rendered image completion. * test(app): expect the reloaded plugin to contribute its pill again A full reload discards the pressed pill with the page, then evaluates the plugin again and its agents snapshot re-adds the pill. The old count-zero assertion only passed on main because the chat was not ready yet when the URL settled; open chats now subscribe from session start, so the pill is rendered by then. * fix(app): suspend hidden chats on web only react-freeze is Suspense with a never-resolving thenable. Suspending a committed subtree hides its host nodes, tears down layout effects, and detaches refs, which changes Fabric ownership of the retained chat. Split the boundary by platform: the web file keeps the two-commit freeze, the native file keeps the chat mounted and live, and the retained-panel signal remains the only thing that stops background work. Restore the mobile-panels rule the branch had rewritten. * fix(creation): never read a receipt while replacing it Admission reads the creation receipt while the runner replaces it atomically. On Windows a rename over a file this process still has open for reading fails with EPERM, and that error marked an otherwise completed creation as failed (#4865). Queue reads and writes per receipt so one owner never overlaps them, and serialize the record when the write is queued because the runner mutates it afterwards. No retry on EPERM: the conflict is removed rather than tolerated. Linux cannot reproduce the rename failure, so the Windows server-tests job (creation/index.test.ts and hub/execution-session.websocket.test.ts) is the verification for this change.
7.0 KiB
Mobile panels
Compact layouts have three mutually exclusive destinations:
agent-liston the leftagentin the centerfile-exploreron 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 (
-1left,0center,1right) - 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. 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.
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:
- Before activation, determine whether horizontal intent may begin.
- 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
SidebarModelProvideroutsideMobileGestureWrapper. The provider shares sidebar derivation across consumers, while Gesture Handler requires the wrapper's direct child to be a nativeViewso its injectedcollapsable={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: nonewithout 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_ANIMATIONSstays off inpackages/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 byuseRetainedPanelActive. - Panels whose gesture wrapper already owns visibility use
RetainedPanelActivityto 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/removeViewAtview reuse) on Android. useIsMobilePanelActivefollows 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, throughretained-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.