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;