Fix bottom anchoring and Claude wake routing

This commit is contained in:
Mohamed Boudra
2026-03-08 12:54:08 +07:00
parent 0c44e7db80
commit b339c5e61d
27 changed files with 3801 additions and 326 deletions
@@ -66,6 +66,7 @@ interface AgentInputAreaProps {
commandDraftConfig?: DraftCommandConfig
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
onMessageSent?: () => void
onComposerHeightChange?: (height: number) => void
/** Controlled status controls rendered in input area (draft flows). */
statusControls?: DraftAgentStatusBarProps
}
@@ -85,6 +86,7 @@ export function AgentInputArea({
onAddImages,
commandDraftConfig,
onMessageSent,
onComposerHeightChange,
statusControls,
}: AgentInputAreaProps) {
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
@@ -823,6 +825,7 @@ export function AgentInputArea({
setCursorIndex(selection.start)
}}
onFocusChange={setIsMessageInputFocused}
onHeightChange={onComposerHeightChange}
/>
</View>
</View>
@@ -9,6 +9,7 @@ import {
isNearBottomForStreamRenderStrategy,
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior,
resolveStreamRenderStrategy,
} from "./agent-stream-render-strategy";
@@ -45,6 +46,10 @@ describe("resolveStreamRenderStrategy", () => {
expect(strategy.getFlatListInverted()).toBe(false);
expect(strategy.getOverlayScrollbarInverted()).toBe(false);
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(true);
expect(strategy.getBottomAnchorTransportBehavior()).toEqual({
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
});
});
it("uses inverted_stream on native", () => {
@@ -57,6 +62,44 @@ describe("resolveStreamRenderStrategy", () => {
expect(strategy.getFlatListInverted()).toBe(true);
expect(strategy.getOverlayScrollbarInverted()).toBe(true);
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(false);
expect(strategy.getBottomAnchorTransportBehavior()).toEqual({
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
});
});
it("delays native verification while viewport settling is in flight", () => {
const strategy = resolveStreamRenderStrategy({
platform: "ios",
isMobileBreakpoint: false,
});
expect(
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: true,
})
).toEqual({
verificationDelayFrames: 4,
verificationRetryMode: "recheck",
});
});
it("does not inflate forward-stream verification delays during web resize", () => {
const strategy = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
expect(
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: true,
})
).toEqual({
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
});
});
});
@@ -11,6 +11,11 @@ export type MaintainVisibleContentPositionConfig = Readonly<{
autoscrollToTopThreshold: number;
}>;
export type BottomAnchorTransportBehavior = Readonly<{
verificationDelayFrames: number;
verificationRetryMode: "rescroll" | "recheck";
}>;
export type StreamViewportMetrics = {
contentHeight: number;
viewportHeight: number;
@@ -29,7 +34,7 @@ export type StreamEdgeSlotProps = {
};
export type StreamRenderRefs = {
flatListRef: RefObject<FlatList<StreamItem> | null>;
flatListRef: RefObject<FlatList<any> | null>;
scrollViewRef: RefObject<ScrollView | null>;
bottomAnchorRef: RefObject<View | null>;
};
@@ -58,6 +63,7 @@ export interface StreamRenderStrategy {
getMaintainVisibleContentPosition: () =>
| MaintainVisibleContentPositionConfig
| undefined;
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
getFlatListInverted: () => boolean;
getOverlayScrollbarInverted: () => boolean;
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
@@ -76,6 +82,8 @@ export interface StreamRenderStrategy {
}) => void;
}
const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4;
type StreamRenderStrategyConfig = {
orderTailReverse: boolean;
orderHeadReverse: boolean;
@@ -84,6 +92,7 @@ type StreamRenderStrategyConfig = {
flatListInverted: boolean;
overlayScrollbarInverted: boolean;
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
bottomAnchorTransportBehavior: BottomAnchorTransportBehavior;
disableParentScrollOnInlineDetailsExpansion: boolean;
anchorBottomOnContentSizeChange: boolean;
animateManualScrollToBottom: boolean;
@@ -137,28 +146,26 @@ function scrollAnchorIntoView(params: {
return true;
}
function forceScrollContainerToBottom(
refs: StreamRenderRefs,
fallbackOffset: number
): void {
const resolveNode = (
input: unknown
): HTMLElement | null => {
if (!(input instanceof HTMLElement)) {
return null;
}
if (input.scrollHeight - input.clientHeight > 1) {
return input;
}
let node: HTMLElement | null = input.parentElement;
while (node) {
if (node.scrollHeight - node.clientHeight > 1) {
return node;
}
node = node.parentElement;
}
function resolvePotentialScrollNode(input: unknown): HTMLElement | null {
if (!(input instanceof HTMLElement)) {
return null;
};
}
if (input.scrollHeight - input.clientHeight > 1) {
return input;
}
let node: HTMLElement | null = input.parentElement;
while (node) {
if (node.scrollHeight - node.clientHeight > 1) {
return node;
}
node = node.parentElement;
}
return input;
}
export function resolveWebScrollContainerNode(
refs: StreamRenderRefs
): HTMLElement | null {
const scrollViewHandle = refs.scrollViewRef.current as
| {
@@ -185,18 +192,43 @@ function forceScrollContainerToBottom(
let scrollNode: HTMLElement | null = null;
for (const candidate of candidates) {
scrollNode = resolveNode(candidate);
scrollNode = resolvePotentialScrollNode(candidate);
if (scrollNode) {
break;
}
}
if (!scrollNode && typeof document !== "undefined") {
scrollNode = resolveNode(
scrollNode = resolvePotentialScrollNode(
document.querySelector("[data-testid='agent-chat-scroll']")
);
}
return scrollNode;
}
export function resolveBottomAnchorTransportBehavior(input: {
strategy: StreamRenderStrategy;
isViewportSettling: boolean;
}): BottomAnchorTransportBehavior {
const baseBehavior = input.strategy.getBottomAnchorTransportBehavior();
if (!input.isViewportSettling || !input.strategy.getFlatListInverted()) {
return baseBehavior;
}
return {
verificationDelayFrames: Math.max(
baseBehavior.verificationDelayFrames,
NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES
),
verificationRetryMode: "recheck",
};
}
function forceScrollContainerToBottom(
refs: StreamRenderRefs,
fallbackOffset: number
): void {
const scrollNode = resolveWebScrollContainerNode(refs);
if (!scrollNode) {
return;
}
@@ -267,6 +299,7 @@ function createStreamRenderStrategy(
};
},
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
getBottomAnchorTransportBehavior: () => config.bottomAnchorTransportBehavior,
getFlatListInverted: () => config.flatListInverted,
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
@@ -289,6 +322,10 @@ function createInvertedStreamStrategy(): StreamRenderStrategy {
flatListInverted: true,
overlayScrollbarInverted: true,
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
bottomAnchorTransportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
disableParentScrollOnInlineDetailsExpansion: false,
anchorBottomOnContentSizeChange: false,
animateManualScrollToBottom: true,
@@ -316,6 +353,10 @@ function createForwardStreamStrategy(): StreamRenderStrategy {
flatListInverted: false,
overlayScrollbarInverted: false,
maintainVisibleContentPosition: undefined,
bottomAnchorTransportBehavior: {
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
},
disableParentScrollOnInlineDetailsExpansion: false,
anchorBottomOnContentSizeChange: true,
animateManualScrollToBottom: false,
@@ -335,6 +376,14 @@ function createForwardStreamStrategy(): StreamRenderStrategy {
0,
metrics.contentHeight - metrics.viewportHeight
);
if (refs.flatListRef.current) {
refs.flatListRef.current.scrollToEnd?.({ animated });
refs.flatListRef.current.scrollToOffset?.({
offset: bottomOffset,
animated,
});
return;
}
const usedAnchor = scrollAnchorIntoView({ refs, animated });
if (!usedAnchor) {
refs.scrollViewRef.current?.scrollToEnd?.({ animated });
@@ -347,6 +396,10 @@ function createForwardStreamStrategy(): StreamRenderStrategy {
forceScrollContainerToBottom(refs, bottomOffset);
},
scrollToOffset: ({ refs, offset, animated }) => {
if (refs.flatListRef.current) {
refs.flatListRef.current.scrollToOffset?.({ offset, animated });
return;
}
refs.scrollViewRef.current?.scrollTo({ y: offset, animated });
},
});
+424 -158
View File
@@ -21,9 +21,9 @@ import {
LayoutChangeEvent,
NativeScrollEvent,
NativeSyntheticEvent,
InteractionManager,
Platform,
ActivityIndicator,
Keyboard,
} from "react-native";
import Markdown from "react-native-markdown-display";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -75,9 +75,22 @@ import {
isNearBottomForStreamRenderStrategy,
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior,
resolveWebScrollContainerNode,
resolveStreamRenderStrategy,
type StreamEdgeSlotProps,
} from "./agent-stream-render-strategy";
import {
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
splitWebVirtualizedHistory,
type IndexedStreamItem,
} from "./agent-stream-web-virtualization";
import {
useBottomAnchorController,
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
@@ -109,7 +122,8 @@ function renderStreamEdgeComponent(
}
export interface AgentStreamViewHandle {
scrollToBottom(): void;
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
prepareForViewportChange(): void;
}
export interface AgentStreamViewProps {
@@ -118,6 +132,8 @@ export interface AgentStreamViewProps {
agent: Agent;
streamItems: StreamItem[];
pendingPermissions: Map<string, PendingPermission>;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
}
export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(function AgentStreamView({
@@ -126,8 +142,10 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
agent,
streamItems,
pendingPermissions,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
}, ref) {
const flatListRef = useRef<FlatList<StreamItem>>(null);
const flatListRef = useRef<FlatList<any>>(null);
const scrollViewRef = useRef<ScrollView>(null);
const bottomAnchorRef = useRef<View>(null);
const { theme } = useUnistyles();
@@ -145,19 +163,23 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const insets = useSafeAreaInsets();
const [isNearBottom, setIsNearBottom] = useState(true);
const hasScrolledInitially = useRef(false);
const hasAutoScrolledOnce = useRef(false);
const isNearBottomRef = useRef(true);
const pendingAnchorRequestRef = useRef(false);
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollAnimatedRef = useRef(false);
const scrollOffsetYRef = useRef(0);
const programmaticScrollEventBudgetRef = useRef(0);
const streamItemCountRef = useRef(0);
const previousStreamItemsRef = useRef<StreamItem[] | null>(null);
const streamViewportMetricsRef = useRef({
containerKey: "unknown",
contentHeight: 0,
viewportWidth: 0,
viewportHeight: 0,
offsetY: 0,
viewportMeasuredForKey: null as string | null,
contentMeasuredForKey: null as string | null,
});
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
@@ -165,6 +187,38 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
() => ({ flatListRef, scrollViewRef, bottomAnchorRef }),
[]
);
const clearNativeViewportSettling = useCallback(() => {
if (nativeViewportSettlingFrameIdRef.current !== null) {
cancelAnimationFrame(nativeViewportSettlingFrameIdRef.current);
nativeViewportSettlingFrameIdRef.current = null;
}
}, []);
const markNativeViewportSettling = useCallback(() => {
if (Platform.OS === "web" || !streamRenderStrategy.getFlatListInverted()) {
return;
}
clearNativeViewportSettling();
setIsNativeViewportSettling(true);
let remainingFrames = 4;
const tick = () => {
if (remainingFrames <= 0) {
nativeViewportSettlingFrameIdRef.current = null;
setIsNativeViewportSettling(false);
return;
}
remainingFrames -= 1;
nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick);
};
nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick);
}, [clearNativeViewportSettling, streamRenderStrategy]);
const bottomAnchorTransportBehavior = useMemo(
() =>
resolveBottomAnchorTransportBehavior({
strategy: streamRenderStrategy,
isViewportSettling: isNativeViewportSettling,
}),
[isNativeViewportSettling, streamRenderStrategy]
);
// Get serverId (fallback to agent's serverId if not provided)
const resolvedServerId = serverId ?? agent.serverId ?? "";
@@ -194,12 +248,52 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
: FadeOut.duration(200);
useEffect(() => {
hasScrolledInitially.current = false;
hasAutoScrolledOnce.current = false;
isNearBottomRef.current = true;
pendingAnchorRequestRef.current = false;
scrollOffsetYRef.current = 0;
programmaticScrollEventBudgetRef.current = 0;
previousStreamItemsRef.current = null;
streamViewportMetricsRef.current = {
containerKey: "unknown",
contentHeight: 0,
viewportWidth: 0,
viewportHeight: 0,
offsetY: 0,
viewportMeasuredForKey: null,
contentMeasuredForKey: null,
};
setExpandedInlineToolCallIds(new Set());
}, [agentId]);
clearNativeViewportSettling();
setIsNativeViewportSettling(false);
}, [agentId, clearNativeViewportSettling]);
useEffect(() => {
if (Platform.OS === "web" || !streamRenderStrategy.getFlatListInverted()) {
return;
}
const keyboardEvents = [
"keyboardWillShow",
"keyboardWillHide",
"keyboardDidShow",
"keyboardDidHide",
"keyboardWillChangeFrame",
"keyboardDidChangeFrame",
] as const;
const subscriptions = keyboardEvents.map((eventName) =>
Keyboard.addListener(eventName, () => {
markNativeViewportSettling();
})
);
return () => {
for (const subscription of subscriptions) {
subscription.remove();
}
clearNativeViewportSettling();
};
}, [
clearNativeViewportSettling,
markNativeViewportSettling,
streamRenderStrategy,
]);
const handleInlinePathPress = useCallback(
(target: InlinePathTarget) => {
@@ -252,23 +346,112 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
setIsNearBottom(value);
}, []);
const requestAnchorToBottom = useCallback(() => {
pendingAnchorRequestRef.current = true;
}, []);
const flatListData = useMemo(() => {
return orderTailForStreamRenderStrategy({
strategy: streamRenderStrategy,
streamItems,
});
}, [streamItems, streamRenderStrategy]);
const indexedFlatListData = useMemo<IndexedStreamItem[]>(
() => flatListData.map((item, index) => ({ item, index })),
[flatListData]
);
const usesVirtualizedList = streamRenderStrategy.shouldUseVirtualizedList();
const shouldUseWebPartialVirtualization =
Platform.OS === "web" &&
!isMobile &&
!usesVirtualizedList &&
indexedFlatListData.length > getWebPartialVirtualizationThreshold();
const currentContainerKey = usesVirtualizedList
? "native-virtualized"
: shouldUseWebPartialVirtualization
? "web-partial-virtualized"
: "scroll-view";
useEffect(() => {
const current = streamViewportMetricsRef.current;
if (current.containerKey === currentContainerKey) {
return;
}
streamViewportMetricsRef.current = {
containerKey: currentContainerKey,
contentHeight: 0,
viewportWidth: 0,
viewportHeight: 0,
offsetY: 0,
viewportMeasuredForKey: null,
contentMeasuredForKey: null,
};
scrollOffsetYRef.current = 0;
}, [currentContainerKey]);
const scrollToBottomInternal = useCallback(
({ animated }: { animated: boolean }) => {
const targetOffset = streamRenderStrategy.getBottomOffset(
streamViewportMetricsRef.current
);
programmaticScrollEventBudgetRef.current = 3;
streamRenderStrategy.scrollToBottom({
refs: streamRenderRefs,
metrics: streamViewportMetricsRef.current,
animated,
});
scrollOffsetYRef.current = targetOffset;
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
offsetY: targetOffset,
};
updateNearBottom(true);
},
[updateNearBottom, streamRenderRefs, streamRenderStrategy]
);
const bottomAnchorController = useBottomAnchorController({
agentId,
routeRequest: routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
renderStrategy:
Platform.OS === "web" ? "forward-stream" : "inverted-stream",
transportBehavior: bottomAnchorTransportBehavior,
getMeasurementState: () => streamViewportMetricsRef.current,
isNearBottom: () => {
const metrics = streamViewportMetricsRef.current;
return isNearBottomForStreamRenderStrategy({
strategy: streamRenderStrategy,
offsetY: metrics.offsetY,
threshold: Math.max(insets.bottom, 32),
contentHeight: metrics.contentHeight,
viewportHeight: metrics.viewportHeight,
});
},
scrollToBottom: (animated) => {
scrollToBottomInternal({ animated });
},
});
useEffect(() => {
if (previousStreamItemsRef.current === null) {
previousStreamItemsRef.current = streamItems;
return;
}
previousStreamItemsRef.current = streamItems;
bottomAnchorController.prepareForStickyContentChange();
}, [bottomAnchorController, streamItems]);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const previousOffsetY = scrollOffsetYRef.current;
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
scrollOffsetYRef.current = contentOffset.y;
streamViewportMetricsRef.current = {
contentHeight: Math.max(0, contentSize.height),
viewportWidth: Math.max(0, layoutMeasurement.width),
viewportHeight: Math.max(0, layoutMeasurement.height),
containerKey: currentContainerKey,
offsetY: contentOffset.y,
viewportMeasuredForKey: currentContainerKey,
contentMeasuredForKey: currentContainerKey,
};
const offsetDelta = contentOffset.y - previousOffsetY;
const contentHeightDelta =
streamViewportMetricsRef.current.contentHeight - previousContentHeight;
const threshold = Math.max(insets.bottom, 32);
const nearBottom = isNearBottomForStreamRenderStrategy({
strategy: streamRenderStrategy,
@@ -277,23 +460,24 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
contentHeight: streamViewportMetricsRef.current.contentHeight,
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
});
updateNearBottom(nearBottom);
const expectedBottomOffset = streamRenderStrategy.getBottomOffset(
streamViewportMetricsRef.current
);
const isProgrammaticBottomEvent =
Math.abs(contentOffset.y - expectedBottomOffset) <= 8;
const pendingAnchorBefore = pendingAnchorRequestRef.current;
const shouldSuppressFalseNearBottom =
pendingAnchorBefore &&
!nearBottom &&
Math.abs(offsetDelta) <= 1 &&
contentHeightDelta > 0;
if (shouldSuppressFalseNearBottom) {
updateNearBottom(true);
if (
programmaticScrollEventBudgetRef.current > 0 &&
isProgrammaticBottomEvent
) {
programmaticScrollEventBudgetRef.current -= 1;
} else {
updateNearBottom(nearBottom);
}
const shouldClearPendingAnchor =
pendingAnchorBefore && !nearBottom && Math.abs(offsetDelta) > 1;
if (shouldClearPendingAnchor) {
pendingAnchorRequestRef.current = false;
programmaticScrollEventBudgetRef.current = 0;
bottomAnchorController.handleScrollNearBottomChange({
nextIsNearBottom: nearBottom,
scrollDelta: contentOffset.y - previousOffsetY,
});
}
if (showDesktopWebScrollbar) {
@@ -305,162 +489,151 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
showDesktopWebScrollbar,
streamRenderStrategy,
streamScrollbarMetrics,
currentContainerKey,
bottomAnchorController,
updateNearBottom,
]
);
const handleListLayout = useCallback(
(event: LayoutChangeEvent) => {
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
const viewportWidth = Math.max(0, event.nativeEvent.layout.width);
const viewportHeight = Math.max(0, event.nativeEvent.layout.height);
const viewportChanged =
(previousViewportWidth > 0 && previousViewportWidth !== viewportWidth) ||
(previousViewportHeight > 0 && previousViewportHeight !== viewportHeight);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
viewportHeight: Math.max(0, event.nativeEvent.layout.height),
containerKey: currentContainerKey,
viewportWidth,
viewportHeight,
viewportMeasuredForKey: currentContainerKey,
};
if (viewportChanged) {
markNativeViewportSettling();
}
bottomAnchorController.handleViewportMetricsChange({
previousViewportWidth,
viewportWidth,
previousViewportHeight,
viewportHeight,
});
if (showDesktopWebScrollbar) {
streamScrollbarMetrics.onLayout(event);
}
},
[showDesktopWebScrollbar, streamScrollbarMetrics]
);
const scrollToBottomInternal = useCallback(
({ animated }: { animated: boolean }) => {
const targetOffset = streamRenderStrategy.getBottomOffset(
streamViewportMetricsRef.current
);
streamRenderStrategy.scrollToBottom({
refs: streamRenderRefs,
metrics: streamViewportMetricsRef.current,
animated,
});
scrollOffsetYRef.current = targetOffset;
updateNearBottom(true);
},
[updateNearBottom, streamRenderRefs, streamRenderStrategy]
[
showDesktopWebScrollbar,
streamScrollbarMetrics,
currentContainerKey,
markNativeViewportSettling,
bottomAnchorController,
]
);
useImperativeHandle(ref, () => ({
scrollToBottom() {
requestAnchorToBottom();
scrollToBottom(reason = "jump-to-bottom") {
bottomAnchorController.requestLocalAnchor({ agentId, reason });
},
}), [requestAnchorToBottom]);
prepareForViewportChange() {
bottomAnchorController.prepareForStickyViewportChange();
},
}), [agentId, bottomAnchorController]);
const handleContentSizeChange = useCallback(
(width: number, height: number) => {
const previousMetrics = streamViewportMetricsRef.current;
const threshold = Math.max(insets.bottom, 32);
const wasNearBottom = isNearBottomForStreamRenderStrategy({
strategy: streamRenderStrategy,
offsetY: scrollOffsetYRef.current,
threshold,
contentHeight: previousMetrics.contentHeight,
viewportHeight: previousMetrics.viewportHeight,
});
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
const nextContentHeight = Math.max(0, height);
streamViewportMetricsRef.current = {
...previousMetrics,
contentHeight: Math.max(0, height),
...streamViewportMetricsRef.current,
containerKey: currentContainerKey,
contentHeight: nextContentHeight,
contentMeasuredForKey: currentContainerKey,
};
if (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) {
if (!hasAutoScrolledOnce.current) {
scrollToBottomInternal({ animated: false });
hasAutoScrolledOnce.current = true;
hasScrolledInitially.current = true;
} else if (
wasNearBottom ||
isNearBottomRef.current ||
pendingAnchorRequestRef.current
) {
scrollToBottomInternal({ animated: false });
}
}
bottomAnchorController.handleContentSizeChange({
previousContentHeight,
contentHeight: nextContentHeight,
});
if (showDesktopWebScrollbar) {
streamScrollbarMetrics.onContentSizeChange(width, height);
}
},
[
insets.bottom,
scrollToBottomInternal,
showDesktopWebScrollbar,
streamRenderStrategy,
streamScrollbarMetrics,
currentContainerKey,
bottomAnchorController,
]
);
const scheduleAutoScroll = useCallback(
({ animated }: { animated: boolean }) => {
pendingAutoScrollAnimatedRef.current =
pendingAutoScrollAnimatedRef.current || animated;
if (pendingAutoScrollFrameRef.current !== null) {
return;
}
pendingAutoScrollFrameRef.current = requestAnimationFrame(() => {
pendingAutoScrollFrameRef.current = null;
const shouldAnimate = pendingAutoScrollAnimatedRef.current;
pendingAutoScrollAnimatedRef.current = false;
scrollToBottomInternal({ animated: shouldAnimate });
});
},
[scrollToBottomInternal]
);
useEffect(() => {
return () => {
if (pendingAutoScrollFrameRef.current !== null) {
cancelAnimationFrame(pendingAutoScrollFrameRef.current);
pendingAutoScrollFrameRef.current = null;
}
pendingAutoScrollAnimatedRef.current = false;
if (
Platform.OS !== "web" ||
typeof ResizeObserver === "undefined"
) {
return;
}
const scrollNode = resolveWebScrollContainerNode(streamRenderRefs);
if (!scrollNode) {
return;
}
const applyObservedMetrics = () => {
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
const viewportWidth = Math.max(0, scrollNode.clientWidth);
const viewportHeight = Math.max(0, scrollNode.clientHeight);
const contentHeight = Math.max(0, scrollNode.scrollHeight);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
containerKey: currentContainerKey,
viewportWidth,
viewportHeight,
contentHeight,
viewportMeasuredForKey: currentContainerKey,
contentMeasuredForKey: currentContainerKey,
};
bottomAnchorController.handleViewportMetricsChange({
previousViewportWidth,
viewportWidth,
previousViewportHeight,
viewportHeight,
});
bottomAnchorController.handleContentSizeChange({
previousContentHeight,
contentHeight,
});
};
}, []);
useEffect(() => {
if (streamItems.length === 0) {
return;
applyObservedMetrics();
const resizeObserver = new ResizeObserver(() => {
applyObservedMetrics();
});
resizeObserver.observe(scrollNode);
const contentNode = scrollNode.firstElementChild;
if (contentNode instanceof HTMLElement) {
resizeObserver.observe(contentNode);
}
if (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) {
// Forward streams anchor from measurement updates in handleContentSizeChange.
return;
}
if (!hasAutoScrolledOnce.current) {
const handle = InteractionManager.runAfterInteractions(() => {
scrollToBottomInternal({ animated: false });
hasAutoScrolledOnce.current = true;
hasScrolledInitially.current = true;
});
return () => handle.cancel();
}
if (!isNearBottomRef.current && !pendingAnchorRequestRef.current) {
return;
}
const shouldAnimate = hasScrolledInitially.current;
scheduleAutoScroll({ animated: shouldAnimate });
hasScrolledInitially.current = true;
}, [
scheduleAutoScroll,
scrollToBottomInternal,
streamItems,
streamRenderStrategy,
]);
return () => {
resizeObserver.disconnect();
};
}, [bottomAnchorController, currentContainerKey, streamRenderRefs]);
function scrollToBottom() {
const animated = streamRenderStrategy.shouldAnimateManualScrollToBottom();
scrollToBottomInternal({ animated });
}
const flatListData = useMemo(() => {
return orderTailForStreamRenderStrategy({
strategy: streamRenderStrategy,
streamItems,
bottomAnchorController.requestLocalAnchor({
agentId,
reason: "jump-to-bottom",
});
}, [streamItems, streamRenderStrategy]);
}
const orderedStreamHead = useMemo(() => {
return orderHeadForStreamRenderStrategy({
@@ -790,7 +963,35 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
const showWorkingIndicator = agent.status === "running";
const showBottomBar = showWorkingIndicator;
const usesVirtualizedList = streamRenderStrategy.shouldUseVirtualizedList();
const webVirtualizedHistoryWindow = useMemo(() => {
if (!shouldUseWebPartialVirtualization) {
return null;
}
return splitWebVirtualizedHistory({
entries: indexedFlatListData,
minMountedCount: getWebMountedRecentStreamItems(),
});
}, [indexedFlatListData, shouldUseWebPartialVirtualization]);
const renderIndexedStreamItem = useCallback(
(entry: IndexedStreamItem) =>
renderStreamItem({
item: entry.item,
index: entry.index,
separators: NOOP_SEPARATORS,
}),
[renderStreamItem]
);
const renderWebVirtualizedStreamItem = useCallback(
({ item }: ListRenderItemInfo<IndexedStreamItem>) => {
const rendered = renderIndexedStreamItem(item);
if (!rendered) {
return null;
}
return <Fragment key={item.item.id}>{rendered}</Fragment>;
},
[renderIndexedStreamItem]
);
const listEdgeSlotComponent = useMemo(() => {
const hasPermissions = pendingPermissionItems.length > 0;
@@ -929,22 +1130,59 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
listEdgeSlotProps.ListFooterComponent
);
const nonVirtualizedItems = useMemo(() => {
if (flatListData.length === 0) {
if (indexedFlatListData.length === 0) {
return null;
}
return flatListData.map((item, index) => {
const rendered = renderStreamItem({
item,
index,
separators: NOOP_SEPARATORS,
});
return indexedFlatListData.map((entry) => {
const rendered = renderIndexedStreamItem(entry);
if (!rendered) {
return null;
}
return <Fragment key={item.id}>{rendered}</Fragment>;
return <Fragment key={entry.item.id}>{rendered}</Fragment>;
});
}, [flatListData, renderStreamItem]);
}, [indexedFlatListData, renderIndexedStreamItem]);
const webVirtualizedListHeader = useMemo(() => {
if (!shouldUseWebPartialVirtualization || !headerEdgeContent) {
return null;
}
return (
<View style={listEdgeSlotProps.ListHeaderComponentStyle}>
{headerEdgeContent}
</View>
);
}, [
headerEdgeContent,
listEdgeSlotProps.ListHeaderComponentStyle,
shouldUseWebPartialVirtualization,
]);
const webVirtualizedListFooter = useMemo(() => {
if (!shouldUseWebPartialVirtualization || !webVirtualizedHistoryWindow) {
return null;
}
return (
<>
{webVirtualizedHistoryWindow.mountedEntries.map((entry) => (
<Fragment key={entry.item.id}>
{renderIndexedStreamItem(entry)}
</Fragment>
))}
{footerEdgeContent ? (
<View style={listEdgeSlotProps.ListFooterComponentStyle}>
{footerEdgeContent}
</View>
) : null}
<View ref={bottomAnchorRef} collapsable={false} />
</>
);
}, [
footerEdgeContent,
listEdgeSlotProps.ListFooterComponentStyle,
renderIndexedStreamItem,
shouldUseWebPartialVirtualization,
webVirtualizedHistoryWindow,
]);
return (
<ToolCallSheetProvider>
@@ -957,6 +1195,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
renderItem={renderStreamItem}
keyExtractor={(item) => item.id}
testID="agent-chat-scroll"
nativeID={`agent-chat-scroll-${currentContainerKey}`}
{...listEdgeSlotProps}
contentContainerStyle={listContentContainerStyle}
style={stylesheet.list}
@@ -975,10 +1214,34 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
inverted={streamRenderStrategy.getFlatListInverted()}
/>
) : shouldUseWebPartialVirtualization && webVirtualizedHistoryWindow ? (
<FlatList
ref={flatListRef}
data={webVirtualizedHistoryWindow.virtualizedEntries}
renderItem={renderWebVirtualizedStreamItem}
keyExtractor={(entry) => entry.item.id}
testID="agent-chat-scroll"
nativeID={`agent-chat-scroll-${currentContainerKey}`}
ListHeaderComponent={webVirtualizedListHeader}
ListFooterComponent={webVirtualizedListFooter}
contentContainerStyle={listContentContainerStyle}
style={stylesheet.list}
onLayout={handleListLayout}
onScroll={handleScroll}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
extraData={flatListExtraData}
initialNumToRender={12}
windowSize={10}
scrollEnabled={streamScrollEnabled}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
inverted={false}
/>
) : (
<ScrollView
ref={scrollViewRef}
testID="agent-chat-scroll"
nativeID={`agent-chat-scroll-${currentContainerKey}`}
contentContainerStyle={listContentContainerStyle}
style={stylesheet.list}
onLayout={handleListLayout}
@@ -1028,6 +1291,9 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
<Pressable
style={stylesheet.scrollToBottomButton}
onPress={scrollToBottom}
accessibilityRole="button"
accessibilityLabel="Scroll to bottom"
testID="scroll-to-bottom-button"
>
<ChevronDown
size={24}
@@ -0,0 +1,173 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import {
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
estimateStreamItemHeight,
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
splitWebVirtualizedHistory,
type IndexedStreamItem,
} from "./agent-stream-web-virtualization";
function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
}
function userMessage(id: string, seed: number): StreamItem {
return {
kind: "user_message",
id,
text: id,
timestamp: createTimestamp(seed),
};
}
function assistantMessage(id: string, seed: number): StreamItem {
return {
kind: "assistant_message",
id,
text: id,
timestamp: createTimestamp(seed),
};
}
function toolCall(id: string, seed: number): StreamItem {
return {
kind: "tool_call",
id,
timestamp: createTimestamp(seed),
payload: {
source: "orchestrator",
data: {
toolCallId: id,
toolName: "test_tool",
arguments: {},
status: "completed",
},
},
};
}
function indexEntries(items: StreamItem[]): IndexedStreamItem[] {
return items.map((item, index) => ({ item, index }));
}
describe("findMountedWindowStart", () => {
it("keeps all items mounted when the chat is below the threshold", () => {
const items = [userMessage("u1", 1), assistantMessage("a1", 2)];
expect(
findMountedWindowStart({
items,
minMountedCount: 50,
})
).toBe(0);
});
it("rewinds to the previous user boundary when the cutoff lands inside a turn", () => {
const items: StreamItem[] = [];
for (let index = 0; index < 30; index += 1) {
const seed = index * 3;
items.push(userMessage(`u${index}`, seed + 1));
items.push(toolCall(`t${index}`, seed + 2));
items.push(assistantMessage(`a${index}`, seed + 3));
}
expect(
findMountedWindowStart({
items,
minMountedCount: 50,
})
).toBe(39);
});
});
describe("splitWebVirtualizedHistory", () => {
it("splits older entries into the virtualized section and keeps the recent window mounted", () => {
const items: StreamItem[] = [];
for (let index = 0; index < 30; index += 1) {
const seed = index * 2;
items.push(userMessage(`u${index}`, seed + 1));
items.push(assistantMessage(`a${index}`, seed + 2));
}
const window = splitWebVirtualizedHistory({
entries: indexEntries(items),
minMountedCount: 50,
});
expect(window.virtualizedEntries).toHaveLength(10);
expect(window.virtualizedEntries[0]?.item.id).toBe("u0");
expect(window.virtualizedEntries.at(-1)?.item.id).toBe("a4");
expect(window.mountedEntries[0]?.item.id).toBe("u5");
expect(window.mountedEntries).toHaveLength(50);
});
});
describe("estimateStreamItemHeight", () => {
it("uses a larger estimate for user messages with image attachments", () => {
const item: StreamItem = {
kind: "user_message",
id: "u-image",
text: "image",
timestamp: createTimestamp(1),
images: [
{
id: "att-1",
mimeType: "image/png",
storageType: "desktop-file",
storageKey: "/tmp/screenshot.png",
fileName: "screenshot.png",
byteSize: 1024,
createdAt: Date.now(),
},
],
};
expect(estimateStreamItemHeight(item)).toBe(220);
});
});
describe("web virtualization test overrides", () => {
it("uses defaults unless explicit positive integer overrides are present", () => {
const globalWithOverrides = globalThis as typeof globalThis & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
};
const previousThreshold =
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
const previousMounted =
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
try {
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
expect(getWebPartialVirtualizationThreshold()).toBe(
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD
);
expect(getWebMountedRecentStreamItems()).toBe(
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS
);
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
expect(getWebPartialVirtualizationThreshold()).toBe(6);
expect(getWebMountedRecentStreamItems()).toBe(4);
} finally {
if (previousThreshold === undefined) {
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
} else {
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD =
previousThreshold;
}
if (previousMounted === undefined) {
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
} else {
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS =
previousMounted;
}
}
});
});
@@ -0,0 +1,94 @@
import type { StreamItem } from "@/types/stream";
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
type BottomAnchorE2ETestGlobals = typeof globalThis & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
};
function readPositiveIntegerOverride(value: unknown): number | null {
if (!Number.isFinite(value)) {
return null;
}
const normalized = Math.trunc(value as number);
return normalized > 0 ? normalized : null;
}
export function getWebPartialVirtualizationThreshold(): number {
const override = readPositiveIntegerOverride(
(globalThis as BottomAnchorE2ETestGlobals)
.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD
);
return override ?? DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
}
export function getWebMountedRecentStreamItems(): number {
const override = readPositiveIntegerOverride(
(globalThis as BottomAnchorE2ETestGlobals)
.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS
);
return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS;
}
export type IndexedStreamItem = {
item: StreamItem;
index: number;
};
export type WebVirtualizedHistoryWindow = {
virtualizedEntries: IndexedStreamItem[];
mountedEntries: IndexedStreamItem[];
};
export function estimateStreamItemHeight(item: StreamItem): number {
switch (item.kind) {
case "user_message":
return item.images && item.images.length > 0 ? 220 : 96;
case "assistant_message":
return 220;
case "tool_call":
return 136;
case "thought":
return 112;
case "todo_list":
return 144;
case "activity_log":
return 88;
case "compaction":
return 72;
default:
return 120;
}
}
export function findMountedWindowStart(input: {
items: StreamItem[];
minMountedCount: number;
}): number {
const { items, minMountedCount } = input;
if (items.length <= minMountedCount) {
return 0;
}
let startIndex = Math.max(items.length - minMountedCount, 0);
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
startIndex -= 1;
}
return startIndex;
}
export function splitWebVirtualizedHistory(input: {
entries: IndexedStreamItem[];
minMountedCount: number;
}): WebVirtualizedHistoryWindow {
const startIndex = findMountedWindowStart({
items: input.entries.map((entry) => entry.item),
minMountedCount: input.minMountedCount,
});
return {
virtualizedEntries: input.entries.slice(0, startIndex),
mountedEntries: input.entries.slice(startIndex),
};
}
@@ -84,6 +84,7 @@ export interface MessageInputProps {
/** Reports cursor selection updates from the underlying input. */
onSelectionChange?: (selection: { start: number; end: number }) => void
onFocusChange?: (focused: boolean) => void
onHeightChange?: (height: number) => void
}
export interface MessageInputRef {
@@ -153,6 +154,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onKeyPress: onKeyPressCallback,
onSelectionChange: onSelectionChangeCallback,
onFocusChange,
onHeightChange,
},
ref
) {
@@ -473,7 +475,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onSubmit(payload)
inputHeightRef.current = MIN_INPUT_HEIGHT
setInputHeight(MIN_INPUT_HEIGHT)
}, [value, images, onSubmit, isAgentRunning])
onHeightChange?.(MIN_INPUT_HEIGHT)
}, [value, images, onSubmit, isAgentRunning, onHeightChange])
const handleQueueMessage = useCallback(() => {
if (!onQueue) return
@@ -487,7 +490,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onChangeText('')
inputHeightRef.current = MIN_INPUT_HEIGHT
setInputHeight(MIN_INPUT_HEIGHT)
}, [value, images, onQueue, onChangeText])
onHeightChange?.(MIN_INPUT_HEIGHT)
}, [value, images, onQueue, onChangeText, onHeightChange])
// Web input height measurement
function isTextAreaLike(v: unknown): v is TextAreaHandle {
@@ -587,6 +591,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
if (Math.abs(inputHeightRef.current - bounded) >= 1) {
inputHeightRef.current = bounded
setInputHeight(bounded)
onHeightChange?.(bounded)
return true
}
return false
@@ -597,6 +602,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
if (Math.abs(inputHeightRef.current - bounded) < 1) return
inputHeightRef.current = bounded
setInputHeight(bounded)
onHeightChange?.(bounded)
}
function handleContentSizeChange(
@@ -0,0 +1,578 @@
import { describe, expect, it, vi } from "vitest";
import {
__private__,
deriveBottomAnchorBlockedReason,
type BottomAnchorMode,
} from "./use-bottom-anchor-controller";
import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strategy";
type MeasurementState = ReturnType<typeof createMeasurementState>;
function createMeasurementState(
overrides?: Partial<{
containerKey: string;
viewportWidth: number;
viewportHeight: number;
contentHeight: number;
offsetY: number;
viewportMeasuredForKey: string | null;
contentMeasuredForKey: string | null;
}>
) {
return {
containerKey: "scroll-view",
viewportWidth: 0,
viewportHeight: 0,
contentHeight: 0,
offsetY: 0,
viewportMeasuredForKey: null,
contentMeasuredForKey: null,
...overrides,
};
}
function createPendingRequest() {
return {
id: 1,
agentId: "agent-1",
reason: "initial-entry" as const,
requestKey: "route:agent-1",
};
}
function createFrameScheduler() {
let sequence = 0;
const tasks = new Map<
number,
{
cancelled: boolean;
remainingFrames: number;
callback: () => void;
kind: "attempt" | "verification";
}
>();
return {
schedule(params: {
kind: "attempt" | "verification";
callback: () => void;
delayFrames?: number;
}) {
const id = ++sequence;
tasks.set(id, {
cancelled: false,
remainingFrames: Math.max(0, params.delayFrames ?? 0),
callback: params.callback,
kind: params.kind,
});
return id;
},
cancel(handle: unknown) {
const task = tasks.get(handle as number);
if (task) {
task.cancelled = true;
}
},
flushFrame() {
const due: Array<() => void> = [];
for (const [id, task] of Array.from(tasks.entries())) {
if (task.cancelled) {
tasks.delete(id);
continue;
}
if (task.remainingFrames > 0) {
task.remainingFrames -= 1;
continue;
}
tasks.delete(id);
due.push(task.callback);
}
for (const callback of due) {
callback();
}
},
flushAll(limit = 20) {
for (let index = 0; index < limit && tasks.size > 0; index += 1) {
this.flushFrame();
}
},
};
}
function createDriverHarness(input?: {
transportBehavior?: BottomAnchorTransportBehavior;
isNearBottom?: boolean;
measurementState?: MeasurementState;
authoritativeReady?: boolean;
}) {
const scheduler = createFrameScheduler();
const measurementState =
input?.measurementState ??
createMeasurementState({
viewportWidth: 800,
viewportHeight: 480,
contentHeight: 1200,
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
});
const context = {
agentId: "agent-1",
authoritativeReady: input?.authoritativeReady ?? true,
renderStrategy: "forward-stream",
transportBehavior:
input?.transportBehavior ?? {
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
},
measurementState,
nearBottom: input?.isNearBottom ?? true,
};
const scrollToBottom = vi.fn(() => {
context.nearBottom = true;
context.measurementState.offsetY = 720;
});
const modeChanges: BottomAnchorMode[] = [];
const warnings: Array<{ agentId: string; reason: string }> = [];
const driver = __private__.createBottomAnchorControllerDriver({
getAgentId: () => context.agentId,
getIsAuthoritativeHistoryReady: () => context.authoritativeReady,
getRenderStrategy: () => context.renderStrategy,
getTransportBehavior: () => context.transportBehavior,
getMeasurementState: () => context.measurementState,
isNearBottom: () => context.nearBottom,
scrollToBottom,
onModeChange: (mode) => {
modeChanges.push(mode);
},
log: () => {},
warn: (details) => warnings.push(details),
scheduleFrame: (params) => scheduler.schedule(params),
cancelFrame: (handle) => scheduler.cancel(handle),
});
return {
context,
driver,
scheduler,
scrollToBottom,
modeChanges,
warnings,
};
}
describe("deriveBottomAnchorBlockedReason", () => {
it("keeps initial-entry pending until history is ready and geometry is measurable", () => {
const pendingRequest = createPendingRequest();
expect(
deriveBottomAnchorBlockedReason({
pendingRequest,
isAuthoritativeHistoryReady: false,
measurementState: createMeasurementState(),
pendingVerificationRequestId: null,
})
).toBe("waiting_for_history_readiness");
expect(
deriveBottomAnchorBlockedReason({
pendingRequest,
isAuthoritativeHistoryReady: true,
measurementState: createMeasurementState({
viewportHeight: 480,
viewportMeasuredForKey: "scroll-view",
}),
pendingVerificationRequestId: null,
})
).toBe("waiting_for_measurable_content");
expect(
deriveBottomAnchorBlockedReason({
pendingRequest,
isAuthoritativeHistoryReady: true,
measurementState: createMeasurementState({
viewportHeight: 480,
contentHeight: 1200,
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
}),
pendingVerificationRequestId: pendingRequest.id,
})
).toBe("waiting_for_post_layout_verification");
});
});
describe("bottom anchor controller driver", () => {
it("keeps initial-entry pending until authoritative history and current geometry exist", () => {
const harness = createDriverHarness({
authoritativeReady: false,
measurementState: createMeasurementState(),
});
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "initial-entry",
requestKey: "route:agent-1:initial-entry",
});
harness.scheduler.flushAll();
expect(harness.scrollToBottom).not.toHaveBeenCalled();
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
blockedReason: "waiting_for_history_readiness",
pendingRequest: {
reason: "initial-entry",
},
});
harness.context.authoritativeReady = true;
harness.context.measurementState.viewportHeight = 480;
harness.context.measurementState.contentHeight = 1200;
harness.context.measurementState.viewportMeasuredForKey = "scroll-view";
harness.context.measurementState.contentMeasuredForKey = "scroll-view";
harness.context.nearBottom = true;
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot()).toMatchObject({
blockedReason: null,
pendingRequest: null,
pendingVerification: null,
});
});
it("suppresses sticky maintenance while detached", () => {
const harness = createDriverHarness();
harness.driver.detachByUser();
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1500,
});
harness.driver.handleViewportMetricsChange({
previousViewportWidth: 800,
viewportWidth: 640,
previousViewportHeight: 480,
viewportHeight: 420,
});
harness.scheduler.flushAll();
expect(harness.driver.getSnapshot().mode).toBe("detached");
expect(harness.scrollToBottom).not.toHaveBeenCalled();
});
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
const harness = createDriverHarness({
isNearBottom: false,
});
harness.driver.detachByUser();
harness.driver.requestLocalAnchor({
agentId: "agent-1",
reason: "jump-to-bottom",
});
harness.scheduler.flushAll();
expect(harness.modeChanges).toContain("detached");
expect(harness.modeChanges).toContain("sticky-bottom");
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
});
it("schedules sticky maintenance on viewport and content growth", () => {
const harness = createDriverHarness();
harness.driver.handleViewportMetricsChange({
previousViewportWidth: 800,
viewportWidth: 640,
previousViewportHeight: 480,
viewportHeight: 420,
});
harness.scheduler.flushAll();
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1600,
});
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
});
it("keeps a pending request blocked when stale container measurements arrive", () => {
const harness = createDriverHarness({
measurementState: createMeasurementState({
containerKey: "web-partial-virtualized",
viewportHeight: 420,
contentHeight: 1200,
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
}),
});
harness.driver.applyRouteRequest({
agentId: "agent-1",
reason: "resume",
requestKey: "route:agent-1:resume",
});
harness.scheduler.flushAll();
expect(harness.scrollToBottom).not.toHaveBeenCalled();
expect(harness.driver.getSnapshot()).toMatchObject({
blockedReason: "waiting_for_measurable_viewport",
pendingRequest: {
reason: "resume",
},
});
harness.context.measurementState.viewportMeasuredForKey = "web-partial-virtualized";
harness.context.measurementState.contentMeasuredForKey = "web-partial-virtualized";
harness.driver.reevaluate();
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
});
it("uses delayed rechecks instead of repeated rescroll loops for native transport", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
isNearBottom: false,
});
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 0;
});
harness.driver.requestLocalAnchor({
agentId: "agent-1",
reason: "jump-to-bottom",
});
harness.scheduler.flushFrame();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
harness.scheduler.flushFrame();
harness.scheduler.flushFrame();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
harness.context.nearBottom = true;
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.warnings).toEqual([]);
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
});
it("keeps sticky-bottom during viewport growth until bottom is re-verified", () => {
const harness = createDriverHarness();
harness.context.nearBottom = false;
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 720;
});
harness.driver.handleViewportMetricsChange({
previousViewportWidth: 800,
viewportWidth: 800,
previousViewportHeight: 480,
viewportHeight: 420,
});
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
pendingVerification: null,
});
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 0,
});
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
harness.context.nearBottom = true;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: true,
scrollDelta: 0,
});
harness.scheduler.flushAll();
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 64,
});
expect(harness.driver.getSnapshot().mode).toBe("detached");
});
it("keeps sticky-bottom during streaming growth until bottom is re-verified", () => {
const harness = createDriverHarness();
harness.context.nearBottom = false;
harness.scrollToBottom.mockImplementation(() => {
harness.context.measurementState.offsetY = 900;
});
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1400,
});
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
pendingVerification: null,
});
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 0,
});
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
harness.context.nearBottom = true;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: true,
scrollDelta: 0,
});
harness.scheduler.flushAll();
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 64,
});
expect(harness.driver.getSnapshot().mode).toBe("detached");
});
});
describe("controller helper predicates", () => {
it("rejects stale container measurements during post-scroll verification", () => {
expect(
__private__.deriveVerificationBlockedReason({
isAuthoritativeHistoryReady: true,
measurementState: createMeasurementState({
containerKey: "web-partial-virtualized",
viewportHeight: 420,
contentHeight: 1200,
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
}),
})
).toBe("waiting_for_measurable_viewport");
});
it("allows verification only after authoritative readiness and current geometry exist", () => {
expect(
__private__.deriveVerificationBlockedReason({
isAuthoritativeHistoryReady: false,
measurementState: createMeasurementState({
containerKey: "scroll-view",
viewportHeight: 420,
contentHeight: 1200,
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
}),
})
).toBe("waiting_for_history_readiness");
expect(
__private__.deriveVerificationBlockedReason({
isAuthoritativeHistoryReady: true,
measurementState: createMeasurementState({
containerKey: "scroll-view",
viewportHeight: 420,
contentHeight: 1200,
viewportMeasuredForKey: "scroll-view",
contentMeasuredForKey: "scroll-view",
}),
})
).toBeNull();
});
it("suppresses auto-anchor helpers while detached", () => {
const mode: BottomAnchorMode = "detached";
expect(
__private__.shouldRestickOnContentChange({
mode,
previousContentHeight: 1000,
contentHeight: 1100,
})
).toBe(false);
expect(
__private__.shouldRestickOnViewportChange({
mode,
previousViewportWidth: 800,
viewportWidth: 640,
previousViewportHeight: 400,
viewportHeight: 360,
})
).toBe(false);
});
it("does not detach from sticky while a restick request is still pending", () => {
expect(
__private__.shouldDetachFromScrollAway({
mode: "sticky-bottom",
nextIsNearBottom: false,
scrollDelta: 0,
hasPendingRequest: true,
hasPendingVerification: false,
hasUnverifiedStickyMeasurementChange: false,
})
).toBe(false);
expect(
__private__.shouldDetachFromScrollAway({
mode: "sticky-bottom",
nextIsNearBottom: false,
scrollDelta: 0,
hasPendingRequest: false,
hasPendingVerification: true,
hasUnverifiedStickyMeasurementChange: false,
})
).toBe(false);
expect(
__private__.shouldDetachFromScrollAway({
mode: "sticky-bottom",
nextIsNearBottom: false,
scrollDelta: 0,
hasPendingRequest: false,
hasPendingVerification: false,
hasUnverifiedStickyMeasurementChange: true,
})
).toBe(false);
expect(
__private__.shouldDetachFromScrollAway({
mode: "sticky-bottom",
nextIsNearBottom: false,
scrollDelta: 0,
hasPendingRequest: false,
hasPendingVerification: false,
hasUnverifiedStickyMeasurementChange: false,
})
).toBe(true);
});
it("treats a large scroll delta as user detach even during an unverified sticky change", () => {
expect(
__private__.shouldDetachFromScrollAway({
mode: "sticky-bottom",
nextIsNearBottom: false,
scrollDelta: 48,
hasPendingRequest: false,
hasPendingVerification: false,
hasUnverifiedStickyMeasurementChange: true,
})
).toBe(true);
});
});
@@ -0,0 +1,817 @@
import { useEffect, useRef, useState } from "react";
import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strategy";
export type BottomAnchorMode = "sticky-bottom" | "detached";
export type BottomAnchorRouteRequest = {
reason: "initial-entry" | "resume";
agentId: string;
requestKey: string;
};
export type BottomAnchorLocalRequest = {
reason: "jump-to-bottom" | "message-sent";
agentId: string;
};
export type BottomAnchorBlockedReason =
| "waiting_for_history_readiness"
| "waiting_for_measurable_viewport"
| "waiting_for_measurable_content"
| "waiting_for_post_layout_verification";
type BottomAnchorRequestReason =
| BottomAnchorRouteRequest["reason"]
| BottomAnchorLocalRequest["reason"];
type BottomAnchorRequest = {
id: number;
agentId: string;
reason: BottomAnchorRequestReason;
requestKey: string;
};
type ControllerMeasurementState = {
containerKey: string;
viewportWidth: number;
viewportHeight: number;
contentHeight: number;
offsetY: number;
viewportMeasuredForKey: string | null;
contentMeasuredForKey: string | null;
};
type AttemptContext = {
requestId: number | null;
retries: number;
};
type ScheduledFrameHandle = {
cancelled: boolean;
rafId: number | null;
remainingFrames: number;
callback: () => void;
};
type BottomAnchorEvent =
| "request_created"
| "attempt_started"
| "attempt_verified"
| "attempt_failed"
| "request_fulfilled"
| "request_cancelled"
| "detached_by_user"
| "blocked_reason_changed";
type BottomAnchorControllerDriver = {
destroy: () => void;
getSnapshot: () => {
mode: BottomAnchorMode;
pendingRequest: BottomAnchorRequest | null;
pendingVerification: AttemptContext | null;
blockedReason: BottomAnchorBlockedReason | null;
};
resetForAgent: () => void;
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
detachByUser: () => void;
handleViewportMetricsChange: (params: {
previousViewportWidth: number;
viewportWidth: number;
previousViewportHeight: number;
viewportHeight: number;
}) => void;
handleContentSizeChange: (params: {
previousContentHeight: number;
contentHeight: number;
}) => void;
prepareForStickyViewportChange: () => void;
prepareForStickyContentChange: () => void;
handleScrollNearBottomChange: (params: {
nextIsNearBottom: boolean;
scrollDelta: number;
}) => void;
notifyAuthoritativeHistoryMaybeChanged: () => void;
reevaluate: (animated?: boolean) => void;
};
type CreateBottomAnchorControllerDriverInput = {
getAgentId: () => string;
getIsAuthoritativeHistoryReady: () => boolean;
getRenderStrategy: () => string;
getTransportBehavior: () => BottomAnchorTransportBehavior;
getMeasurementState: () => ControllerMeasurementState;
isNearBottom: () => boolean;
scrollToBottom: (animated: boolean) => void;
onModeChange: (mode: BottomAnchorMode) => void;
log: (event: BottomAnchorEvent, details: Record<string, unknown>) => void;
warn: (details: { agentId: string; reason: BottomAnchorRequestReason }) => void;
scheduleFrame: (params: {
kind: "attempt" | "verification";
callback: () => void;
delayFrames?: number;
}) => unknown;
cancelFrame: (handle: unknown) => void;
};
const MAX_VERIFICATION_RETRIES = 3;
const USER_SCROLL_AWAY_DELTA_PX = 24;
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logBottomAnchorEvent(
event: BottomAnchorEvent,
details: Record<string, unknown>
): void {
if (!IS_DEV) {
return;
}
console.debug("[BottomAnchor]", event, details);
}
function scheduleAnimationFrameWithDelay(input: {
callback: () => void;
delayFrames?: number;
}): ScheduledFrameHandle {
const handle: ScheduledFrameHandle = {
cancelled: false,
rafId: null,
remainingFrames: Math.max(0, input.delayFrames ?? 0),
callback: input.callback,
};
const tick = () => {
if (handle.cancelled) {
return;
}
if (handle.remainingFrames > 0) {
handle.remainingFrames -= 1;
handle.rafId = requestAnimationFrame(tick);
return;
}
handle.rafId = null;
input.callback();
};
handle.rafId = requestAnimationFrame(tick);
return handle;
}
function cancelScheduledAnimationFrame(handle: unknown): void {
const scheduled = handle as ScheduledFrameHandle | null;
if (!scheduled) {
return;
}
scheduled.cancelled = true;
if (scheduled.rafId !== null) {
cancelAnimationFrame(scheduled.rafId);
scheduled.rafId = null;
}
}
function deriveVerificationBlockedReason(input: {
isAuthoritativeHistoryReady: boolean;
measurementState: ControllerMeasurementState;
}): Exclude<BottomAnchorBlockedReason, "waiting_for_post_layout_verification"> | null {
if (!input.isAuthoritativeHistoryReady) {
return "waiting_for_history_readiness";
}
if (
input.measurementState.viewportHeight <= 0 ||
input.measurementState.viewportMeasuredForKey !== input.measurementState.containerKey
) {
return "waiting_for_measurable_viewport";
}
if (
input.measurementState.contentHeight <= 0 ||
input.measurementState.contentMeasuredForKey !== input.measurementState.containerKey
) {
return "waiting_for_measurable_content";
}
return null;
}
export function deriveBottomAnchorBlockedReason(input: {
pendingRequest: BottomAnchorRequest | null;
isAuthoritativeHistoryReady: boolean;
measurementState: ControllerMeasurementState;
pendingVerificationRequestId: number | null;
}): BottomAnchorBlockedReason | null {
if (!input.pendingRequest) {
return null;
}
if (!input.isAuthoritativeHistoryReady) {
return "waiting_for_history_readiness";
}
if (
input.measurementState.viewportHeight <= 0 ||
input.measurementState.viewportMeasuredForKey !== input.measurementState.containerKey
) {
return "waiting_for_measurable_viewport";
}
if (
input.measurementState.contentHeight <= 0 ||
input.measurementState.contentMeasuredForKey !== input.measurementState.containerKey
) {
return "waiting_for_measurable_content";
}
if (input.pendingVerificationRequestId === input.pendingRequest.id) {
return "waiting_for_post_layout_verification";
}
return null;
}
function deriveRetryDisposition(input: {
mode: BottomAnchorMode;
retries: number;
verificationRetryMode: BottomAnchorTransportBehavior["verificationRetryMode"];
}): "retry-scroll" | "retry-verify" | "fail" {
if (input.mode !== "sticky-bottom" || input.retries >= MAX_VERIFICATION_RETRIES) {
return "fail";
}
return input.verificationRetryMode === "recheck"
? "retry-verify"
: "retry-scroll";
}
function createBottomAnchorControllerDriver(
input: CreateBottomAnchorControllerDriverInput
): BottomAnchorControllerDriver {
let requestSequence = 0;
let mode: BottomAnchorMode = "sticky-bottom";
let pendingRequest: BottomAnchorRequest | null = null;
let pendingVerification: AttemptContext | null = null;
let blockedReason: BottomAnchorBlockedReason | null = null;
let attemptHandle: unknown = null;
let verificationHandle: unknown = null;
let lastRouteRequestKey: string | null = null;
let stickyMeasurementRevision = 0;
let lastVerifiedStickyMeasurementRevision = 0;
const getLogContext = (extra?: Record<string, unknown>) => {
const measurementState = input.getMeasurementState();
return {
agentId: input.getAgentId(),
requestReason: pendingRequest?.reason ?? null,
authoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
contentHeight: measurementState.contentHeight,
viewportHeight: measurementState.viewportHeight,
offset: measurementState.offsetY,
renderStrategy: input.getRenderStrategy(),
blockedReason,
mode,
containerKey: measurementState.containerKey,
...extra,
};
};
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
if (blockedReason === nextBlockedReason) {
return;
}
blockedReason = nextBlockedReason;
input.log(
"blocked_reason_changed",
getLogContext({ nextBlockedReason })
);
};
const setModeInternal = (nextMode: BottomAnchorMode) => {
if (mode === nextMode) {
return;
}
mode = nextMode;
input.onModeChange(nextMode);
if (nextMode === "detached") {
lastVerifiedStickyMeasurementRevision = stickyMeasurementRevision;
}
};
const markStickyMeasurementChanged = () => {
stickyMeasurementRevision += 1;
};
const markStickyMeasurementVerified = () => {
lastVerifiedStickyMeasurementRevision = stickyMeasurementRevision;
};
const cancelPendingAttempt = () => {
if (attemptHandle) {
input.cancelFrame(attemptHandle);
attemptHandle = null;
}
if (verificationHandle) {
input.cancelFrame(verificationHandle);
verificationHandle = null;
}
pendingVerification = null;
};
const cancelPendingRequest = (reason: string) => {
const currentRequest = pendingRequest;
if (!currentRequest) {
cancelPendingAttempt();
setBlockedReason(null);
return;
}
input.log(
"request_cancelled",
getLogContext({
cancelledRequestReason: currentRequest.reason,
cancelReason: reason,
})
);
pendingRequest = null;
cancelPendingAttempt();
setBlockedReason(null);
};
const scheduleVerification = (attemptContext: AttemptContext) => {
if (verificationHandle) {
input.cancelFrame(verificationHandle);
}
verificationHandle = input.scheduleFrame({
kind: "verification",
delayFrames: input.getTransportBehavior().verificationDelayFrames,
callback: () => {
verificationHandle = null;
const currentRequest = pendingRequest;
const isRequestAttempt =
currentRequest && attemptContext.requestId === currentRequest.id;
const measurementState = input.getMeasurementState();
const verificationBlockedReason = deriveVerificationBlockedReason({
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
measurementState,
});
if (verificationBlockedReason) {
pendingVerification = attemptContext;
setBlockedReason(verificationBlockedReason);
return;
}
const verifiedNearBottom = input.isNearBottom();
input.log(
"attempt_verified",
getLogContext({
verifiedNearBottom,
retries: attemptContext.retries,
})
);
if (verifiedNearBottom) {
pendingVerification = null;
markStickyMeasurementVerified();
if (isRequestAttempt) {
input.log("request_fulfilled", getLogContext());
pendingRequest = null;
}
setBlockedReason(null);
return;
}
const retryDisposition = deriveRetryDisposition({
mode,
retries: attemptContext.retries,
verificationRetryMode: input.getTransportBehavior().verificationRetryMode,
});
if (retryDisposition === "retry-verify") {
pendingVerification = {
requestId: attemptContext.requestId,
retries: attemptContext.retries + 1,
};
setBlockedReason("waiting_for_post_layout_verification");
scheduleVerification(pendingVerification);
return;
}
if (retryDisposition === "retry-scroll") {
pendingVerification = {
requestId: attemptContext.requestId,
retries: attemptContext.retries + 1,
};
evaluate(false);
return;
}
input.log(
"attempt_failed",
getLogContext({ retries: attemptContext.retries })
);
pendingVerification = null;
if (isRequestAttempt && currentRequest) {
input.warn({
agentId: input.getAgentId(),
reason: currentRequest.reason,
});
}
setBlockedReason(
isRequestAttempt ? "waiting_for_post_layout_verification" : null
);
},
});
};
const runAttempt = (animated: boolean) => {
const attemptContext: AttemptContext = {
requestId: pendingRequest?.id ?? null,
retries: pendingVerification?.retries ?? 0,
};
pendingVerification = attemptContext;
input.log(
"attempt_started",
getLogContext({ animated, retries: attemptContext.retries })
);
input.scrollToBottom(animated);
scheduleVerification(attemptContext);
setBlockedReason(
deriveBottomAnchorBlockedReason({
pendingRequest,
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
measurementState: input.getMeasurementState(),
pendingVerificationRequestId: attemptContext.requestId,
})
);
};
const evaluate = (animated: boolean) => {
if (attemptHandle) {
return;
}
attemptHandle = input.scheduleFrame({
kind: "attempt",
callback: () => {
attemptHandle = null;
const nextBlockedReason = deriveBottomAnchorBlockedReason({
pendingRequest,
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
measurementState: input.getMeasurementState(),
pendingVerificationRequestId: pendingVerification?.requestId ?? null,
});
setBlockedReason(nextBlockedReason);
const shouldAttemptForPendingRequest =
pendingRequest !== null && nextBlockedReason === null;
const shouldAttemptForStickyVerification =
mode === "sticky-bottom" &&
pendingVerification !== null &&
nextBlockedReason === null;
if (
!shouldAttemptForPendingRequest &&
!shouldAttemptForStickyVerification
) {
return;
}
runAttempt(animated);
},
});
};
const createRequest = (request: BottomAnchorRouteRequest | BottomAnchorLocalRequest) => {
const existing = pendingRequest;
if (existing) {
input.log(
"request_cancelled",
getLogContext({
cancelledRequestReason: existing.reason,
cancelReason: "replaced_by_new_request",
})
);
}
cancelPendingAttempt();
const nextRequest: BottomAnchorRequest = {
id: requestSequence + 1,
agentId: request.agentId,
reason: request.reason,
requestKey:
"requestKey" in request
? request.requestKey
: `${request.agentId}:${request.reason}:${requestSequence + 1}`,
};
requestSequence = nextRequest.id;
pendingRequest = nextRequest;
pendingVerification = null;
setModeInternal(
"requestKey" in request
? "sticky-bottom"
: __private__.deriveModeForLocalRequest({ reason: request.reason })
);
input.log(
"request_created",
getLogContext({ requestReason: request.reason })
);
evaluate(request.reason === "jump-to-bottom");
};
return {
destroy() {
cancelPendingAttempt();
},
getSnapshot() {
return {
mode,
pendingRequest,
pendingVerification,
blockedReason,
};
},
resetForAgent() {
lastRouteRequestKey = null;
pendingRequest = null;
blockedReason = null;
cancelPendingAttempt();
stickyMeasurementRevision = 0;
lastVerifiedStickyMeasurementRevision = 0;
mode = "sticky-bottom";
input.onModeChange("sticky-bottom");
},
applyRouteRequest(request) {
if (!request) {
return;
}
if (lastRouteRequestKey === request.requestKey) {
return;
}
lastRouteRequestKey = request.requestKey;
createRequest(request);
},
requestLocalAnchor(request) {
createRequest(request);
},
detachByUser() {
if (mode === "detached") {
return;
}
cancelPendingRequest("user_scrolled_away");
setModeInternal("detached");
input.log("detached_by_user", getLogContext());
},
handleViewportMetricsChange(params) {
if (
params.previousViewportWidth !== params.viewportWidth ||
params.previousViewportHeight !== params.viewportHeight
) {
markStickyMeasurementChanged();
}
const shouldRestick = __private__.shouldRestickOnViewportChange({
mode,
previousViewportWidth: params.previousViewportWidth,
viewportWidth: params.viewportWidth,
previousViewportHeight: params.previousViewportHeight,
viewportHeight: params.viewportHeight,
});
if (shouldRestick && !pendingRequest) {
pendingVerification = { requestId: null, retries: 0 };
}
if (shouldRestick || pendingRequest) {
evaluate(false);
}
},
handleContentSizeChange(params) {
if (params.previousContentHeight !== params.contentHeight) {
markStickyMeasurementChanged();
}
const shouldRestick = __private__.shouldRestickOnContentChange({
mode,
previousContentHeight: params.previousContentHeight,
contentHeight: params.contentHeight,
});
if (shouldRestick && !pendingRequest) {
pendingVerification = { requestId: null, retries: 0 };
}
if (shouldRestick || pendingRequest) {
evaluate(false);
}
},
prepareForStickyViewportChange() {
if (mode !== "sticky-bottom") {
return;
}
markStickyMeasurementChanged();
},
prepareForStickyContentChange() {
if (mode !== "sticky-bottom") {
return;
}
markStickyMeasurementChanged();
},
handleScrollNearBottomChange(params) {
const { nextIsNearBottom, scrollDelta } = params;
if (
nextIsNearBottom &&
mode === "sticky-bottom" &&
stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision
) {
markStickyMeasurementVerified();
}
const hasUnverifiedStickyMeasurementChange =
stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision;
if (
__private__.shouldDetachFromScrollAway({
mode,
nextIsNearBottom,
scrollDelta,
hasPendingRequest: pendingRequest !== null,
hasPendingVerification: pendingVerification !== null,
hasUnverifiedStickyMeasurementChange,
})
) {
this.detachByUser();
return;
}
if (
mode === "sticky-bottom" &&
!nextIsNearBottom &&
hasUnverifiedStickyMeasurementChange
) {
if (!pendingRequest && !pendingVerification) {
pendingVerification = { requestId: null, retries: 0 };
}
evaluate(false);
return;
}
if (nextIsNearBottom && pendingRequest) {
evaluate(false);
}
},
notifyAuthoritativeHistoryMaybeChanged() {
if (!pendingVerification && !pendingRequest) {
return;
}
evaluate(false);
},
reevaluate(animated = false) {
evaluate(animated);
},
};
}
export const __private__ = {
createBottomAnchorControllerDriver,
deriveBottomAnchorBlockedReason,
deriveVerificationBlockedReason,
deriveRetryDisposition,
deriveModeForLocalRequest(input: {
reason: BottomAnchorLocalRequest["reason"];
}): BottomAnchorMode {
return "sticky-bottom";
},
shouldRestickOnViewportChange(input: {
mode: BottomAnchorMode;
previousViewportWidth: number;
viewportWidth: number;
previousViewportHeight: number;
viewportHeight: number;
}): boolean {
return (
input.mode === "sticky-bottom" &&
((input.previousViewportHeight > 0 &&
input.viewportHeight > 0 &&
input.previousViewportHeight !== input.viewportHeight) ||
(input.previousViewportWidth > 0 &&
input.viewportWidth > 0 &&
input.previousViewportWidth !== input.viewportWidth))
);
},
shouldRestickOnContentChange(input: {
mode: BottomAnchorMode;
previousContentHeight: number;
contentHeight: number;
}): boolean {
return (
input.mode === "sticky-bottom" &&
input.previousContentHeight > 0 &&
input.contentHeight > input.previousContentHeight
);
},
shouldDetachFromScrollAway(input: {
mode: BottomAnchorMode;
nextIsNearBottom: boolean;
scrollDelta: number;
hasPendingRequest: boolean;
hasPendingVerification: boolean;
hasUnverifiedStickyMeasurementChange: boolean;
}): boolean {
const scrolledAwayIntentionally =
Math.abs(input.scrollDelta) >= USER_SCROLL_AWAY_DELTA_PX;
return (
input.mode === "sticky-bottom" &&
!input.nextIsNearBottom &&
!input.hasPendingRequest &&
!input.hasPendingVerification &&
(!input.hasUnverifiedStickyMeasurementChange || scrolledAwayIntentionally)
);
},
};
export function useBottomAnchorController(input: {
agentId: string;
routeRequest: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady: boolean;
renderStrategy: string;
transportBehavior: BottomAnchorTransportBehavior;
getMeasurementState: () => ControllerMeasurementState;
isNearBottom: () => boolean;
scrollToBottom: (animated: boolean) => void;
}) {
const [mode, setMode] = useState<BottomAnchorMode>("sticky-bottom");
const agentIdRef = useRef(input.agentId);
const readinessRef = useRef(input.isAuthoritativeHistoryReady);
const renderStrategyRef = useRef(input.renderStrategy);
const transportBehaviorRef = useRef(input.transportBehavior);
const getMeasurementStateRef = useRef(input.getMeasurementState);
const isNearBottomRef = useRef(input.isNearBottom);
const scrollToBottomRef = useRef(input.scrollToBottom);
const driverRef = useRef<BottomAnchorControllerDriver | null>(null);
agentIdRef.current = input.agentId;
readinessRef.current = input.isAuthoritativeHistoryReady;
renderStrategyRef.current = input.renderStrategy;
transportBehaviorRef.current = input.transportBehavior;
getMeasurementStateRef.current = input.getMeasurementState;
isNearBottomRef.current = input.isNearBottom;
scrollToBottomRef.current = input.scrollToBottom;
if (!driverRef.current) {
driverRef.current = __private__.createBottomAnchorControllerDriver({
getAgentId: () => agentIdRef.current,
getIsAuthoritativeHistoryReady: () => readinessRef.current,
getRenderStrategy: () => renderStrategyRef.current,
getTransportBehavior: () => transportBehaviorRef.current,
getMeasurementState: () => getMeasurementStateRef.current(),
isNearBottom: () => isNearBottomRef.current(),
scrollToBottom: (animated) => scrollToBottomRef.current(animated),
onModeChange: (nextMode) => setMode(nextMode),
log: (event, details) => logBottomAnchorEvent(event, details),
warn: (details) => {
console.warn("[BottomAnchor] request could not be fulfilled", details);
},
scheduleFrame: ({ callback, delayFrames }) =>
scheduleAnimationFrameWithDelay({ callback, delayFrames }),
cancelFrame: (handle) => cancelScheduledAnimationFrame(handle),
});
}
useEffect(() => {
driverRef.current?.resetForAgent();
}, [input.agentId]);
useEffect(() => {
driverRef.current?.applyRouteRequest(input.routeRequest);
}, [input.routeRequest]);
useEffect(() => {
driverRef.current?.notifyAuthoritativeHistoryMaybeChanged();
}, [input.isAuthoritativeHistoryReady]);
useEffect(() => {
return () => {
driverRef.current?.destroy();
driverRef.current = null;
};
}, []);
return {
mode,
requestLocalAnchor(request: BottomAnchorLocalRequest) {
driverRef.current?.requestLocalAnchor(request);
},
detachByUser() {
driverRef.current?.detachByUser();
},
handleViewportLayout() {},
handleViewportMetricsChange(params: {
previousViewportWidth: number;
viewportWidth: number;
previousViewportHeight: number;
viewportHeight: number;
}) {
driverRef.current?.handleViewportMetricsChange(params);
},
handleContentSizeChange(params: {
previousContentHeight: number;
contentHeight: number;
}) {
driverRef.current?.handleContentSizeChange(params);
},
prepareForStickyViewportChange() {
driverRef.current?.prepareForStickyViewportChange();
},
prepareForStickyContentChange() {
driverRef.current?.prepareForStickyContentChange();
},
handleScrollNearBottomChange(params: {
nextIsNearBottom: boolean;
scrollDelta: number;
}) {
driverRef.current?.handleScrollNearBottomChange(params);
},
reevaluate(animated = false) {
driverRef.current?.reevaluate(animated);
},
};
}
@@ -240,6 +240,9 @@ function SessionProviderInternal({
const markAgentHistorySynchronized = useSessionStore(
(state) => state.markAgentHistorySynchronized
);
const setAgentAuthoritativeHistoryApplied = useSessionStore(
(state) => state.setAgentAuthoritativeHistoryApplied
);
const setHasHydratedAgents = useSessionStore(
(state) => state.setHasHydratedAgents
);
@@ -719,6 +722,7 @@ function SessionProviderInternal({
next.delete(agentId);
return next;
});
setAgentAuthoritativeHistoryApplied(serverId, agentId, false);
return;
}
@@ -771,6 +775,8 @@ function SessionProviderInternal({
) => {
const agentId = payload.agentId;
const initKey = getInitKey(serverId, agentId);
const shouldMarkAuthoritativeHistoryApplied =
payload.direction === "tail" || payload.direction === "after";
// Read current store state
const session = useSessionStore.getState().sessions[serverId];
@@ -890,6 +896,9 @@ function SessionProviderInternal({
});
}
if (shouldMarkAuthoritativeHistoryApplied) {
setAgentAuthoritativeHistoryApplied(serverId, agentId, true);
}
if (result.initResolution === "resolve") {
resolveInitDeferred(initKey);
}
@@ -904,6 +913,7 @@ function SessionProviderInternal({
markAgentHistorySynchronized,
requestCanonicalCatchUp,
serverId,
setAgentAuthoritativeHistoryApplied,
setAgentStreamTail,
setAgentTimelineCursor,
setInitializingAgents,
@@ -64,4 +64,10 @@ describe("useAgentInitialization timeline request policy", () => {
projection: "canonical",
});
});
it("does not expose an RPC-success init fallback", () => {
expect(
"shouldResolveInitFromRpcSuccess" in __private__
).toBe(false);
});
});
@@ -62,7 +62,7 @@ export function useAgentInitialization({
const cursor = session?.agentTimelineCursor.get(agentId);
const initialTimelineLimit = resolveInitialTimelineLimit();
const hasAuthoritativeHistory =
(session?.agentHistorySyncGeneration.get(agentId) ?? -1) >= 0;
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
const timelineRequest = deriveInitialTimelineRequest({
cursor: cursor
? { epoch: cursor.epoch, seq: cursor.endSeq }
@@ -95,10 +95,6 @@ export function useAgentInitialization({
client
.fetchAgentTimeline(agentId, timelineRequest)
.then(() => {
// No-op: hydration completion is handled by SessionContext
// when it processes fetch_agent_timeline_response.
})
.catch((error) => {
setAgentInitializing(agentId, false);
rejectInitDeferred(
@@ -342,6 +342,46 @@ describe("deriveAgentScreenViewState", () => {
expect(ready.sync.status).toBe("idle");
});
it("keeps first route entry blocked until authoritative history is applied", () => {
const memory = createBaseMemory();
const input: AgentScreenMachineInput = {
...createBaseInput(),
agent: createAgent("agent-1"),
needsAuthoritativeSync: true,
isHistorySyncing: true,
hasHydratedHistoryBefore: false,
};
const result = deriveAgentScreenViewState({ input, memory });
expect(result.state).toEqual({
tag: "boot",
reason: "loading",
source: "none",
});
expect(result.memory.hasRenderedReady).toBe(false);
expect(result.memory.lastReadyAgent).toBeNull();
});
it("still allows optimistic create flow to render before authoritative history arrives", () => {
const memory = createBaseMemory();
const input: AgentScreenMachineInput = {
...createBaseInput(),
agent: createAgentWithStatus({ id: "agent-1", status: "idle" }),
placeholderAgent: createAgent("agent-1"),
shouldUseOptimisticStream: true,
needsAuthoritativeSync: true,
isHistorySyncing: true,
hasHydratedHistoryBefore: false,
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
expect(ready.source).toBe("optimistic");
expect(ready.agent.status).toBe("running");
});
it("keeps optimistic flow non-blocking while transitioning to authoritative stream", () => {
const initialMemory = createBaseMemory();
const optimisticInput: AgentScreenMachineInput = {
@@ -19,6 +19,16 @@ export interface AgentScreenMachineInput {
hasHydratedHistoryBefore: boolean;
}
function shouldBlockInitialAuthoritativeReadyState(
input: AgentScreenMachineInput
): boolean {
return (
!input.shouldUseOptimisticStream &&
!input.hasHydratedHistoryBefore &&
(input.needsAuthoritativeSync || input.isHistorySyncing)
);
}
export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
export interface AgentScreenMachineMemory {
@@ -104,10 +114,7 @@ export function deriveAgentScreenViewState({
input.agent && useOptimisticCreateFlowAgent && input.placeholderAgent
? { ...input.agent, status: input.placeholderAgent.status }
: input.agent ?? input.placeholderAgent;
if (candidateAgent) {
nextMemory.hasRenderedReady = true;
nextMemory.lastReadyAgent = candidateAgent;
}
const shouldBlockReadyState = shouldBlockInitialAuthoritativeReadyState(input);
if (input.missingAgentState.kind === "not_found") {
return {
@@ -129,6 +136,22 @@ export function deriveAgentScreenViewState({
};
}
if (candidateAgent && shouldBlockReadyState) {
return {
state: {
tag: "boot",
reason: "loading",
source: "none",
},
memory: nextMemory,
};
}
if (candidateAgent) {
nextMemory.hasRenderedReady = true;
nextMemory.lastReadyAgent = candidateAgent;
}
const displayAgent =
candidateAgent ?? (nextMemory.hasRenderedReady ? nextMemory.lastReadyAgent : null);
if (!displayAgent) {
@@ -978,6 +978,68 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
it("re-subscribes agent directory updates after reconnect", async () => {
const host = makeHost({
serverId: "srv_resubscribe",
connections: [
{
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
},
],
});
const fakeClient = new FakeDaemonClient();
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
measureLatency: async () => 5,
getClientId: async () => "cid_test_runtime",
},
});
useSessionStore.getState().initializeSession(
host.serverId,
fakeClient as unknown as DaemonClient,
null as any
);
store.syncHosts([host]);
const initialTimeoutAt = Date.now() + 200;
while (fakeClient.fetchAgentsCalls.length < 1 && Date.now() < initialTimeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
fakeClient.setConnectionState({
status: "disconnected",
reason: "client_closed",
});
fakeClient.setConnectionState({ status: "connected" });
const reconnectTimeoutAt = Date.now() + 200;
while (fakeClient.fetchAgentsCalls.length < 2 && Date.now() < reconnectTimeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(fakeClient.fetchAgentsCalls).toEqual([
{
filter: { includeArchived: true },
sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_resubscribe" },
page: { limit: 200 },
},
{
filter: { includeArchived: true },
sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_resubscribe" },
page: { limit: 200 },
},
]);
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("surfaces startup failures as error instead of leaving host idle", async () => {
const host = makeHost({
connections: [
+8 -5
View File
@@ -1043,16 +1043,19 @@ export class HostRuntimeStore {
const snapshot = controller.getSnapshot();
const previousStatus = this.lastConnectionStatusByServer.get(serverId);
this.lastConnectionStatusByServer.set(serverId, snapshot.connectionStatus);
if (snapshot.connectionStatus === "online" && previousStatus !== "online") {
const didTransitionOnline =
snapshot.connectionStatus === "online" && previousStatus !== "online";
if (didTransitionOnline) {
useSessionStore.getState().bumpHistorySyncGeneration(serverId);
}
// Runtime owns directory bootstrap policy, including reconnect and delayed
// session initialization races.
if (
snapshot.connectionStatus !== "online" ||
snapshot.hasEverLoadedAgentDirectory
) {
if (snapshot.connectionStatus !== "online") {
this.clearAgentDirectorySessionRetry(serverId);
return;
}
if (!didTransitionOnline && snapshot.hasEverLoadedAgentDirectory) {
this.clearAgentDirectorySessionRetry(serverId);
return;
}
@@ -0,0 +1,37 @@
import type { BottomAnchorRouteRequest } from "@/components/use-bottom-anchor-controller";
export type RouteBottomAnchorIntent = {
routeKey: string;
reason: BottomAnchorRouteRequest["reason"];
};
export function deriveRouteBottomAnchorIntent(input: {
cachedIntent: RouteBottomAnchorIntent | null;
routeKey: string | null;
hasAppliedAuthoritativeHistoryAtEntry: boolean;
}): RouteBottomAnchorIntent | null {
if (!input.routeKey) {
return null;
}
if (input.cachedIntent?.routeKey === input.routeKey) {
return input.cachedIntent;
}
return {
routeKey: input.routeKey,
reason: input.hasAppliedAuthoritativeHistoryAtEntry ? "resume" : "initial-entry",
};
}
export function deriveRouteBottomAnchorRequest(input: {
intent: RouteBottomAnchorIntent | null;
effectiveAgentId: string | null;
}): BottomAnchorRouteRequest | null {
if (!input.intent || !input.effectiveAgentId) {
return null;
}
return {
reason: input.intent.reason,
agentId: input.effectiveAgentId,
requestKey: `${input.intent.routeKey}:${input.intent.reason}`,
};
}
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
deriveRouteBottomAnchorIntent,
deriveRouteBottomAnchorRequest,
} from "./agent-ready-screen-bottom-anchor";
describe("agent-ready-screen bottom anchor intent", () => {
it("latches initial-entry on first route entry before authoritative history is applied", () => {
const intentAtEntry = deriveRouteBottomAnchorIntent({
cachedIntent: null,
routeKey: "server-1:agent-1",
hasAppliedAuthoritativeHistoryAtEntry: false,
});
const intentAfterHistoryApplies = deriveRouteBottomAnchorIntent({
cachedIntent: intentAtEntry,
routeKey: "server-1:agent-1",
hasAppliedAuthoritativeHistoryAtEntry: true,
});
expect(intentAfterHistoryApplies).toEqual({
routeKey: "server-1:agent-1",
reason: "initial-entry",
});
expect(
deriveRouteBottomAnchorRequest({
intent: intentAfterHistoryApplies,
effectiveAgentId: "agent-1",
})
).toEqual({
agentId: "agent-1",
reason: "initial-entry",
requestKey: "server-1:agent-1:initial-entry",
});
});
it("creates resume requests when revisiting an already-hydrated route", () => {
const intent = deriveRouteBottomAnchorIntent({
cachedIntent: null,
routeKey: "server-1:agent-2",
hasAppliedAuthoritativeHistoryAtEntry: true,
});
expect(
deriveRouteBottomAnchorRequest({
intent,
effectiveAgentId: "agent-2",
})
).toEqual({
agentId: "agent-2",
reason: "resume",
requestKey: "server-1:agent-2:resume",
});
});
it("does not create a request until the effective agent exists", () => {
const intent = deriveRouteBottomAnchorIntent({
cachedIntent: null,
routeKey: "server-1:agent-3",
hasAppliedAuthoritativeHistoryAtEntry: false,
});
expect(
deriveRouteBottomAnchorRequest({
intent,
effectiveAgentId: null,
})
).toBeNull();
});
});
@@ -59,6 +59,10 @@ import { shouldClearAgentAttention } from "@/utils/agent-attention";
import type { DaemonClient } from "@server/client/daemon-client";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import type { ExplorerCheckoutContext } from "@/stores/panel-store";
import {
deriveRouteBottomAnchorIntent,
deriveRouteBottomAnchorRequest,
} from "./agent-ready-screen-bottom-anchor";
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
@@ -390,12 +394,19 @@ function AgentScreenContent({
const historySyncGeneration = useSessionStore(
(state) => state.sessions[serverId]?.historySyncGeneration ?? 0
);
const hasAppliedAuthoritativeHistory = useSessionStore((state) =>
resolvedAgentId
? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get(
resolvedAgentId
) === true
: false
);
const agentHistorySyncGeneration = useSessionStore((state) =>
resolvedAgentId
? state.sessions[serverId]?.agentHistorySyncGeneration?.get(resolvedAgentId) ?? -1
: -1
);
const hasHydratedHistoryBefore = agentHistorySyncGeneration >= 0;
const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory;
// Select raw pending permissions - filter with useMemo to avoid new Map on every render
const allPendingPermissions = useSessionStore(
@@ -432,6 +443,10 @@ function AgentScreenContent({
});
const reconnectToastArmedRef = useRef(false);
const initAttemptTokenRef = useRef(0);
const routeBottomAnchorRequestRef = useRef<{
routeKey: string;
reason: "initial-entry" | "resume";
} | null>(null);
const attentionClientRef = useRef(client);
const attentionConnectedRef = useRef(isConnected);
const setFocusedAgentId = useCallback(
@@ -658,6 +673,20 @@ function AgentScreenContent({
});
const effectiveAgent = viewState.tag === "ready" ? viewState.agent : null;
const routeEntryKey = resolvedAgentId ? `${serverId}:${resolvedAgentId}` : null;
routeBottomAnchorRequestRef.current = deriveRouteBottomAnchorIntent({
cachedIntent: routeBottomAnchorRequestRef.current,
routeKey: routeEntryKey,
hasAppliedAuthoritativeHistoryAtEntry: hasAppliedAuthoritativeHistory,
});
const routeBottomAnchorRequest = useMemo(
() =>
deriveRouteBottomAnchorRequest({
intent: routeBottomAnchorRequestRef.current,
effectiveAgentId: effectiveAgent?.id ?? null,
}),
[effectiveAgent?.id]
);
useEffect(() => {
if (!isPendingCreateForRoute || !pendingCreate) {
return;
@@ -914,6 +943,8 @@ function AgentScreenContent({
shouldUseOptimisticStream ? mergedStreamItems : streamItems
}
pendingPermissions={pendingPermissions}
routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
/>
</ReanimatedAnimated.View>
</View>
@@ -926,7 +957,12 @@ function AgentScreenContent({
autoFocus
isSubmitLoading={showPendingCreateSubmitLoading}
onAddImages={handleAddImagesCallback}
onMessageSent={() => streamViewRef.current?.scrollToBottom()}
onComposerHeightChange={() =>
streamViewRef.current?.prepareForViewportChange()
}
onMessageSent={() =>
streamViewRef.current?.scrollToBottom("message-sent")
}
/>
)}
@@ -166,6 +166,10 @@ export function WorkspaceDesktopTabsRow({
const labelCharCap = layoutItem?.labelCharCap ?? tab.label.length;
const renderedLabel = showLabel ? tab.label.slice(0, Math.max(1, labelCharCap)) : "";
const presentation = deriveWorkspaceTabPresentation({ tab, agent: tabAgent });
const tooltipLabel =
tab.kind === "agent" && tab.titleState === "loading"
? "Loading agent title"
: presentation.label;
const contextMenuTestId = `workspace-tab-context-${tab.key}`;
const isFirstTab = index === 0;
@@ -174,107 +178,114 @@ export function WorkspaceDesktopTabsRow({
return (
<ContextMenu key={tab.key}>
<ContextMenuTrigger
testID={`workspace-tab-${tab.key}`}
enabledOnMobile={false}
style={({ hovered, pressed }) => [
styles.tab,
{
minWidth: resolvedTabWidth,
width: resolvedTabWidth,
maxWidth: resolvedTabWidth,
},
isActive && styles.tabActive,
(hovered || pressed || isCloseHovered) && styles.tabHovered,
]}
onHoverIn={() => {
setHoveredTabKey(tab.key);
}}
onHoverOut={() => {
setHoveredTabKey((current) => (current === tab.key ? null : current));
}}
onPressIn={() => {
onNavigateTab(tab.tabId);
}}
onPress={() => {
onNavigateTab(tab.tabId);
}}
accessibilityLabel={
tab.kind === "agent" && tab.titleState === "loading"
? "Loading agent title"
: tab.label
}
>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef}
style={styles.tabHandle}
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID={`workspace-tab-tooltip-${tab.key}`}
accessibilityRole="none"
style={styles.tabTooltipTrigger}
>
<View style={styles.tabIcon}>
<WorkspaceTabIcon presentation={presentation} active={isActive} />
</View>
{showLabel ? (
presentation.titleState === "loading" ? (
<View
style={[
styles.tabLabelSkeleton,
shouldShowCloseButton && styles.tabLabelSkeletonWithCloseButton,
]}
/>
) : (
<Text
style={[
styles.tabLabel,
isActive && styles.tabLabelActive,
shouldShowCloseButton && styles.tabLabelWithCloseButton,
]}
numberOfLines={1}
>
{renderedLabel}
</Text>
)
) : null}
</View>
{shouldShowCloseButton ? (
<Pressable
testID={
tab.kind === "agent"
? `workspace-agent-close-${tab.agentId}`
: tab.kind === "terminal"
? `workspace-terminal-close-${tab.terminalId}`
: tab.kind === "draft"
? `workspace-draft-close-${tab.draftId}`
: `workspace-file-close-${encodeFilePathForPathSegment(tab.filePath)}`
}
disabled={isClosingTab}
<ContextMenuTrigger
testID={`workspace-tab-${tab.key}`}
enabledOnMobile={false}
style={({ hovered, pressed }) => [
styles.tab,
{
minWidth: resolvedTabWidth,
width: resolvedTabWidth,
maxWidth: resolvedTabWidth,
},
isActive && styles.tabActive,
(hovered || pressed || isCloseHovered) && styles.tabHovered,
]}
onHoverIn={() => {
setHoveredTabKey(tab.key);
setHoveredCloseTabKey(tab.key);
}}
onHoverOut={() => {
setHoveredTabKey((current) => (current === tab.key ? null : current));
setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
}}
onPress={(event) => {
event.stopPropagation?.();
void onCloseTab(tab.tabId);
onPressIn={() => {
onNavigateTab(tab.tabId);
}}
style={({ hovered, pressed }) => [
styles.tabCloseButton,
styles.tabCloseButtonShown,
(hovered || pressed) && styles.tabCloseButtonActive,
]}
onPress={() => {
onNavigateTab(tab.tabId);
}}
accessibilityLabel={tooltipLabel}
>
{isClosingTab ? (
<ActivityIndicator size={12} color={theme.colors.foregroundMuted} />
) : (
<X size={12} color={theme.colors.foregroundMuted} />
)}
</Pressable>
) : null}
</ContextMenuTrigger>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef}
style={styles.tabHandle}
>
<View style={styles.tabIcon}>
<WorkspaceTabIcon presentation={presentation} active={isActive} />
</View>
{showLabel ? (
presentation.titleState === "loading" ? (
<View
style={[
styles.tabLabelSkeleton,
shouldShowCloseButton && styles.tabLabelSkeletonWithCloseButton,
]}
/>
) : (
<Text
style={[
styles.tabLabel,
isActive && styles.tabLabelActive,
shouldShowCloseButton && styles.tabLabelWithCloseButton,
]}
numberOfLines={1}
>
{renderedLabel}
</Text>
)
) : null}
</View>
{shouldShowCloseButton ? (
<Pressable
testID={
tab.kind === "agent"
? `workspace-agent-close-${tab.agentId}`
: tab.kind === "terminal"
? `workspace-terminal-close-${tab.terminalId}`
: tab.kind === "draft"
? `workspace-draft-close-${tab.draftId}`
: `workspace-file-close-${encodeFilePathForPathSegment(tab.filePath)}`
}
disabled={isClosingTab}
onHoverIn={() => {
setHoveredTabKey(tab.key);
setHoveredCloseTabKey(tab.key);
}}
onHoverOut={() => {
setHoveredTabKey((current) => (current === tab.key ? null : current));
setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
}}
onPress={(event) => {
event.stopPropagation?.();
void onCloseTab(tab.tabId);
}}
style={({ hovered, pressed }) => [
styles.tabCloseButton,
styles.tabCloseButtonShown,
(hovered || pressed) && styles.tabCloseButtonActive,
]}
>
{isClosingTab ? (
<ActivityIndicator size={12} color={theme.colors.foregroundMuted} />
) : (
<X size={12} color={theme.colors.foregroundMuted} />
)}
</Pressable>
) : null}
</ContextMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
</TooltipContent>
</Tooltip>
<ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}>
{tab.kind === "agent" ? (
@@ -426,6 +437,9 @@ const styles = StyleSheet.create((theme) => ({
paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[1],
},
tabTooltipTrigger: {
flexShrink: 0,
},
tab: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
@@ -12,8 +12,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
import {
ChevronDown,
Copy,
Folder,
GitBranch,
MoreVertical,
PanelRight,
Plus,
SquareTerminal,
@@ -24,6 +26,12 @@ import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { ScreenHeader } from "@/components/headers/screen-header";
import { Combobox } from "@/components/ui/combobox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
@@ -1006,6 +1014,20 @@ function WorkspaceScreenContent({
[sessionAgents, toast]
);
const handleCopyWorkspacePath = useCallback(async () => {
if (!normalizedWorkspaceId.startsWith("/")) {
toast.error("Workspace path not available");
return;
}
try {
await Clipboard.setStringAsync(normalizedWorkspaceId);
toast.copied("Workspace path");
} catch {
toast.error("Copy failed");
}
}, [normalizedWorkspaceId, toast]);
const handleBulkCloseTabs = useCallback(
async (input: { tabsToClose: WorkspaceTabDescriptor[]; title: string; logLabel: string }) => {
const { tabsToClose, title, logLabel } = input;
@@ -1252,6 +1274,7 @@ function WorkspaceScreenContent({
if (target.kind === "agent") {
return (
<AgentReadyScreen
key={`${normalizedServerId}:${normalizedWorkspaceId}:${target.agentId}`}
serverId={normalizedServerId}
agentId={target.agentId}
showExplorerSidebar={false}
@@ -1331,13 +1354,45 @@ function WorkspaceScreenContent({
}
right={
<View style={styles.headerRight}>
<DropdownMenu>
<DropdownMenuTrigger
testID="workspace-header-menu-trigger"
style={styles.headerActionButton}
accessibilityRole="button"
accessibilityLabel="Workspace actions"
>
<MoreVertical
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
width={220}
testID="workspace-header-menu"
>
<DropdownMenuItem
testID="workspace-header-copy-path"
leading={
<Copy
size={16}
color={theme.colors.foregroundMuted}
/>
}
disabled={!normalizedWorkspaceId.startsWith("/")}
onSelect={handleCopyWorkspacePath}
>
Copy workspace path
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<HeaderToggleButton
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
tooltipLabel="Toggle explorer"
tooltipKeys={["mod", "E"]}
tooltipSide="left"
style={styles.menuButton}
style={styles.headerActionButton}
accessible
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
@@ -1599,10 +1654,11 @@ const styles = StyleSheet.create((theme) => ({
headerRight: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
gap: theme.spacing[1],
},
menuButton: {
padding: theme.spacing[3],
headerActionButton: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
newTabActions: {
+45
View File
@@ -230,6 +230,7 @@ export interface SessionState {
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
historySyncGeneration: number;
agentHistorySyncGeneration: Map<string, number>;
agentAuthoritativeHistoryApplied: Map<string, boolean>;
// Initializing agents (used for UI loading state)
initializingAgents: Map<string, boolean>;
@@ -295,6 +296,11 @@ interface SessionStoreActions {
) => void;
bumpHistorySyncGeneration: (serverId: string) => void;
markAgentHistorySynchronized: (serverId: string, agentId: string) => void;
setAgentAuthoritativeHistoryApplied: (
serverId: string,
agentId: string,
applied: boolean
) => void;
// Initializing agents
setInitializingAgents: (serverId: string, state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>)) => void;
@@ -391,6 +397,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio
agentTimelineCursor: new Map(),
historySyncGeneration: 0,
agentHistorySyncGeneration: new Map(),
agentAuthoritativeHistoryApplied: new Map(),
initializingAgents: new Map(),
agents: new Map(),
workspaces: new Map(),
@@ -855,6 +862,44 @@ export const useSessionStore = create<SessionStore>()(
});
},
setAgentAuthoritativeHistoryApplied: (serverId, agentId, applied) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
const previousApplied =
session.agentAuthoritativeHistoryApplied.get(agentId) ?? false;
if (previousApplied === applied) {
return prev;
}
const nextApplied = new Map(session.agentAuthoritativeHistoryApplied);
if (applied) {
nextApplied.set(agentId, true);
} else {
nextApplied.delete(agentId);
}
logSessionStoreUpdate("setAgentAuthoritativeHistoryApplied", serverId, {
agentId,
applied,
});
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: {
...session,
agentAuthoritativeHistoryApplied: nextApplied,
},
},
};
});
},
// Initializing agents
setInitializingAgents: (serverId, state) => {
set((prev) => {