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.
This commit is contained in:
Mohamed Boudra
2026-09-02 00:52:44 +02:00
committed by GitHub
parent 3998ea11d9
commit 8dd4da6ee0
27 changed files with 692 additions and 278 deletions
+9
View File
@@ -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
+21 -10
View File
@@ -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.
+5
View File
@@ -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
}
}
}
@@ -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<StreamItem, HistoryRowDisplayVariants>();
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],
);
}
@@ -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);
+102 -55
View File
@@ -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<StreamItem, StreamLayoutItem>();
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,
});
}
@@ -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<StreamItem, HistoryRowDisplayVariants>();
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 =
@@ -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<StreamViewportHandle>();
const host = userMessage(1);
const other = userMessage(2);
const renderCountById = new Map<string, number>();
const HistoryRow = React.memo(function HistoryRow({ item }: { item: StreamItem }) {
renderCountById.set(item.id, (renderCountById.get(item.id) ?? 0) + 1);
return <div>{item.id}</div>;
});
const renderInput: StreamRenderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [host, other],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: {
...createRenderers(vi.fn()),
renderHistoryMountedRow: (item) => <HistoryRow item={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<string>(),
displayStateById: new Set<string>(),
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<StreamViewportHandle>();
+12 -1
View File
@@ -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<HTMLElement | null>(null);
+30 -3
View File
@@ -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<string, StreamLayoutItem>;
@@ -229,7 +245,13 @@ function renderHistoryStreamItem(input: {
if (!layoutItem) {
return null;
}
return input.renderStreamItem(layoutItem);
return (
<HistoryStreamRow
item={input.item}
layoutItem={layoutItem}
renderStreamItem={input.renderStreamItem}
/>
);
}
function renderLiveHeadStreamItem(input: {
@@ -789,9 +811,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
);
// Read through a stable event so live group updates do not change the renderer identity
// every tick; history hosts whose group changed are revised through `historyRowRevision`.
const getToolCallGroup = useStableEvent((hostId: string) =>
projectedToolCalls.groupsByHostId.get(hostId),
);
const renderToolCallItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
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<AgentStreamViewHandle, AgentStreamVi
);
},
[
projectedToolCalls.groupsByHostId,
expandedToolCallGroupIds,
getToolCallGroup,
renderSingleToolCallItem,
setToolCallGroupExpanded,
],
+5 -6
View File
@@ -95,7 +95,7 @@ import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelsProvider } from "@/mobile-panels/provider";
import { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider";
import { I18nProvider } from "@/i18n/provider";
import {
KeyboardActionDispatcherProvider,
@@ -114,7 +114,7 @@ import {
useHosts,
} from "@/runtime/host-runtime";
import { getDaemonStartService } from "@/runtime/daemon-start-service";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { usePanelStore } from "@/stores/panel-store";
import { flushDraftPersistStorage } from "@/stores/draft-store";
import { getNextThemePreference } from "@/styles/theme";
import { useSessionStore } from "@/stores/session-store";
@@ -627,10 +627,9 @@ function SidebarChrome({
keyboardShortcutsEnabled: boolean;
}) {
const isCompactLayout = useIsCompactFormFactor();
const isOpen = usePanelStore((state) =>
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 (
<SidebarModelProvider active={active}>
{mounted ? <LeftSidebar active={active} /> : null}
@@ -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 {
@@ -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 (
<RetainedPanelActivity active={isOpen}>
<RetainedPanelActivity active={isActive}>
<MobilePanelOverlay
panel="file-explorer"
closeGesture={closeGesture}
@@ -141,7 +142,7 @@ export function CompactExplorerSidebar({
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isOpen={isOpen}
isOpen={isActive}
onOpenFile={onOpenFile}
/>
</MobilePanelOverlay>
+4 -3
View File
@@ -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
<RetainedPanelActivity active={active}>
<MobileSidebar
{...sharedProps}
active={active}
insetsTop={insets.top}
insetsBottom={insets.bottom}
closeSidebar={showMobileAgent}
@@ -604,6 +605,7 @@ function SidebarFooter({
}
function MobileSidebar({
active,
theme,
workspaceGroups,
projectIconTargets,
@@ -638,7 +640,6 @@ function MobileSidebar({
const isSessionsActive = pathname.includes("/sessions");
const isSchedulesActive = pathname.includes("/schedules");
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
const dragGestureHostPresented = useIsMobilePanelPresented("agent-list");
const handleViewMore = useCallback(() => {
closeSidebar();
@@ -737,7 +738,7 @@ function MobileSidebar({
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
parentGestureRef={closeGestureRef}
dragGestureHostPresented={dragGestureHostPresented}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
@@ -234,7 +234,7 @@ interface SidebarWorkspaceListProps {
listHeaderComponent?: ReactElement | null;
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
parentGestureRef?: MutableRefObject<GestureType | undefined>;
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<string>;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
@@ -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}
/>
) : (
<ProjectModeList
@@ -1977,7 +1977,7 @@ export function SidebarWorkspaceList({
sidebarFilterEmpty={sidebarFilterEmpty}
hasActiveProjectFilter={hasActiveProjectFilter}
parentGestureRef={parentGestureRef}
dragGestureHostPresented={dragGestureHostPresented}
dragGestureHostActive={dragGestureHostActive}
pathname={pathname}
hostBadgeByServerId={hostBadgeByServerId}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
@@ -2010,7 +2010,7 @@ function SidebarGroupedModeList({
listHeaderComponent,
sidebarFilterEmpty,
parentGestureRef,
dragGestureHostPresented,
dragGestureHostActive,
}: {
workspaceGroups: SidebarWorkspaceGroup[];
pinnedGroups: PinnedSidebarGroups;
@@ -2025,7 +2025,7 @@ function SidebarGroupedModeList({
listHeaderComponent?: ReactElement | null;
sidebarFilterEmpty: boolean;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
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 ? (
@@ -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<GestureType | undefined>;
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 ? (
<SidebarGroupToggleRow
@@ -131,6 +131,46 @@ describe("EditingTextInputNative", () => {
expect(document.activeElement).toBe(container?.querySelector("input"));
});
it("focuses the replacement input when focus is requested before a cleared input remounts", () => {
const handleRef = createRef<EditingTextInputHandle>();
act(() => {
root?.render(<EditingTextInput ref={handleRef} initialValue="stale" />);
});
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<EditingTextInputHandle>();
act(() => {
root?.render(<EditingTextInput ref={handleRef} initialValue="stale" />);
});
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<EditingTextInputHandle>();
@@ -35,31 +35,54 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
const inputRef = useRef<NativeInput | null>(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<EditingTextInputHandle, EditingTextIn
[onPasteError, onPasteImages],
);
const autoFocus = replacement.revision === 0 ? props.autoFocus : replacement.autoFocus;
if (onPasteImages || onPasteError) {
return (
<PasteInput
{...props}
key={nativeInputRevision}
autoFocus={autoFocus}
key={replacement.revision}
ref={assignInputRef as React.Ref<PasteTextInputInstance>}
defaultValue={textRef.current}
onChangeText={handleChangeText}
@@ -109,7 +135,8 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
return (
<BottomSheetTextInput
{...props}
key={nativeInputRevision}
autoFocus={autoFocus}
key={replacement.revision}
ref={assignInputRef as unknown as React.Ref<never>}
defaultValue={textRef.current}
onChangeText={handleChangeText}
@@ -119,7 +146,8 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
return (
<TextInput
{...props}
key={nativeInputRevision}
autoFocus={autoFocus}
key={replacement.revision}
ref={assignInputRef as React.Ref<TextInput>}
defaultValue={textRef.current}
onChangeText={handleChangeText}
@@ -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);
}
@@ -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(
@@ -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;
}
},
},
+1 -4
View File
@@ -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) => {
+104 -7
View File
@@ -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",
+37 -22
View File
@@ -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));
+16 -11
View File
@@ -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<MobilePanelView, "agent">;
@@ -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. */}
<View
accessibilityElementsHidden={!isOpen}
collapsable={false}
importantForAccessibility={isOpen ? "auto" : "no-hide-descendants"}
nativeID={`${panel}-gesture-host`}
pointerEvents={overlayPointerEvents}
style={styles.overlay}
>
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
<Animated.View
pointerEvents={overlayPointerEvents}
style={[styles.overlay, overlayAnimatedStyle]}
>
<Pressable
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
onPress={showMobileAgent}
pointerEvents={isOpen ? "auto" : "none"}
style={StyleSheet.absoluteFillObject}
testID={`${panel}-backdrop`}
testID={isOpen ? `${panel}-backdrop` : undefined}
>
<Animated.View pointerEvents="none" style={backdropStyle} />
</Pressable>
@@ -93,7 +98,7 @@ export function MobilePanelOverlay({
<Animated.View pointerEvents={isOpen ? "auto" : "none"} style={combinedPanelStyle}>
<WindowChromeRootRegion corners="both">{children}</WindowChromeRootRegion>
</Animated.View>
</View>
</Animated.View>
</View>
</GestureDetector>
);
+36 -61
View File
@@ -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<MobilePanelsRuntime | null>(null);
const MobilePanelPresentationContext = createContext(0);
const MobilePanelActiveContext = createContext<MobilePanelView>("agent");
export function MobilePanelsProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
@@ -92,7 +80,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
const leftCloseGestureRef = useRef<GestureType | undefined>(undefined);
const rightOpenGestureRef = useRef<GestureType | undefined>(undefined);
const rightCloseGestureRef = useRef<GestureType | undefined>(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 (
<MobilePanelsContext.Provider value={value}>
<MobilePanelPresentationContext.Provider value={presentedPanels}>
<MobilePanelActiveContext.Provider value={activePanel}>
{children}
</MobilePanelPresentationContext.Provider>
</MobilePanelActiveContext.Provider>
</MobilePanelsContext.Provider>
);
}
@@ -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 {
@@ -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<ReplicaRow, number>();
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<string, ReplicaRow>();
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;