Compare commits

...
Author SHA1 Message Date
Mohamed Boudra ea7602b968 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.
2026-07-22 11:44:37 +00:00
5 changed files with 76 additions and 31 deletions
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { TurnTiming } from "@/timeline/turn-time"; import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import { import {
collectAssistantTurnContentForStreamRenderStrategy,
orderHeadForStreamRenderStrategy, orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy, orderTailForStreamRenderStrategy,
type StreamStrategy, type StreamStrategy,
@@ -310,6 +311,34 @@ describe("layoutStream", () => {
expect(footerOwners(layout)).toEqual([assistant.id]); 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)( it.each(["web", "android"] as const)(
"places inline footer after trailing visible tool rows before the next user on %s", "places inline footer after trailing visible tool rows before the next user on %s",
(platform) => { (platform) => {
+13
View File
@@ -10,6 +10,8 @@ export interface TurnFooterHost {
items: StreamItem[]; items: StreamItem[];
timing?: TurnTiming; timing?: TurnTiming;
startIndex: number; startIndex: number;
precedingItems: StreamItem[] | null;
precedingStartIndex: number | null;
} }
export interface StreamLayoutItem { export interface StreamLayoutItem {
@@ -66,12 +68,16 @@ function createTurnFooterHost(input: {
items: StreamItem[]; items: StreamItem[];
index: number; index: number;
timingByAssistantId: Map<string, TurnTiming>; timingByAssistantId: Map<string, TurnTiming>;
precedingItems?: StreamItem[] | null;
precedingStartIndex?: number | null;
}): TurnFooterHost { }): TurnFooterHost {
return { return {
itemId: input.item.id, itemId: input.item.id,
items: input.items, items: input.items,
timing: input.timingByAssistantId.get(input.item.id), timing: input.timingByAssistantId.get(input.item.id),
startIndex: input.index, startIndex: input.index,
precedingItems: input.precedingItems ?? null,
precedingStartIndex: input.precedingStartIndex ?? null,
}; };
} }
@@ -141,6 +147,11 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
items: assistant.items, items: assistant.items,
index: assistant.index, index: assistant.index,
timingByAssistantId: input.timingByAssistantId, 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, items: assistant.items,
index: assistant.index, index: assistant.index,
timingByAssistantId: input.timingByAssistantId, timingByAssistantId: input.timingByAssistantId,
precedingItems: input.boundaryAboveItems,
precedingStartIndex: input.boundaryAboveIndex,
}); });
} }
+18 -1
View File
@@ -272,8 +272,25 @@ export function collectAssistantTurnContentForStreamRenderStrategy(params: {
strategy: StreamStrategy; strategy: StreamStrategy;
items: StreamItem[]; items: StreamItem[];
startIndex: number; startIndex: number;
precedingItems?: StreamItem[] | null;
precedingStartIndex?: number | null;
}): string { }): 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( export function isNearBottomForStreamRenderStrategy(
+15 -27
View File
@@ -3,8 +3,6 @@ import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import { import {
collectAssistantTurnContentForStreamRenderStrategy, collectAssistantTurnContentForStreamRenderStrategy,
type StreamStrategy, type StreamStrategy,
@@ -62,9 +60,7 @@ export const TurnFooter = memo(function TurnFooter({
return ( return (
<CompletedTurnFooterRow <CompletedTurnFooterRow
strategy={strategy} strategy={strategy}
items={host.items} host={host}
timing={host.timing}
startIndex={host.startIndex}
supportsTimelineCursor={supportsTimelineCursor} supportsTimelineCursor={supportsTimelineCursor}
onForkAssistantTurn={onForkAssistantTurn} onForkAssistantTurn={onForkAssistantTurn}
/> />
@@ -73,16 +69,12 @@ export const TurnFooter = memo(function TurnFooter({
export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
strategy, strategy,
items, host,
timing,
startIndex,
supportsTimelineCursor, supportsTimelineCursor,
onForkAssistantTurn, onForkAssistantTurn,
}: { }: {
strategy: TurnContentStrategy; strategy: TurnContentStrategy;
items: StreamItem[]; host: TurnFooterHost;
timing?: TurnTiming;
startIndex: number;
supportsTimelineCursor: boolean; supportsTimelineCursor: boolean;
onForkAssistantTurn?: AssistantTurnForkHandler; onForkAssistantTurn?: AssistantTurnForkHandler;
}) { }) {
@@ -90,9 +82,7 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
<TurnFooterRow> <TurnFooterRow>
<CompletedTurnFooter <CompletedTurnFooter
strategy={strategy} strategy={strategy}
items={items} host={host}
timing={timing}
startIndex={startIndex}
supportsTimelineCursor={supportsTimelineCursor} supportsTimelineCursor={supportsTimelineCursor}
onForkAssistantTurn={onForkAssistantTurn} onForkAssistantTurn={onForkAssistantTurn}
/> />
@@ -133,16 +123,12 @@ function RunningTurnFooter({ inFlightTurnStartedAt }: { inFlightTurnStartedAt: D
function CompletedTurnFooter({ function CompletedTurnFooter({
strategy, strategy,
items, host,
timing,
startIndex,
supportsTimelineCursor, supportsTimelineCursor,
onForkAssistantTurn, onForkAssistantTurn,
}: { }: {
strategy: TurnContentStrategy; strategy: TurnContentStrategy;
items: StreamItem[]; host: TurnFooterHost;
timing?: TurnTiming;
startIndex: number;
supportsTimelineCursor: boolean; supportsTimelineCursor: boolean;
onForkAssistantTurn?: AssistantTurnForkHandler; onForkAssistantTurn?: AssistantTurnForkHandler;
}) { }) {
@@ -150,14 +136,16 @@ function CompletedTurnFooter({
() => () =>
collectAssistantTurnContentForStreamRenderStrategy({ collectAssistantTurnContentForStreamRenderStrategy({
strategy, strategy,
items, items: host.items,
startIndex, startIndex: host.startIndex,
precedingItems: host.precedingItems,
precedingStartIndex: host.precedingStartIndex,
}), }),
[strategy, items, startIndex], [strategy, host],
); );
const boundary = resolveAssistantTurnForkBoundary({ const boundary = resolveAssistantTurnForkBoundary({
items, items: host.items,
startIndex, startIndex: host.startIndex,
supportsTimelineCursor, supportsTimelineCursor,
}); });
const handleFork = useCallback( const handleFork = useCallback(
@@ -173,8 +161,8 @@ function CompletedTurnFooter({
<View style={stylesheet.turnFooterSlot}> <View style={stylesheet.turnFooterSlot}>
<AssistantTurnFooter <AssistantTurnFooter
getContent={getContent} getContent={getContent}
completedAt={timing?.completedAt} completedAt={host.timing?.completedAt}
durationMs={timing?.durationMs} durationMs={host.timing?.durationMs}
onFork={boundary && onForkAssistantTurn ? handleFork : undefined} onFork={boundary && onForkAssistantTurn ? handleFork : undefined}
/> />
</View> </View>
+1 -3
View File
@@ -153,9 +153,7 @@ function renderStreamItemWithTurnFooter(input: {
const footer = footerHost ? ( const footer = footerHost ? (
<CompletedTurnFooterRow <CompletedTurnFooterRow
strategy={input.strategy} strategy={input.strategy}
items={footerHost.items} host={footerHost}
timing={footerHost.timing}
startIndex={footerHost.startIndex}
supportsTimelineCursor={input.supportsTimelineCursor} supportsTimelineCursor={input.supportsTimelineCursor}
onForkAssistantTurn={input.onForkAssistantTurn} onForkAssistantTurn={input.onForkAssistantTurn}
/> />