refactor(app): organize agent stream rendering
This commit is contained in:
@@ -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
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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
-1
@@ -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`);
|
||||
+3
-6
@@ -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[];
|
||||
+90
-2
@@ -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
-1
@@ -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;
|
||||
+6
-3
@@ -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,
|
||||
+3
-3
@@ -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,
|
||||
+3
-3
@@ -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 {
|
||||
+7
-4
@@ -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,
|
||||
+49
-2
@@ -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();
|
||||
}
|
||||
+4
-49
@@ -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,
|
||||
+111
-250
@@ -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({
|
||||
+1
-1
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user