From ea7602b9685966d5b685a89829514a3f71818f36 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Jul 2026 11:44:37 +0000 Subject: [PATCH] fix(app): copy complete assistant turns on iOS Completed turns can span the rendered history and live-head segments. Preserve that boundary in the footer host so Copy turn includes assistant text from both segments. --- packages/app/src/agent-stream/layout.test.ts | 29 +++++++++++++ packages/app/src/agent-stream/layout.ts | 13 ++++++ packages/app/src/agent-stream/strategy.ts | 19 ++++++++- packages/app/src/agent-stream/turn-footer.tsx | 42 +++++++------------ packages/app/src/agent-stream/view.tsx | 4 +- 5 files changed, 76 insertions(+), 31 deletions(-) diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts index b996543be5..9e6901cc24 100644 --- a/packages/app/src/agent-stream/layout.test.ts +++ b/packages/app/src/agent-stream/layout.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { TurnTiming } from "@/timeline/turn-time"; import type { StreamItem } from "@/types/stream"; import { + collectAssistantTurnContentForStreamRenderStrategy, orderHeadForStreamRenderStrategy, orderTailForStreamRenderStrategy, type StreamStrategy, @@ -310,6 +311,34 @@ describe("layoutStream", () => { expect(footerOwners(layout)).toEqual([assistant.id]); }); + it.each(["web", "android"] as const)( + "copies every assistant block when a completed turn spans history and live head on %s", + (platform) => { + const strategy = strategyFor(platform); + const layout = layoutFor({ + platform, + tail: [ + userMessage("u1", 1), + assistantMessage("first block", 2, { groupId: "turn-1", index: 0 }), + assistantMessage("second block", 3, { groupId: "turn-1", index: 1 }), + ], + head: [assistantMessage("final block", 4, { groupId: "turn-1", index: 2 })], + }); + const footer = layout.auxiliaryTurnFooter; + + expect(footer).not.toBeNull(); + expect( + collectAssistantTurnContentForStreamRenderStrategy({ + strategy, + items: footer!.items, + startIndex: footer!.startIndex, + precedingItems: footer!.precedingItems, + precedingStartIndex: footer!.precedingStartIndex, + }), + ).toBe("first block\n\nsecond block\n\nfinal block"); + }, + ); + it.each(["web", "android"] as const)( "places inline footer after trailing visible tool rows before the next user on %s", (platform) => { diff --git a/packages/app/src/agent-stream/layout.ts b/packages/app/src/agent-stream/layout.ts index f276db3825..fc6ab3140d 100644 --- a/packages/app/src/agent-stream/layout.ts +++ b/packages/app/src/agent-stream/layout.ts @@ -10,6 +10,8 @@ export interface TurnFooterHost { items: StreamItem[]; timing?: TurnTiming; startIndex: number; + precedingItems: StreamItem[] | null; + precedingStartIndex: number | null; } export interface StreamLayoutItem { @@ -66,12 +68,16 @@ function createTurnFooterHost(input: { items: StreamItem[]; index: number; timingByAssistantId: Map; + precedingItems?: StreamItem[] | null; + precedingStartIndex?: number | null; }): TurnFooterHost { return { itemId: input.item.id, items: input.items, timing: input.timingByAssistantId.get(input.item.id), startIndex: input.index, + precedingItems: input.precedingItems ?? null, + precedingStartIndex: input.precedingStartIndex ?? null, }; } @@ -141,6 +147,11 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | items: assistant.items, index: assistant.index, timingByAssistantId: input.timingByAssistantId, + precedingItems: footerItems === input.liveHead ? input.history : null, + precedingStartIndex: + footerItems === input.liveHead + ? input.strategy.getHistoryLiveBoundaryIndex(input.history) + : null, }); } @@ -174,6 +185,8 @@ function resolveCompletedFooter(input: { items: assistant.items, index: assistant.index, timingByAssistantId: input.timingByAssistantId, + precedingItems: input.boundaryAboveItems, + precedingStartIndex: input.boundaryAboveIndex, }); } diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index dd9a798edf..7f44773dff 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -272,8 +272,25 @@ export function collectAssistantTurnContentForStreamRenderStrategy(params: { strategy: StreamStrategy; items: StreamItem[]; startIndex: number; + precedingItems?: StreamItem[] | null; + precedingStartIndex?: number | null; }): string { - return params.strategy.collectAssistantTurnContent(params.items, params.startIndex); + const content = params.strategy.collectAssistantTurnContent(params.items, params.startIndex); + if (!params.precedingItems || params.precedingStartIndex == null) { + return content; + } + + const precedingContent = params.strategy.collectAssistantTurnContent( + params.precedingItems, + params.precedingStartIndex, + ); + if (!precedingContent) { + return content; + } + if (!content) { + return precedingContent; + } + return `${precedingContent}\n\n${content}`; } export function isNearBottomForStreamRenderStrategy( diff --git a/packages/app/src/agent-stream/turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx index 5b7e01e8d7..5b942e8755 100644 --- a/packages/app/src/agent-stream/turn-footer.tsx +++ b/packages/app/src/agent-stream/turn-footer.tsx @@ -3,8 +3,6 @@ import { View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import type { Theme } from "@/styles/theme"; -import type { TurnTiming } from "@/timeline/turn-time"; -import type { StreamItem } from "@/types/stream"; import { collectAssistantTurnContentForStreamRenderStrategy, type StreamStrategy, @@ -62,9 +60,7 @@ export const TurnFooter = memo(function TurnFooter({ return ( @@ -73,16 +69,12 @@ export const TurnFooter = memo(function TurnFooter({ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({ strategy, - items, - timing, - startIndex, + host, supportsTimelineCursor, onForkAssistantTurn, }: { strategy: TurnContentStrategy; - items: StreamItem[]; - timing?: TurnTiming; - startIndex: number; + host: TurnFooterHost; supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; }) { @@ -90,9 +82,7 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({ @@ -133,16 +123,12 @@ function RunningTurnFooter({ inFlightTurnStartedAt }: { inFlightTurnStartedAt: D function CompletedTurnFooter({ strategy, - items, - timing, - startIndex, + host, supportsTimelineCursor, onForkAssistantTurn, }: { strategy: TurnContentStrategy; - items: StreamItem[]; - timing?: TurnTiming; - startIndex: number; + host: TurnFooterHost; supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; }) { @@ -150,14 +136,16 @@ function CompletedTurnFooter({ () => collectAssistantTurnContentForStreamRenderStrategy({ strategy, - items, - startIndex, + items: host.items, + startIndex: host.startIndex, + precedingItems: host.precedingItems, + precedingStartIndex: host.precedingStartIndex, }), - [strategy, items, startIndex], + [strategy, host], ); const boundary = resolveAssistantTurnForkBoundary({ - items, - startIndex, + items: host.items, + startIndex: host.startIndex, supportsTimelineCursor, }); const handleFork = useCallback( @@ -173,8 +161,8 @@ function CompletedTurnFooter({ diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index c670920295..5782c81835 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -153,9 +153,7 @@ function renderStreamItemWithTurnFooter(input: { const footer = footerHost ? (