diff --git a/docs/mobile-testing.md b/docs/mobile-testing.md index 7cb96deae..216c5d0cd 100644 --- a/docs/mobile-testing.md +++ b/docs/mobile-testing.md @@ -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 diff --git a/packages/app/src/components/use-bottom-anchor-controller.test.ts b/packages/app/src/agent-stream/bottom-anchor-controller.test.ts similarity index 99% rename from packages/app/src/components/use-bottom-anchor-controller.test.ts rename to packages/app/src/agent-stream/bottom-anchor-controller.test.ts index 3b710c730..06bcc4cd5 100644 --- a/packages/app/src/components/use-bottom-anchor-controller.test.ts +++ b/packages/app/src/agent-stream/bottom-anchor-controller.test.ts @@ -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; diff --git a/packages/app/src/components/use-bottom-anchor-controller.ts b/packages/app/src/agent-stream/bottom-anchor-controller.ts similarity index 99% rename from packages/app/src/components/use-bottom-anchor-controller.ts rename to packages/app/src/agent-stream/bottom-anchor-controller.ts index 00fe07a2f..dd78dd5f7 100644 --- a/packages/app/src/components/use-bottom-anchor-controller.ts +++ b/packages/app/src/agent-stream/bottom-anchor-controller.ts @@ -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"; diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts new file mode 100644 index 000000000..cf39b6920 --- /dev/null +++ b/packages/app/src/agent-stream/layout.test.ts @@ -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 { + return { + kind: "user_message", + id, + text: id, + timestamp: timestamp(seed), + }; +} + +function assistantMessage( + id: string, + seed: number, + block?: { groupId: string; index: number }, +): Extract { + return { + kind: "assistant_message", + id, + text: id, + timestamp: timestamp(seed), + ...(block ? { blockGroupId: block.groupId, blockIndex: block.index } : {}), + }; +} + +function toolCall(id: string, seed: number): Extract { + 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 { + return { + kind: "thought", + id, + text: id, + timestamp: timestamp(seed), + status: "ready", + }; +} + +function timingFor(...ids: string[]): Map { + 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]); + }); +}); diff --git a/packages/app/src/agent-stream/layout.ts b/packages/app/src/agent-stream/layout.ts new file mode 100644 index 000000000..de0fe38f9 --- /dev/null +++ b/packages/app/src/agent-stream/layout.ts @@ -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; +} + +interface LayoutSegmentInput { + strategy: StreamStrategy; + agentStatus: string; + items: StreamItem[]; + timingByAssistantId: Map; + 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; +}): 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), + }; +} diff --git a/packages/app/src/components/agent-stream-render-model.test.ts b/packages/app/src/agent-stream/model.test.ts similarity index 98% rename from packages/app/src/components/agent-stream-render-model.test.ts rename to packages/app/src/agent-stream/model.test.ts index 344e279be..806a501d6 100644 --- a/packages/app/src/components/agent-stream-render-model.test.ts +++ b/packages/app/src/agent-stream/model.test.ts @@ -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`); diff --git a/packages/app/src/components/agent-stream-render-model.ts b/packages/app/src/agent-stream/model.ts similarity index 96% rename from packages/app/src/components/agent-stream-render-model.ts rename to packages/app/src/agent-stream/model.ts index b5e836a67..20f1f787c 100644 --- a/packages/app/src/components/agent-stream-render-model.ts +++ b/packages/app/src/agent-stream/model.ts @@ -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[]; diff --git a/packages/app/src/components/agent-stream-render-strategy.test.ts b/packages/app/src/agent-stream/render-strategy.test.ts similarity index 77% rename from packages/app/src/components/agent-stream-render-strategy.test.ts rename to packages/app/src/agent-stream/render-strategy.test.ts index 6530705dd..ef6478455 100644 --- a/packages/app/src/components/agent-stream-render-strategy.test.ts +++ b/packages/app/src/agent-stream/render-strategy.test.ts @@ -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", + ); + }); +}); diff --git a/packages/app/src/components/agent-stream-view-data.test.ts b/packages/app/src/agent-stream/spacing.test.ts similarity index 99% rename from packages/app/src/components/agent-stream-view-data.test.ts rename to packages/app/src/agent-stream/spacing.test.ts index dc9df12bc..fd883bd1c 100644 --- a/packages/app/src/components/agent-stream-view-data.test.ts +++ b/packages/app/src/agent-stream/spacing.test.ts @@ -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; diff --git a/packages/app/src/components/agent-stream-view-data.ts b/packages/app/src/agent-stream/spacing.ts similarity index 100% rename from packages/app/src/components/agent-stream-view-data.ts rename to packages/app/src/agent-stream/spacing.ts diff --git a/packages/app/src/components/stream-strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx similarity index 98% rename from packages/app/src/components/stream-strategy-native.tsx rename to packages/app/src/agent-stream/strategy-native.tsx index a31a771dc..5632c9129 100644 --- a/packages/app/src/components/stream-strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -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, diff --git a/packages/app/src/components/stream-strategy-resolver.ts b/packages/app/src/agent-stream/strategy-resolver.ts similarity index 68% rename from packages/app/src/components/stream-strategy-resolver.ts rename to packages/app/src/agent-stream/strategy-resolver.ts index 6aa9e3773..5b21515e1 100644 --- a/packages/app/src/components/stream-strategy-resolver.ts +++ b/packages/app/src/agent-stream/strategy-resolver.ts @@ -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, diff --git a/packages/app/src/components/stream-strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx similarity index 97% rename from packages/app/src/components/stream-strategy-web.test.tsx rename to packages/app/src/agent-stream/strategy-web.test.tsx index 384abc502..b4e14f895 100644 --- a/packages/app/src/components/stream-strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -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 { diff --git a/packages/app/src/components/stream-strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx similarity index 98% rename from packages/app/src/components/stream-strategy-web.tsx rename to packages/app/src/agent-stream/strategy-web.tsx index 660e4ff3d..3053aded0 100644 --- a/packages/app/src/components/stream-strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -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, diff --git a/packages/app/src/components/stream-strategy.ts b/packages/app/src/agent-stream/strategy.ts similarity index 84% rename from packages/app/src/components/stream-strategy.ts rename to packages/app/src/agent-stream/strategy.ts index 67fb78da7..f8c272d28 100644 --- a/packages/app/src/components/stream-strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -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(); +} diff --git a/packages/app/src/components/agent-stream-turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx similarity index 75% rename from packages/app/src/components/agent-stream-turn-footer.tsx rename to packages/app/src/agent-stream/turn-footer.tsx index 00e87d29c..4ca005234 100644 --- a/packages/app/src/components/agent-stream-turn-footer.tsx +++ b/packages/app/src/agent-stream/turn-footer.tsx @@ -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; -}): 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, diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/agent-stream/view.tsx similarity index 78% rename from packages/app/src/components/agent-stream-view.tsx rename to packages/app/src/agent-stream/view.tsx index 8db233236..bbb212406 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -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 ? ( + + ) : null; + const content = ( + {input.content} + ); + + if (input.layoutItem.frameOrder === "footer-then-content") { + return ( + <> + {footer} + {content} + + ); + } return ( <> - {input.content} - {showCompletedFooter ? ( - - ) : null} + {content} + {footer} ); } @@ -194,47 +175,26 @@ function renderListEmptyComponent(input: { function renderHistoryStreamItem(input: { item: StreamItem; - historyIndexById: Map; - historyItems: StreamItem[]; - lastHistoryItem: StreamItem | null; - firstLiveHeadItem: StreamItem | null; - bottomTurnFooterHost: TurnFooterHost | null; - renderStreamItem: ( - item: StreamItem, - index: number, - items: StreamItem[], - seams?: StreamItemBoundarySeams, - ) => ReactNode; + layoutItemById: Map; + 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; + 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 + 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, - 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) => { return ( ); }, - [streamRenderStrategy], + [], ); const renderAssistantMessageItem = useCallback( - ( - item: Extract, - 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) => { return ( ); }, - [client, handleInlinePathPress, resolvedServerId, streamRenderStrategy, toast, workspaceRoot], + [client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot], ); const renderThoughtItem = useCallback( - (item: Extract, 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) => { return ( ); }, - [streamRenderStrategy, setInlineDetailsExpanded], + [setInlineDetailsExpanded], ); const renderToolCallItem = useCallback( - (item: Extract, index: number, items: StreamItem[]) => { + (layoutItem: StreamLayoutItem, item: Extract) => { 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 ); @@ -594,33 +507,29 @@ const AgentStreamViewComponent = forwardRef ); }, - [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 { - 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 [stylesheet.emptyState, stylesheet.contentWrapper], []); const listEmptyComponent = useMemo( @@ -755,38 +625,32 @@ const AgentStreamViewComponent = forwardRef { - const indexById = new Map(); - historyItems.forEach((item, index) => { - indexById.set(item.id, index); - }); - return indexById; - }, [historyItems]); + const layoutHistoryItemById = useMemo(() => { + const itemById = new Map(); + for (const item of streamLayout.history) { + itemById.set(item.item.id, item); + } + return itemById; + }, [streamLayout.history]); + + const layoutLiveHeadItemById = useMemo(() => { + const itemById = new Map(); + 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( - (item, index, items) => + (item) => renderLiveHeadStreamItem({ item, - index, - items, - lastHistoryItem, - bottomTurnFooterHost, + layoutItemById: layoutLiveHeadItemById, renderStreamItem, }), - [bottomTurnFooterHost, lastHistoryItem, renderStreamItem], + [layoutLiveHeadItemById, renderStreamItem], ); const renderLiveAuxiliary = useCallback(() => { return renderLiveAuxiliaryNode({ diff --git a/packages/app/src/components/agent-stream-web-virtualization.test.ts b/packages/app/src/agent-stream/web-virtualization.test.ts similarity index 99% rename from packages/app/src/components/agent-stream-web-virtualization.test.ts rename to packages/app/src/agent-stream/web-virtualization.test.ts index 736fef315..de6fe1052 100644 --- a/packages/app/src/components/agent-stream-web-virtualization.test.ts +++ b/packages/app/src/agent-stream/web-virtualization.test.ts @@ -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`); diff --git a/packages/app/src/components/agent-stream-web-virtualization.ts b/packages/app/src/agent-stream/web-virtualization.ts similarity index 100% rename from packages/app/src/components/agent-stream-web-virtualization.ts rename to packages/app/src/agent-stream/web-virtualization.ts diff --git a/packages/app/src/components/agent-stream-render-strategy.ts b/packages/app/src/components/agent-stream-render-strategy.ts deleted file mode 100644 index 9965bdeb3..000000000 --- a/packages/app/src/components/agent-stream-render-strategy.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./stream-strategy"; -export * from "./stream-strategy-resolver"; -export * from "./agent-stream-render-model"; diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 6798f471e..6c40eab6d 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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"; diff --git a/packages/app/src/screens/agent/agent-ready-screen-bottom-anchor.ts b/packages/app/src/screens/agent/agent-ready-screen-bottom-anchor.ts index 7b2bedf13..7bd3498b6 100644 --- a/packages/app/src/screens/agent/agent-ready-screen-bottom-anchor.ts +++ b/packages/app/src/screens/agent/agent-ready-screen-bottom-anchor.ts @@ -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; diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 28442654e..fdf5024f9 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -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";