chore: commit all local changes

This commit is contained in:
Mohamed Boudra
2026-02-25 13:53:23 +07:00
parent a060ae1256
commit c4523c70b5
18 changed files with 1818 additions and 67 deletions
@@ -61,6 +61,8 @@ interface AgentInputAreaProps {
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void
/** Optional draft context for listing commands before an agent exists. */
commandDraftConfig?: DraftCommandConfig
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
onMessageSent?: () => void
}
const EMPTY_ARRAY: readonly QueuedMessage[] = []
@@ -77,6 +79,7 @@ export function AgentInputArea({
autoFocus = false,
onAddImages,
commandDraftConfig,
onMessageSent,
}: AgentInputAreaProps) {
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
const { theme } = useUnistyles()
@@ -169,6 +172,7 @@ export function AgentInputArea({
}, [addImages, onAddImages])
const submitMessage = useCallback(async (text: string, images?: ImageAttachment[]) => {
onMessageSent?.()
if (onSubmitMessageRef.current) {
await onSubmitMessageRef.current({ text, images })
return
@@ -177,7 +181,7 @@ export function AgentInputArea({
throw new Error('Host is not connected')
}
await sendAgentMessageRef.current(agentIdRef.current, text, images)
}, [])
}, [onMessageSent])
useEffect(() => {
agentIdRef.current = agentId
@@ -137,6 +137,82 @@ 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;
}
return null;
};
const scrollViewHandle = refs.scrollViewRef.current as
| {
getNativeScrollRef?: () => unknown;
getScrollableNode?: () => unknown;
getInnerViewNode?: () => unknown;
getNativeRef?: () => unknown;
}
| null;
const anchorHandle = refs.bottomAnchorRef.current as
| ({ getNativeRef?: () => unknown } & object)
| null;
const candidates: unknown[] = [
scrollViewHandle?.getNativeScrollRef?.(),
scrollViewHandle?.getScrollableNode?.(),
scrollViewHandle?.getInnerViewNode?.(),
scrollViewHandle?.getNativeRef?.(),
scrollViewHandle,
typeof anchorHandle?.getNativeRef === "function"
? anchorHandle.getNativeRef()
: anchorHandle,
];
let scrollNode: HTMLElement | null = null;
for (const candidate of candidates) {
scrollNode = resolveNode(candidate);
if (scrollNode) {
break;
}
}
if (!scrollNode && typeof document !== "undefined") {
scrollNode = resolveNode(
document.querySelector("[data-testid='agent-chat-scroll']")
);
}
if (!scrollNode) {
return;
}
const snap = () => {
scrollNode.scrollTop = Math.max(
fallbackOffset,
scrollNode.scrollHeight - scrollNode.clientHeight
);
};
snap();
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(snap);
}
}
function createStreamRenderStrategy(
config: StreamRenderStrategyConfig
): StreamRenderStrategy {
@@ -255,14 +331,20 @@ function createForwardStreamStrategy(): StreamRenderStrategy {
getBottomOffset: (metrics) =>
Math.max(0, metrics.contentHeight - metrics.viewportHeight),
scrollToBottom: ({ refs, metrics, animated }) => {
if (scrollAnchorIntoView({ refs, animated })) {
return;
const bottomOffset = Math.max(
0,
metrics.contentHeight - metrics.viewportHeight
);
const usedAnchor = scrollAnchorIntoView({ refs, animated });
if (!usedAnchor) {
refs.scrollViewRef.current?.scrollToEnd?.({ animated });
}
refs.scrollViewRef.current?.scrollToEnd?.({ animated });
// Always apply deterministic bottom offset to avoid partial anchors.
refs.scrollViewRef.current?.scrollTo?.({
y: Math.max(0, metrics.contentHeight - metrics.viewportHeight),
y: bottomOffset,
animated,
});
forceScrollContainerToBottom(refs, bottomOffset);
},
scrollToOffset: ({ refs, offset, animated }) => {
refs.scrollViewRef.current?.scrollTo({ y: offset, animated });
@@ -1,12 +1,14 @@
import {
Fragment,
createElement,
forwardRef,
isValidElement,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
useCallback,
} from "react";
import type { ComponentType, ReactElement, ReactNode } from "react";
import {
@@ -104,6 +106,10 @@ function renderStreamEdgeComponent(
return createElement(component);
}
export interface AgentStreamViewHandle {
scrollToBottom(): void;
}
export interface AgentStreamViewProps {
agentId: string;
serverId?: string;
@@ -112,13 +118,13 @@ export interface AgentStreamViewProps {
pendingPermissions: Map<string, PendingPermission>;
}
export function AgentStreamView({
export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(function AgentStreamView({
agentId,
serverId,
agent,
streamItems,
pendingPermissions,
}: AgentStreamViewProps) {
}, ref) {
const flatListRef = useRef<FlatList<StreamItem>>(null);
const scrollViewRef = useRef<ScrollView>(null);
const bottomAnchorRef = useRef<View>(null);
@@ -139,6 +145,7 @@ export function AgentStreamView({
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);
@@ -182,6 +189,7 @@ export function AgentStreamView({
hasScrolledInitially.current = false;
hasAutoScrolledOnce.current = false;
isNearBottomRef.current = true;
pendingAnchorRequestRef.current = false;
setExpandedInlineToolCallIds(new Set());
}, [agentId]);
@@ -224,14 +232,29 @@ export function AgentStreamView({
]
);
const updateNearBottom = useCallback((value: boolean) => {
if (isNearBottomRef.current === value) return;
isNearBottomRef.current = value;
setIsNearBottom(value);
}, []);
const requestAnchorToBottom = useCallback(() => {
pendingAnchorRequestRef.current = true;
}, []);
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),
viewportHeight: Math.max(0, layoutMeasurement.height),
};
const offsetDelta = contentOffset.y - previousOffsetY;
const contentHeightDelta =
streamViewportMetricsRef.current.contentHeight - previousContentHeight;
const threshold = Math.max(insets.bottom, 32);
const nearBottom = isNearBottomForStreamRenderStrategy({
strategy: streamRenderStrategy,
@@ -241,9 +264,22 @@ export function AgentStreamView({
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
});
if (isNearBottomRef.current !== nearBottom) {
isNearBottomRef.current = nearBottom;
setIsNearBottom(nearBottom);
const pendingAnchorBefore = pendingAnchorRequestRef.current;
const shouldSuppressFalseNearBottom =
pendingAnchorBefore &&
!nearBottom &&
Math.abs(offsetDelta) <= 1 &&
contentHeightDelta > 0;
if (shouldSuppressFalseNearBottom) {
updateNearBottom(true);
} else {
updateNearBottom(nearBottom);
}
const shouldClearPendingAnchor =
pendingAnchorBefore && !nearBottom && Math.abs(offsetDelta) > 1;
if (shouldClearPendingAnchor) {
pendingAnchorRequestRef.current = false;
}
if (showDesktopWebScrollbar) {
@@ -255,6 +291,7 @@ export function AgentStreamView({
showDesktopWebScrollbar,
streamRenderStrategy,
streamScrollbarMetrics,
updateNearBottom,
]
);
@@ -282,12 +319,17 @@ export function AgentStreamView({
animated,
});
scrollOffsetYRef.current = targetOffset;
isNearBottomRef.current = true;
setIsNearBottom(true);
updateNearBottom(true);
},
[streamRenderRefs, streamRenderStrategy]
[updateNearBottom, streamRenderRefs, streamRenderStrategy]
);
useImperativeHandle(ref, () => ({
scrollToBottom() {
requestAnchorToBottom();
},
}), [requestAnchorToBottom]);
const handleContentSizeChange = useCallback(
(width: number, height: number) => {
const previousMetrics = streamViewportMetricsRef.current;
@@ -310,7 +352,11 @@ export function AgentStreamView({
scrollToBottomInternal({ animated: false });
hasAutoScrolledOnce.current = true;
hasScrolledInitially.current = true;
} else if (wasNearBottom || isNearBottomRef.current) {
} else if (
wasNearBottom ||
isNearBottomRef.current ||
pendingAnchorRequestRef.current
) {
scrollToBottomInternal({ animated: false });
}
}
@@ -376,7 +422,7 @@ export function AgentStreamView({
return () => handle.cancel();
}
if (!isNearBottomRef.current) {
if (!isNearBottomRef.current && !pendingAnchorRequestRef.current) {
return;
}
@@ -393,8 +439,6 @@ export function AgentStreamView({
function scrollToBottom() {
const animated = streamRenderStrategy.shouldAnimateManualScrollToBottom();
scrollToBottomInternal({ animated });
isNearBottomRef.current = true;
setIsNearBottom(true);
}
const flatListData = useMemo(() => {
@@ -982,7 +1026,7 @@ export function AgentStreamView({
</View>
</ToolCallSheetProvider>
);
}
});
function normalizeInlinePath(
rawPath: string,
@@ -153,7 +153,9 @@ export function processTimelineResponse(
// ----------------------------------------------------------------
// Replace path: full hydration from scratch
// ----------------------------------------------------------------
nextTail = hydrateStreamState(toHydratedEvents(timelineUnits));
nextTail = hydrateStreamState(toHydratedEvents(timelineUnits), {
source: "canonical",
});
nextHead = [];
if (payload.startCursor && payload.endCursor) {
@@ -222,7 +224,9 @@ export function processTimelineResponse(
if (acceptedUnits.length > 0) {
nextTail = acceptedUnits.reduce<StreamItem[]>(
(state, { event, timestamp }) =>
reduceStreamUpdate(state, event, timestamp),
reduceStreamUpdate(state, event, timestamp, {
source: "canonical",
}),
currentTail
);
}
@@ -385,6 +389,7 @@ export function processAgentStreamEvent(
head: currentHead,
event,
timestamp,
source: "live",
})
: {
tail: currentTail,
@@ -31,7 +31,7 @@ import {
import { MenuHeader } from "@/components/headers/menu-header";
import { BackHeader } from "@/components/headers/back-header";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { AgentStreamView } from "@/components/agent-stream-view";
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
import { AgentInputArea } from "@/components/agent-input-area";
import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { FileDropZone } from "@/components/file-drop-zone";
@@ -240,6 +240,7 @@ function AgentScreenContent({
const resolvedAgentId = agentId;
const { isArchivingAgent } = useArchiveAgent();
const streamViewRef = useRef<AgentStreamViewHandle>(null);
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
@@ -1235,6 +1236,7 @@ function AgentScreenContent({
style={[styles.content, animatedKeyboardStyle]}
>
<AgentStreamView
ref={streamViewRef}
agentId={effectiveAgent.id}
serverId={serverId}
agent={effectiveAgent}
@@ -1254,6 +1256,7 @@ function AgentScreenContent({
autoFocus
isSubmitLoading={showPendingCreateSubmitLoading}
onAddImages={handleAddImagesCallback}
onMessageSent={() => streamViewRef.current?.scrollToBottom()}
/>
)}
+89
View File
@@ -522,4 +522,93 @@ describe("stream reducer canonical tool calls", () => {
authoritativeTimestamp.getTime()
);
});
it("keeps canonical assistant/user/assistant order during replay", () => {
const state: StreamItem[] = [
{
kind: "assistant_message",
id: "a1",
text: "Saved that preference. ",
timestamp: new Date("2025-01-01T11:20:00Z"),
},
{
kind: "user_message",
id: "u1",
text: "the other qeustion is i mgiht be thinking that its winner takes it all",
timestamp: new Date("2025-01-01T11:20:01Z"),
},
];
const event: AgentStreamEventPayload = {
type: "timeline",
provider: "claude",
item: {
type: "assistant_message",
text: "Right. And it probably isn't.",
},
};
const next = reduceStreamUpdate(
state,
event,
new Date("2025-01-01T11:20:02Z"),
{ source: "canonical" }
);
assert.deepStrictEqual(next.map((item) => item.kind), [
"assistant_message",
"user_message",
"assistant_message",
]);
assert.strictEqual(
next[0]?.kind === "assistant_message" ? next[0].text : null,
"Saved that preference. "
);
assert.strictEqual(
next[2]?.kind === "assistant_message" ? next[2].text : null,
"Right. And it probably isn't."
);
});
it("keeps live optimistic assistant merge behavior", () => {
const state: StreamItem[] = [
{
kind: "assistant_message",
id: "a1",
text: "Saved that preference. ",
timestamp: new Date("2025-01-01T11:21:00Z"),
},
{
kind: "user_message",
id: "u1",
text: "the other qeustion is i mgiht be thinking that its winner takes it all",
timestamp: new Date("2025-01-01T11:21:01Z"),
},
];
const event: AgentStreamEventPayload = {
type: "timeline",
provider: "claude",
item: {
type: "assistant_message",
text: "Right. And it probably isn't.",
},
};
const next = reduceStreamUpdate(
state,
event,
new Date("2025-01-01T11:21:02Z"),
{ source: "live" }
);
assert.deepStrictEqual(next.map((item) => item.kind), [
"assistant_message",
"user_message",
]);
assert.strictEqual(
next[0]?.kind === "assistant_message" ? next[0].text : null,
"Saved that preference. Right. And it probably isn't."
);
});
});
+25 -8
View File
@@ -160,6 +160,8 @@ export interface TodoListItem {
items: TodoEntry[];
}
export type StreamUpdateSource = "live" | "canonical";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -230,7 +232,8 @@ function appendUserMessage(
function appendAssistantMessage(
state: StreamItem[],
text: string,
timestamp: Date
timestamp: Date,
source: StreamUpdateSource
): StreamItem[] {
const { chunk, hasContent } = normalizeChunk(text);
if (!chunk) {
@@ -250,7 +253,11 @@ function appendAssistantMessage(
// If the last item is a user_message (optimistic append to head during
// interrupt), look one further back for the streaming assistant_message.
const secondLast = state[state.length - 2];
if (last?.kind === "user_message" && secondLast?.kind === "assistant_message") {
if (
source === "live" &&
last?.kind === "user_message" &&
secondLast?.kind === "assistant_message"
) {
const updated: AssistantMessageItem = {
...secondLast,
text: `${secondLast.text}${chunk}`,
@@ -525,8 +532,10 @@ function formatErrorMessage(message: string): string {
export function reduceStreamUpdate(
state: StreamItem[],
event: AgentStreamEventPayload,
timestamp: Date
timestamp: Date,
options?: { source?: StreamUpdateSource }
): StreamItem[] {
const source = options?.source ?? "live";
switch (event.type) {
case "timeline": {
const item = event.item;
@@ -541,7 +550,12 @@ export function reduceStreamUpdate(
);
break;
case "assistant_message":
nextState = appendAssistantMessage(state, item.text, timestamp);
nextState = appendAssistantMessage(
state,
item.text,
timestamp,
source
);
break;
case "reasoning":
return appendThought(state, item.text, timestamp);
@@ -691,11 +705,12 @@ export function reduceStreamUpdate(
* Hydrate stream state from a batch of AgentManager stream events
*/
export function hydrateStreamState(
events: Array<{ event: AgentStreamEventPayload; timestamp: Date }>
events: Array<{ event: AgentStreamEventPayload; timestamp: Date }>,
options?: { source?: StreamUpdateSource }
): StreamItem[] {
const hydrated = events.reduce<StreamItem[]>(
(state, { event, timestamp }) => {
return reduceStreamUpdate(state, event, timestamp);
return reduceStreamUpdate(state, event, timestamp, options);
},
[]
);
@@ -853,8 +868,10 @@ export function applyStreamEvent(params: {
head: StreamItem[];
event: AgentStreamEventPayload;
timestamp: Date;
source?: StreamUpdateSource;
}): ApplyStreamEventResult {
const { tail, head, event, timestamp } = params;
const source = params.source ?? "live";
let nextTail = tail;
let nextHead = head;
let changedTail = false;
@@ -894,7 +911,7 @@ export function applyStreamEvent(params: {
// For streamable kinds, apply to head
if (incomingKind !== null && isStreamableKind(incomingKind)) {
const reduced = reduceStreamUpdate(nextHead, event, timestamp);
const reduced = reduceStreamUpdate(nextHead, event, timestamp, { source });
if (reduced !== nextHead) {
nextHead = reduced;
changedHead = true;
@@ -903,7 +920,7 @@ export function applyStreamEvent(params: {
}
// For non-streamable kinds or non-timeline events, apply to tail
const reduced = reduceStreamUpdate(nextTail, event, timestamp);
const reduced = reduceStreamUpdate(nextTail, event, timestamp, { source });
if (reduced !== nextTail) {
nextTail = reduced;
changedTail = true;