From 8dd4da6ee0151028b195afbdd998556188e43334 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 2 Sep 2026 00:52:44 +0200 Subject: [PATCH] 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. --- docs/agent-stream-performance.md | 9 + docs/mobile-panels.md | 31 ++-- packages/app/package.json | 5 + .../src/agent-stream/history-row-revision.ts | 47 ++++++ packages/app/src/agent-stream/layout.test.ts | 17 ++ packages/app/src/agent-stream/layout.ts | 157 ++++++++++++------ .../app/src/agent-stream/strategy-native.tsx | 41 +---- .../src/agent-stream/strategy-web.test.tsx | 68 ++++++++ .../app/src/agent-stream/strategy-web.tsx | 13 +- packages/app/src/agent-stream/view.tsx | 33 +++- packages/app/src/app/_layout.tsx | 11 +- .../compact-explorer-sidebar-host.tsx | 17 +- .../components/compact-explorer-sidebar.tsx | 11 +- packages/app/src/components/left-sidebar.tsx | 7 +- .../src/components/sidebar-workspace-list.tsx | 32 ++-- .../sidebar/sidebar-status-list.tsx | 6 +- .../ui/text-input/text-input.native.test.tsx | 40 +++++ .../ui/text-input/text-input.native.tsx | 52 ++++-- .../app/src/hooks/keyboard-shift-policy.ts | 8 + .../hooks/use-keyboard-shift-style.test.ts | 18 ++ .../app/src/hooks/use-keyboard-shift-style.ts | 7 +- packages/app/src/mobile-panels/gestures.ts | 5 +- packages/app/src/mobile-panels/model.test.ts | 111 ++++++++++++- packages/app/src/mobile-panels/model.ts | 59 ++++--- .../app/src/mobile-panels/presentation.tsx | 27 +-- packages/app/src/mobile-panels/provider.tsx | 97 ++++------- .../app/src/runtime/replica-cache/index.ts | 41 ++++- 27 files changed, 692 insertions(+), 278 deletions(-) create mode 100644 packages/app/src/agent-stream/history-row-revision.ts diff --git a/docs/agent-stream-performance.md b/docs/agent-stream-performance.md index 95e0c23706..0d2fd30fa6 100644 --- a/docs/agent-stream-performance.md +++ b/docs/agent-stream-performance.md @@ -31,6 +31,15 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste - **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each. - **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete. - **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way. +- **A history row re-renders only when its item or layout item identity changes.** The inverted + FlatList hands every mounted cell a new `index` and `ref` whenever a row is prepended, so without a + memo boundary each coalesced tick re-rendered every mounted row (about 50 on a phone, 100–250 ms of + JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed, + `useRevisedHistoryRows` hands a fresh item identity to rows whose tool-call group, expanded state, + or breakpoint changed, and `HistoryStreamRow` memoizes on both. Every viewport runs its history + through that hook; the web viewport once skipped it and history hosts of a live tool group went + stale. A new field on `StreamLayoutItem` must be added to `areLayoutItemsEquivalent`, or sharing + silently stops. ## Measuring diff --git a/docs/mobile-panels.md b/docs/mobile-panels.md index 100aacf9dc..447b7108e4 100644 --- a/docs/mobile-panels.md +++ b/docs/mobile-panels.md @@ -30,8 +30,10 @@ The UI worklet owns transient motion: - the active gesture's starting revision - the last settled target -React also owns presentation lifecycle: whether an overlay is mounted/displayed and whether it may -receive pointer events. Worklets never own `display` or `pointerEvents`. +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 @@ -55,8 +57,10 @@ 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. Animation completion is accepted only when -its target and revision still match the canonical command. +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: @@ -85,8 +89,14 @@ definition, no longer eligible to begin. - 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. -- The plain React wrapper owns `display: none` after settlement. This prevents a stale Fabric animated - prop commit from resurrecting a closed overlay. +- `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 @@ -98,8 +108,9 @@ definition, no longer eligible to begin. - 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. -- The newly active panel must be included in the same render that changes selection. Adding it from an - effect creates a committed frame where every retained panel is hidden, which is a real blank screen. +- `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. @@ -107,5 +118,5 @@ definition, no longer eligible to begin. ## Tests `packages/app/src/mobile-panels/model.test.ts` exercises command, drag, cancellation, interruption, -rapid-command, stale-completion, and width-projection sequences through the transition model. Add a -sequence there whenever ownership or ordering changes. +rapid-command, position-settlement, and width-projection sequences through the transition model. Add +a sequence there whenever ownership or ordering changes. diff --git a/packages/app/package.json b/packages/app/package.json index 1c25d55947..d9af74353e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -162,5 +162,10 @@ "vitest": "^4.1.6", "wrangler": "^4.105.0", "ws": "^8.20.0" + }, + "reanimated": { + "staticFeatureFlags": { + "FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false + } } } diff --git a/packages/app/src/agent-stream/history-row-revision.ts b/packages/app/src/agent-stream/history-row-revision.ts new file mode 100644 index 0000000000..9f02074ac2 --- /dev/null +++ b/packages/app/src/agent-stream/history-row-revision.ts @@ -0,0 +1,47 @@ +import { useMemo } from "react"; +import type { StreamItem } from "@/types/stream"; +import type { StreamHistoryRowRevision } from "./strategy"; + +interface HistoryRowDisplayVariants { + regular?: StreamItem; + compact?: StreamItem; +} + +const historyRowDisplayVariants = new WeakMap(); + +function getHistoryRowDisplayVariant(item: StreamItem, compact: boolean): StreamItem { + let variants = historyRowDisplayVariants.get(item); + if (!variants) { + variants = {}; + historyRowDisplayVariants.set(item, variants); + } + const key = compact ? "compact" : "regular"; + variants[key] ??= { ...item }; + return variants[key]; +} + +// Item identity is the render signal for history rows: the row boundary in view.tsx bails out +// until its item changes. Every viewport runs its history through this hook so a history host +// whose tool-call group keeps updating from the live head, a group whose expanded state changed, +// or a breakpoint change reaches the row as a fresh identity. Unchanged rows keep their identity, +// which is what limits a live update to the rows it touched. +export function useRevisedHistoryRows( + items: StreamItem[], + revision: StreamHistoryRowRevision | undefined, +): StreamItem[] { + const globalDisplayState = revision?.globalDisplayState ?? false; + const displayStateById = revision?.displayStateById; + const contentById = revision?.contentById; + const globallyRevisedRows = useMemo( + () => items.map((item) => getHistoryRowDisplayVariant(item, globalDisplayState)), + [items, globalDisplayState], + ); + const displayStateRevisedRows = useMemo( + () => globallyRevisedRows.map((item) => (displayStateById?.has(item.id) ? { ...item } : item)), + [globallyRevisedRows, displayStateById], + ); + return useMemo( + () => displayStateRevisedRows.map((item) => (contentById?.has(item.id) ? { ...item } : item)), + [displayStateRevisedRows, contentById], + ); +} diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts index cfb379345d..3ab2a9f8f0 100644 --- a/packages/app/src/agent-stream/layout.test.ts +++ b/packages/app/src/agent-stream/layout.test.ts @@ -365,6 +365,23 @@ describe("layoutStream", () => { }, ); + it("keeps layout item identity for rows whose layout did not change", () => { + const user = userMessage("u1", 1); + const first = toolCall("tool-1", 2); + const second = toolCall("tool-2", 3); + const before = layoutFor({ platform: "android", tail: [user, first, second] }); + + const third = toolCall("tool-3", 4); + const after = layoutFor({ platform: "android", tail: [user, first, second, third] }); + + // A newest-first list shifts every index, but only the rows next to the insertion change. + expect(findLayoutItem(after, user.id)).toBe(findLayoutItem(before, user.id)); + expect(findLayoutItem(after, first.id)).toBe(findLayoutItem(before, first.id)); + expect(findLayoutItem(after, second.id)).not.toBe(findLayoutItem(before, second.id)); + expect(findLayoutItem(after, second.id).isLastInToolSequence).toBe(false); + expect(findLayoutItem(after, third.id).isLastInToolSequence).toBe(true); + }); + it("computes tool sequence position from strategy-aware neighbors", () => { const shell = toolCall("tool-1", 2); const thinking = thought("thought-1", 3); diff --git a/packages/app/src/agent-stream/layout.ts b/packages/app/src/agent-stream/layout.ts index bc140189c7..d460c1fa2e 100644 --- a/packages/app/src/agent-stream/layout.ts +++ b/packages/app/src/agent-stream/layout.ts @@ -15,8 +15,6 @@ export interface TurnFooterHost { export interface StreamLayoutItem { item: StreamItem; - index: number; - items: StreamItem[]; aboveItem: StreamItem | null; belowItem: StreamItem | null; gapBelow: number; @@ -232,60 +230,109 @@ function getSegmentNeighbor(input: { return null; } -function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] { - return input.items.map((item, index) => { - const aboveItem = getSegmentNeighbor({ - strategy: input.strategy, - items: input.items, - index, - relation: "above", - boundaryIndex: input.boundaryIndex, - boundaryItem: input.boundaryAboveItem, - }); - const belowItem = getSegmentNeighbor({ - strategy: input.strategy, - items: input.items, - index, - relation: "below", - boundaryIndex: input.boundaryIndex, - boundaryItem: input.boundaryBelowItem, - }); - const completedFooter = resolveCompletedFooter({ - strategy: input.strategy, - items: input.items, - index, - item, - belowItem, - timingByAssistantId: input.timingByAssistantId, - auxiliaryTurnFooter: input.auxiliaryTurnFooter, - boundaryAboveItems: input.boundaryAboveItems, - boundaryAboveIndex: input.boundaryAboveIndex, - }); - const assistantSpacing = getAssistantBlockSpacing({ - item, - aboveItem, - belowItem, - hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null), - }); +// Last layout emitted for each stream item. A row only rerenders when its layout item identity +// changes, so an item whose render-relevant layout is unchanged must keep its previous object even +// when the surrounding array was rebuilt (a new row shifts every index in a newest-first list). +const previousLayoutItemByStreamItem = new WeakMap(); - return { - item, - index, - items: input.items, - aboveItem, - belowItem, - gapBelow: completedFooter ? 0 : getGapBetweenStreamItems(item, belowItem), - assistantSpacing, - completedFooter, - toolSequence: getToolSequence({ item, aboveItem, belowItem }), - isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message", - isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message", - isLastInToolSequence: - isToolSequenceItem(item) && - !(isToolSequenceItem(belowItem) && continuesTurn(item, belowItem)), - frameOrder: input.frameOrder, - phase: input.phase, - }; +function areTurnFooterHostsEqual( + left: TurnFooterHost | null, + right: TurnFooterHost | null, +): boolean { + if (left === right) return true; + if (!left || !right) return false; + return ( + left.itemId === right.itemId && + left.timing === right.timing && + left.startIndex === right.startIndex && + left.items === right.items + ); +} + +function areLayoutItemsEquivalent(previous: StreamLayoutItem, next: StreamLayoutItem): boolean { + return ( + previous.item === next.item && + previous.aboveItem === next.aboveItem && + previous.belowItem === next.belowItem && + previous.gapBelow === next.gapBelow && + previous.assistantSpacing === next.assistantSpacing && + areTurnFooterHostsEqual(previous.completedFooter, next.completedFooter) && + previous.toolSequence === next.toolSequence && + previous.isFirstInUserGroup === next.isFirstInUserGroup && + previous.isLastInUserGroup === next.isLastInUserGroup && + previous.isLastInToolSequence === next.isLastInToolSequence && + previous.frameOrder === next.frameOrder && + previous.phase === next.phase + ); +} + +function shareLayoutItem(next: StreamLayoutItem): StreamLayoutItem { + const previous = previousLayoutItemByStreamItem.get(next.item); + if (previous && areLayoutItemsEquivalent(previous, next)) { + return previous; + } + previousLayoutItemByStreamItem.set(next.item, next); + return next; +} + +function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] { + return input.items.map((item, index) => layoutSegmentItem(input, item, index)); +} + +function layoutSegmentItem( + input: LayoutSegmentInput, + item: StreamItem, + index: number, +): StreamLayoutItem { + const aboveItem = getSegmentNeighbor({ + strategy: input.strategy, + items: input.items, + index, + relation: "above", + boundaryIndex: input.boundaryIndex, + boundaryItem: input.boundaryAboveItem, + }); + const belowItem = getSegmentNeighbor({ + strategy: input.strategy, + items: input.items, + index, + relation: "below", + boundaryIndex: input.boundaryIndex, + boundaryItem: input.boundaryBelowItem, + }); + const completedFooter = resolveCompletedFooter({ + strategy: input.strategy, + items: input.items, + index, + item, + belowItem, + timingByAssistantId: input.timingByAssistantId, + auxiliaryTurnFooter: input.auxiliaryTurnFooter, + boundaryAboveItems: input.boundaryAboveItems, + boundaryAboveIndex: input.boundaryAboveIndex, + }); + const assistantSpacing = getAssistantBlockSpacing({ + item, + aboveItem, + belowItem, + hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null), + }); + + return shareLayoutItem({ + item, + aboveItem, + belowItem, + gapBelow: completedFooter ? 0 : getGapBetweenStreamItems(item, belowItem), + assistantSpacing, + completedFooter, + toolSequence: getToolSequence({ item, aboveItem, belowItem }), + isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message", + isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message", + isLastInToolSequence: + isToolSequenceItem(item) && + !(isToolSequenceItem(belowItem) && continuesTurn(item, belowItem)), + frameOrder: input.frameOrder, + phase: input.phase, }); } diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index cc314b4762..b51954a6d0 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -23,6 +23,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; import type { StreamItem } from "@/types/stream"; import type { Theme } from "@/styles/theme"; import { useStableEvent } from "@/hooks/use-stable-event"; +import { useRevisedHistoryRows } from "./history-row-revision"; import { useBottomAnchorController } from "./bottom-anchor-controller"; import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; @@ -63,24 +64,6 @@ const historyStartSlotStyle: ViewStyle = { }; const HISTORY_START_SETTLE_FRAMES = 2; -interface HistoryRowDisplayVariants { - regular?: StreamItem; - compact?: StreamItem; -} - -const historyRowDisplayVariants = new WeakMap(); - -function getHistoryRowDisplayVariant(item: StreamItem, compact: boolean): StreamItem { - let variants = historyRowDisplayVariants.get(item); - if (!variants) { - variants = {}; - historyRowDisplayVariants.set(item, variants); - } - const key = compact ? "compact" : "regular"; - variants[key] ??= { ...item }; - return variants[key]; -} - function keyExtractor(item: { id: string }): string { return item.id; } @@ -138,27 +121,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat } return [...segments.historyVirtualized, ...segments.historyMounted]; }, [segments.historyMounted, segments.historyVirtualized]); - // Keep unchanged item identities intact so live updates only rerender rows - // whose projected content or local display state actually changed. A rare - // breakpoint change intentionally refreshes the whole history window. - const globallyRevisedHistoryRows = useMemo(() => { - const globalDisplayState = historyRowRevision?.globalDisplayState ?? false; - return historyItems.map((item) => getHistoryRowDisplayVariant(item, globalDisplayState)); - }, [historyItems, historyRowRevision?.globalDisplayState]); - const displayStateHistoryRows = useMemo( - () => - globallyRevisedHistoryRows.map((item) => - historyRowRevision?.displayStateById.has(item.id) ? { ...item } : item, - ), - [globallyRevisedHistoryRows, historyRowRevision?.displayStateById], - ); - const historyRows = useMemo( - () => - displayStateHistoryRows.map((item) => - historyRowRevision?.contentById.has(item.id) ? { ...item } : item, - ), - [displayStateHistoryRows, historyRowRevision?.contentById], - ); + const historyRows = useRevisedHistoryRows(historyItems, historyRowRevision); const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => { const metrics = streamViewportMetricsRef.current; const hasMeasuredViewport = diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 445a84f4ba..1d86943e05 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -252,6 +252,74 @@ describe("createWebStreamStrategy", () => { expect(renderLiveHeadRow).toHaveBeenCalledTimes(2); }); + it("rerenders only the history row whose content revision changed", () => { + const strategy = createWebStreamStrategy({ isMobileBreakpoint: false }); + const viewportRef = React.createRef(); + const host = userMessage(1); + const other = userMessage(2); + const renderCountById = new Map(); + const HistoryRow = React.memo(function HistoryRow({ item }: { item: StreamItem }) { + renderCountById.set(item.id, (renderCountById.get(item.id) ?? 0) + 1); + return
{item.id}
; + }); + const renderInput: StreamRenderInput = { + agentId: "agent", + segments: { + historyVirtualized: [], + historyMounted: [host, other], + liveHead: [], + }, + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: true, + hasLiveHead: false, + }, + renderers: { + ...createRenderers(vi.fn()), + renderHistoryMountedRow: (item) => , + }, + listEmptyComponent: null, + viewportRef, + routeBottomAnchorRequest: null, + isAuthoritativeHistoryReady: true, + onNearBottomChange: vi.fn(), + onNearHistoryStart: vi.fn().mockReturnValue(true), + isLoadingOlderHistory: false, + hasOlderHistory: false, + olderHistoryProgressKey: null, + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + }; + const unrevised = { + contentById: new Set(), + displayStateById: new Set(), + globalDisplayState: false, + }; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render(strategy.render({ ...renderInput, historyRowRevision: unrevised })); + }); + expect(renderCountById.get(host.id)).toBe(1); + expect(renderCountById.get(other.id)).toBe(1); + + act(() => { + root?.render( + strategy.render({ + ...renderInput, + historyRowRevision: { ...unrevised, contentById: new Set([host.id]) }, + }), + ); + }); + + expect(renderCountById.get(host.id)).toBe(2); + expect(renderCountById.get(other.id)).toBe(1); + }); + it("keeps a row mounted when it moves from the live head into mounted history", () => { const strategy = createWebStreamStrategy({ isMobileBreakpoint: false }); const viewportRef = React.createRef(); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 4f19448a80..25cda1829d 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -20,6 +20,7 @@ import { shouldAdjustScrollForVirtualRowResize, } from "./web-virtualization"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; +import { useRevisedHistoryRows } from "./history-row-revision"; import { createStreamStrategy } from "./strategy"; import { abandonHistoryStartPaginationRequest, @@ -279,7 +280,8 @@ function isScrollContainerOverscrolledPastBottom( function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) { const { - segments, + segments: inputSegments, + historyRowRevision, liveHeadRowRevision, boundary, renderers, @@ -296,6 +298,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scrollEnabled, isMobileBreakpoint, } = props; + const historyVirtualized = useRevisedHistoryRows( + inputSegments.historyVirtualized, + historyRowRevision, + ); + const historyMounted = useRevisedHistoryRows(inputSegments.historyMounted, historyRowRevision); + const segments = useMemo( + () => ({ ...inputSegments, historyVirtualized, historyMounted }), + [historyMounted, historyVirtualized, inputSegments], + ); const isActive = useRetainedPanelActive(); const isActiveRef = useRef(isActive); const scrollContainerRef = useRef(null); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 5e92c1ccfe..fb18b2e4dc 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -220,6 +220,22 @@ function renderListEmptyComponent(input: { ); } +// History rows sit inside FlatList cells that rerender on every data change (RN recreates each +// CellRenderer with a fresh ref and, in a newest-first list, a shifted index). This boundary is +// what stops that churn: a row renders again only when its stream item identity, its layout item +// identity, or the renderer itself changes. Item identity is the revision signal the strategy +// already uses (`useRevisedHistoryRows` clones items whose content or display state changed). +const HistoryStreamRow = memo(function HistoryStreamRow({ + layoutItem, + renderStreamItem, +}: { + item: StreamItem; + layoutItem: StreamLayoutItem; + renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode; +}) { + return <>{renderStreamItem(layoutItem)}; +}); + function renderHistoryStreamItem(input: { item: StreamItem; layoutItemById: Map; @@ -229,7 +245,13 @@ function renderHistoryStreamItem(input: { if (!layoutItem) { return null; } - return input.renderStreamItem(layoutItem); + return ( + + ); } function renderLiveHeadStreamItem(input: { @@ -789,9 +811,14 @@ const AgentStreamViewComponent = forwardRef + projectedToolCalls.groupsByHostId.get(hostId), + ); const renderToolCallItem = useCallback( (layoutItem: StreamLayoutItem, item: Extract) => { - const group = projectedToolCalls.groupsByHostId.get(item.id); + const group = getToolCallGroup(item.id); if (!group) { return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence); } @@ -818,8 +845,8 @@ const AgentStreamViewComponent = forwardRef - selectIsAgentListOpen(state, { isCompact: isCompactLayout }), - ); - const active = visible && isOpen; + const isMobileActive = useIsMobilePanelActive("agent-list"); + const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen); + const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen); return ( {mounted ? : null} diff --git a/packages/app/src/components/compact-explorer-sidebar-host.tsx b/packages/app/src/components/compact-explorer-sidebar-host.tsx index c2be2d5a89..3a05f1c265 100644 --- a/packages/app/src/components/compact-explorer-sidebar-host.tsx +++ b/packages/app/src/components/compact-explorer-sidebar-host.tsx @@ -8,8 +8,9 @@ import { NativeExplorerSidebarDock, } from "@/components/compact-explorer-sidebar"; import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures"; +import { useIsMobilePanelActive } from "@/mobile-panels/provider"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store"; +import { usePanelStore } from "@/stores/panel-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status"; @@ -51,7 +52,7 @@ function useActiveCompactExplorerSidebarModel( ): CompactExplorerSidebarHostModel | null { const selection = useActiveWorkspaceSelection(); const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null); - const isExplorerOpen = usePanelStore(selectIsCompactFileExplorerOpen); + const isExplorerActive = useIsMobilePanelActive("file-explorer"); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const client = useHostRuntimeClient(selection?.serverId ?? ""); const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? ""); @@ -67,32 +68,32 @@ function useActiveCompactExplorerSidebarModel( const resolvedModel = useMemo( () => resolveCompactExplorerSidebarHostModel({ - previous: isExplorerOpen ? retainedModelRef.current : null, + previous: isExplorerActive ? retainedModelRef.current : null, selection, workspace, isGit: checkoutQuery.data?.isGit ?? false, }), - [checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace], + [checkoutQuery.data?.isGit, isExplorerActive, selection, workspace], ); useEffect(() => { if (!selection) { retainedModelRef.current = null; - if (enabled && isExplorerOpen) { + if (enabled && isExplorerActive) { showMobileAgent(); } return; } - if (!isExplorerOpen) { + if (!isExplorerActive) { retainedModelRef.current = null; return; } if (resolvedModel) { retainedModelRef.current = resolvedModel; } - }, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]); + }, [enabled, isExplorerActive, resolvedModel, selection, showMobileAgent]); - return selection ? (resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null)) : null; + return selection ? (resolvedModel ?? (isExplorerActive ? retainedModelRef.current : null)) : null; } interface CompactExplorerSidebarHostProps { diff --git a/packages/app/src/components/compact-explorer-sidebar.tsx b/packages/app/src/components/compact-explorer-sidebar.tsx index ea186995bf..fc4d00cf2f 100644 --- a/packages/app/src/components/compact-explorer-sidebar.tsx +++ b/packages/app/src/components/compact-explorer-sidebar.tsx @@ -14,6 +14,7 @@ import { type ExplorerTab, } from "@/stores/panel-store"; import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures"; +import { useIsMobilePanelActive } from "@/mobile-panels/provider"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { HEADER_INNER_HEIGHT, @@ -81,7 +82,7 @@ export function CompactExplorerSidebar({ }: ExplorerSidebarProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); - const isOpen = usePanelStore(selectIsCompactFileExplorerOpen); + const isActive = useIsMobilePanelActive("file-explorer"); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({ serverId, @@ -99,11 +100,11 @@ export function CompactExplorerSidebar({ (reason: string) => { logExplorerSidebar("handleClose", { reason, - isOpen, + isOpen: isActive, }); showMobileAgent(); }, - [isOpen, showMobileAgent], + [isActive, showMobileAgent], ); const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]); @@ -127,7 +128,7 @@ export function CompactExplorerSidebar({ ); return ( - + diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 23e3e2ca82..d4e3375142 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -62,7 +62,6 @@ import { usePanelStore } from "@/stores/panel-store"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; -import { useIsMobilePanelPresented } from "@/mobile-panels/provider"; import { buildOpenProjectRoute, buildNewWorkspaceRoute, @@ -121,6 +120,7 @@ interface SidebarLabels { } interface MobileSidebarProps extends SidebarSharedProps { + active: boolean; insetsTop: number; insetsBottom: number; closeSidebar: () => void; @@ -273,6 +273,7 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole { closeSidebar(); @@ -737,7 +738,7 @@ function MobileSidebar({ onWorkspacePress={handleWorkspacePress} onAddProject={handleOpenProject} parentGestureRef={closeGestureRef} - dragGestureHostPresented={dragGestureHostPresented} + dragGestureHostActive={active} listHeaderComponent={workspacesSectionHeaderElement} /> )} diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 77f813e602..65d3cab964 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -234,7 +234,7 @@ interface SidebarWorkspaceListProps { listHeaderComponent?: ReactElement | null; /** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */ parentGestureRef?: MutableRefObject; - dragGestureHostPresented?: boolean; + dragGestureHostActive?: boolean; } interface ProjectHeaderRowProps { @@ -1540,7 +1540,7 @@ function ProjectBlock({ isDragging, dragHandleProps, useNestable, - dragGestureHostPresented, + dragGestureHostActive, creatingWorkspaceIds, activeWorkspaceSelection, hostBadgeByServerId, @@ -1565,7 +1565,7 @@ function ProjectBlock({ isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; useNestable: boolean; - dragGestureHostPresented?: boolean; + dragGestureHostActive?: boolean; creatingWorkspaceIds: ReadonlySet; activeWorkspaceSelection: ActiveWorkspaceSelection | null; hostBadgeByServerId: ReadonlyMap; @@ -1745,7 +1745,7 @@ function ProjectBlock({ useDragHandle nestable={useNestable} simultaneousGestureRef={parentGestureRef} - gestureHostPresented={dragGestureHostPresented} + gestureHostPresented={dragGestureHostActive} containerStyle={styles.workspaceListContainer} /> {canToggleWorkspaces ? ( @@ -1829,7 +1829,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo previous.isDragging === next.isDragging && previous.dragHandleProps === next.dragHandleProps && previous.useNestable === next.useNestable && - previous.dragGestureHostPresented === next.dragGestureHostPresented && + previous.dragGestureHostActive === next.dragGestureHostActive && previous.creatingWorkspaceIds === next.creatingWorkspaceIds && areProjectBlockSelectionsEqual(previous, next) ); @@ -1882,7 +1882,7 @@ export function SidebarWorkspaceList({ listFooterComponent, listHeaderComponent, parentGestureRef, - dragGestureHostPresented, + dragGestureHostActive, }: SidebarWorkspaceListProps) { const pathname = usePathname(); const hosts = useHosts(); @@ -1959,7 +1959,7 @@ export function SidebarWorkspaceList({ listHeaderComponent={listHeaderComponent} sidebarFilterEmpty={sidebarFilterEmpty} parentGestureRef={parentGestureRef} - dragGestureHostPresented={dragGestureHostPresented} + dragGestureHostActive={dragGestureHostActive} /> ) : ( ; - dragGestureHostPresented?: boolean; + dragGestureHostActive?: boolean; }) { const showShortcutBadges = useShowShortcutBadges(); const pinnedWorkspaces = useMemo( @@ -2052,7 +2052,7 @@ function SidebarGroupedModeList({ listHeaderComponent={listHeaderComponent} sidebarFilterEmpty={sidebarFilterEmpty} parentGestureRef={parentGestureRef} - dragGestureHostPresented={dragGestureHostPresented} + dragGestureHostActive={dragGestureHostActive} /> ); } @@ -2072,7 +2072,7 @@ function ProjectModeList({ sidebarFilterEmpty, hasActiveProjectFilter, parentGestureRef, - dragGestureHostPresented, + dragGestureHostActive, pathname, hostBadgeByServerId, supportsMultiplicityByServerId, @@ -2289,7 +2289,7 @@ function ProjectModeList({ isDragging={dragState.isDragging} dragHandleProps={dragState.dragHandleProps} useNestable={platformIsNative} - dragGestureHostPresented={dragGestureHostPresented} + dragGestureHostActive={dragGestureHostActive} creatingWorkspaceIds={creatingWorkspaceIds} activeWorkspaceSelection={activeWorkspaceSelection} hostBadgeByServerId={hostBadgeByServerId} @@ -2311,7 +2311,7 @@ function ProjectModeList({ onWorkspacePress, onToggleProjectCollapsed, parentGestureRef, - dragGestureHostPresented, + dragGestureHostActive, projectIconByProjectViewKey, selectionEnabled, shortcutIndexByWorkspaceKey, @@ -2388,7 +2388,7 @@ function ProjectModeList({ useDragHandle nestable={platformIsNative} simultaneousGestureRef={parentGestureRef} - gestureHostPresented={dragGestureHostPresented} + gestureHostPresented={dragGestureHostActive} containerStyle={styles.projectListContainer} /> ); @@ -2411,7 +2411,7 @@ function ProjectModeList({ useDragHandle nestable={platformIsNative} simultaneousGestureRef={parentGestureRef} - gestureHostPresented={dragGestureHostPresented} + gestureHostPresented={dragGestureHostActive} containerStyle={styles.workspaceListContainer} /> {canTogglePinnedChats ? ( diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 84b5442152..def203c9cd 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -126,7 +126,7 @@ interface StatusWorkspaceListProps { /** Swaps the group list for the label filter's empty state. Never the header above it. */ sidebarFilterEmpty?: boolean; parentGestureRef?: MutableRefObject; - dragGestureHostPresented?: boolean; + dragGestureHostActive?: boolean; } export function SidebarStatusWorkspaceList({ @@ -143,7 +143,7 @@ export function SidebarStatusWorkspaceList({ listHeaderComponent, sidebarFilterEmpty = false, parentGestureRef, - dragGestureHostPresented, + dragGestureHostActive, }: StatusWorkspaceListProps) { const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore( (state) => state.collapsedWorkspaceGroupKeys, @@ -214,7 +214,7 @@ export function SidebarStatusWorkspaceList({ useDragHandle nestable={platformIsNative} simultaneousGestureRef={parentGestureRef} - gestureHostPresented={dragGestureHostPresented} + gestureHostPresented={dragGestureHostActive} /> {canTogglePinnedWorkspaces ? ( { expect(document.activeElement).toBe(container?.querySelector("input")); }); + it("focuses the replacement input when focus is requested before a cleared input remounts", () => { + const handleRef = createRef(); + + act(() => { + root?.render(); + }); + const originalInput = container?.querySelector("input"); + if (!originalInput) throw new Error("Expected native input"); + const originalFocus = vi.spyOn(originalInput, "focus"); + + act(() => { + handleRef.current?.replaceText(""); + handleRef.current?.focus(); + }); + + const replacementInput = container?.querySelector("input"); + expect(replacementInput).not.toBe(originalInput); + expect(originalFocus).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(replacementInput); + }); + + it("drops a pending focus restore when blur is requested before a cleared input remounts", () => { + const handleRef = createRef(); + + act(() => { + root?.render(); + }); + const originalInput = container?.querySelector("input"); + if (!originalInput) throw new Error("Expected native input"); + Object.assign(originalInput, { isFocused: () => true }); + originalInput.focus(); + + act(() => { + handleRef.current?.replaceText(""); + handleRef.current?.blur(); + }); + + expect(document.activeElement).not.toBe(container?.querySelector("input")); + }); + it("updates textRef and text when replaceText receives non-empty text", () => { const handleRef = createRef(); diff --git a/packages/app/src/components/ui/text-input/text-input.native.tsx b/packages/app/src/components/ui/text-input/text-input.native.tsx index 8ebbddcb4f..c505a9b917 100644 --- a/packages/app/src/components/ui/text-input/text-input.native.tsx +++ b/packages/app/src/components/ui/text-input/text-input.native.tsx @@ -35,31 +35,54 @@ export const EditingTextInput = forwardRef(null); const initialTextRef = useRef(initialValue); const textRef = useRef(initialTextRef.current); - const restoreFocusAfterResetRef = useRef(false); - const [nativeInputRevision, setNativeInputRevision] = useState(0); + // Clearing swaps the native input for a fresh instance (see replaceText). + // Until React commits that swap, `inputRef` still points at the doomed + // instance: focusing it asks Android for the keyboard and then tears the + // focused view down, which cancels the show. Fabric also runs view + // commands before mount items, so a focus command sent alongside the swap + // reaches a view that is not attached yet and the IME ignores it. Focus is + // therefore carried by the replacement's `autoFocus`, which native applies + // once the view is attached. + const isAwaitingReplacementRef = useRef(false); + const [replacement, setReplacement] = useState({ revision: 0, autoFocus: false }); const assignInputRef = useCallback((input: NativeInput | null) => { inputRef.current = input; - if (!input || !restoreFocusAfterResetRef.current) return; - restoreFocusAfterResetRef.current = false; - input.focus(); + if (input) isAwaitingReplacementRef.current = false; + }, []); + + const setReplacementFocus = useCallback((autoFocus: boolean) => { + setReplacement((current) => ({ ...current, autoFocus })); }, []); useImperativeHandle(ref, () => ({ - focus: () => inputRef.current?.focus(), - blur: () => inputRef.current?.blur(), + focus: () => { + if (isAwaitingReplacementRef.current) { + setReplacementFocus(true); + return; + } + inputRef.current?.focus(); + }, + blur: () => { + if (isAwaitingReplacementRef.current) { + setReplacementFocus(false); + return; + } + inputRef.current?.blur(); + }, isFocused: () => inputRef.current?.isFocused?.() ?? false, getText: () => textRef.current, replaceText: (nextText, selection) => { textRef.current = nextText; if (nextText === "") { - restoreFocusAfterResetRef.current = inputRef.current?.isFocused?.() ?? false; + const autoFocus = inputRef.current?.isFocused?.() ?? false; if (inputRef.current?.replaceText) { inputRef.current.replaceText(nextText, selection); } else { inputRef.current?.clear?.(); } - setNativeInputRevision((revision) => revision + 1); + isAwaitingReplacementRef.current = true; + setReplacement((current) => ({ revision: current.revision + 1, autoFocus })); return; } if (inputRef.current?.replaceText) { @@ -93,11 +116,14 @@ export const EditingTextInput = forwardRef} defaultValue={textRef.current} onChangeText={handleChangeText} @@ -109,7 +135,8 @@ export const EditingTextInput = forwardRef} defaultValue={textRef.current} onChangeText={handleChangeText} @@ -119,7 +146,8 @@ export const EditingTextInput = forwardRef} defaultValue={textRef.current} onChangeText={handleChangeText} diff --git a/packages/app/src/hooks/keyboard-shift-policy.ts b/packages/app/src/hooks/keyboard-shift-policy.ts index 1923d76130..68e013e1b1 100644 --- a/packages/app/src/hooks/keyboard-shift-policy.ts +++ b/packages/app/src/hooks/keyboard-shift-policy.ts @@ -28,3 +28,11 @@ export function resolveKeyboardShift(input: { return Math.max(0, input.rawKeyboardHeight - input.bottomInset); } + +export function shouldReconcileHiddenKeyboardEnd(input: { + height: number; + progress: number; +}): boolean { + "worklet"; + return !(input.height > 0) || !(input.progress > 0); +} diff --git a/packages/app/src/hooks/use-keyboard-shift-style.test.ts b/packages/app/src/hooks/use-keyboard-shift-style.test.ts index 031f094201..96165d7f1f 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.test.ts +++ b/packages/app/src/hooks/use-keyboard-shift-style.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + shouldReconcileHiddenKeyboardEnd, resolveKeyboardShift, shouldUseCompactExplorerKeyboardPadding, } from "./keyboard-shift-policy"; @@ -42,6 +43,23 @@ describe("resolveKeyboardShift", () => { }); }); +describe("shouldReconcileHiddenKeyboardEnd", () => { + it("closes stale iOS keyboard state without letting a late visible end resurrect it", () => { + expect( + shouldReconcileHiddenKeyboardEnd({ + height: 0, + progress: 0, + }), + ).toBe(true); + expect( + shouldReconcileHiddenKeyboardEnd({ + height: 320, + progress: 1, + }), + ).toBe(false); + }); +}); + describe("shouldUseCompactExplorerKeyboardPadding", () => { it("keeps the changes viewport stable while preserving padding for other tabs", () => { expect(shouldUseCompactExplorerKeyboardPadding({ isGit: true, explorerTab: "changes" })).toBe( diff --git a/packages/app/src/hooks/use-keyboard-shift-style.ts b/packages/app/src/hooks/use-keyboard-shift-style.ts index 441d73520e..6365771573 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.ts +++ b/packages/app/src/hooks/use-keyboard-shift-style.ts @@ -15,6 +15,7 @@ import { import { DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, resolveKeyboardShift, + shouldReconcileHiddenKeyboardEnd, } from "@/hooks/keyboard-shift-policy"; import { KeyboardShiftContext, useKeyboardShift } from "@/hooks/keyboard-shift-context"; @@ -34,9 +35,9 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { { onEnd: (event) => { "worklet"; - if (isIos) { - keyboardHeight.value = -event.height; - keyboardProgress.value = event.progress; + if (isIos && shouldReconcileHiddenKeyboardEnd(event)) { + keyboardHeight.value = 0; + keyboardProgress.value = 0; } }, }, diff --git a/packages/app/src/mobile-panels/gestures.ts b/packages/app/src/mobile-panels/gestures.ts index a3b26adc73..84e5d48d09 100644 --- a/packages/app/src/mobile-panels/gestures.ts +++ b/packages/app/src/mobile-panels/gestures.ts @@ -95,7 +95,7 @@ export function useOpenAgentListGesture(enabled: boolean) { } }) .onStart(() => { - startedRevision.value = beginGesture({ origin: "agent", preview: "agent-list" }); + startedRevision.value = beginGesture({ origin: "agent" }); }) .onUpdate((event) => { updateGesture(startedRevision.value, -event.translationX / windowWidth); @@ -188,7 +188,6 @@ export function useCloseAgentListGesture() { .onStart(() => { startedRevision.value = beginGesture({ origin: "agent-list", - preview: "agent-list", }); }) .onUpdate((event) => { @@ -290,7 +289,6 @@ export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorer .onStart(() => { startedRevision.value = beginGesture({ origin: "agent", - preview: "file-explorer", }); }) .onUpdate((event) => { @@ -386,7 +384,6 @@ export function useCloseFileExplorerGesture() { .onStart(() => { startedRevision.value = beginGesture({ origin: "file-explorer", - preview: "file-explorer", }); }) .onUpdate((event) => { diff --git a/packages/app/src/mobile-panels/model.test.ts b/packages/app/src/mobile-panels/model.test.ts index 258db2af22..5e2d8e229c 100644 --- a/packages/app/src/mobile-panels/model.test.ts +++ b/packages/app/src/mobile-panels/model.test.ts @@ -4,6 +4,7 @@ import { canBeginMobilePanelGesture, createMobilePanelMotionState, getMobilePanelFrame, + isMobilePanelActive, isMobilePanelGestureCurrent, transitionMobilePanel, type MobilePanelCommit, @@ -53,8 +54,8 @@ class MobilePanelsScenario { return this; } - finishAnimation(target: MobilePanelView, revision = this.state.revision) { - this.dispatch({ type: "animation.finished", revision, target }); + settleAt(position: number) { + this.dispatch({ type: "position.changed", position }); return this; } @@ -77,10 +78,107 @@ class MobilePanelsScenario { } describe("mobile panel ownership", () => { + it("publishes activity only after motion settles", () => { + const initial = createMobilePanelMotionState({ target: "agent", revision: 0 }); + const dragging = transitionMobilePanel(initial, { + type: "gesture.begin", + origin: "agent", + }).state; + const released = transitionMobilePanel(dragging, { + type: "gesture.finish", + startedRevision: 0, + success: true, + target: "agent-list", + }).state; + const commandTransition = transitionMobilePanel(released, { + type: "command", + selection: { target: "agent-list", revision: 1 }, + }); + const commanded = commandTransition.state; + + expect(isMobilePanelActive(dragging, "agent-list")).toBe(false); + expect(isMobilePanelActive(released, "agent-list")).toBe(false); + expect(isMobilePanelActive(commanded, "agent-list")).toBe(false); + expect(commandTransition.animationTarget).toBeUndefined(); + + const settled = transitionMobilePanel(commanded, { + type: "position.changed", + position: -1, + }).state; + expect(isMobilePanelActive(settled, "agent-list")).toBe(true); + }); + + it("activates immediately when the command arrives after position settles", () => { + const initial = createMobilePanelMotionState({ target: "agent", revision: 0 }); + const dragging = transitionMobilePanel(initial, { + type: "gesture.begin", + origin: "agent", + }).state; + const released = transitionMobilePanel(dragging, { + type: "gesture.finish", + startedRevision: 0, + success: true, + target: "agent-list", + }).state; + const reachedAnchor = transitionMobilePanel(released, { + type: "position.changed", + position: -1, + }).state; + + expect(isMobilePanelActive(reachedAnchor, "agent-list")).toBe(false); + + const commanded = transitionMobilePanel(reachedAnchor, { + type: "command", + selection: { target: "agent-list", revision: 1 }, + }); + expect(commanded.animationTarget).toBeUndefined(); + + const settled = transitionMobilePanel(commanded.state, { + type: "position.changed", + position: -1, + }).state; + expect(isMobilePanelActive(settled, "agent-list")).toBe(true); + }); + + it("does not change activity for a cancelled preview", () => { + const initial = createMobilePanelMotionState({ target: "agent", revision: 0 }); + const dragging = transitionMobilePanel(initial, { + type: "gesture.begin", + origin: "agent", + }).state; + const cancelled = transitionMobilePanel(dragging, { + type: "gesture.finish", + startedRevision: 0, + success: false, + target: "agent-list", + }).state; + + expect(isMobilePanelActive(dragging, "agent")).toBe(true); + expect(isMobilePanelActive(cancelled, "agent")).toBe(true); + expect(isMobilePanelActive(cancelled, "agent-list")).toBe(false); + }); + + it("keeps the visible panel active until its closing motion settles", () => { + const initial = createMobilePanelMotionState({ target: "agent-list", revision: 0 }); + const closing = transitionMobilePanel(initial, { + type: "command", + selection: { target: "agent", revision: 1 }, + }).state; + + expect(isMobilePanelActive(closing, "agent-list")).toBe(true); + + const settled = transitionMobilePanel(closing, { + type: "position.changed", + position: 0, + }).state; + expect(isMobilePanelActive(settled, "agent-list")).toBe(false); + expect(isMobilePanelActive(settled, "agent")).toBe(true); + }); + it("follows programmatic commands through left, center, and right", () => { const panels = new MobilePanelsScenario(); - panels.command("agent-list").finishAnimation("agent-list"); + panels.command("agent-list").settleAt(-1); expect(panels.snapshot()).toEqual({ target: "agent-list", motionTarget: "agent-list", @@ -88,7 +186,7 @@ describe("mobile panel ownership", () => { revision: 1, }); - panels.command("agent").command("file-explorer").finishAnimation("file-explorer"); + panels.command("agent").command("file-explorer").settleAt(1); expect(panels.snapshot()).toEqual({ target: "file-explorer", motionTarget: "file-explorer", @@ -139,13 +237,12 @@ describe("mobile panel ownership", () => { expect(panels.commits).toEqual([]); }); - it("keeps the latest rapid command and rejects stale animation completion", () => { + it("keeps the latest rapid command when position reaches a stale anchor", () => { const panels = new MobilePanelsScenario(); panels.command("agent-list"); - const staleRevision = panels.snapshot().revision; panels.command("agent").command("file-explorer"); - panels.finishAnimation("agent-list", staleRevision); + panels.settleAt(-1); expect(panels.snapshot()).toEqual({ target: "file-explorer", diff --git a/packages/app/src/mobile-panels/model.ts b/packages/app/src/mobile-panels/model.ts index a3c4f246ff..a7b821e82e 100644 --- a/packages/app/src/mobile-panels/model.ts +++ b/packages/app/src/mobile-panels/model.ts @@ -21,6 +21,32 @@ export interface MobilePanelTransition { state: MobilePanelMotionState; } +export function getMobilePanelAnchor(panel: MobilePanelView): number { + "worklet"; + if (panel === "agent-list") { + return -1; + } + if (panel === "file-explorer") { + return 1; + } + return 0; +} + +function settleMobilePanelAtPosition( + state: MobilePanelMotionState, + position: number, +): MobilePanelTransition { + "worklet"; + const isCanonicalMotion = state.target === state.motionTarget; + const isAtTarget = Math.abs(position - getMobilePanelAnchor(state.target)) <= 0.002; + if (state.gesture || !isCanonicalMotion || !isAtTarget || state.settledTarget === state.target) { + return { state }; + } + return { + state: { ...state, settledTarget: state.target }, + }; +} + export type MobilePanelEvent = | { type: "command"; selection: MobilePanelSelection } | { type: "gesture.begin"; origin: MobilePanelView } @@ -30,7 +56,7 @@ export type MobilePanelEvent = success: boolean; target: MobilePanelView; } - | { type: "animation.finished"; revision: number; target: MobilePanelView }; + | { type: "position.changed"; position: number }; export function createMobilePanelMotionState( selection: MobilePanelSelection, @@ -52,8 +78,9 @@ export function transitionMobilePanel( if (event.selection.revision <= state.revision) { return { state }; } + const continuesCurrentMotion = state.motionTarget === event.selection.target; return { - animationTarget: event.selection.target, + animationTarget: continuesCurrentMotion ? undefined : event.selection.target, state: { ...state, ...event.selection, @@ -101,26 +128,7 @@ export function transitionMobilePanel( }; } - const ownsCurrentRevision = event.revision === state.revision; - const isCanonicalTarget = event.target === state.target; - const isCurrentMotionTarget = event.target === state.motionTarget; - if (!ownsCurrentRevision || !isCanonicalTarget || !isCurrentMotionTarget) { - return { state }; - } - return { - state: { ...state, settledTarget: event.target }, - }; -} - -export function getMobilePanelAnchor(panel: MobilePanelView): number { - "worklet"; - if (panel === "agent-list") { - return -1; - } - if (panel === "file-explorer") { - return 1; - } - return 0; + return settleMobilePanelAtPosition(state, event.position); } export function canBeginMobilePanelGesture( @@ -143,6 +151,13 @@ export function isMobilePanelGestureCurrent( return state.revision === startedRevision && state.gesture?.startedRevision === startedRevision; } +export function isMobilePanelActive( + state: MobilePanelMotionState, + panel: MobilePanelView, +): boolean { + return state.settledTarget === panel; +} + export function getMobilePanelFrame(position: number, width: number) { "worklet"; const clampedPosition = Math.max(-1, Math.min(1, position)); diff --git a/packages/app/src/mobile-panels/presentation.tsx b/packages/app/src/mobile-panels/presentation.tsx index bc917419cd..b1d8a0036e 100644 --- a/packages/app/src/mobile-panels/presentation.tsx +++ b/packages/app/src/mobile-panels/presentation.tsx @@ -6,7 +6,7 @@ import { isWeb } from "@/constants/platform"; import { WindowChromeRootRegion } from "@/utils/desktop-window"; import { usePanelStore, type MobilePanelView } from "@/stores/panel-store"; import { getMobilePanelFrame } from "./model"; -import { useIsMobilePanelPresented, useMobilePanelsRuntime } from "./provider"; +import { useIsMobilePanelActive, useMobilePanelsRuntime } from "./provider"; type OverlayPanel = Exclude; @@ -24,10 +24,8 @@ export function MobilePanelOverlay({ panelStyle, }: MobilePanelOverlayProps) { const { position, windowWidth } = useMobilePanelsRuntime(); - const target = usePanelStore((state) => state.mobilePanel.target); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); - const isOpen = target === panel; - const isPresented = useIsMobilePanelPresented(panel); + const isOpen = useIsMobilePanelActive(panel); const isLeft = panel === "agent-list"; const sidebarAnimatedStyle = useAnimatedStyle(() => { @@ -42,10 +40,11 @@ export function MobilePanelOverlay({ return { opacity: isLeft ? frame.leftBackdropOpacity : frame.rightBackdropOpacity }; }, [isLeft, windowWidth]); - const overlayStyle = useMemo( - () => [styles.overlay, { display: isPresented ? ("flex" as const) : ("none" as const) }], - [isPresented], - ); + const overlayAnimatedStyle = useAnimatedStyle(() => { + const isVisible = isLeft ? position.value < 0 : position.value > 0; + return { display: isVisible ? ("flex" as const) : ("none" as const) }; + }, [isLeft]); + const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel; const backdropStyle = useMemo( () => [styles.backdrop, backdropAnimatedStyle], @@ -73,19 +72,25 @@ export function MobilePanelOverlay({ {/* Fabric needs an always-mounted native host to attach the close handler while the retained panel content is hidden. nativeID keeps that host registered. */} - + @@ -93,7 +98,7 @@ export function MobilePanelOverlay({ {children} - + ); diff --git a/packages/app/src/mobile-panels/provider.tsx b/packages/app/src/mobile-panels/provider.tsx index 93e92ab39c..f88e3e6e74 100644 --- a/packages/app/src/mobile-panels/provider.tsx +++ b/packages/app/src/mobile-panels/provider.tsx @@ -15,6 +15,7 @@ import type { GestureType } from "react-native-gesture-handler"; import { cancelAnimation, Easing, + useAnimatedReaction, useSharedValue, withTiming, type SharedValue, @@ -39,18 +40,6 @@ import { const ANIMATION_DURATION = 220; const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); -const LEFT_PANEL_MASK = 1; -const RIGHT_PANEL_MASK = 2; - -function getPanelMask(panel: MobilePanelView): number { - if (panel === "agent-list") { - return LEFT_PANEL_MASK; - } - if (panel === "file-explorer") { - return RIGHT_PANEL_MASK; - } - return 0; -} interface MobilePanelsRuntime { beginGesture: (input: BeginGestureInput) => number; @@ -69,7 +58,6 @@ interface MobilePanelsRuntime { interface BeginGestureInput { origin: MobilePanelView; - preview: MobilePanelView; } interface FinishGestureInput { @@ -79,7 +67,7 @@ interface FinishGestureInput { } const MobilePanelsContext = createContext(null); -const MobilePanelPresentationContext = createContext(0); +const MobilePanelActiveContext = createContext("agent"); export function MobilePanelsProvider({ children }: { children: ReactNode }) { const { width: windowWidth } = useWindowDimensions(); @@ -92,7 +80,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) { const leftCloseGestureRef = useRef(undefined); const rightOpenGestureRef = useRef(undefined); const rightCloseGestureRef = useRef(undefined); - const [presentedPanels, setPresentedPanels] = useState(getPanelMask(initialSelection.target)); + const [activePanel, setActivePanel] = useState(initialSelection.target); const setOpenGestureBlocked = useCallback( (owner: symbol, blocked: boolean) => { @@ -106,21 +94,33 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) { [openGesturesBlocked], ); - const presentPanel = useCallback((panel: MobilePanelView) => { - const mask = getPanelMask(panel); - if (mask) { - setPresentedPanels((current) => current | mask); - } - }, []); - - const settlePresentation = useCallback((panel: MobilePanelView, revision: number) => { + const publishActivePanel = useCallback((panel: MobilePanelView, revision: number) => { const selection = usePanelStore.getState().mobilePanel; if (selection.revision !== revision || selection.target !== panel) { return; } - setPresentedPanels(getPanelMask(panel)); + if (isNative && panel !== "agent") { + Keyboard.dismiss(); + } + setActivePanel(panel); }, []); + useAnimatedReaction( + () => ({ motionState: motionState.value, position: position.value }), + ({ motionState: currentState, position: currentPosition }) => { + const settled = transitionMobilePanel(currentState, { + type: "position.changed", + position: currentPosition, + }); + if (settled.state === currentState) { + return; + } + motionState.value = settled.state; + scheduleOnRN(publishActivePanel, settled.state.settledTarget, settled.state.revision); + }, + [motionState, position, publishActivePanel], + ); + const animateTransition = useCallback( (transition: MobilePanelTransition) => { "worklet"; @@ -128,29 +128,12 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) { return; } const target = transition.animationTarget; - const revision = transition.state.revision; - position.value = withTiming( - getMobilePanelAnchor(target), - { duration: ANIMATION_DURATION, easing: ANIMATION_EASING }, - (finished) => { - if (!finished) { - return; - } - const currentState = motionState.value; - const settled = transitionMobilePanel(currentState, { - type: "animation.finished", - revision, - target, - }); - if (settled.state === currentState) { - return; - } - motionState.value = settled.state; - scheduleOnRN(settlePresentation, target, revision); - }, - ); + position.value = withTiming(getMobilePanelAnchor(target), { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); }, - [motionState, position, settlePresentation], + [position], ); const applySelection = useCallback( @@ -176,18 +159,12 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) { if (selection === previousState.mobilePanel) { return; } - if (selection.target !== "agent") { - presentPanel(selection.target); - if (isNative) { - Keyboard.dismiss(); - } - } scheduleOnUI(applySelection, selection); }); - }, [applySelection, presentPanel]); + }, [applySelection]); const beginGesture = useCallback( - ({ origin, preview }: BeginGestureInput): number => { + ({ origin }: BeginGestureInput): number => { "worklet"; const currentState = motionState.value; if (!canBeginMobilePanelGesture(currentState, origin, position.value)) { @@ -199,10 +176,9 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) { }); motionState.value = transition.state; cancelAnimation(position); - scheduleOnRN(presentPanel, preview); return transition.state.gesture?.startedRevision ?? -1; }, - [motionState, position, presentPanel], + [motionState, position], ); const updateGesture = useCallback( @@ -266,9 +242,9 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) { return ( - + {children} - + ); } @@ -282,9 +258,8 @@ export function useMobilePanelsRuntime(): MobilePanelsRuntime { return context; } -export function useIsMobilePanelPresented(panel: MobilePanelView): boolean { - const presentedPanels = useContext(MobilePanelPresentationContext); - return (presentedPanels & getPanelMask(panel)) !== 0; +export function useIsMobilePanelActive(panel: MobilePanelView): boolean { + return useContext(MobilePanelActiveContext) === panel; } export function useBlockMobilePanelOpenGestures(blocked: boolean): void { diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 6d581aa268..d4062af032 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -1,4 +1,3 @@ -import { Buffer } from "buffer"; import { z } from "zod"; import { AgentStatusSchema, @@ -715,8 +714,32 @@ function pendingRowKey(key: ReplicaRowKey): string { return `${key.serverId}\u0000${rowKey(key)}`; } -function payloadBytes(payload: string): number { - return Buffer.byteLength(payload, "utf8"); +// Budget accounting runs over every stored row of a touched host on each persist. Rows are +// immutable once stored, so their size is computed once. The count itself avoids the JS Buffer +// polyfill, which materialises the whole byte array just to measure it. +const rowBytesCache = new WeakMap(); + +function utf8ByteLength(text: string): number { + let bytes = 0; + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index); + if (code < 0x80) bytes += 1; + else if (code < 0x800) bytes += 2; + else if (code >= 0xd800 && code <= 0xdbff) { + bytes += 4; + index += 1; + } else bytes += 3; + } + return bytes; +} + +function rowBytes(row: ReplicaRow): number { + let bytes = rowBytesCache.get(row); + if (bytes === undefined) { + bytes = utf8ByteLength(row.payload); + rowBytesCache.set(row, bytes); + } + return bytes; } function parseJsonPayload(payload: string): unknown { @@ -1053,7 +1076,7 @@ export class ReplicaCache { this.storedRows.delete(oldServerId); for (const [key, row] of rows) newRows.set(key, { ...row, serverId: newServerId }); this.storedRows.set(newServerId, newRows); - const bytes = [...newRows.values()].reduce((sum, row) => sum + payloadBytes(row.payload), 0); + const bytes = [...newRows.values()].reduce((sum, row) => sum + rowBytes(row), 0); this.hostBytes.delete(oldServerId); this.hostBytes.set(newServerId, bytes); this.totalBytes += bytes; @@ -1237,17 +1260,17 @@ export class ReplicaCache { const previous = rows?.get(rowKey(key)); if (previous) { rows?.delete(rowKey(key)); - this.adjustHostBytes(key.serverId, -payloadBytes(previous.payload)); + this.adjustHostBytes(key.serverId, -rowBytes(previous)); } touchedServerIds.add(key.serverId); } for (const row of changes.upserts) { const rows = this.storedRows.get(row.serverId) ?? new Map(); const previous = rows.get(rowKey(row)); - const previousBytes = previous ? payloadBytes(previous.payload) : 0; + const previousBytes = previous ? rowBytes(previous) : 0; rows.set(rowKey(row), row); this.storedRows.set(row.serverId, rows); - this.adjustHostBytes(row.serverId, payloadBytes(row.payload) - previousBytes); + this.adjustHostBytes(row.serverId, rowBytes(row) - previousBytes); touchedServerIds.add(row.serverId); } for (const serverId of touchedServerIds) { @@ -1288,7 +1311,7 @@ export class ReplicaCache { for (const [serverId, rows] of projectedRows) { projectedBytes.set( serverId, - [...rows.values()].reduce((sum, row) => sum + payloadBytes(row.payload), 0), + [...rows.values()].reduce((sum, row) => sum + rowBytes(row), 0), ); } @@ -1371,7 +1394,7 @@ export class ReplicaCache { continue; } const rows = new Map(host.rows.map((row) => [rowKey(row), row])); - const bytes = host.rows.reduce((sum, row) => sum + payloadBytes(row.payload), 0); + const bytes = host.rows.reduce((sum, row) => sum + rowBytes(row), 0); this.storedRows.set(host.serverId, rows); this.hostBytes.set(host.serverId, bytes); this.totalBytes += bytes;