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