refactor(app): organize agent stream rendering

This commit is contained in:
Mohamed Boudra
2026-05-25 12:15:20 +07:00
parent ba724956df
commit 62780448dc
23 changed files with 772 additions and 334 deletions
+17
View File
@@ -249,6 +249,23 @@ const { theme } = useUnistyles();
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`. Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
## Native Chat Stream Layout
The native agent stream uses an inverted `FlatList`, so chat layout has three coordinate systems:
- chronological stream order
- strategy-ordered array order
- native inverted cell visual order
Do not compute stream neighbors, history/live-head seams, turn footer ownership, assistant block spacing, or tool sequence endings inside React render loops. Those policies live in `packages/app/src/agent-stream/layout.ts` and are unit-tested without React Native rendering.
Platform-specific stream edges belong on `StreamStrategy`:
- forward web uses the last history item as the history/live-head boundary and renders content before a footer
- native inverted uses the first history item as the history/live-head boundary and compensates for inverted cell child order
If a chat footer looks duplicated or appears above the assistant message on mobile, start with `packages/app/src/agent-stream/layout.test.ts`. Do not add a React Native renderer test for this class of bug; make the pure layout invariant fail first.
## iOS Simulator ## iOS Simulator
```bash ```bash
@@ -3,8 +3,8 @@ import {
__private__, __private__,
deriveBottomAnchorBlockedReason, deriveBottomAnchorBlockedReason,
type BottomAnchorMode, type BottomAnchorMode,
} from "./use-bottom-anchor-controller"; } from "./bottom-anchor-controller";
import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strategy"; import type { BottomAnchorTransportBehavior } from "./strategy";
type MeasurementState = ReturnType<typeof createMeasurementState>; type MeasurementState = ReturnType<typeof createMeasurementState>;
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strategy"; import type { BottomAnchorTransportBehavior } from "./strategy";
export type BottomAnchorMode = "sticky-bottom" | "detached"; export type BottomAnchorMode = "sticky-bottom" | "detached";
@@ -0,0 +1,222 @@
import { describe, expect, it } from "vitest";
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import {
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
type StreamStrategy,
} from "./strategy";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { layoutStream, type StreamLayout, type StreamLayoutItem } from "./layout";
function timestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
}
function userMessage(id: string, seed: number): Extract<StreamItem, { kind: "user_message" }> {
return {
kind: "user_message",
id,
text: id,
timestamp: timestamp(seed),
};
}
function assistantMessage(
id: string,
seed: number,
block?: { groupId: string; index: number },
): Extract<StreamItem, { kind: "assistant_message" }> {
return {
kind: "assistant_message",
id,
text: id,
timestamp: timestamp(seed),
...(block ? { blockGroupId: block.groupId, blockIndex: block.index } : {}),
};
}
function toolCall(id: string, seed: number): Extract<StreamItem, { kind: "tool_call" }> {
return {
kind: "tool_call",
id,
timestamp: timestamp(seed),
payload: {
source: "orchestrator",
data: {
toolCallId: id,
toolName: "Shell",
arguments: "echo hi",
result: null,
status: "completed",
},
},
};
}
function thought(id: string, seed: number): Extract<StreamItem, { kind: "thought" }> {
return {
kind: "thought",
id,
text: id,
timestamp: timestamp(seed),
status: "ready",
};
}
function timingFor(...ids: string[]): Map<string, TurnTiming> {
const timing = {
startedAt: timestamp(1),
completedAt: timestamp(9),
durationMs: 8000,
};
return new Map(ids.map((id) => [id, timing]));
}
function strategyFor(platform: "web" | "android"): StreamStrategy {
return resolveStreamRenderStrategy({
platform,
isMobileBreakpoint: false,
});
}
function layoutFor(input: {
platform: "web" | "android";
agentStatus?: string;
tail: StreamItem[];
head?: StreamItem[];
timingIds?: string[];
}): StreamLayout {
const strategy = strategyFor(input.platform);
return layoutStream({
strategy,
agentStatus: input.agentStatus ?? "idle",
history: orderTailForStreamRenderStrategy({
strategy,
streamItems: input.tail,
}),
liveHead: orderHeadForStreamRenderStrategy({
strategy,
streamHead: input.head ?? [],
}),
timingByAssistantId: timingFor(...(input.timingIds ?? [])),
});
}
function footerOwners(layout: StreamLayout): string[] {
const owners = [
...layout.history.flatMap((item) => (item.completedFooter ? [item.item.id] : [])),
...layout.liveHead.flatMap((item) => (item.completedFooter ? [item.item.id] : [])),
...(layout.auxiliaryTurnFooter ? [layout.auxiliaryTurnFooter.itemId] : []),
];
return owners;
}
function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
const item = [...layout.history, ...layout.liveHead].find(
(candidate) => candidate.item.id === id,
);
if (!item) {
throw new Error(`Missing layout item ${id}`);
}
return item;
}
describe("layoutStream", () => {
it("does not duplicate footers when a native assistant turn spans history and live head", () => {
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
const layout = layoutFor({
platform: "android",
tail: [userMessage("u1", 1), historyBlock],
head: [headBlock],
timingIds: [historyBlock.id, headBlock.id],
});
expect(footerOwners(layout)).toEqual([headBlock.id]);
expect(findLayoutItem(layout, historyBlock.id).belowItem?.id).toBe(headBlock.id);
expect(findLayoutItem(layout, historyBlock.id).completedFooter).toBeNull();
});
it("does not duplicate footers when a web assistant turn spans history and live head", () => {
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
const layout = layoutFor({
platform: "web",
tail: [userMessage("u1", 1), historyBlock],
head: [headBlock],
timingIds: [historyBlock.id, headBlock.id],
});
expect(footerOwners(layout)).toEqual([headBlock.id]);
expect(findLayoutItem(layout, historyBlock.id).belowItem?.id).toBe(headBlock.id);
expect(findLayoutItem(layout, headBlock.id).aboveItem?.id).toBe(historyBlock.id);
});
it("keeps the completed footer visually after the assistant after a native user reply", () => {
const assistant = assistantMessage("a1", 2);
const layout = layoutFor({
platform: "android",
tail: [userMessage("u1", 1), assistant, userMessage("u2", 3)],
timingIds: [assistant.id],
});
const assistantRow = findLayoutItem(layout, assistant.id);
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(assistantRow.completedFooter?.itemId).toBe(assistant.id);
expect(assistantRow.belowItem?.id).toBe("u2");
expect(assistantRow.frameOrder).toBe("footer-then-content");
});
it("keeps forward stream content before its completed footer", () => {
const assistant = assistantMessage("a1", 2);
const layout = layoutFor({
platform: "web",
tail: [userMessage("u1", 1), assistant, userMessage("u2", 3)],
timingIds: [assistant.id],
});
const assistantRow = findLayoutItem(layout, assistant.id);
expect(assistantRow.completedFooter?.itemId).toBe(assistant.id);
expect(assistantRow.frameOrder).toBe("content-then-footer");
});
it("compacts assistant block spacing across the history and live-head boundary", () => {
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
const layout = layoutFor({
platform: "android",
tail: [userMessage("u1", 1), historyBlock],
head: [headBlock],
timingIds: [historyBlock.id, headBlock.id],
});
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("compactBottom");
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactTop");
});
it("computes tool sequence position from strategy-aware neighbors", () => {
const shell = toolCall("tool-1", 2);
const thinking = thought("thought-1", 3);
const layout = layoutFor({
platform: "android",
tail: [userMessage("u1", 1), shell, thinking, assistantMessage("a1", 4)],
});
expect(findLayoutItem(layout, shell.id).toolSequence).toBe("first");
expect(findLayoutItem(layout, thinking.id).toolSequence).toBe("last");
});
it("keeps bottom and inline footer ownership mutually exclusive", () => {
const assistant = assistantMessage("a1", 2);
const layout = layoutFor({
platform: "web",
tail: [userMessage("u1", 1), assistant],
timingIds: [assistant.id],
});
expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id);
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
expect(footerOwners(layout)).toEqual([assistant.id]);
});
});
+248
View File
@@ -0,0 +1,248 @@
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./spacing";
import type { StreamFrameChildOrder, StreamStrategy } from "./strategy";
export type StreamToolSequence = "single" | "first" | "middle" | "last" | "none";
export interface TurnFooterHost {
itemId: string;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
}
export interface StreamLayoutItem {
item: StreamItem;
index: number;
items: StreamItem[];
aboveItem: StreamItem | null;
belowItem: StreamItem | null;
gapBelow: number;
assistantSpacing: "default" | "compactTop" | "compactBottom" | "compactBoth";
completedFooter: TurnFooterHost | null;
toolSequence: StreamToolSequence;
isFirstInUserGroup: boolean;
isLastInUserGroup: boolean;
isLastInToolSequence: boolean;
frameOrder: StreamFrameChildOrder;
}
export interface StreamLayout {
history: StreamLayoutItem[];
liveHead: StreamLayoutItem[];
auxiliaryTurnFooter: TurnFooterHost | null;
historyToHeadGap: number;
}
export interface StreamLayoutInput {
strategy: StreamStrategy;
agentStatus: string;
history: StreamItem[];
liveHead: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>;
}
interface LayoutSegmentInput {
strategy: StreamStrategy;
agentStatus: string;
items: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
frameOrder: StreamFrameChildOrder;
boundaryIndex: number | null;
boundaryAboveItem: StreamItem | null;
boundaryBelowItem: StreamItem | null;
}
function createTurnFooterHost(input: {
item: StreamItem;
items: StreamItem[];
index: number;
timingByAssistantId: Map<string, TurnTiming>;
}): TurnFooterHost {
return {
itemId: input.item.id,
items: input.items,
timing: input.timingByAssistantId.get(input.item.id),
startIndex: input.index,
};
}
function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null {
if (input.agentStatus === "running") {
return null;
}
const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history;
const startIndex = input.strategy.getLatestItemIndex(footerItems);
if (startIndex === null) {
return null;
}
const item = footerItems[startIndex];
if (!item || item.kind !== "assistant_message") {
return null;
}
return createTurnFooterHost({
item,
items: footerItems,
index: startIndex,
timingByAssistantId: input.timingByAssistantId,
});
}
function shouldRenderCompletedFooter(input: {
item: StreamItem;
belowItem: StreamItem | null;
agentStatus: string;
auxiliaryTurnFooter: TurnFooterHost | null;
}): boolean {
return (
input.item.kind === "assistant_message" &&
input.auxiliaryTurnFooter?.itemId !== input.item.id &&
(input.belowItem?.kind === "user_message" ||
(input.belowItem === null && input.agentStatus !== "running"))
);
}
function isToolSequenceItem(item: StreamItem | null): boolean {
return item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
}
function getToolSequence(input: {
item: StreamItem;
aboveItem: StreamItem | null;
belowItem: StreamItem | null;
}): StreamToolSequence {
if (!isToolSequenceItem(input.item)) {
return "none";
}
const hasAbove = isToolSequenceItem(input.aboveItem);
const hasBelow = isToolSequenceItem(input.belowItem);
if (hasAbove && hasBelow) {
return "middle";
}
if (hasAbove) {
return "last";
}
if (hasBelow) {
return "first";
}
return "single";
}
function getSegmentNeighbor(input: {
strategy: StreamStrategy;
items: StreamItem[];
index: number;
relation: "above" | "below";
boundaryIndex: number | null;
boundaryItem: StreamItem | null;
}): StreamItem | null {
const neighbor = input.strategy.getNeighborItem(input.items, input.index, input.relation);
if (neighbor) {
return neighbor;
}
if (input.index === input.boundaryIndex) {
return input.boundaryItem;
}
return null;
}
function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
return input.items.map((item, index) => {
const aboveItem = getSegmentNeighbor({
strategy: input.strategy,
items: input.items,
index,
relation: "above",
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryAboveItem,
});
const belowItem = getSegmentNeighbor({
strategy: input.strategy,
items: input.items,
index,
relation: "below",
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryBelowItem,
});
const assistantSpacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
});
const completedFooter = shouldRenderCompletedFooter({
item,
belowItem,
agentStatus: input.agentStatus,
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
})
? createTurnFooterHost({
item,
items: input.items,
index,
timingByAssistantId: input.timingByAssistantId,
})
: null;
return {
item,
index,
items: input.items,
aboveItem,
belowItem,
gapBelow: completedFooter ? 0 : getGapBetweenStreamItems(item, belowItem),
assistantSpacing,
completedFooter,
toolSequence: getToolSequence({ item, aboveItem, belowItem }),
isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message",
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
isLastInToolSequence: isToolSequenceItem(item) && !isToolSequenceItem(belowItem),
frameOrder: input.frameOrder,
};
});
}
export function layoutStream(input: StreamLayoutInput): StreamLayout {
const auxiliaryTurnFooter = resolveAuxiliaryTurnFooter(input);
const historyBoundaryIndex = input.strategy.getHistoryLiveBoundaryIndex(input.history);
const liveHeadBoundaryIndex = input.strategy.getLiveHeadHistoryBoundaryIndex(input.liveHead);
const historyBoundaryItem =
historyBoundaryIndex === null ? null : (input.history[historyBoundaryIndex] ?? null);
const liveHeadBoundaryItem =
liveHeadBoundaryIndex === null ? null : (input.liveHead[liveHeadBoundaryIndex] ?? null);
const frameOrder = input.strategy.getFrameChildOrder();
const history = layoutSegment({
strategy: input.strategy,
agentStatus: input.agentStatus,
items: input.history,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
frameOrder,
boundaryIndex: historyBoundaryIndex,
boundaryAboveItem: null,
boundaryBelowItem: liveHeadBoundaryItem,
});
const liveHead = layoutSegment({
strategy: input.strategy,
agentStatus: input.agentStatus,
items: input.liveHead,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
frameOrder,
boundaryIndex: liveHeadBoundaryIndex,
boundaryAboveItem: historyBoundaryItem,
boundaryBelowItem: null,
});
return {
history,
liveHead,
auxiliaryTurnFooter,
historyToHeadGap: getGapBetweenStreamItems(historyBoundaryItem, liveHeadBoundaryItem),
};
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import { buildAgentStreamRenderModel } from "./agent-stream-render-model"; import { buildAgentStreamRenderModel } from "./model";
function createTimestamp(seed: number): Date { function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`); return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
@@ -5,12 +5,9 @@ import {
findMountedWindowStart, findMountedWindowStart,
getWebMountedRecentStreamItems, getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold, getWebPartialVirtualizationThreshold,
} from "./agent-stream-web-virtualization"; } from "./web-virtualization";
import { import { orderHeadForStreamRenderStrategy, orderTailForStreamRenderStrategy } from "./strategy";
orderHeadForStreamRenderStrategy, import { resolveStreamRenderStrategy } from "./strategy-resolver";
orderTailForStreamRenderStrategy,
} from "./stream-strategy";
import { resolveStreamRenderStrategy } from "./stream-strategy-resolver";
export interface StreamRenderSegments { export interface StreamRenderSegments {
historyVirtualized: StreamItem[]; historyVirtualized: StreamItem[];
@@ -3,6 +3,9 @@ import type { StreamItem } from "@/types/stream";
import { import {
collectAssistantTurnContentForStreamRenderStrategy, collectAssistantTurnContentForStreamRenderStrategy,
getBottomOffsetForStreamRenderStrategy, getBottomOffsetForStreamRenderStrategy,
getFrameChildOrderForStreamRenderStrategy,
getHistoryLiveBoundaryIndexForStreamRenderStrategy,
getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy,
getStreamEdgeSlotProps, getStreamEdgeSlotProps,
getStreamNeighborIndex, getStreamNeighborIndex,
getStreamNeighborItem, getStreamNeighborItem,
@@ -10,8 +13,8 @@ import {
orderHeadForStreamRenderStrategy, orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy, orderTailForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior, resolveBottomAnchorTransportBehavior,
resolveStreamRenderStrategy, } from "./strategy";
} from "./agent-stream-render-strategy"; import { resolveStreamRenderStrategy } from "./strategy-resolver";
function createTimestamp(seed: number): Date { function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:0${seed}.000Z`); return new Date(`2026-01-01T00:00:0${seed}.000Z`);
@@ -328,3 +331,88 @@ describe("edge slot semantics", () => {
}); });
}); });
}); });
describe("layout strategy edges", () => {
const streamItems: StreamItem[] = [
userMessage("u1", "user-1", 1),
assistantMessage("a1", "assistant-1", 2),
];
it("uses the newest history edge as the history/live boundary", () => {
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const inverted = resolveStreamRenderStrategy({
platform: "android",
isMobileBreakpoint: false,
});
const forwardHistory = orderTailForStreamRenderStrategy({ strategy: forward, streamItems });
const invertedHistory = orderTailForStreamRenderStrategy({ strategy: inverted, streamItems });
expect(
getHistoryLiveBoundaryIndexForStreamRenderStrategy({
strategy: forward,
history: forwardHistory,
}),
).toBe(1);
expect(
getHistoryLiveBoundaryIndexForStreamRenderStrategy({
strategy: inverted,
history: invertedHistory,
}),
).toBe(0);
});
it("uses the oldest live-head edge as the live-head/history boundary", () => {
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const inverted = resolveStreamRenderStrategy({
platform: "ios",
isMobileBreakpoint: false,
});
const forwardHead = orderHeadForStreamRenderStrategy({
strategy: forward,
streamHead: streamItems,
});
const invertedHead = orderHeadForStreamRenderStrategy({
strategy: inverted,
streamHead: streamItems,
});
expect(
getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy({
strategy: forward,
liveHead: forwardHead,
}),
).toBe(0);
expect(
getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy({
strategy: inverted,
liveHead: invertedHead,
}),
).toBe(1);
});
it("names the frame child order needed by native inverted cells", () => {
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const inverted = resolveStreamRenderStrategy({
platform: "android",
isMobileBreakpoint: false,
});
expect(getFrameChildOrderForStreamRenderStrategy({ strategy: forward })).toBe(
"content-then-footer",
);
expect(getFrameChildOrderForStreamRenderStrategy({ strategy: inverted })).toBe(
"footer-then-content",
);
});
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./agent-stream-view-data"; import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./spacing";
function assistantBlock(params: { function assistantBlock(params: {
id: string; id: string;
@@ -19,13 +19,13 @@ import {
} from "react-native"; } from "react-native";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { useBottomAnchorController } from "./use-bottom-anchor-controller"; import { useBottomAnchorController } from "./bottom-anchor-controller";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { import {
createStreamStrategy, createStreamStrategy,
isNearBottomForStreamRenderStrategy, isNearBottomForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior, resolveBottomAnchorTransportBehavior,
} from "./stream-strategy"; } from "./strategy";
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
minIndexForVisible: 0, minIndexForVisible: 0,
@@ -380,6 +380,9 @@ export function createNativeStreamStrategy(): StreamStrategy {
orderHeadReverse: true, orderHeadReverse: true,
assistantTurnTraversalStep: 1, assistantTurnTraversalStep: 1,
edgeSlot: "header", edgeSlot: "header",
historyLiveBoundaryEdge: "first",
liveHeadHistoryBoundaryEdge: "last",
frameChildOrder: "footer-then-content",
flatListInverted: true, flatListInverted: true,
overlayScrollbarInverted: true, overlayScrollbarInverted: true,
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION, maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
@@ -1,6 +1,6 @@
import type { ResolveStreamRenderStrategyInput, StreamStrategy } from "./stream-strategy"; import type { ResolveStreamRenderStrategyInput, StreamStrategy } from "./strategy";
import { createNativeStreamStrategy } from "./stream-strategy-native"; import { createNativeStreamStrategy } from "./strategy-native";
import { createWebStreamStrategy } from "./stream-strategy-web"; import { createWebStreamStrategy } from "./strategy-web";
export function resolveStreamRenderStrategy( export function resolveStreamRenderStrategy(
input: ResolveStreamRenderStrategyInput, input: ResolveStreamRenderStrategyInput,
@@ -6,8 +6,8 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import type { StreamSegmentRenderers, StreamViewportHandle } from "./stream-strategy"; import type { StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
import { createWebStreamStrategy } from "./stream-strategy-web"; import { createWebStreamStrategy } from "./strategy-web";
vi.hoisted(() => { vi.hoisted(() => {
Object.defineProperty(window, "matchMedia", { Object.defineProperty(window, "matchMedia", {
@@ -25,7 +25,7 @@ vi.hoisted(() => {
}); });
}); });
vi.mock("./use-web-scrollbar", () => ({ useWebElementScrollbar: () => null })); vi.mock("@/components/use-web-scrollbar", () => ({ useWebElementScrollbar: () => null }));
function userMessage(index: number): StreamItem { function userMessage(index: number): StreamItem {
return { return {
@@ -10,9 +10,9 @@ import React, {
} from "react"; } from "react";
import { ActivityIndicator } from "react-native"; import { ActivityIndicator } from "react-native";
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual"; import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
import { estimateStreamItemHeight } from "./agent-stream-web-virtualization"; import { estimateStreamItemHeight } from "./web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { createStreamStrategy } from "./stream-strategy"; import { createStreamStrategy } from "./strategy";
interface CreateWebStreamStrategyInput { interface CreateWebStreamStrategyInput {
isMobileBreakpoint: boolean; isMobileBreakpoint: boolean;
@@ -25,7 +25,7 @@ const USER_SCROLL_DELTA_EPSILON = 1;
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64; const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1; const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const HISTORY_START_THRESHOLD_PX = 96; const HISTORY_START_THRESHOLD_PX = 96;
import { useWebElementScrollbar } from "./use-web-scrollbar"; import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
const historyStartSlotStyle: CSSProperties = { const historyStartSlotStyle: CSSProperties = {
display: "flex", display: "flex",
@@ -610,6 +610,9 @@ export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): St
orderHeadReverse: false, orderHeadReverse: false,
assistantTurnTraversalStep: -1, assistantTurnTraversalStep: -1,
edgeSlot: "footer", edgeSlot: "footer",
historyLiveBoundaryEdge: "last",
liveHeadHistoryBoundaryEdge: "first",
frameChildOrder: "content-then-footer",
flatListInverted: false, flatListInverted: false,
overlayScrollbarInverted: false, overlayScrollbarInverted: false,
maintainVisibleContentPosition: undefined, maintainVisibleContentPosition: undefined,
@@ -1,15 +1,16 @@
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react"; import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native"; import type { StyleProp, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import type { StreamHistoryBoundary, StreamRenderSegments } from "./agent-stream-render-model"; import type { StreamHistoryBoundary, StreamRenderSegments } from "./model";
import type { import type {
BottomAnchorLocalRequest, BottomAnchorLocalRequest,
BottomAnchorRouteRequest, BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller"; } from "./bottom-anchor-controller";
type EdgeSlot = "header" | "footer"; type EdgeSlot = "header" | "footer";
type NeighborRelation = "above" | "below"; type NeighborRelation = "above" | "below";
type AssistantTurnTraversalStep = -1 | 1; type AssistantTurnTraversalStep = -1 | 1;
export type StreamFrameChildOrder = "content-then-footer" | "footer-then-content";
export type MaintainVisibleContentPositionConfig = Readonly<{ export type MaintainVisibleContentPositionConfig = Readonly<{
minIndexForVisible: number; minIndexForVisible: number;
@@ -93,6 +94,10 @@ export interface StreamStrategy {
) => StreamEdgeSlotProps; ) => StreamEdgeSlotProps;
getMaintainVisibleContentPosition: () => MaintainVisibleContentPositionConfig | undefined; getMaintainVisibleContentPosition: () => MaintainVisibleContentPositionConfig | undefined;
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior; getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
getHistoryLiveBoundaryIndex: (history: StreamItem[]) => number | null;
getLiveHeadHistoryBoundaryIndex: (liveHead: StreamItem[]) => number | null;
getLatestItemIndex: (items: StreamItem[]) => number | null;
getFrameChildOrder: () => StreamFrameChildOrder;
getFlatListInverted: () => boolean; getFlatListInverted: () => boolean;
getOverlayScrollbarInverted: () => boolean; getOverlayScrollbarInverted: () => boolean;
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean; shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
@@ -107,6 +112,9 @@ interface StreamStrategyConfig {
orderHeadReverse: boolean; orderHeadReverse: boolean;
assistantTurnTraversalStep: AssistantTurnTraversalStep; assistantTurnTraversalStep: AssistantTurnTraversalStep;
edgeSlot: EdgeSlot; edgeSlot: EdgeSlot;
historyLiveBoundaryEdge: "first" | "last";
liveHeadHistoryBoundaryEdge: "first" | "last";
frameChildOrder: StreamFrameChildOrder;
flatListInverted: boolean; flatListInverted: boolean;
overlayScrollbarInverted: boolean; overlayScrollbarInverted: boolean;
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig; maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
@@ -175,6 +183,25 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate
}, },
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition, getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
getBottomAnchorTransportBehavior: () => config.bottomAnchorTransportBehavior, getBottomAnchorTransportBehavior: () => config.bottomAnchorTransportBehavior,
getHistoryLiveBoundaryIndex: (history) => {
if (history.length === 0) {
return null;
}
return config.historyLiveBoundaryEdge === "first" ? 0 : history.length - 1;
},
getLiveHeadHistoryBoundaryIndex: (liveHead) => {
if (liveHead.length === 0) {
return null;
}
return config.liveHeadHistoryBoundaryEdge === "first" ? 0 : liveHead.length - 1;
},
getLatestItemIndex: (items) => {
if (items.length === 0) {
return null;
}
return config.historyLiveBoundaryEdge === "first" ? 0 : items.length - 1;
},
getFrameChildOrder: () => config.frameChildOrder,
getFlatListInverted: () => config.flatListInverted, getFlatListInverted: () => config.flatListInverted,
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted, getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
shouldDisableParentScrollOnInlineDetailsExpansion: () => shouldDisableParentScrollOnInlineDetailsExpansion: () =>
@@ -270,3 +297,23 @@ export function getStreamEdgeSlotProps(params: {
}): StreamEdgeSlotProps { }): StreamEdgeSlotProps {
return params.strategy.getEdgeSlotProps(params.component, params.gapSize); return params.strategy.getEdgeSlotProps(params.component, params.gapSize);
} }
export function getHistoryLiveBoundaryIndexForStreamRenderStrategy(params: {
strategy: StreamStrategy;
history: StreamItem[];
}): number | null {
return params.strategy.getHistoryLiveBoundaryIndex(params.history);
}
export function getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy(params: {
strategy: StreamStrategy;
liveHead: StreamItem[];
}): number | null {
return params.strategy.getLiveHeadHistoryBoundaryIndex(params.liveHead);
}
export function getFrameChildOrderForStreamRenderStrategy(params: {
strategy: StreamStrategy;
}): StreamFrameChildOrder {
return params.strategy.getFrameChildOrder();
}
@@ -8,9 +8,10 @@ import type { StreamItem } from "@/types/stream";
import { import {
collectAssistantTurnContentForStreamRenderStrategy, collectAssistantTurnContentForStreamRenderStrategy,
type StreamStrategy, type StreamStrategy,
} from "./agent-stream-render-strategy"; } from "./strategy";
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "./message"; import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "@/components/message";
import { SyncedLoader } from "./synced-loader"; import type { TurnFooterHost } from "./layout";
import { SyncedLoader } from "@/components/synced-loader";
const ThemedSyncedLoader = withUnistyles(SyncedLoader); const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({ const workingIndicatorColorMapping = (theme: Theme) => ({
@@ -22,52 +23,6 @@ const workingIndicatorColorMapping = (theme: Theme) => ({
export type TurnContentStrategy = StreamStrategy; export type TurnContentStrategy = StreamStrategy;
export interface TurnFooterHost {
itemId: string;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
}
export function resolveBottomTurnFooterHost(input: {
agentStatus: string;
history: StreamItem[];
liveHead: StreamItem[];
isInverted: boolean;
timingByAssistantId: Map<string, TurnTiming>;
}): TurnFooterHost | null {
if (input.agentStatus === "running") {
return null;
}
const usesLiveHead = input.liveHead.length > 0;
const footerItems = usesLiveHead ? input.liveHead : input.history;
const startIndex = input.isInverted ? 0 : footerItems.length - 1;
const item = footerItems[startIndex];
if (!item || item.kind !== "assistant_message") {
return null;
}
return {
itemId: item.id,
items: footerItems,
timing: input.timingByAssistantId.get(item.id),
startIndex,
};
}
export function shouldRenderCompletedTurnFooter(input: {
item: StreamItem;
belowItem: StreamItem | undefined;
agentStatus: string;
suppressTurnFooter: boolean | undefined;
}): boolean {
return (
input.item.kind === "assistant_message" &&
!input.suppressTurnFooter &&
(input.belowItem?.kind === "user_message" ||
(input.belowItem === undefined && input.agentStatus !== "running"))
);
}
export const TurnFooter = memo(function TurnFooter({ export const TurnFooter = memo(function TurnFooter({
isRunning, isRunning,
inFlightTurnStartedAt, inFlightTurnStartedAt,
@@ -36,8 +36,8 @@ import {
CompactionMarker, CompactionMarker,
MessageOuterSpacingProvider, MessageOuterSpacingProvider,
type InlinePathTarget, type InlinePathTarget,
} from "./message"; } from "@/components/message";
import { PlanCard } from "./plan-card"; import { PlanCard } from "@/components/plan-card";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared"; import type { PendingPermission } from "@/types/shared";
import type { import type {
@@ -50,30 +50,18 @@ import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history"; import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
import type { ToastApi } from "@/components/toast-host"; import type { ToastApi } from "@/components/toast-host";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import { ToolCallDetailsContent } from "./tool-call-details"; import { ToolCallDetailsContent } from "@/components/tool-call-details";
import { QuestionFormCard } from "./question-form-card"; import { QuestionFormCard } from "@/components/question-form-card";
import { ToolCallSheetProvider } from "./tool-call-sheet"; import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
import { import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
buildAgentStreamRenderModel, import { resolveStreamRenderStrategy } from "./strategy-resolver";
getStreamNeighborItem, import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
resolveStreamRenderStrategy, import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer";
type AgentStreamRenderModel, import { layoutStream, type StreamLayoutItem } from "./layout";
type StreamSegmentRenderers,
type StreamViewportHandle,
} from "./agent-stream-render-strategy";
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./agent-stream-view-data";
import {
CompletedTurnFooterRow,
resolveBottomTurnFooterHost,
shouldRenderCompletedTurnFooter,
TurnFooter,
type TurnContentStrategy,
type TurnFooterHost,
} from "./agent-stream-turn-footer";
import { import {
type BottomAnchorLocalRequest, type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest, type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller"; } from "./bottom-anchor-controller";
import { import {
AssistantFileLinkResolverProvider, AssistantFileLinkResolverProvider,
normalizeInlinePathTarget, normalizeInlinePathTarget,
@@ -90,12 +78,6 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
interface StreamItemBoundarySeams {
aboveItem?: StreamItem | null;
belowItem?: StreamItem | null;
suppressTurnFooter?: boolean;
}
function renderLiveAuxiliaryNode(input: { function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode; pendingPermissions: ReactNode;
turnFooter: ReactNode; turnFooter: ReactNode;
@@ -133,40 +115,39 @@ function renderPendingPermissionsNode(input: {
function renderStreamItemWithTurnFooter(input: { function renderStreamItemWithTurnFooter(input: {
content: ReactNode; content: ReactNode;
item: StreamItem; layoutItem: StreamLayoutItem;
nextItem: StreamItem | undefined;
items: StreamItem[];
timing: AgentStreamRenderModel["turnTiming"]["byAssistantId"];
index: number;
agentStatus: string;
suppressTurnFooter: boolean | undefined;
strategy: TurnContentStrategy; strategy: TurnContentStrategy;
}): ReactNode { }): ReactNode {
if (!input.content) { if (!input.content) {
return null; return null;
} }
const showCompletedFooter = shouldRenderCompletedTurnFooter({ const footerHost = input.layoutItem.completedFooter;
item: input.item, const footer = footerHost ? (
belowItem: input.nextItem, <CompletedTurnFooterRow
agentStatus: input.agentStatus, strategy={input.strategy}
suppressTurnFooter: input.suppressTurnFooter, items={footerHost.items}
}); timing={footerHost.timing}
const gapBelow = showCompletedFooter startIndex={footerHost.startIndex}
? 0 />
: getGapBetweenStreamItems(input.item, input.nextItem ?? null); ) : null;
const content = (
<StreamItemWrapper gapBelow={input.layoutItem.gapBelow}>{input.content}</StreamItemWrapper>
);
if (input.layoutItem.frameOrder === "footer-then-content") {
return (
<>
{footer}
{content}
</>
);
}
return ( return (
<> <>
<StreamItemWrapper gapBelow={gapBelow}>{input.content}</StreamItemWrapper> {content}
{showCompletedFooter ? ( {footer}
<CompletedTurnFooterRow
strategy={input.strategy}
items={input.items}
timing={input.timing.get(input.item.id)}
startIndex={input.index}
/>
) : null}
</> </>
); );
} }
@@ -194,47 +175,26 @@ function renderListEmptyComponent(input: {
function renderHistoryStreamItem(input: { function renderHistoryStreamItem(input: {
item: StreamItem; item: StreamItem;
historyIndexById: Map<string, number>; layoutItemById: Map<string, StreamLayoutItem>;
historyItems: StreamItem[]; renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
lastHistoryItem: StreamItem | null;
firstLiveHeadItem: StreamItem | null;
bottomTurnFooterHost: TurnFooterHost | null;
renderStreamItem: (
item: StreamItem,
index: number,
items: StreamItem[],
seams?: StreamItemBoundarySeams,
) => ReactNode;
}): ReactNode { }): ReactNode {
const historyIndex = input.historyIndexById.get(input.item.id); const layoutItem = input.layoutItemById.get(input.item.id);
if (historyIndex === undefined) { if (!layoutItem) {
return null; return null;
} }
const seamBelowItem = return input.renderStreamItem(layoutItem);
input.item.id === input.lastHistoryItem?.id ? input.firstLiveHeadItem : null;
return input.renderStreamItem(input.item, historyIndex, input.historyItems, {
belowItem: seamBelowItem,
suppressTurnFooter: input.item.id === input.bottomTurnFooterHost?.itemId,
});
} }
function renderLiveHeadStreamItem(input: { function renderLiveHeadStreamItem(input: {
item: StreamItem; item: StreamItem;
index: number; layoutItemById: Map<string, StreamLayoutItem>;
items: StreamItem[]; renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
lastHistoryItem: StreamItem | null;
bottomTurnFooterHost: TurnFooterHost | null;
renderStreamItem: (
item: StreamItem,
index: number,
items: StreamItem[],
seams?: StreamItemBoundarySeams,
) => ReactNode;
}): ReactNode { }): ReactNode {
return input.renderStreamItem(input.item, input.index, input.items, { const layoutItem = input.layoutItemById.get(input.item.id);
aboveItem: input.index === 0 ? input.lastHistoryItem : null, if (!layoutItem) {
suppressTurnFooter: input.item.id === input.bottomTurnFooterHost?.itemId, return null;
}); }
return input.renderStreamItem(layoutItem);
} }
export interface AgentStreamViewHandle { export interface AgentStreamViewHandle {
@@ -396,6 +356,23 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isMobileBreakpoint: isMobile, isMobileBreakpoint: isMobile,
}); });
}, [agent.status, isMobile, streamHead, streamItems]); }, [agent.status, isMobile, streamHead, streamItems]);
const streamLayout = useMemo(
() =>
layoutStream({
strategy: streamRenderStrategy,
agentStatus: agent.status,
history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead,
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
}),
[
agent.status,
baseRenderModel.history,
baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byAssistantId,
streamRenderStrategy,
],
);
useImperativeHandle( useImperativeHandle(
ref, ref,
() => ({ () => ({
@@ -432,73 +409,23 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
); );
const renderUserMessageItem = useCallback( const renderUserMessageItem = useCallback(
( (layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
item: Extract<StreamItem, { kind: "user_message" }>,
index: number,
items: StreamItem[],
seamAboveItem: StreamItem | null,
) => {
const aboveItem =
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "above",
}) ??
seamAboveItem ??
undefined;
const belowItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isFirstInGroup = aboveItem?.kind !== "user_message";
const isLastInGroup = belowItem?.kind !== "user_message";
return ( return (
<UserMessage <UserMessage
message={item.text} message={item.text}
images={item.images} images={item.images}
attachments={item.attachments} attachments={item.attachments}
timestamp={item.timestamp.getTime()} timestamp={item.timestamp.getTime()}
isFirstInGroup={isFirstInGroup} isFirstInGroup={layoutItem.isFirstInUserGroup}
isLastInGroup={isLastInGroup} isLastInGroup={layoutItem.isLastInUserGroup}
/> />
); );
}, },
[streamRenderStrategy], [],
); );
const renderAssistantMessageItem = useCallback( const renderAssistantMessageItem = useCallback(
( (layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "assistant_message" }>) => {
item: Extract<StreamItem, { kind: "assistant_message" }>,
index: number,
items: StreamItem[],
seams: StreamItemBoundarySeams,
) => {
const aboveItem =
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "above",
}) ??
seams.aboveItem ??
undefined;
const belowItem =
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
}) ??
seams.belowItem ??
undefined;
const spacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
});
return ( return (
<AssistantFileLinkResolverProvider <AssistantFileLinkResolverProvider
client={client} client={client}
@@ -513,23 +440,16 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceRoot={workspaceRoot} workspaceRoot={workspaceRoot}
serverId={resolvedServerId} serverId={resolvedServerId}
client={client} client={client}
spacing={spacing} spacing={layoutItem.assistantSpacing}
/> />
</AssistantFileLinkResolverProvider> </AssistantFileLinkResolverProvider>
); );
}, },
[client, handleInlinePathPress, resolvedServerId, streamRenderStrategy, toast, workspaceRoot], [client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
); );
const renderThoughtItem = useCallback( const renderThoughtItem = useCallback(
(item: Extract<StreamItem, { kind: "thought" }>, index: number, items: StreamItem[]) => { (layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "thought" }>) => {
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
return ( return (
<ToolCallSlot <ToolCallSlot
itemId={item.id} itemId={item.id}
@@ -537,23 +457,16 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
toolName="thinking" toolName="thinking"
args={item.text} args={item.text}
status={item.status === "ready" ? "completed" : "executing"} status={item.status === "ready" ? "completed" : "executing"}
isLastInSequence={isLastInSequence} isLastInSequence={layoutItem.isLastInToolSequence}
/> />
); );
}, },
[streamRenderStrategy, setInlineDetailsExpanded], [setInlineDetailsExpanded],
); );
const renderToolCallItem = useCallback( const renderToolCallItem = useCallback(
(item: Extract<StreamItem, { kind: "tool_call" }>, index: number, items: StreamItem[]) => { (layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
const { payload } = item; const { payload } = item;
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
if (payload.source === "agent") { if (payload.source === "agent") {
const data = payload.data; const data = payload.data;
@@ -579,7 +492,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
detail={data.detail} detail={data.detail}
cwd={agent.cwd} cwd={agent.cwd}
metadata={data.metadata} metadata={data.metadata}
isLastInSequence={isLastInSequence} isLastInSequence={layoutItem.isLastInToolSequence}
onOpenFilePath={handleToolCallOpenFile} onOpenFilePath={handleToolCallOpenFile}
/> />
); );
@@ -594,33 +507,29 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
args={data.arguments} args={data.arguments}
result={data.result} result={data.result}
status={data.status} status={data.status}
isLastInSequence={isLastInSequence} isLastInSequence={layoutItem.isLastInToolSequence}
onOpenFilePath={handleToolCallOpenFile} onOpenFilePath={handleToolCallOpenFile}
/> />
); );
}, },
[agent.cwd, streamRenderStrategy, setInlineDetailsExpanded, handleToolCallOpenFile], [agent.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
); );
const renderStreamItemContent = useCallback( const renderStreamItemContent = useCallback(
( (layoutItem: StreamLayoutItem) => {
item: StreamItem, const item = layoutItem.item;
index: number,
items: StreamItem[],
seams: StreamItemBoundarySeams = {},
) => {
switch (item.kind) { switch (item.kind) {
case "user_message": case "user_message":
return renderUserMessageItem(item, index, items, seams.aboveItem ?? null); return renderUserMessageItem(layoutItem, item);
case "assistant_message": case "assistant_message":
return renderAssistantMessageItem(item, index, items, seams); return renderAssistantMessageItem(layoutItem, item);
case "thought": case "thought":
return renderThoughtItem(item, index, items); return renderThoughtItem(layoutItem, item);
case "tool_call": case "tool_call":
return renderToolCallItem(item, index, items); return renderToolCallItem(layoutItem, item);
case "activity_log": case "activity_log":
return ( return (
@@ -651,54 +560,18 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem], [renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem],
); );
const bottomTurnFooterHost = useMemo(() => { const bottomTurnFooterHost = streamLayout.auxiliaryTurnFooter;
return resolveBottomTurnFooterHost({
agentStatus: agent.status,
history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead,
isInverted: streamRenderStrategy.getFlatListInverted(),
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
});
}, [
agent.status,
baseRenderModel.history,
baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byAssistantId,
streamRenderStrategy,
]);
const renderStreamItem = useCallback( const renderStreamItem = useCallback(
( (layoutItem: StreamLayoutItem) => {
item: StreamItem, const content = renderStreamItemContent(layoutItem);
index: number,
items: StreamItem[],
seams: StreamItemBoundarySeams = {},
) => {
const content = renderStreamItemContent(item, index, items, seams);
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
return renderStreamItemWithTurnFooter({ return renderStreamItemWithTurnFooter({
content, content,
item, layoutItem,
nextItem,
items,
timing: baseRenderModel.turnTiming.byAssistantId,
index,
agentStatus: agent.status,
suppressTurnFooter: seams.suppressTurnFooter,
strategy: streamRenderStrategy, strategy: streamRenderStrategy,
}); });
}, },
[ [renderStreamItemContent, streamRenderStrategy],
agent.status,
baseRenderModel.turnTiming.byAssistantId,
renderStreamItemContent,
streamRenderStrategy,
],
); );
const pendingPermissionItems = useMemo( const pendingPermissionItems = useMemo(
@@ -737,17 +610,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
...baseRenderModel, ...baseRenderModel,
boundary: { boundary: {
...baseRenderModel.boundary, ...baseRenderModel.boundary,
historyToHeadGap: getGapBetweenStreamItems( historyToHeadGap: streamLayout.historyToHeadGap,
baseRenderModel.history.at(-1) ?? null,
baseRenderModel.segments.liveHead[0] ?? null,
),
}, },
auxiliary: { auxiliary: {
pendingPermissions: pendingPermissionsNode, pendingPermissions: pendingPermissionsNode,
turnFooter: turnFooterNode, turnFooter: turnFooterNode,
}, },
}; };
}, [baseRenderModel, pendingPermissionsNode, turnFooterNode]); }, [baseRenderModel, pendingPermissionsNode, streamLayout.historyToHeadGap, turnFooterNode]);
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []); const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
const listEmptyComponent = useMemo( const listEmptyComponent = useMemo(
@@ -755,38 +625,32 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[renderModel, emptyStateStyle], [renderModel, emptyStateStyle],
); );
const historyItems = renderModel.history;
const { boundary, auxiliary } = renderModel; const { boundary, auxiliary } = renderModel;
const lastHistoryItem = historyItems.at(-1) ?? null;
const firstLiveHeadItem = renderModel.segments.liveHead[0] ?? null;
const historyIndexById = useMemo(() => { const layoutHistoryItemById = useMemo(() => {
const indexById = new Map<string, number>(); const itemById = new Map<string, StreamLayoutItem>();
historyItems.forEach((item, index) => { for (const item of streamLayout.history) {
indexById.set(item.id, index); itemById.set(item.item.id, item);
}); }
return indexById; return itemById;
}, [historyItems]); }, [streamLayout.history]);
const layoutLiveHeadItemById = useMemo(() => {
const itemById = new Map<string, StreamLayoutItem>();
for (const item of streamLayout.liveHead) {
itemById.set(item.item.id, item);
}
return itemById;
}, [streamLayout.liveHead]);
const renderHistoryRow = useCallback( const renderHistoryRow = useCallback(
(item: StreamItem) => (item: StreamItem) =>
renderHistoryStreamItem({ renderHistoryStreamItem({
item, item,
historyIndexById, layoutItemById: layoutHistoryItemById,
historyItems,
lastHistoryItem,
firstLiveHeadItem,
bottomTurnFooterHost,
renderStreamItem, renderStreamItem,
}), }),
[ [layoutHistoryItemById, renderStreamItem],
bottomTurnFooterHost,
firstLiveHeadItem,
historyIndexById,
historyItems,
lastHistoryItem,
renderStreamItem,
],
); );
const renderHistoryVirtualizedRow = useCallback< const renderHistoryVirtualizedRow = useCallback<
@@ -797,16 +661,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[renderHistoryRow], [renderHistoryRow],
); );
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>( const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
(item, index, items) => (item) =>
renderLiveHeadStreamItem({ renderLiveHeadStreamItem({
item, item,
index, layoutItemById: layoutLiveHeadItemById,
items,
lastHistoryItem,
bottomTurnFooterHost,
renderStreamItem, renderStreamItem,
}), }),
[bottomTurnFooterHost, lastHistoryItem, renderStreamItem], [layoutLiveHeadItemById, renderStreamItem],
); );
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => { const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => {
return renderLiveAuxiliaryNode({ return renderLiveAuxiliaryNode({
@@ -13,7 +13,7 @@ import {
getWebPartialVirtualizationThreshold, getWebPartialVirtualizationThreshold,
splitWebVirtualizedHistory, splitWebVirtualizedHistory,
type IndexedStreamItem, type IndexedStreamItem,
} from "./agent-stream-web-virtualization"; } from "./web-virtualization";
function createTimestamp(seed: number): Date { function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`); return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
@@ -1,3 +0,0 @@
export * from "./stream-strategy";
export * from "./stream-strategy-resolver";
export * from "./agent-stream-render-model";
+1 -1
View File
@@ -7,7 +7,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import { shallow, useShallow } from "zustand/shallow"; import { shallow, useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional"; import { useStoreWithEqualityFn } from "zustand/traditional";
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view"; import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view";
import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
import { Composer } from "@/components/composer"; import { Composer } from "@/components/composer";
import { FileDropZone } from "@/components/file-drop-zone"; import { FileDropZone } from "@/components/file-drop-zone";
@@ -1,4 +1,4 @@
import type { BottomAnchorRouteRequest } from "@/components/use-bottom-anchor-controller"; import type { BottomAnchorRouteRequest } from "@/agent-stream/bottom-anchor-controller";
export interface RouteBottomAnchorIntent { export interface RouteBottomAnchorIntent {
routeKey: string; routeKey: string;
@@ -6,7 +6,7 @@ import invariant from "tiny-invariant";
import { Composer } from "@/components/composer"; import { Composer } from "@/components/composer";
import { ComposerImportPill } from "@/screens/workspace/composer-import-pill"; import { ComposerImportPill } from "@/screens/workspace/composer-import-pill";
import { FileDropZone } from "@/components/file-drop-zone"; import { FileDropZone } from "@/components/file-drop-zone";
import { AgentStreamView } from "@/components/agent-stream-view"; import { AgentStreamView } from "@/agent-stream/view";
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments"; import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
import type { ImageAttachment } from "@/components/message-input"; import type { ImageAttachment } from "@/components/message-input";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";