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`.
## 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
```bash
@@ -3,8 +3,8 @@ import {
__private__,
deriveBottomAnchorBlockedReason,
type BottomAnchorMode,
} from "./use-bottom-anchor-controller";
import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strategy";
} from "./bottom-anchor-controller";
import type { BottomAnchorTransportBehavior } from "./strategy";
type MeasurementState = ReturnType<typeof createMeasurementState>;
@@ -1,5 +1,5 @@
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";
@@ -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 type { StreamItem } from "@/types/stream";
import { buildAgentStreamRenderModel } from "./agent-stream-render-model";
import { buildAgentStreamRenderModel } from "./model";
function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
@@ -5,12 +5,9 @@ import {
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
} from "./agent-stream-web-virtualization";
import {
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
} from "./stream-strategy";
import { resolveStreamRenderStrategy } from "./stream-strategy-resolver";
} from "./web-virtualization";
import { orderHeadForStreamRenderStrategy, orderTailForStreamRenderStrategy } from "./strategy";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
export interface StreamRenderSegments {
historyVirtualized: StreamItem[];
@@ -3,6 +3,9 @@ import type { StreamItem } from "@/types/stream";
import {
collectAssistantTurnContentForStreamRenderStrategy,
getBottomOffsetForStreamRenderStrategy,
getFrameChildOrderForStreamRenderStrategy,
getHistoryLiveBoundaryIndexForStreamRenderStrategy,
getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy,
getStreamEdgeSlotProps,
getStreamNeighborIndex,
getStreamNeighborItem,
@@ -10,8 +13,8 @@ import {
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior,
resolveStreamRenderStrategy,
} from "./agent-stream-render-strategy";
} from "./strategy";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
function createTimestamp(seed: number): Date {
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 type { StreamItem } from "@/types/stream";
import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./agent-stream-view-data";
import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./spacing";
function assistantBlock(params: {
id: string;
@@ -19,13 +19,13 @@ import {
} from "react-native";
import type { StreamItem } from "@/types/stream";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useBottomAnchorController } from "./use-bottom-anchor-controller";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy";
import { useBottomAnchorController } from "./bottom-anchor-controller";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import {
createStreamStrategy,
isNearBottomForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior,
} from "./stream-strategy";
} from "./strategy";
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
minIndexForVisible: 0,
@@ -380,6 +380,9 @@ export function createNativeStreamStrategy(): StreamStrategy {
orderHeadReverse: true,
assistantTurnTraversalStep: 1,
edgeSlot: "header",
historyLiveBoundaryEdge: "first",
liveHeadHistoryBoundaryEdge: "last",
frameChildOrder: "footer-then-content",
flatListInverted: true,
overlayScrollbarInverted: true,
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
@@ -1,6 +1,6 @@
import type { ResolveStreamRenderStrategyInput, StreamStrategy } from "./stream-strategy";
import { createNativeStreamStrategy } from "./stream-strategy-native";
import { createWebStreamStrategy } from "./stream-strategy-web";
import type { ResolveStreamRenderStrategyInput, StreamStrategy } from "./strategy";
import { createNativeStreamStrategy } from "./strategy-native";
import { createWebStreamStrategy } from "./strategy-web";
export function resolveStreamRenderStrategy(
input: ResolveStreamRenderStrategyInput,
@@ -6,8 +6,8 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { StreamItem } from "@/types/stream";
import type { StreamSegmentRenderers, StreamViewportHandle } from "./stream-strategy";
import { createWebStreamStrategy } from "./stream-strategy-web";
import type { StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
import { createWebStreamStrategy } from "./strategy-web";
vi.hoisted(() => {
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 {
return {
@@ -10,9 +10,9 @@ import React, {
} from "react";
import { ActivityIndicator } from "react-native";
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
import { estimateStreamItemHeight } from "./agent-stream-web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy";
import { createStreamStrategy } from "./stream-strategy";
import { estimateStreamItemHeight } from "./web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { createStreamStrategy } from "./strategy";
interface CreateWebStreamStrategyInput {
isMobileBreakpoint: boolean;
@@ -25,7 +25,7 @@ const USER_SCROLL_DELTA_EPSILON = 1;
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const HISTORY_START_THRESHOLD_PX = 96;
import { useWebElementScrollbar } from "./use-web-scrollbar";
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
const historyStartSlotStyle: CSSProperties = {
display: "flex",
@@ -610,6 +610,9 @@ export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): St
orderHeadReverse: false,
assistantTurnTraversalStep: -1,
edgeSlot: "footer",
historyLiveBoundaryEdge: "last",
liveHeadHistoryBoundaryEdge: "first",
frameChildOrder: "content-then-footer",
flatListInverted: false,
overlayScrollbarInverted: false,
maintainVisibleContentPosition: undefined,
@@ -1,15 +1,16 @@
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream";
import type { StreamHistoryBoundary, StreamRenderSegments } from "./agent-stream-render-model";
import type { StreamHistoryBoundary, StreamRenderSegments } from "./model";
import type {
BottomAnchorLocalRequest,
BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller";
} from "./bottom-anchor-controller";
type EdgeSlot = "header" | "footer";
type NeighborRelation = "above" | "below";
type AssistantTurnTraversalStep = -1 | 1;
export type StreamFrameChildOrder = "content-then-footer" | "footer-then-content";
export type MaintainVisibleContentPositionConfig = Readonly<{
minIndexForVisible: number;
@@ -93,6 +94,10 @@ export interface StreamStrategy {
) => StreamEdgeSlotProps;
getMaintainVisibleContentPosition: () => MaintainVisibleContentPositionConfig | undefined;
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
getHistoryLiveBoundaryIndex: (history: StreamItem[]) => number | null;
getLiveHeadHistoryBoundaryIndex: (liveHead: StreamItem[]) => number | null;
getLatestItemIndex: (items: StreamItem[]) => number | null;
getFrameChildOrder: () => StreamFrameChildOrder;
getFlatListInverted: () => boolean;
getOverlayScrollbarInverted: () => boolean;
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
@@ -107,6 +112,9 @@ interface StreamStrategyConfig {
orderHeadReverse: boolean;
assistantTurnTraversalStep: AssistantTurnTraversalStep;
edgeSlot: EdgeSlot;
historyLiveBoundaryEdge: "first" | "last";
liveHeadHistoryBoundaryEdge: "first" | "last";
frameChildOrder: StreamFrameChildOrder;
flatListInverted: boolean;
overlayScrollbarInverted: boolean;
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
@@ -175,6 +183,25 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate
},
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
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,
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
@@ -270,3 +297,23 @@ export function getStreamEdgeSlotProps(params: {
}): StreamEdgeSlotProps {
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 {
collectAssistantTurnContentForStreamRenderStrategy,
type StreamStrategy,
} from "./agent-stream-render-strategy";
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "./message";
import { SyncedLoader } from "./synced-loader";
} from "./strategy";
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "@/components/message";
import type { TurnFooterHost } from "./layout";
import { SyncedLoader } from "@/components/synced-loader";
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({
@@ -22,52 +23,6 @@ const workingIndicatorColorMapping = (theme: Theme) => ({
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({
isRunning,
inFlightTurnStartedAt,
@@ -36,8 +36,8 @@ import {
CompactionMarker,
MessageOuterSpacingProvider,
type InlinePathTarget,
} from "./message";
import { PlanCard } from "./plan-card";
} from "@/components/message";
import { PlanCard } from "@/components/plan-card";
import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type {
@@ -50,30 +50,18 @@ import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
import type { ToastApi } from "@/components/toast-host";
import type { DaemonClient } from "@server/client/daemon-client";
import { ToolCallDetailsContent } from "./tool-call-details";
import { QuestionFormCard } from "./question-form-card";
import { ToolCallSheetProvider } from "./tool-call-sheet";
import {
buildAgentStreamRenderModel,
getStreamNeighborItem,
resolveStreamRenderStrategy,
type AgentStreamRenderModel,
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 { ToolCallDetailsContent } from "@/components/tool-call-details";
import { QuestionFormCard } from "@/components/question-form-card";
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer";
import { layoutStream, type StreamLayoutItem } from "./layout";
import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller";
} from "./bottom-anchor-controller";
import {
AssistantFileLinkResolverProvider,
normalizeInlinePathTarget,
@@ -90,12 +78,6 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
interface StreamItemBoundarySeams {
aboveItem?: StreamItem | null;
belowItem?: StreamItem | null;
suppressTurnFooter?: boolean;
}
function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode;
turnFooter: ReactNode;
@@ -133,40 +115,39 @@ function renderPendingPermissionsNode(input: {
function renderStreamItemWithTurnFooter(input: {
content: ReactNode;
item: StreamItem;
nextItem: StreamItem | undefined;
items: StreamItem[];
timing: AgentStreamRenderModel["turnTiming"]["byAssistantId"];
index: number;
agentStatus: string;
suppressTurnFooter: boolean | undefined;
layoutItem: StreamLayoutItem;
strategy: TurnContentStrategy;
}): ReactNode {
if (!input.content) {
return null;
}
const showCompletedFooter = shouldRenderCompletedTurnFooter({
item: input.item,
belowItem: input.nextItem,
agentStatus: input.agentStatus,
suppressTurnFooter: input.suppressTurnFooter,
});
const gapBelow = showCompletedFooter
? 0
: getGapBetweenStreamItems(input.item, input.nextItem ?? null);
const footerHost = input.layoutItem.completedFooter;
const footer = footerHost ? (
<CompletedTurnFooterRow
strategy={input.strategy}
items={footerHost.items}
timing={footerHost.timing}
startIndex={footerHost.startIndex}
/>
) : null;
const content = (
<StreamItemWrapper gapBelow={input.layoutItem.gapBelow}>{input.content}</StreamItemWrapper>
);
if (input.layoutItem.frameOrder === "footer-then-content") {
return (
<>
{footer}
{content}
</>
);
}
return (
<>
<StreamItemWrapper gapBelow={gapBelow}>{input.content}</StreamItemWrapper>
{showCompletedFooter ? (
<CompletedTurnFooterRow
strategy={input.strategy}
items={input.items}
timing={input.timing.get(input.item.id)}
startIndex={input.index}
/>
) : null}
{content}
{footer}
</>
);
}
@@ -194,47 +175,26 @@ function renderListEmptyComponent(input: {
function renderHistoryStreamItem(input: {
item: StreamItem;
historyIndexById: Map<string, number>;
historyItems: StreamItem[];
lastHistoryItem: StreamItem | null;
firstLiveHeadItem: StreamItem | null;
bottomTurnFooterHost: TurnFooterHost | null;
renderStreamItem: (
item: StreamItem,
index: number,
items: StreamItem[],
seams?: StreamItemBoundarySeams,
) => ReactNode;
layoutItemById: Map<string, StreamLayoutItem>;
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
}): ReactNode {
const historyIndex = input.historyIndexById.get(input.item.id);
if (historyIndex === undefined) {
const layoutItem = input.layoutItemById.get(input.item.id);
if (!layoutItem) {
return null;
}
const seamBelowItem =
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,
});
return input.renderStreamItem(layoutItem);
}
function renderLiveHeadStreamItem(input: {
item: StreamItem;
index: number;
items: StreamItem[];
lastHistoryItem: StreamItem | null;
bottomTurnFooterHost: TurnFooterHost | null;
renderStreamItem: (
item: StreamItem,
index: number,
items: StreamItem[],
seams?: StreamItemBoundarySeams,
) => ReactNode;
layoutItemById: Map<string, StreamLayoutItem>;
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
}): ReactNode {
return input.renderStreamItem(input.item, input.index, input.items, {
aboveItem: input.index === 0 ? input.lastHistoryItem : null,
suppressTurnFooter: input.item.id === input.bottomTurnFooterHost?.itemId,
});
const layoutItem = input.layoutItemById.get(input.item.id);
if (!layoutItem) {
return null;
}
return input.renderStreamItem(layoutItem);
}
export interface AgentStreamViewHandle {
@@ -396,6 +356,23 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isMobileBreakpoint: isMobile,
});
}, [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(
ref,
() => ({
@@ -432,73 +409,23 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const renderUserMessageItem = useCallback(
(
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";
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
return (
<UserMessage
message={item.text}
images={item.images}
attachments={item.attachments}
timestamp={item.timestamp.getTime()}
isFirstInGroup={isFirstInGroup}
isLastInGroup={isLastInGroup}
isFirstInGroup={layoutItem.isFirstInUserGroup}
isLastInGroup={layoutItem.isLastInUserGroup}
/>
);
},
[streamRenderStrategy],
[],
);
const renderAssistantMessageItem = useCallback(
(
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,
});
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "assistant_message" }>) => {
return (
<AssistantFileLinkResolverProvider
client={client}
@@ -513,23 +440,16 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceRoot={workspaceRoot}
serverId={resolvedServerId}
client={client}
spacing={spacing}
spacing={layoutItem.assistantSpacing}
/>
</AssistantFileLinkResolverProvider>
);
},
[client, handleInlinePathPress, resolvedServerId, streamRenderStrategy, toast, workspaceRoot],
[client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
);
const renderThoughtItem = useCallback(
(item: Extract<StreamItem, { kind: "thought" }>, index: number, items: StreamItem[]) => {
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "thought" }>) => {
return (
<ToolCallSlot
itemId={item.id}
@@ -537,23 +457,16 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
toolName="thinking"
args={item.text}
status={item.status === "ready" ? "completed" : "executing"}
isLastInSequence={isLastInSequence}
isLastInSequence={layoutItem.isLastInToolSequence}
/>
);
},
[streamRenderStrategy, setInlineDetailsExpanded],
[setInlineDetailsExpanded],
);
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 nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
if (payload.source === "agent") {
const data = payload.data;
@@ -579,7 +492,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
detail={data.detail}
cwd={agent.cwd}
metadata={data.metadata}
isLastInSequence={isLastInSequence}
isLastInSequence={layoutItem.isLastInToolSequence}
onOpenFilePath={handleToolCallOpenFile}
/>
);
@@ -594,33 +507,29 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
args={data.arguments}
result={data.result}
status={data.status}
isLastInSequence={isLastInSequence}
isLastInSequence={layoutItem.isLastInToolSequence}
onOpenFilePath={handleToolCallOpenFile}
/>
);
},
[agent.cwd, streamRenderStrategy, setInlineDetailsExpanded, handleToolCallOpenFile],
[agent.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
);
const renderStreamItemContent = useCallback(
(
item: StreamItem,
index: number,
items: StreamItem[],
seams: StreamItemBoundarySeams = {},
) => {
(layoutItem: StreamLayoutItem) => {
const item = layoutItem.item;
switch (item.kind) {
case "user_message":
return renderUserMessageItem(item, index, items, seams.aboveItem ?? null);
return renderUserMessageItem(layoutItem, item);
case "assistant_message":
return renderAssistantMessageItem(item, index, items, seams);
return renderAssistantMessageItem(layoutItem, item);
case "thought":
return renderThoughtItem(item, index, items);
return renderThoughtItem(layoutItem, item);
case "tool_call":
return renderToolCallItem(item, index, items);
return renderToolCallItem(layoutItem, item);
case "activity_log":
return (
@@ -651,54 +560,18 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem],
);
const bottomTurnFooterHost = useMemo(() => {
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 bottomTurnFooterHost = streamLayout.auxiliaryTurnFooter;
const renderStreamItem = useCallback(
(
item: StreamItem,
index: number,
items: StreamItem[],
seams: StreamItemBoundarySeams = {},
) => {
const content = renderStreamItemContent(item, index, items, seams);
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
(layoutItem: StreamLayoutItem) => {
const content = renderStreamItemContent(layoutItem);
return renderStreamItemWithTurnFooter({
content,
item,
nextItem,
items,
timing: baseRenderModel.turnTiming.byAssistantId,
index,
agentStatus: agent.status,
suppressTurnFooter: seams.suppressTurnFooter,
layoutItem,
strategy: streamRenderStrategy,
});
},
[
agent.status,
baseRenderModel.turnTiming.byAssistantId,
renderStreamItemContent,
streamRenderStrategy,
],
[renderStreamItemContent, streamRenderStrategy],
);
const pendingPermissionItems = useMemo(
@@ -737,17 +610,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
...baseRenderModel,
boundary: {
...baseRenderModel.boundary,
historyToHeadGap: getGapBetweenStreamItems(
baseRenderModel.history.at(-1) ?? null,
baseRenderModel.segments.liveHead[0] ?? null,
),
historyToHeadGap: streamLayout.historyToHeadGap,
},
auxiliary: {
pendingPermissions: pendingPermissionsNode,
turnFooter: turnFooterNode,
},
};
}, [baseRenderModel, pendingPermissionsNode, turnFooterNode]);
}, [baseRenderModel, pendingPermissionsNode, streamLayout.historyToHeadGap, turnFooterNode]);
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
const listEmptyComponent = useMemo(
@@ -755,38 +625,32 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[renderModel, emptyStateStyle],
);
const historyItems = renderModel.history;
const { boundary, auxiliary } = renderModel;
const lastHistoryItem = historyItems.at(-1) ?? null;
const firstLiveHeadItem = renderModel.segments.liveHead[0] ?? null;
const historyIndexById = useMemo(() => {
const indexById = new Map<string, number>();
historyItems.forEach((item, index) => {
indexById.set(item.id, index);
});
return indexById;
}, [historyItems]);
const layoutHistoryItemById = useMemo(() => {
const itemById = new Map<string, StreamLayoutItem>();
for (const item of streamLayout.history) {
itemById.set(item.item.id, item);
}
return itemById;
}, [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(
(item: StreamItem) =>
renderHistoryStreamItem({
item,
historyIndexById,
historyItems,
lastHistoryItem,
firstLiveHeadItem,
bottomTurnFooterHost,
layoutItemById: layoutHistoryItemById,
renderStreamItem,
}),
[
bottomTurnFooterHost,
firstLiveHeadItem,
historyIndexById,
historyItems,
lastHistoryItem,
renderStreamItem,
],
[layoutHistoryItemById, renderStreamItem],
);
const renderHistoryVirtualizedRow = useCallback<
@@ -797,16 +661,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[renderHistoryRow],
);
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
(item, index, items) =>
(item) =>
renderLiveHeadStreamItem({
item,
index,
items,
lastHistoryItem,
bottomTurnFooterHost,
layoutItemById: layoutLiveHeadItemById,
renderStreamItem,
}),
[bottomTurnFooterHost, lastHistoryItem, renderStreamItem],
[layoutLiveHeadItemById, renderStreamItem],
);
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => {
return renderLiveAuxiliaryNode({
@@ -13,7 +13,7 @@ import {
getWebPartialVirtualizationThreshold,
splitWebVirtualizedHistory,
type IndexedStreamItem,
} from "./agent-stream-web-virtualization";
} from "./web-virtualization";
function createTimestamp(seed: number): Date {
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 { shallow, useShallow } from "zustand/shallow";
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 { Composer } from "@/components/composer";
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 {
routeKey: string;
@@ -6,7 +6,7 @@ import invariant from "tiny-invariant";
import { Composer } from "@/components/composer";
import { ComposerImportPill } from "@/screens/workspace/composer-import-pill";
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 type { ImageAttachment } from "@/components/message-input";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";