test: harden daemon lifecycle and e2e reliability across app/cli/server

This commit is contained in:
Mohamed Boudra
2026-02-25 10:21:46 +07:00
parent 094ce49d4f
commit a060ae1256
98 changed files with 5716 additions and 1771 deletions
+1
View File
@@ -464,6 +464,7 @@ export default function RootLayout() {
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
+14 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo } from "react";
import { ActivityIndicator, View } from "react-native";
import { useRouter } from "expo-router";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useUnistyles } from "react-native-unistyles";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
@@ -9,14 +9,26 @@ import { buildHostAgentDraftRoute } from "@/utils/host-routes";
export default function Index() {
const router = useRouter();
const params = useLocalSearchParams<{ serverId?: string }>();
const { theme } = useUnistyles();
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
const requestedServerId = useMemo(() => {
return typeof params.serverId === "string" ? params.serverId.trim() : "";
}, [params.serverId]);
const targetServerId = useMemo(() => {
if (daemons.length === 0) {
return null;
}
if (requestedServerId) {
const requested = daemons.find(
(daemon) => daemon.serverId === requestedServerId
);
if (requested) {
return requested.serverId;
}
}
if (preferences.serverId) {
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
if (match) {
@@ -24,7 +36,7 @@ export default function Index() {
}
}
return daemons[0]?.serverId ?? null;
}, [daemons, preferences.serverId]);
}, [daemons, preferences.serverId, requestedServerId]);
useEffect(() => {
if (registryLoading || preferencesLoading) {
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useMemo } from "react";
import { ActivityIndicator, View } from "react-native";
import { useRouter } from "expo-router";
import { useUnistyles } from "react-native-unistyles";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import { buildHostSettingsRoute } from "@/utils/host-routes";
export default function LegacySettingsRoute() {
const router = useRouter();
const { theme } = useUnistyles();
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
const targetServerId = useMemo(() => {
if (daemons.length === 0) {
return null;
}
if (preferences.serverId) {
const match = daemons.find(
(daemon) => daemon.serverId === preferences.serverId
);
if (match) {
return match.serverId;
}
}
return daemons[0]?.serverId ?? null;
}, [daemons, preferences.serverId]);
useEffect(() => {
if (registryLoading || preferencesLoading) {
return;
}
if (!targetServerId) {
return;
}
router.replace(buildHostSettingsRoute(targetServerId) as any);
}, [preferencesLoading, registryLoading, router, targetServerId]);
if (registryLoading || preferencesLoading) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.colors.surface0,
}}
>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
);
}
if (!targetServerId) {
return <DraftAgentScreen />;
}
return null;
}
@@ -324,6 +324,7 @@ interface ComboSelectProps {
onSelect: (id: string) => void;
icon?: ReactElement;
showLabel?: boolean;
testID?: string;
}
export function ComboSelect({
@@ -338,6 +339,7 @@ export function ComboSelect({
onSelect,
icon,
showLabel = true,
testID,
}: ComboSelectProps): ReactElement {
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
@@ -361,6 +363,7 @@ export function ComboSelect({
controlRef={anchorRef}
icon={icon}
showLabel={showLabel}
testID={testID}
/>
<Combobox
options={options}
@@ -573,6 +576,7 @@ export function AgentConfigRow({
onSelect={onSelectProvider}
icon={<Bot size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false}
testID="draft-provider-select"
/>
</View>
<View style={styles.agentConfigColumn}>
@@ -587,6 +591,7 @@ export function AgentConfigRow({
onSelect={onSelectModel}
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false}
testID="draft-model-select"
/>
</View>
<View style={styles.agentConfigColumn}>
@@ -600,6 +605,7 @@ export function AgentConfigRow({
onSelect={onSelectMode}
icon={<Shield size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false}
testID="draft-mode-select"
/>
</View>
{thinkingSelectOptions.length > 0 ? (
@@ -36,6 +36,7 @@ import {
persistAttachmentFromFileUri,
} from '@/attachments/service'
import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard'
import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation'
type QueuedMessage = {
id: string
@@ -77,6 +78,7 @@ export function AgentInputArea({
onAddImages,
commandDraftConfig,
}: AgentInputAreaProps) {
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
const { theme } = useUnistyles()
const insets = useSafeAreaInsets()
const { height: keyboardHeight } = useReanimatedKeyboardAnimation()
@@ -41,9 +41,10 @@ describe("resolveStreamRenderStrategy", () => {
isMobileBreakpoint: false,
});
expect(strategy.kind).toBe("forward_stream");
expect(strategy.flatListInverted).toBe(false);
expect(strategy.overlayScrollbarInverted).toBe(false);
expect(strategy.shouldUseVirtualizedList()).toBe(false);
expect(strategy.getFlatListInverted()).toBe(false);
expect(strategy.getOverlayScrollbarInverted()).toBe(false);
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(true);
});
it("uses inverted_stream on native", () => {
@@ -52,9 +53,10 @@ describe("resolveStreamRenderStrategy", () => {
isMobileBreakpoint: false,
});
expect(strategy.kind).toBe("inverted_stream");
expect(strategy.flatListInverted).toBe(true);
expect(strategy.overlayScrollbarInverted).toBe(true);
expect(strategy.shouldUseVirtualizedList()).toBe(true);
expect(strategy.getFlatListInverted()).toBe(true);
expect(strategy.getOverlayScrollbarInverted()).toBe(true);
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(false);
});
});
@@ -1,5 +1,5 @@
import type { ComponentType, ReactElement } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { ComponentType, ReactElement, RefObject } from "react";
import type { FlatList, ScrollView, StyleProp, View, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream";
type EdgeSlot = "header" | "footer";
@@ -11,44 +11,6 @@ export type MaintainVisibleContentPositionConfig = Readonly<{
autoscrollToTopThreshold: number;
}>;
type StreamRenderStrategyBase = {
kind: "inverted_stream" | "forward_stream";
flatListInverted: boolean;
edgeSlot: EdgeSlot;
overlayScrollbarInverted: boolean;
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
assistantTurnTraversalStep: AssistantTurnTraversalStep;
disableParentScrollOnInlineDetailsExpansion: boolean;
};
export type InvertedStreamRenderStrategy = StreamRenderStrategyBase & {
kind: "inverted_stream";
flatListInverted: true;
edgeSlot: "header";
overlayScrollbarInverted: true;
maintainVisibleContentPosition: MaintainVisibleContentPositionConfig;
assistantTurnTraversalStep: 1;
disableParentScrollOnInlineDetailsExpansion: false;
};
export type ForwardStreamRenderStrategy = StreamRenderStrategyBase & {
kind: "forward_stream";
flatListInverted: false;
edgeSlot: "footer";
overlayScrollbarInverted: false;
maintainVisibleContentPosition?: undefined;
assistantTurnTraversalStep: -1;
};
export type StreamRenderStrategy =
| InvertedStreamRenderStrategy
| ForwardStreamRenderStrategy;
export type ResolveStreamRenderStrategyInput = {
platform: string;
isMobileBreakpoint: boolean;
};
export type StreamViewportMetrics = {
contentHeight: number;
viewportHeight: number;
@@ -66,65 +28,269 @@ export type StreamEdgeSlotProps = {
ListFooterComponentStyle?: StyleProp<ViewStyle>;
};
export type StreamRenderRefs = {
flatListRef: RefObject<FlatList<StreamItem> | null>;
scrollViewRef: RefObject<ScrollView | null>;
bottomAnchorRef: RefObject<View | null>;
};
export type ResolveStreamRenderStrategyInput = {
platform: string;
isMobileBreakpoint: boolean;
};
export interface StreamRenderStrategy {
orderTail: (streamItems: StreamItem[]) => StreamItem[];
orderHead: (streamHead: StreamItem[]) => StreamItem[];
getNeighborIndex: (index: number, relation: NeighborRelation) => number;
getNeighborItem: (
items: StreamItem[],
index: number,
relation: NeighborRelation
) => StreamItem | undefined;
collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string;
isNearBottom: (input: StreamNearBottomInput) => boolean;
getBottomOffset: (metrics: StreamViewportMetrics) => number;
getEdgeSlotProps: (
component: ReactElement | ComponentType<any> | null,
gapSize: number
) => StreamEdgeSlotProps;
getMaintainVisibleContentPosition: () =>
| MaintainVisibleContentPositionConfig
| undefined;
getFlatListInverted: () => boolean;
getOverlayScrollbarInverted: () => boolean;
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
shouldAnchorBottomOnContentSizeChange: () => boolean;
shouldAnimateManualScrollToBottom: () => boolean;
shouldUseVirtualizedList: () => boolean;
scrollToBottom: (params: {
refs: StreamRenderRefs;
metrics: StreamViewportMetrics;
animated: boolean;
}) => void;
scrollToOffset: (params: {
refs: StreamRenderRefs;
offset: number;
animated: boolean;
}) => void;
}
type StreamRenderStrategyConfig = {
orderTailReverse: boolean;
orderHeadReverse: boolean;
assistantTurnTraversalStep: AssistantTurnTraversalStep;
edgeSlot: EdgeSlot;
flatListInverted: boolean;
overlayScrollbarInverted: boolean;
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
disableParentScrollOnInlineDetailsExpansion: boolean;
anchorBottomOnContentSizeChange: boolean;
animateManualScrollToBottom: boolean;
useVirtualizedList: boolean;
isNearBottom: (input: StreamNearBottomInput) => boolean;
getBottomOffset: (metrics: StreamViewportMetrics) => number;
scrollToBottom: (params: {
refs: StreamRenderRefs;
metrics: StreamViewportMetrics;
animated: boolean;
}) => void;
scrollToOffset: (params: {
refs: StreamRenderRefs;
offset: number;
animated: boolean;
}) => void;
};
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION: MaintainVisibleContentPositionConfig =
Object.freeze({
minIndexForVisible: 0,
autoscrollToTopThreshold: 0,
});
const INVERTED_STREAM_STRATEGY: InvertedStreamRenderStrategy = {
kind: "inverted_stream",
flatListInverted: true,
edgeSlot: "header",
overlayScrollbarInverted: true,
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
assistantTurnTraversalStep: 1,
disableParentScrollOnInlineDetailsExpansion: false,
};
function scrollAnchorIntoView(params: {
refs: StreamRenderRefs;
animated: boolean;
}): boolean {
const anchorHandle = params.refs.bottomAnchorRef.current as
| ({ getNativeRef?: () => unknown; scrollIntoView?: (options?: unknown) => void } &
object)
| null;
if (!anchorHandle) {
return false;
}
const FORWARD_STREAM_STRATEGY_DESKTOP: ForwardStreamRenderStrategy = {
kind: "forward_stream",
flatListInverted: false,
edgeSlot: "footer",
overlayScrollbarInverted: false,
assistantTurnTraversalStep: -1,
disableParentScrollOnInlineDetailsExpansion: true,
};
const maybeNative =
typeof anchorHandle.getNativeRef === "function"
? anchorHandle.getNativeRef()
: anchorHandle;
const FORWARD_STREAM_STRATEGY_MOBILE: ForwardStreamRenderStrategy = {
...FORWARD_STREAM_STRATEGY_DESKTOP,
disableParentScrollOnInlineDetailsExpansion: false,
};
const domElement = maybeNative as { scrollIntoView?: (options?: unknown) => void };
if (typeof domElement.scrollIntoView !== "function") {
return false;
}
domElement.scrollIntoView({
block: "end",
behavior: params.animated ? "smooth" : "auto",
});
return true;
}
function createStreamRenderStrategy(
config: StreamRenderStrategyConfig
): StreamRenderStrategy {
return {
orderTail: (streamItems) =>
config.orderTailReverse ? [...streamItems].reverse() : streamItems,
orderHead: (streamHead) =>
config.orderHeadReverse ? [...streamHead].reverse() : streamHead,
getNeighborIndex: (index, relation) =>
relation === "above"
? index + config.assistantTurnTraversalStep
: index - config.assistantTurnTraversalStep,
getNeighborItem: (items, index, relation) => {
const neighborIndex =
relation === "above"
? index + config.assistantTurnTraversalStep
: index - config.assistantTurnTraversalStep;
if (neighborIndex < 0 || neighborIndex >= items.length) {
return undefined;
}
return items[neighborIndex];
},
collectAssistantTurnContent: (items, startIndex) => {
const messages: string[] = [];
for (
let index = startIndex;
index >= 0 && index < items.length;
index += config.assistantTurnTraversalStep
) {
const currentItem = items[index];
if (currentItem.kind === "user_message") {
break;
}
if (currentItem.kind === "assistant_message") {
messages.push(currentItem.text);
}
}
return messages.reverse().join("\n\n");
},
isNearBottom: (input) => config.isNearBottom(input),
getBottomOffset: (metrics) => config.getBottomOffset(metrics),
getEdgeSlotProps: (component, gapSize) => {
if (config.edgeSlot === "header") {
return {
ListHeaderComponent: component,
ListHeaderComponentStyle: { marginBottom: gapSize },
};
}
return {
ListFooterComponent: component,
ListFooterComponentStyle: { marginTop: gapSize },
};
},
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
getFlatListInverted: () => config.flatListInverted,
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
config.disableParentScrollOnInlineDetailsExpansion,
shouldAnchorBottomOnContentSizeChange: () =>
config.anchorBottomOnContentSizeChange,
shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom,
shouldUseVirtualizedList: () => config.useVirtualizedList,
scrollToBottom: (params) => config.scrollToBottom(params),
scrollToOffset: (params) => config.scrollToOffset(params),
};
}
function createInvertedStreamStrategy(): StreamRenderStrategy {
return createStreamRenderStrategy({
orderTailReverse: true,
orderHeadReverse: true,
assistantTurnTraversalStep: 1,
edgeSlot: "header",
flatListInverted: true,
overlayScrollbarInverted: true,
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
disableParentScrollOnInlineDetailsExpansion: false,
anchorBottomOnContentSizeChange: false,
animateManualScrollToBottom: true,
useVirtualizedList: true,
isNearBottom: (input) => input.offsetY <= input.threshold,
getBottomOffset: () => 0,
scrollToBottom: ({ refs, animated }) => {
refs.flatListRef.current?.scrollToOffset({
offset: 0,
animated,
});
},
scrollToOffset: ({ refs, offset, animated }) => {
refs.flatListRef.current?.scrollToOffset({ offset, animated });
},
});
}
function createForwardStreamStrategy(): StreamRenderStrategy {
return createStreamRenderStrategy({
orderTailReverse: false,
orderHeadReverse: false,
assistantTurnTraversalStep: -1,
edgeSlot: "footer",
flatListInverted: false,
overlayScrollbarInverted: false,
maintainVisibleContentPosition: undefined,
disableParentScrollOnInlineDetailsExpansion: false,
anchorBottomOnContentSizeChange: true,
animateManualScrollToBottom: false,
useVirtualizedList: false,
isNearBottom: (inputMetrics) => {
const distanceFromBottom = Math.max(
0,
inputMetrics.contentHeight -
(inputMetrics.offsetY + inputMetrics.viewportHeight)
);
return distanceFromBottom <= inputMetrics.threshold;
},
getBottomOffset: (metrics) =>
Math.max(0, metrics.contentHeight - metrics.viewportHeight),
scrollToBottom: ({ refs, metrics, animated }) => {
if (scrollAnchorIntoView({ refs, animated })) {
return;
}
refs.scrollViewRef.current?.scrollToEnd?.({ animated });
refs.scrollViewRef.current?.scrollTo?.({
y: Math.max(0, metrics.contentHeight - metrics.viewportHeight),
animated,
});
},
scrollToOffset: ({ refs, offset, animated }) => {
refs.scrollViewRef.current?.scrollTo({ y: offset, animated });
},
});
}
export function resolveStreamRenderStrategy(
input: ResolveStreamRenderStrategyInput
): StreamRenderStrategy {
if (input.platform === "web") {
return input.isMobileBreakpoint
? FORWARD_STREAM_STRATEGY_MOBILE
: FORWARD_STREAM_STRATEGY_DESKTOP;
return createForwardStreamStrategy();
}
return INVERTED_STREAM_STRATEGY;
return createInvertedStreamStrategy();
}
export function orderTailForStreamRenderStrategy(params: {
strategy: StreamRenderStrategy;
streamItems: StreamItem[];
}): StreamItem[] {
const { strategy, streamItems } = params;
return strategy.kind === "inverted_stream"
? [...streamItems].reverse()
: streamItems;
return params.strategy.orderTail(params.streamItems);
}
export function orderHeadForStreamRenderStrategy(params: {
strategy: StreamRenderStrategy;
streamHead: StreamItem[];
}): StreamItem[] {
const { strategy, streamHead } = params;
return strategy.kind === "inverted_stream"
? [...streamHead].reverse()
: streamHead;
return params.strategy.orderHead(params.streamHead);
}
export function getStreamNeighborIndex(params: {
@@ -132,11 +298,7 @@ export function getStreamNeighborIndex(params: {
index: number;
relation: NeighborRelation;
}): number {
const { strategy, index, relation } = params;
if (strategy.kind === "inverted_stream") {
return relation === "above" ? index + 1 : index - 1;
}
return relation === "above" ? index - 1 : index + 1;
return params.strategy.getNeighborIndex(params.index, params.relation);
}
export function getStreamNeighborItem(params: {
@@ -145,11 +307,11 @@ export function getStreamNeighborItem(params: {
index: number;
relation: NeighborRelation;
}): StreamItem | undefined {
const nextIndex = getStreamNeighborIndex(params);
if (nextIndex < 0 || nextIndex >= params.items.length) {
return undefined;
}
return params.items[nextIndex];
return params.strategy.getNeighborItem(
params.items,
params.index,
params.relation
);
}
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
@@ -157,49 +319,32 @@ export function collectAssistantTurnContentForStreamRenderStrategy(params: {
items: StreamItem[];
startIndex: number;
}): string {
const { strategy, items, startIndex } = params;
const messages: string[] = [];
for (
let index = startIndex;
index >= 0 && index < items.length;
index += strategy.assistantTurnTraversalStep
) {
const currentItem = items[index];
if (currentItem.kind === "user_message") {
break;
}
if (currentItem.kind === "assistant_message") {
messages.push(currentItem.text);
}
}
return messages.reverse().join("\n\n");
return params.strategy.collectAssistantTurnContent(
params.items,
params.startIndex
);
}
export function isNearBottomForStreamRenderStrategy(
params: StreamNearBottomInput & { strategy: StreamRenderStrategy }
): boolean {
const { strategy, threshold, offsetY } = params;
if (strategy.kind === "inverted_stream") {
return offsetY <= threshold;
}
const distanceFromBottom = Math.max(
0,
params.contentHeight - (offsetY + params.viewportHeight)
);
return distanceFromBottom <= threshold;
return params.strategy.isNearBottom({
offsetY: params.offsetY,
threshold: params.threshold,
contentHeight: params.contentHeight,
viewportHeight: params.viewportHeight,
});
}
export function getBottomOffsetForStreamRenderStrategy(params: StreamViewportMetrics & {
strategy: StreamRenderStrategy;
}): number {
const { strategy } = params;
if (strategy.kind === "inverted_stream") {
return 0;
export function getBottomOffsetForStreamRenderStrategy(
params: StreamViewportMetrics & {
strategy: StreamRenderStrategy;
}
return Math.max(0, params.contentHeight - params.viewportHeight);
): number {
return params.strategy.getBottomOffset({
contentHeight: params.contentHeight,
viewportHeight: params.viewportHeight,
});
}
export function getStreamEdgeSlotProps(params: {
@@ -207,15 +352,5 @@ export function getStreamEdgeSlotProps(params: {
component: ReactElement | ComponentType<any> | null;
gapSize: number;
}): StreamEdgeSlotProps {
const { strategy, component, gapSize } = params;
if (strategy.edgeSlot === "header") {
return {
ListHeaderComponent: component,
ListHeaderComponentStyle: { marginBottom: gapSize },
};
}
return {
ListFooterComponent: component,
ListFooterComponentStyle: { marginTop: gapSize },
};
return params.strategy.getEdgeSlotProps(params.component, params.gapSize);
}
+207 -69
View File
@@ -1,10 +1,20 @@
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import type { ReactNode } from "react";
import {
Fragment,
createElement,
isValidElement,
useEffect,
useMemo,
useRef,
useState,
useCallback,
} from "react";
import type { ComponentType, ReactElement, ReactNode } from "react";
import {
View,
Text,
Pressable,
FlatList,
ScrollView,
ListRenderItemInfo,
LayoutChangeEvent,
NativeScrollEvent,
@@ -57,13 +67,13 @@ import {
} from "./web-desktop-scrollbar";
import {
collectAssistantTurnContentForStreamRenderStrategy,
getBottomOffsetForStreamRenderStrategy,
getStreamEdgeSlotProps,
getStreamNeighborItem,
isNearBottomForStreamRenderStrategy,
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveStreamRenderStrategy,
type StreamEdgeSlotProps,
} from "./agent-stream-render-strategy";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
@@ -76,6 +86,24 @@ const isToolSequenceItem = (item?: StreamItem) =>
const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
const STREAM_ITEM_LOG_MIN_COUNT = 200;
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50;
const NOOP_SEPARATORS: ListRenderItemInfo<StreamItem>["separators"] = {
highlight: () => {},
unhighlight: () => {},
updateProps: () => {},
};
function renderStreamEdgeComponent(
component: ReactElement | ComponentType<any> | null | undefined
): ReactNode {
if (!component) {
return null;
}
if (isValidElement(component)) {
return component;
}
return createElement(component);
}
export interface AgentStreamViewProps {
agentId: string;
serverId?: string;
@@ -92,6 +120,8 @@ export function AgentStreamView({
pendingPermissions,
}: AgentStreamViewProps) {
const flatListRef = useRef<FlatList<StreamItem>>(null);
const scrollViewRef = useRef<ScrollView>(null);
const bottomAnchorRef = useRef<View>(null);
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -111,6 +141,7 @@ export function AgentStreamView({
const isNearBottomRef = useRef(true);
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollAnimatedRef = useRef(false);
const scrollOffsetYRef = useRef(0);
const streamItemCountRef = useRef(0);
const streamViewportMetricsRef = useRef({
contentHeight: 0,
@@ -120,6 +151,10 @@ export function AgentStreamView({
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const streamRenderRefs = useMemo(
() => ({ flatListRef, scrollViewRef, bottomAnchorRef }),
[]
);
// Get serverId (fallback to agent's serverId if not provided)
const resolvedServerId = serverId ?? agent.serverId ?? "";
@@ -192,6 +227,7 @@ export function AgentStreamView({
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
scrollOffsetYRef.current = contentOffset.y;
streamViewportMetricsRef.current = {
contentHeight: Math.max(0, contentSize.height),
viewportHeight: Math.max(0, layoutMeasurement.height),
@@ -235,36 +271,61 @@ export function AgentStreamView({
[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;
isNearBottomRef.current = true;
setIsNearBottom(true);
},
[streamRenderRefs, streamRenderStrategy]
);
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,
});
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
...previousMetrics,
contentHeight: Math.max(0, height),
};
if (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) {
if (!hasAutoScrolledOnce.current) {
scrollToBottomInternal({ animated: false });
hasAutoScrolledOnce.current = true;
hasScrolledInitially.current = true;
} else if (wasNearBottom || isNearBottomRef.current) {
scrollToBottomInternal({ animated: false });
}
}
if (showDesktopWebScrollbar) {
streamScrollbarMetrics.onContentSizeChange(width, height);
}
},
[showDesktopWebScrollbar, streamScrollbarMetrics]
);
const scrollToBottomInternal = useCallback(
({ animated }: { animated: boolean }) => {
const list = flatListRef.current;
if (!list) {
return;
}
const offset = getBottomOffsetForStreamRenderStrategy({
strategy: streamRenderStrategy,
contentHeight: streamViewportMetricsRef.current.contentHeight,
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
});
list.scrollToOffset({ offset, animated });
isNearBottomRef.current = true;
setIsNearBottom(true);
},
[streamRenderStrategy]
[
insets.bottom,
scrollToBottomInternal,
showDesktopWebScrollbar,
streamRenderStrategy,
streamScrollbarMetrics,
]
);
const scheduleAutoScroll = useCallback(
@@ -301,6 +362,11 @@ export function AgentStreamView({
return;
}
if (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) {
// Forward streams anchor from measurement updates in handleContentSizeChange.
return;
}
if (!hasAutoScrolledOnce.current) {
const handle = InteractionManager.runAfterInteractions(() => {
scrollToBottomInternal({ animated: false });
@@ -317,10 +383,16 @@ export function AgentStreamView({
const shouldAnimate = hasScrolledInitially.current;
scheduleAutoScroll({ animated: shouldAnimate });
hasScrolledInitially.current = true;
}, [streamItems, scheduleAutoScroll]);
}, [
scheduleAutoScroll,
scrollToBottomInternal,
streamItems,
streamRenderStrategy,
]);
function scrollToBottom() {
scrollToBottomInternal({ animated: true });
const animated = streamRenderStrategy.shouldAnimateManualScrollToBottom();
scrollToBottomInternal({ animated });
isNearBottomRef.current = true;
setIsNearBottom(true);
}
@@ -396,7 +468,7 @@ export function AgentStreamView({
(item: StreamItem, index: number, items: StreamItem[]) => {
const handleInlineDetailsExpandedChange = (expanded: boolean) => {
if (
!streamRenderStrategy.disableParentScrollOnInlineDetailsExpansion
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()
) {
return;
}
@@ -660,6 +732,7 @@ export function AgentStreamView({
const showWorkingIndicator = agent.status === "running";
const showBottomBar = showWorkingIndicator;
const usesVirtualizedList = streamRenderStrategy.shouldUseVirtualizedList();
const listEdgeSlotComponent = useMemo(() => {
const hasPermissions = pendingPermissionItems.length > 0;
@@ -718,8 +791,8 @@ export function AgentStreamView({
client,
orderedStreamHead,
renderStreamItemContent,
tightGap,
showBottomBar,
tightGap,
]);
const flatListExtraData = useMemo(
@@ -735,7 +808,7 @@ export function AgentStreamView({
]
);
const listEdgeSlotProps = useMemo(() => {
const listEdgeSlotProps = useMemo<StreamEdgeSlotProps>(() => {
if (!listEdgeSlotComponent) {
return {};
}
@@ -781,10 +854,45 @@ export function AgentStreamView({
theme.colors.foregroundMuted,
]);
const streamScrollEnabled =
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
expandedInlineToolCallIds.size === 0;
const listContentContainerStyle = useMemo(
() =>
usesVirtualizedList
? stylesheet.listContentContainer
: [stylesheet.listContentContainer, stylesheet.forwardListContentContainer],
[usesVirtualizedList]
);
const headerEdgeContent = renderStreamEdgeComponent(
listEdgeSlotProps.ListHeaderComponent
);
const footerEdgeContent = renderStreamEdgeComponent(
listEdgeSlotProps.ListFooterComponent
);
const nonVirtualizedItems = useMemo(() => {
if (flatListData.length === 0) {
return null;
}
return flatListData.map((item, index) => {
const rendered = renderStreamItem({
item,
index,
separators: NOOP_SEPARATORS,
});
if (!rendered) {
return null;
}
return <Fragment key={item.id}>{rendered}</Fragment>;
});
}, [flatListData, renderStreamItem]);
return (
<ToolCallSheetProvider>
<View style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing>
<MessageOuterSpacingProvider disableOuterSpacing>
{usesVirtualizedList ? (
<FlatList
ref={flatListRef}
data={flatListData}
@@ -792,7 +900,7 @@ export function AgentStreamView({
keyExtractor={(item) => item.id}
testID="agent-chat-scroll"
{...listEdgeSlotProps}
contentContainerStyle={stylesheet.listContentContainer}
contentContainerStyle={listContentContainerStyle}
style={stylesheet.list}
onLayout={handleListLayout}
onScroll={handleScroll}
@@ -801,51 +909,77 @@ export function AgentStreamView({
ListEmptyComponent={listEmptyComponent}
extraData={flatListExtraData}
maintainVisibleContentPosition={
streamRenderStrategy.maintainVisibleContentPosition
streamRenderStrategy.getMaintainVisibleContentPosition()
}
initialNumToRender={12}
windowSize={10}
scrollEnabled={
!streamRenderStrategy.disableParentScrollOnInlineDetailsExpansion ||
expandedInlineToolCallIds.size === 0
}
scrollEnabled={streamScrollEnabled}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
inverted={streamRenderStrategy.flatListInverted}
inverted={streamRenderStrategy.getFlatListInverted()}
/>
</MessageOuterSpacingProvider>
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar}
metrics={streamScrollbarMetrics}
inverted={streamRenderStrategy.overlayScrollbarInverted}
onScrollToOffset={(nextOffset) => {
flatListRef.current?.scrollToOffset({
offset: nextOffset,
animated: false,
});
}}
/>
{/* Scroll to bottom button */}
{!isNearBottom && (
<Animated.View
style={stylesheet.scrollToBottomContainer}
entering={scrollIndicatorFadeIn}
exiting={scrollIndicatorFadeOut}
) : (
<ScrollView
ref={scrollViewRef}
testID="agent-chat-scroll"
contentContainerStyle={listContentContainerStyle}
style={stylesheet.list}
onLayout={handleListLayout}
onScroll={handleScroll}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
scrollEnabled={streamScrollEnabled}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<View style={stylesheet.scrollToBottomInner}>
<Pressable
style={stylesheet.scrollToBottomButton}
onPress={scrollToBottom}
>
<ChevronDown
size={24}
color={stylesheet.scrollToBottomIcon.color}
/>
</Pressable>
</View>
</Animated.View>
{headerEdgeContent ? (
<View style={listEdgeSlotProps.ListHeaderComponentStyle}>
{headerEdgeContent}
</View>
) : null}
{nonVirtualizedItems}
{flatListData.length === 0 ? listEmptyComponent : null}
{footerEdgeContent ? (
<View style={listEdgeSlotProps.ListFooterComponentStyle}>
{footerEdgeContent}
</View>
) : null}
<View ref={bottomAnchorRef} collapsable={false} />
</ScrollView>
)}
</View>
</MessageOuterSpacingProvider>
<WebDesktopScrollbarOverlay
enabled={showDesktopWebScrollbar}
metrics={streamScrollbarMetrics}
inverted={streamRenderStrategy.getOverlayScrollbarInverted()}
onScrollToOffset={(nextOffset) => {
streamRenderStrategy.scrollToOffset({
refs: streamRenderRefs,
offset: nextOffset,
animated: false,
});
}}
/>
{/* Scroll to bottom button */}
{!isNearBottom && (
<Animated.View
style={stylesheet.scrollToBottomContainer}
entering={scrollIndicatorFadeIn}
exiting={scrollIndicatorFadeOut}
>
<View style={stylesheet.scrollToBottomInner}>
<Pressable
style={stylesheet.scrollToBottomButton}
onPress={scrollToBottom}
>
<ChevronDown
size={24}
color={stylesheet.scrollToBottomIcon.color}
/>
</Pressable>
</View>
</Animated.View>
)}
</View>
</ToolCallSheetProvider>
);
}
@@ -1380,6 +1514,10 @@ const stylesheet = StyleSheet.create((theme) => ({
md: theme.spacing[4],
},
},
forwardListContentContainer: {
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[4],
},
list: {
flex: 1,
},
@@ -162,6 +162,8 @@ export function DictationOverlay({
<Pressable
onPress={handleCancel}
disabled={actionsDisabled && !isFailed}
accessibilityRole="button"
accessibilityLabel="Cancel dictation"
style={[
overlayStyles.cancelButton,
actionsDisabled && !isFailed && overlayStyles.buttonDisabled,
@@ -213,6 +215,8 @@ export function DictationOverlay({
) : isFailed ? (
<Pressable
onPress={onRetry}
accessibilityRole="button"
accessibilityLabel="Retry dictation"
style={[
overlayStyles.actionButton,
{ backgroundColor: theme.colors.palette.white },
@@ -228,6 +232,8 @@ export function DictationOverlay({
<>
<Pressable
onPress={onAccept}
accessibilityRole="button"
accessibilityLabel="Insert transcription"
style={[
overlayStyles.actionButton,
{ backgroundColor: "rgba(255, 255, 255, 0.25)" },
@@ -241,6 +247,8 @@ export function DictationOverlay({
</Pressable>
<Pressable
onPress={onAcceptAndSend}
accessibilityRole="button"
accessibilityLabel="Insert transcription and send"
style={[
overlayStyles.actionButton,
{ backgroundColor: theme.colors.palette.white },
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import Animated, {
useAnimatedStyle,
useSharedValue,
@@ -52,6 +53,7 @@ interface ExplorerSidebarProps {
export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSidebarProps) {
const { theme } = useUnistyles();
const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -267,6 +269,12 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
// On web, keep it interactive only while open so closed sidebars don't eat taps.
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
// screens so only the active screen exposes explorer/terminal surfaces.
if (!isScreenFocused) {
return null;
}
if (isMobile) {
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
+27 -5
View File
@@ -33,6 +33,10 @@ import { useAttachmentPreviewUrl } from '@/attachments/use-attachment-preview-ur
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Shortcut } from '@/components/ui/shortcut'
import type { MessageInputKeyboardActionKind } from '@/keyboard/actions'
import {
markScrollInvestigationEvent,
markScrollInvestigationRender,
} from '@/utils/scroll-jank-investigation'
export type ImageAttachment = AttachmentMetadata
@@ -148,6 +152,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
ref
) {
const { theme } = useUnistyles()
const investigationComponentId = `MessageInput:${voiceServerId ?? 'unknown-server'}:${voiceAgentId ?? 'unknown-agent'}`
markScrollInvestigationRender(investigationComponentId)
const toast = useToast()
const voice = useVoiceOptional()
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT)
@@ -247,7 +253,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
if (shouldAutoSend) {
const imageAttachments = images.length > 0 ? images : undefined
onSubmit({ text: nextValue, images: imageAttachments })
onSubmit({
text: nextValue,
images: imageAttachments,
forceSend: isAgentRunning || undefined,
})
} else {
onChangeText(nextValue)
}
@@ -258,7 +268,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
})
}
},
[onChangeText, onSubmit, images]
[onChangeText, onSubmit, images, isAgentRunning]
)
const handleDictationError = useCallback((error: Error) => {
@@ -302,8 +312,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onError: handleDictationError,
canStart: canStartDictation,
canConfirm: canConfirmDictation,
autoStopWhenHidden:
Platform.OS === 'web' ? undefined : { isVisible: isScreenFocused },
autoStopWhenHidden: { isVisible: isScreenFocused },
enableDuration: true,
})
@@ -330,9 +339,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const startDictationIfAvailable = useCallback(async () => {
if (dictationUnavailableMessage) {
isDictatingRef.current = false
toast.error(dictationUnavailableMessage)
return
}
// Keep hotkey toggling deterministic between the async start call and the
// state-ref sync effect, so a rapid second toggle routes to confirm.
isDictatingRef.current = true
await startDictation()
}, [dictationUnavailableMessage, startDictation, toast])
@@ -590,6 +603,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const shouldHandleDesktopSubmit = IS_WEB
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
markScrollInvestigationEvent(investigationComponentId, 'keyPress')
if (!shouldHandleDesktopSubmit) return
// Allow parent to intercept key events (e.g., for autocomplete navigation)
@@ -634,6 +648,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
? 'Send and interrupt'
: 'Send message'
const handleInputChange = useCallback(
(nextValue: string) => {
markScrollInvestigationEvent(investigationComponentId, 'inputChange')
onChangeText(nextValue)
},
[investigationComponentId, onChangeText]
)
return (
<View style={styles.container} testID="message-input-root">
{/* Regular input */}
@@ -672,7 +694,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<TextInput
ref={textInputRef}
value={value}
onChangeText={onChangeText}
onChangeText={handleInputChange}
placeholder={placeholder}
placeholderTextColor={theme.colors.mutedForeground}
onFocus={() => {
+7 -1
View File
@@ -72,6 +72,7 @@ import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url";
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
export type { InlinePathTarget } from "@/utils/inline-path";
import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
@@ -1293,6 +1294,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
const detailContent =
hasDetailContent && isExpanded ? renderDetails?.() : null;
const detailWrapperRef = useRef<View | null>(null);
const wheelInvestigationComponentId = `ExpandableBadgeWheel:${
testID ?? label
}`;
const nativeGradientIdRef = useRef(
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`
@@ -1428,11 +1432,13 @@ const ExpandableBadge = memo(function ExpandableBadge({
}
};
markScrollInvestigationEvent(wheelInvestigationComponentId, "wheelAttach");
node.addEventListener("wheel", stopWheelPropagation, { passive: true });
return () => {
markScrollInvestigationEvent(wheelInvestigationComponentId, "wheelDetach");
node.removeEventListener("wheel", stopWheelPropagation);
};
}, [isExpanded, hasDetailContent]);
}, [hasDetailContent, isExpanded, wheelInvestigationComponentId]);
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
transform: [{ translateX: shimmerTranslateX.value }],
+77 -28
View File
@@ -17,6 +17,7 @@ import Svg, {
} from "react-native-svg";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import {
hasPendingTerminalModifiers,
@@ -557,29 +558,60 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
}
}, []);
const dispatchTerminalInputEntry = useCallback(
(entry: PendingTerminalInput): boolean => {
if (!client) {
return false;
}
const terminalId = selectedTerminalIdRef.current;
if (!terminalId) {
return false;
}
if (entry.type === "data") {
client.sendTerminalInput(terminalId, {
type: "input",
data: entry.data,
});
return true;
}
const encoded = encodeTerminalKeyInput(entry.input);
if (encoded.length === 0) {
return true;
}
client.sendTerminalInput(terminalId, {
type: "input",
data: encoded,
});
return true;
},
[client]
);
const flushPendingTerminalInput = useCallback(() => {
if (!client) {
return;
}
const currentStreamId = getCurrentActiveStreamId();
if (currentStreamId === null) {
return;
}
const queue = pendingTerminalInputRef.current;
if (queue.length === 0) {
return;
}
const pending = queue.splice(0, queue.length);
for (const entry of pending) {
if (entry.type === "data") {
client.sendTerminalStreamInput(currentStreamId, entry.data);
continue;
let sentCount = 0;
while (sentCount < queue.length) {
const entry = queue[sentCount];
if (!entry) {
break;
}
client.sendTerminalStreamKey(currentStreamId, entry.input);
if (!dispatchTerminalInputEntry(entry)) {
break;
}
sentCount += 1;
}
}, [client, getCurrentActiveStreamId]);
if (sentCount > 0) {
queue.splice(0, sentCount);
}
}, [dispatchTerminalInputEntry]);
useEffect(() => {
flushPendingTerminalInput();
@@ -625,8 +657,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
meta?: boolean;
}
): boolean => {
const currentStreamId = getCurrentActiveStreamId();
if (!client || currentStreamId === null) {
if (!client || !selectedTerminalIdRef.current) {
enqueuePendingTerminalInput({
type: "key",
input: {
@@ -641,6 +672,16 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
}
const normalizedKey = normalizeTerminalTransportKey(input.key);
const pendingEntry: PendingTerminalInput = {
type: "key",
input: {
key: normalizedKey,
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
meta: input.meta,
},
};
terminalDebugLog({
scope: "terminal-pane",
event: "input:key:send",
@@ -649,19 +690,20 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
activeStreamId: currentStreamId,
activeStreamId: getCurrentActiveStreamId(),
},
});
client.sendTerminalStreamKey(currentStreamId, {
key: normalizedKey,
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
meta: input.meta,
});
if (!dispatchTerminalInputEntry(pendingEntry)) {
enqueuePendingTerminalInput(pendingEntry);
}
return true;
},
[client, enqueuePendingTerminalInput, getCurrentActiveStreamId]
[
client,
dispatchTerminalInputEntry,
enqueuePendingTerminalInput,
getCurrentActiveStreamId,
]
);
const handleTerminalData = useCallback(
@@ -705,7 +747,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
}
}
if (!client || currentStreamId === null) {
if (!client || !selectedTerminalIdRef.current) {
enqueuePendingTerminalInput({
type: "data",
data,
@@ -721,11 +763,18 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
activeStreamId: currentStreamId,
},
});
client.sendTerminalStreamInput(currentStreamId, data);
const pendingEntry: PendingTerminalInput = {
type: "data",
data,
};
if (!dispatchTerminalInputEntry(pendingEntry)) {
enqueuePendingTerminalInput(pendingEntry);
}
},
[
clearPendingModifiers,
client,
dispatchTerminalInputEntry,
getCurrentActiveStreamId,
modifiers.alt,
modifiers.ctrl,
@@ -3,18 +3,45 @@ import { __private__ } from "./use-agent-initialization";
describe("useAgentInitialization timeline request policy", () => {
it("uses canonical tail bootstrap when cursor is missing", () => {
expect(__private__.buildInitialTimelineRequest(undefined)).toEqual({
expect(
__private__.buildInitialTimelineRequest({
cursor: undefined,
hasLocalTail: false,
initialTimelineLimit: 200,
})
).toEqual({
direction: "tail",
limit: 200,
projection: "canonical",
});
});
it("uses canonical catch-up after the current cursor when present", () => {
it("uses canonical tail bootstrap when cursor exists but local tail is empty", () => {
expect(
__private__.buildInitialTimelineRequest({
epoch: "epoch-1",
endSeq: 42,
cursor: {
epoch: "epoch-1",
endSeq: 42,
},
hasLocalTail: false,
initialTimelineLimit: 200,
})
).toEqual({
direction: "tail",
limit: 200,
projection: "canonical",
});
});
it("uses canonical catch-up after the current cursor when local tail exists", () => {
expect(
__private__.buildInitialTimelineRequest({
cursor: {
epoch: "epoch-1",
endSeq: 42,
},
hasLocalTail: true,
initialTimelineLimit: 200,
})
).toEqual({
direction: "after",
@@ -23,4 +50,18 @@ describe("useAgentInitialization timeline request policy", () => {
projection: "canonical",
});
});
it("supports unbounded tail bootstrap policy", () => {
expect(
__private__.buildInitialTimelineRequest({
cursor: undefined,
hasLocalTail: false,
initialTimelineLimit: 0,
})
).toEqual({
direction: "tail",
limit: 0,
projection: "canonical",
});
});
});
@@ -1,4 +1,5 @@
import { useCallback } from "react";
import { Platform } from "react-native";
import { useSessionStore } from "@/stores/session-store";
import type {
DaemonClient,
@@ -13,20 +14,28 @@ import {
} from "@/utils/agent-initialization";
const INIT_TIMEOUT_MS = 5 * 60_000;
const DEFAULT_INITIAL_TIMELINE_LIMIT = 200;
const NATIVE_INITIAL_TIMELINE_LIMIT = 200;
const UNBOUNDED_TIMELINE_LIMIT = 0;
type TimelineCursorState = {
epoch: string;
endSeq: number;
};
type BuildInitialTimelineRequestInput = {
cursor: TimelineCursorState | undefined;
hasLocalTail: boolean;
initialTimelineLimit: number;
};
function buildInitialTimelineRequest(
cursor: TimelineCursorState | undefined
input: BuildInitialTimelineRequestInput
): FetchAgentTimelineOptions {
if (!cursor) {
const { cursor, hasLocalTail, initialTimelineLimit } = input;
if (!cursor || !hasLocalTail) {
return {
direction: "tail",
limit: DEFAULT_INITIAL_TIMELINE_LIMIT,
limit: initialTimelineLimit,
projection: "canonical",
};
}
@@ -40,8 +49,15 @@ function buildInitialTimelineRequest(
};
}
function resolveInitialTimelineLimit(): number {
return Platform.OS === "web"
? UNBOUNDED_TIMELINE_LIMIT
: NATIVE_INITIAL_TIMELINE_LIMIT;
}
export const __private__ = {
buildInitialTimelineRequest,
resolveInitialTimelineLimit,
};
export function useAgentInitialization({
@@ -76,7 +92,13 @@ export function useAgentInitialization({
const session = useSessionStore.getState().sessions[serverId];
const cursor = session?.agentTimelineCursor.get(agentId);
const timelineRequest = buildInitialTimelineRequest(cursor);
const hasLocalTail = (session?.agentStreamTail.get(agentId)?.length ?? 0) > 0;
const initialTimelineLimit = resolveInitialTimelineLimit();
const timelineRequest = buildInitialTimelineRequest({
cursor,
hasLocalTail,
initialTimelineLimit,
});
const initRequestDirection =
timelineRequest.direction === "after" ? "after" : "tail";
@@ -128,9 +150,10 @@ export function useAgentInitialization({
try {
await client.refreshAgent(agentId);
const initialTimelineLimit = resolveInitialTimelineLimit();
await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: DEFAULT_INITIAL_TIMELINE_LIMIT,
limit: initialTimelineLimit,
projection: "canonical",
});
} catch (error) {
@@ -57,6 +57,92 @@ const concatInt16 = (a: Int16Array, b: Int16Array): Int16Array => {
return out;
};
const int16ToFloat32 = (input: Int16Array): Float32Array => {
const out = new Float32Array(input.length);
for (let i = 0; i < input.length; i += 1) {
out[i] = input[i]! / 32768;
}
return out;
};
type Pcm16Wav = {
sampleRate: number;
samples: Int16Array;
};
const parsePcm16Wav = (buffer: ArrayBuffer): Pcm16Wav | null => {
if (buffer.byteLength < 44) {
return null;
}
const view = new DataView(buffer);
const readAscii = (offset: number, length: number): string => {
let out = "";
for (let i = 0; i < length; i += 1) {
out += String.fromCharCode(view.getUint8(offset + i));
}
return out;
};
if (readAscii(0, 4) !== "RIFF" || readAscii(8, 4) !== "WAVE") {
return null;
}
let offset = 12;
let channels = 0;
let sampleRate = 0;
let bitsPerSample = 0;
let dataOffset = 0;
let dataSize = 0;
while (offset + 8 <= buffer.byteLength) {
const chunkId = readAscii(offset, 4);
const chunkSize = view.getUint32(offset + 4, true);
const chunkDataOffset = offset + 8;
if (chunkDataOffset + chunkSize > buffer.byteLength) {
break;
}
if (chunkId === "fmt " && chunkSize >= 16) {
const audioFormat = view.getUint16(chunkDataOffset, true);
channels = view.getUint16(chunkDataOffset + 2, true);
sampleRate = view.getUint32(chunkDataOffset + 4, true);
bitsPerSample = view.getUint16(chunkDataOffset + 14, true);
if (audioFormat !== 1) {
return null;
}
} else if (chunkId === "data") {
dataOffset = chunkDataOffset;
dataSize = chunkSize;
break;
}
offset = chunkDataOffset + chunkSize + (chunkSize % 2);
}
if (!dataOffset || !dataSize || sampleRate <= 0 || bitsPerSample !== 16 || channels <= 0) {
return null;
}
const sampleCount = Math.floor(dataSize / 2);
const interleaved = new Int16Array(buffer, dataOffset, sampleCount);
if (channels === 1) {
return { sampleRate, samples: new Int16Array(interleaved) };
}
const frameCount = Math.floor(interleaved.length / channels);
const mono = new Int16Array(frameCount);
for (let frame = 0; frame < frameCount; frame += 1) {
let sum = 0;
for (let ch = 0; ch < channels; ch += 1) {
sum += interleaved[frame * channels + ch] ?? 0;
}
mono[frame] = Math.round(sum / channels);
}
return { sampleRate, samples: mono };
};
const int16ToBase64 = (pcm: Int16Array): string => {
const bytes = new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength);
let binary = "";
@@ -359,15 +445,22 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
}
}
if (mode === "recorder" && recorder.stoppedPromise && context) {
if (mode === "recorder" && recorder.stoppedPromise) {
try {
const blob = await recorder.stoppedPromise;
const arrayBuffer = await blob.arrayBuffer();
if (arrayBuffer.byteLength > 0) {
const decoded = await decodeAudioData(context, arrayBuffer);
const floatPcm = decoded.getChannelData(0);
const pcm16 = resampleToPcm16(floatPcm, decoded.sampleRate, 16000);
emitPcmSegments(pcm16);
const parsedWav = parsePcm16Wav(arrayBuffer);
if (parsedWav) {
const floatPcm = int16ToFloat32(parsedWav.samples);
const pcm16 = resampleToPcm16(floatPcm, parsedWav.sampleRate, 16000);
emitPcmSegments(pcm16);
} else if (context) {
const decoded = await decodeAudioData(context, arrayBuffer);
const floatPcm = decoded.getChannelData(0);
const pcm16 = resampleToPcm16(floatPcm, decoded.sampleRate, 16000);
emitPcmSegments(pcm16);
}
}
} catch (err) {
onErrorRef.current?.(err instanceof Error ? err : new Error(String(err)));
+9 -5
View File
@@ -32,6 +32,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
const [duration, setDuration] = useState(0);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<DictationStatus>("idle");
const latestPartialTranscriptRef = useRef("");
const onTranscriptRef = useRef(onTranscript);
useEffect(() => {
@@ -130,6 +131,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
const clearStreamingState = useCallback(() => {
senderRef.current?.clearAll();
latestPartialTranscriptRef.current = "";
setPartialTranscript("");
}, []);
@@ -181,6 +183,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
return;
}
const next = message.payload.text ?? "";
latestPartialTranscriptRef.current = next;
setPartialTranscript(next);
onPartialTranscriptRef.current?.(next, { requestId: generateMessageId() });
});
@@ -203,13 +206,14 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
(text: string, requestId: string) => {
setIsProcessing(false);
isProcessingRef.current = false;
setPartialTranscript("");
setDuration(0);
setStatus("idle");
const transcriptText = (
text.trim().length > 0 ? text.trim() : latestPartialTranscriptRef.current.trim()
);
clearStreamingState();
const transcriptText = text.trim();
if (!transcriptText) {
return;
}
@@ -261,14 +265,14 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
try {
await audio.start();
if (client?.isConnected) {
await startNewStream("start");
}
isRecordingRef.current = true;
setIsRecording(true);
if (enableDuration) {
startDurationTracking();
}
if (client?.isConnected) {
await startNewStream("start");
}
} catch (err) {
await audio.stop().catch(() => undefined);
stopDurationTracking();
@@ -745,6 +745,7 @@ describe("HostRuntimeStore", () => {
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
filter: { labels: { ui: "true" } },
subscribe: { subscriptionId: "app:srv_test" },
page: { limit: 200 },
});
const snapshot = store.getSnapshot(host.serverId);
@@ -1088,8 +1088,9 @@ function AgentScreenContent({
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={DROPDOWN_WIDTH} testID="agent-overflow-content">
<View style={styles.menuMetaContainer}>
<View style={styles.menuMetaContainer} testID="agent-details-sheet">
<Pressable
testID="agent-details-agent-id"
style={({ hovered, pressed }) => [
styles.menuMetaRow,
(hovered || pressed) && styles.menuMetaRowActive,
@@ -1102,6 +1103,7 @@ function AgentScreenContent({
Directory
</Text>
<Text
testID="agent-details-agent-id-value"
style={styles.menuMetaValue}
numberOfLines={1}
ellipsizeMode="middle"
@@ -1179,6 +1181,7 @@ function AgentScreenContent({
</Pressable>
<Pressable
testID="agent-details-persistence-session-id"
style={({ hovered, pressed }) => [
styles.menuMetaRow,
providerSessionId && (hovered || pressed) && styles.menuMetaRowActive,
@@ -1192,6 +1195,7 @@ function AgentScreenContent({
{providerLabel} ID
</Text>
<Text
testID="agent-details-persistence-session-id-value"
style={[styles.menuMetaValue, !providerSessionId && styles.menuMetaValueError]}
numberOfLines={1}
ellipsizeMode="middle"
@@ -1209,6 +1209,7 @@ function DraftAgentScreenContent({
icon={<GitBranch size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
showLabel={false}
valueEllipsizeMode="middle"
testID="worktree-select-trigger"
/>
{worktreeMode === 'create' ? (
<FormSelectTrigger
@@ -1221,6 +1222,7 @@ function DraftAgentScreenContent({
disabled={repoInfoStatus === 'loading'}
icon={<GitBranch size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
showLabel={false}
testID="worktree-base-branch-trigger"
/>
) : null}
</View>
@@ -15,6 +15,7 @@ interface KeyboardShortcutsState {
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
sidebarShortcutAgentKeys: string[];
messageInputActionRequest: MessageInputActionRequest | null;
nextMessageInputActionRequestId: number;
setCommandCenterOpen: (open: boolean) => void;
setShortcutsDialogOpen: (open: boolean) => void;
@@ -38,6 +39,7 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
cmdOrCtrlDown: false,
sidebarShortcutAgentKeys: [],
messageInputActionRequest: null,
nextMessageInputActionRequestId: 1,
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
@@ -47,9 +49,11 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
requestMessageInputAction: ({ agentKey, kind }) => {
const previous = get().messageInputActionRequest;
const id = (previous?.id ?? 0) + 1;
set({ messageInputActionRequest: { id, agentKey, kind } });
const id = get().nextMessageInputActionRequestId;
set({
messageInputActionRequest: { id, agentKey, kind },
nextMessageInputActionRequestId: id + 1,
});
},
clearMessageInputActionRequest: (id) => {
const current = get().messageInputActionRequest;
@@ -0,0 +1,582 @@
import { Platform } from "react-native";
type ListenerStats = {
adds: number;
removes: number;
active: number;
};
type TimerStats = {
created: number;
fired: number;
cleared: number;
active: number;
};
type WebSocketStats = {
created: number;
opened: number;
closed: number;
errored: number;
active: number;
};
type ComponentStats = {
mounts: number;
unmounts: number;
renders: number;
scrollEvents: number;
nearBottomTransitions: number;
metricUpdates: number;
itemRenderCalls: number;
wheelAttach: number;
wheelDetach: number;
inputChanges: number;
keyPresses: number;
lastRenderAtMs: number;
};
type ScrollInvestigationStore = {
markRender: (componentId: string) => void;
markEvent: (
componentId: string,
event:
| "mount"
| "unmount"
| "scrollEvent"
| "nearBottomTransition"
| "metricUpdate"
| "itemRenderCall"
| "wheelAttach"
| "wheelDetach"
| "inputChange"
| "keyPress"
) => void;
snapshot: () => {
listeners: {
byType: Record<string, ListenerStats>;
byCallsite: Record<string, number>;
activeUniqueKeys: number;
activeByTypeAndTarget: Record<string, Record<string, number>>;
};
timers: {
timeout: TimerStats;
interval: TimerStats;
raf: TimerStats;
};
websockets: {
totals: WebSocketStats;
activeByUrl: Record<string, number>;
};
components: Record<string, ComponentStats>;
};
printSnapshot: (label?: string) => void;
_installedAtMs: number;
};
type ScrollInvestigationGlobal = typeof globalThis & {
__PASEO_SCROLL_JANK_INVESTIGATION__?: ScrollInvestigationStore;
__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__?: boolean;
};
const TRACKED_EVENT_TYPES = new Set([
"wheel",
"scroll",
"pointermove",
"pointerup",
"pointercancel",
]);
const SOURCE_LABEL = "[ScrollJankInvestigation]";
function shouldInstall(): boolean {
const runtime = globalThis as ScrollInvestigationGlobal;
const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
return (
Platform.OS === "web" &&
isDev &&
!runtime.__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__
);
}
function normalizeCapture(options?: AddEventListenerOptions | boolean): boolean {
if (typeof options === "boolean") {
return options;
}
return Boolean(options?.capture);
}
function describeEventTarget(target: EventTarget): string {
const element = target as Element;
if (element && typeof element === "object" && "tagName" in element) {
const tagName = (element.tagName || "unknown").toLowerCase();
const testId = element.getAttribute?.("data-testid");
const role = element.getAttribute?.("role");
const id = (element as HTMLElement).id || null;
const className = (element as HTMLElement).className;
const classLabel =
typeof className === "string" && className.trim().length > 0
? className.trim().split(/\s+/).slice(0, 2).join(".")
: null;
const connectivityLabel =
typeof (element as Node).isConnected === "boolean"
? (element as Node).isConnected
? "[connected]"
: "[detached]"
: null;
return [
tagName,
id ? `#${id}` : null,
testId ? `[data-testid=${testId}]` : null,
role ? `[role=${role}]` : null,
classLabel ? `.${classLabel}` : null,
connectivityLabel,
]
.filter(Boolean)
.join("");
}
const ctorName = (target as { constructor?: { name?: string } }).constructor
?.name;
return ctorName || "unknown-target";
}
function inferCallsite(): string {
const stack = new Error().stack;
if (!stack) {
return "unknown";
}
const frames = stack.split("\n");
for (const raw of frames.slice(2)) {
const line = raw.trim();
if (!line) {
continue;
}
if (line.includes("scroll-jank-investigation")) {
continue;
}
if (line.includes("patchedAddEventListener")) {
continue;
}
return line;
}
return "unknown";
}
function ensureListenerStats(
map: Map<string, ListenerStats>,
type: string
): ListenerStats {
const existing = map.get(type);
if (existing) {
return existing;
}
const next: ListenerStats = { adds: 0, removes: 0, active: 0 };
map.set(type, next);
return next;
}
function ensureComponentStats(
map: Map<string, ComponentStats>,
componentId: string
): ComponentStats {
const existing = map.get(componentId);
if (existing) {
return existing;
}
const next: ComponentStats = {
mounts: 0,
unmounts: 0,
renders: 0,
scrollEvents: 0,
nearBottomTransitions: 0,
metricUpdates: 0,
itemRenderCalls: 0,
wheelAttach: 0,
wheelDetach: 0,
inputChanges: 0,
keyPresses: 0,
lastRenderAtMs: 0,
};
map.set(componentId, next);
return next;
}
export function installScrollJankInvestigation(): void {
if (!shouldInstall()) {
return;
}
const runtime = globalThis as ScrollInvestigationGlobal;
if (runtime.__PASEO_SCROLL_JANK_INVESTIGATION__) {
return;
}
const targetIds = new WeakMap<object, number>();
const listenerIds = new WeakMap<object, number>();
const activeListenerKeys = new Set<string>();
const activeListenerMeta = new Map<
string,
{ type: string; target: string }
>();
const listenerStatsByType = new Map<string, ListenerStats>();
const listenerCallsiteCount = new Map<string, number>();
const componentStatsById = new Map<string, ComponentStats>();
const activeWsByUrl = new Map<string, number>();
const timeoutHandles = new Map<number, true>();
const intervalHandles = new Map<number, true>();
const rafHandles = new Map<number, true>();
let nextTargetId = 1;
let nextListenerId = 1;
const timerStats = {
timeout: { created: 0, fired: 0, cleared: 0, active: 0 } as TimerStats,
interval: { created: 0, fired: 0, cleared: 0, active: 0 } as TimerStats,
raf: { created: 0, fired: 0, cleared: 0, active: 0 } as TimerStats,
};
const websocketStats: WebSocketStats = {
created: 0,
opened: 0,
closed: 0,
errored: 0,
active: 0,
};
const eventTargetProto = EventTarget.prototype as EventTarget & {
addEventListener: EventTarget["addEventListener"];
removeEventListener: EventTarget["removeEventListener"];
};
const nativeAddEventListener = eventTargetProto.addEventListener;
const nativeRemoveEventListener = eventTargetProto.removeEventListener;
function getTargetId(target: EventTarget): string {
const targetObj = target as unknown as object;
const existing = targetIds.get(targetObj);
if (existing) {
return String(existing);
}
const next = nextTargetId++;
targetIds.set(targetObj, next);
return String(next);
}
function getListenerId(listener: EventListenerOrEventListenerObject): string {
const listenerObj = listener as unknown as object;
const existing = listenerIds.get(listenerObj);
if (existing) {
return String(existing);
}
const next = nextListenerId++;
listenerIds.set(listenerObj, next);
return String(next);
}
function toListenerKey(
target: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions | boolean
): string {
return [
getTargetId(target),
type,
normalizeCapture(options) ? "capture" : "bubble",
getListenerId(listener),
].join("|");
}
eventTargetProto.addEventListener = function patchedAddEventListener(
this: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: AddEventListenerOptions | boolean
): void {
nativeAddEventListener.call(this, type, listener as any, options as any);
if (!listener) {
return;
}
const stats = ensureListenerStats(listenerStatsByType, type);
stats.adds += 1;
const key = toListenerKey(this, type, listener, options);
if (!activeListenerKeys.has(key)) {
activeListenerKeys.add(key);
stats.active += 1;
activeListenerMeta.set(key, {
type,
target: describeEventTarget(this),
});
}
if (TRACKED_EVENT_TYPES.has(type)) {
const callsite = inferCallsite();
const metricKey = `${type} :: ${callsite}`;
listenerCallsiteCount.set(
metricKey,
(listenerCallsiteCount.get(metricKey) ?? 0) + 1
);
}
};
eventTargetProto.removeEventListener = function patchedRemoveEventListener(
this: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: EventListenerOptions | boolean
): void {
nativeRemoveEventListener.call(this, type, listener as any, options as any);
if (!listener) {
return;
}
const stats = ensureListenerStats(listenerStatsByType, type);
stats.removes += 1;
const key = toListenerKey(this, type, listener, options);
if (activeListenerKeys.delete(key)) {
stats.active = Math.max(0, stats.active - 1);
activeListenerMeta.delete(key);
}
};
const nativeSetTimeout = globalThis.setTimeout.bind(globalThis);
const nativeClearTimeout = globalThis.clearTimeout.bind(globalThis);
const nativeSetInterval = globalThis.setInterval.bind(globalThis);
const nativeClearInterval = globalThis.clearInterval.bind(globalThis);
const nativeRequestAnimationFrame =
globalThis.requestAnimationFrame.bind(globalThis);
const nativeCancelAnimationFrame =
globalThis.cancelAnimationFrame.bind(globalThis);
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
timerStats.timeout.created += 1;
let timeoutId = -1;
const wrapped =
typeof handler === "function"
? (...handlerArgs: unknown[]) => {
if (timeoutHandles.delete(timeoutId)) {
timerStats.timeout.fired += 1;
timerStats.timeout.active = timeoutHandles.size;
}
return handler(...handlerArgs);
}
: handler;
timeoutId = nativeSetTimeout(wrapped, timeout, ...(args as any[])) as unknown as number;
timeoutHandles.set(timeoutId, true);
timerStats.timeout.active = timeoutHandles.size;
return timeoutId as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout;
globalThis.clearTimeout = ((timeoutId?: number) => {
if (typeof timeoutId === "number" && timeoutHandles.delete(timeoutId)) {
timerStats.timeout.cleared += 1;
timerStats.timeout.active = timeoutHandles.size;
}
return nativeClearTimeout(timeoutId);
}) as typeof clearTimeout;
globalThis.setInterval = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
timerStats.interval.created += 1;
let intervalId = -1;
const wrapped =
typeof handler === "function"
? (...handlerArgs: unknown[]) => {
timerStats.interval.fired += 1;
return handler(...handlerArgs);
}
: handler;
intervalId = nativeSetInterval(wrapped, timeout, ...(args as any[])) as unknown as number;
intervalHandles.set(intervalId, true);
timerStats.interval.active = intervalHandles.size;
return intervalId as unknown as ReturnType<typeof setInterval>;
}) as typeof setInterval;
globalThis.clearInterval = ((intervalId?: number) => {
if (typeof intervalId === "number" && intervalHandles.delete(intervalId)) {
timerStats.interval.cleared += 1;
timerStats.interval.active = intervalHandles.size;
}
return nativeClearInterval(intervalId);
}) as typeof clearInterval;
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
timerStats.raf.created += 1;
let rafId = -1;
const wrapped = (timestamp: number) => {
if (rafHandles.delete(rafId)) {
timerStats.raf.fired += 1;
timerStats.raf.active = rafHandles.size;
}
callback(timestamp);
};
rafId = nativeRequestAnimationFrame(wrapped) as unknown as number;
rafHandles.set(rafId, true);
timerStats.raf.active = rafHandles.size;
return rafId as unknown as ReturnType<typeof requestAnimationFrame>;
}) as typeof requestAnimationFrame;
globalThis.cancelAnimationFrame = ((rafId: number) => {
if (rafHandles.delete(rafId)) {
timerStats.raf.cleared += 1;
timerStats.raf.active = rafHandles.size;
}
return nativeCancelAnimationFrame(rafId);
}) as typeof cancelAnimationFrame;
const NativeWebSocket = globalThis.WebSocket;
if (typeof NativeWebSocket === "function") {
class InstrumentedWebSocket extends NativeWebSocket {
constructor(url: string | URL, protocols?: string | string[]) {
if (protocols === undefined) {
super(url);
} else {
super(url, protocols);
}
const urlKey = String(url);
websocketStats.created += 1;
websocketStats.active += 1;
activeWsByUrl.set(urlKey, (activeWsByUrl.get(urlKey) ?? 0) + 1);
const handleOpen = () => {
websocketStats.opened += 1;
};
const handleError = () => {
websocketStats.errored += 1;
};
const handleClose = () => {
websocketStats.closed += 1;
websocketStats.active = Math.max(0, websocketStats.active - 1);
const current = activeWsByUrl.get(urlKey) ?? 0;
if (current <= 1) {
activeWsByUrl.delete(urlKey);
} else {
activeWsByUrl.set(urlKey, current - 1);
}
this.removeEventListener("open", handleOpen);
this.removeEventListener("error", handleError);
this.removeEventListener("close", handleClose);
};
this.addEventListener("open", handleOpen);
this.addEventListener("error", handleError);
this.addEventListener("close", handleClose);
}
}
globalThis.WebSocket = InstrumentedWebSocket as typeof WebSocket;
}
const store: ScrollInvestigationStore = {
markRender(componentId: string) {
const stats = ensureComponentStats(componentStatsById, componentId);
stats.renders += 1;
stats.lastRenderAtMs = performance.now();
},
markEvent(componentId: string, event) {
const stats = ensureComponentStats(componentStatsById, componentId);
switch (event) {
case "mount":
stats.mounts += 1;
return;
case "unmount":
stats.unmounts += 1;
return;
case "scrollEvent":
stats.scrollEvents += 1;
return;
case "nearBottomTransition":
stats.nearBottomTransitions += 1;
return;
case "metricUpdate":
stats.metricUpdates += 1;
return;
case "itemRenderCall":
stats.itemRenderCalls += 1;
return;
case "wheelAttach":
stats.wheelAttach += 1;
return;
case "wheelDetach":
stats.wheelDetach += 1;
return;
case "inputChange":
stats.inputChanges += 1;
return;
case "keyPress":
stats.keyPresses += 1;
return;
default:
return;
}
},
snapshot() {
const activeByTypeAndTarget: Record<string, Record<string, number>> = {};
for (const { type, target } of activeListenerMeta.values()) {
const existingByType = activeByTypeAndTarget[type] ?? {};
existingByType[target] = (existingByType[target] ?? 0) + 1;
activeByTypeAndTarget[type] = existingByType;
}
return {
listeners: {
byType: Object.fromEntries(listenerStatsByType.entries()),
byCallsite: Object.fromEntries(listenerCallsiteCount.entries()),
activeUniqueKeys: activeListenerKeys.size,
activeByTypeAndTarget,
},
timers: {
timeout: { ...timerStats.timeout, active: timeoutHandles.size },
interval: { ...timerStats.interval, active: intervalHandles.size },
raf: { ...timerStats.raf, active: rafHandles.size },
},
websockets: {
totals: { ...websocketStats },
activeByUrl: Object.fromEntries(activeWsByUrl.entries()),
},
components: Object.fromEntries(componentStatsById.entries()),
};
},
printSnapshot(label?: string) {
console.log(`${SOURCE_LABEL} ${label ?? "snapshot"}`, this.snapshot());
},
_installedAtMs: Date.now(),
};
runtime.__PASEO_SCROLL_JANK_INVESTIGATION__ = store;
console.log(
`${SOURCE_LABEL} installed`,
"Use window.__PASEO_SCROLL_JANK_INVESTIGATION__.snapshot()"
);
}
function getStore(): ScrollInvestigationStore | null {
const runtime = globalThis as ScrollInvestigationGlobal;
return runtime.__PASEO_SCROLL_JANK_INVESTIGATION__ ?? null;
}
export function markScrollInvestigationRender(componentId: string): void {
if (!shouldInstall()) {
return;
}
getStore()?.markRender(componentId);
}
export function markScrollInvestigationEvent(
componentId: string,
event:
| "mount"
| "unmount"
| "scrollEvent"
| "nearBottomTransition"
| "metricUpdate"
| "itemRenderCall"
| "wheelAttach"
| "wheelDetach"
| "inputChange"
| "keyPress"
): void {
if (!shouldInstall()) {
return;
}
getStore()?.markEvent(componentId, event);
}
+16
View File
@@ -0,0 +1,16 @@
type ScrollInvestigationEvent =
| "scrollEvent"
| "nearBottomTransition"
| "metricUpdate"
| "itemRenderCall"
| "wheelAttach"
| "wheelDetach";
export function installScrollJankInvestigation(): void {}
export function markScrollInvestigationRender(_componentId: string): void {}
export function markScrollInvestigationEvent(
_componentId: string,
_event: ScrollInvestigationEvent
): void {}
@@ -48,16 +48,25 @@ const daemonClientMock = vi.hoisted(() => {
};
});
const clientIdMock = vi.hoisted(() => ({
getOrCreateClientId: vi.fn(async () => "cid_shared_probe_test"),
}));
vi.mock("@server/client/daemon-client", () => ({
DaemonClient: daemonClientMock.MockDaemonClient,
}));
vi.mock("./client-id", () => ({
getOrCreateClientId: clientIdMock.getOrCreateClientId,
}));
describe("test-daemon-connection probe client identity", () => {
beforeEach(() => {
daemonClientMock.createdConfigs.length = 0;
clientIdMock.getOrCreateClientId.mockClear();
});
it("uses isolated probe clientId values for direct latency probes", async () => {
it("reuses the app clientId for direct latency probes", async () => {
const mod = await import("./test-daemon-connection");
await mod.measureConnectionLatency({
@@ -72,8 +81,8 @@ describe("test-daemon-connection probe client identity", () => {
});
const [first, second] = daemonClientMock.createdConfigs;
expect(first?.clientId).toMatch(/^cid_probe_/);
expect(second?.clientId).toMatch(/^cid_probe_/);
expect(first?.clientId).not.toBe(second?.clientId);
expect(first?.clientId).toBe("cid_shared_probe_test");
expect(second?.clientId).toBe("cid_shared_probe_test");
expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2);
});
});
@@ -1,18 +1,9 @@
import { DaemonClient } from "@server/client/daemon-client";
import type { DaemonClientConfig } from "@server/client/daemon-client";
import type { HostConnection } from "@/contexts/daemon-registry-context";
import { getOrCreateClientId } from "./client-id";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
function createProbeClientId(): string {
const randomUuid = (() => {
const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined;
if (cryptoObj && typeof cryptoObj.randomUUID === "function") {
return cryptoObj.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
})();
return `cid_probe_${randomUuid}`;
}
function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
@@ -54,7 +45,7 @@ async function buildClientConfig(
connection: HostConnection,
serverId?: string
): Promise<DaemonClientConfig> {
const clientId = createProbeClientId();
const clientId = await getOrCreateClientId();
const tauriTransportFactory = createTauriWebSocketTransportFactory();
const base = {
clientId,