fix(app): merge hidden-prompt turns visually (#3484)

System-injected prompts never enter the Paseo timeline, but their provider runs keep distinct canonical turn IDs. Keep those IDs for lifecycle correctness while grouping adjacent output without a visible prompt into one response.
This commit is contained in:
Mohamed Boudra
2026-08-17 14:01:20 +02:00
committed by GitHub
parent c8b2c8351f
commit f3202ac419
8 changed files with 55 additions and 24 deletions
+3 -3
View File
@@ -150,7 +150,7 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
}
describe("layoutStream", () => {
it("does not borrow an assistant footer across adjacent tagged tool-only turns", () => {
it("places one response footer after an adjacent tagged tool-only turn", () => {
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
const nextTool = toolCall("next-tool", 2, "turn-2");
const layout = layoutFor({
@@ -159,14 +159,14 @@ describe("layoutStream", () => {
timingIds: [priorAssistant.id],
});
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(layout.auxiliaryTurnFooter?.itemId).toBe(priorAssistant.id);
expect(footerOwners(layout)).toEqual([priorAssistant.id]);
});
it("recomputes cached history layout when only the live boundary turn changes", () => {
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
const history = [priorAssistant];
const liveHead = [toolCall("live-tool", 2, "turn-1")];
const liveHead: StreamItem[] = [{ ...userMessage("live-user", 2), turnId: "turn-1" }];
const strategy = strategyFor("web");
const first = layoutStream({
strategy,
+6 -6
View File
@@ -2,7 +2,7 @@ import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./spacing";
import type { StreamFrameChildOrder, StreamStrategy } from "./strategy";
import { continuesTurn, isTurnBoundary } from "./turn-membership";
import { continuesResponse, continuesTurn, isResponseBoundary } from "./turn-membership";
export type StreamToolSequence = "single" | "first" | "middle" | "last" | "none";
@@ -78,7 +78,7 @@ function createTurnFooterHost(input: {
};
}
function findLatestAssistantInTurn(input: {
function findLatestAssistantInResponse(input: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
@@ -97,7 +97,7 @@ function findLatestAssistantInTurn(input: {
index = input.strategy.getNeighborIndex(index, "above")
) {
const item = items[index];
if (!item || (laterItem && !continuesTurn(item, laterItem))) {
if (!item || (laterItem && !continuesResponse(item, laterItem))) {
return null;
}
if (item.kind === "assistant_message") {
@@ -132,7 +132,7 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
return null;
}
const assistant = findLatestAssistantInTurn({
const assistant = findLatestAssistantInResponse({
strategy: input.strategy,
items: footerItems,
startIndex: latestIndex,
@@ -160,11 +160,11 @@ function resolveCompletedFooter(input: {
boundaryAboveItems: StreamItem[] | null;
boundaryAboveIndex: number | null;
}): TurnFooterHost | null {
if (input.item.kind === "user_message" || !isTurnBoundary(input.item, input.belowItem)) {
if (input.item.kind === "user_message" || !isResponseBoundary(input.item, input.belowItem)) {
return null;
}
const assistant = findLatestAssistantInTurn({
const assistant = findLatestAssistantInResponse({
strategy: input.strategy,
items: input.items,
startIndex: input.index,
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import {
collectAssistantTurnContentForStreamRenderStrategy,
collectAssistantResponseContentForStreamRenderStrategy,
getBottomOffsetForStreamRenderStrategy,
getFrameChildOrderForStreamRenderStrategy,
getHistoryLiveBoundaryIndexForStreamRenderStrategy,
@@ -175,7 +175,7 @@ describe("neighbor and traversal semantics", () => {
expect(getStreamNeighborIndex({ strategy: inverted, index: 3, relation: "below" })).toBe(2);
});
it("collects assistant turn content with strategy traversal direction", () => {
it("collects assistant response content with strategy traversal direction", () => {
const chronological: StreamItem[] = [
userMessage("u1", "user-1", 1),
assistantMessage("a1", "assistant-1", 2),
@@ -189,7 +189,7 @@ describe("neighbor and traversal semantics", () => {
});
const forwardStartIndex = chronological.findIndex((item) => item.id === "a2");
expect(
collectAssistantTurnContentForStreamRenderStrategy({
collectAssistantResponseContentForStreamRenderStrategy({
strategy: forward,
items: chronological,
startIndex: forwardStartIndex,
@@ -206,7 +206,7 @@ describe("neighbor and traversal semantics", () => {
});
const invertedStartIndex = invertedItems.findIndex((item) => item.id === "a2");
expect(
collectAssistantTurnContentForStreamRenderStrategy({
collectAssistantResponseContentForStreamRenderStrategy({
strategy: inverted,
items: invertedItems,
startIndex: invertedStartIndex,
@@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => {
).toBe("assistant-1\n\nassistant-2");
});
it("does not collect copy or fork content across an adjacent tagged turn", () => {
it("collects copy content across adjacent turns without a visible prompt", () => {
const chronological: StreamItem[] = [
{ ...assistantMessage("a1", "first turn", 1), turnId: "turn-1" },
{ ...toolCall("tool", 2), turnId: "turn-2" },
@@ -223,12 +223,12 @@ describe("neighbor and traversal semantics", () => {
const strategy = resolveStreamRenderStrategy({ platform: "web", isMobileBreakpoint: false });
expect(
collectAssistantTurnContentForStreamRenderStrategy({
collectAssistantResponseContentForStreamRenderStrategy({
strategy,
items: chronological,
startIndex: 2,
}),
).toBe("second turn");
).toBe("first turn\n\nsecond turn");
});
it("returns undefined neighbor when index would be out of bounds", () => {
+6 -6
View File
@@ -1,7 +1,7 @@
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream";
import { continuesTurn } from "./turn-membership";
import { continuesResponse } from "./turn-membership";
import type { StreamHistoryBoundary, StreamRenderSegments } from "./model";
import type {
BottomAnchorLocalRequest,
@@ -99,7 +99,7 @@ export interface StreamStrategy {
index: number,
relation: NeighborRelation,
) => StreamItem | undefined;
collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string;
collectAssistantResponseContent: (items: StreamItem[], startIndex: number) => string;
isNearBottom: (input: StreamNearBottomInput) => boolean;
getBottomOffset: (metrics: StreamViewportMetrics) => number;
getEdgeSlotProps: (
@@ -164,7 +164,7 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate
}
return items[neighborIndex];
},
collectAssistantTurnContent: (items, startIndex) => {
collectAssistantResponseContent: (items, startIndex) => {
const messages: string[] = [];
let laterItem: StreamItem | null = null;
for (
@@ -173,7 +173,7 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate
index += config.assistantTurnTraversalStep
) {
const currentItem = items[index];
if (!currentItem || (laterItem && !continuesTurn(currentItem, laterItem))) {
if (!currentItem || (laterItem && !continuesResponse(currentItem, laterItem))) {
break;
}
if (currentItem.kind === "assistant_message") {
@@ -276,12 +276,12 @@ export function getStreamNeighborItem(params: {
return params.strategy.getNeighborItem(params.items, params.index, params.relation);
}
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
export function collectAssistantResponseContentForStreamRenderStrategy(params: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
}): string {
return params.strategy.collectAssistantTurnContent(params.items, params.startIndex);
return params.strategy.collectAssistantResponseContent(params.items, params.startIndex);
}
export function isNearBottomForStreamRenderStrategy(
@@ -6,7 +6,7 @@ import type { Theme } from "@/styles/theme";
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import {
collectAssistantTurnContentForStreamRenderStrategy,
collectAssistantResponseContentForStreamRenderStrategy,
type StreamStrategy,
} from "./strategy";
import { resolveAssistantTurnForkBoundary, type AssistantTurnForkBoundary } from "./turn-boundary";
@@ -173,7 +173,7 @@ function CompletedTurnFooter({
}) {
const getContent = useCallback(
() =>
collectAssistantTurnContentForStreamRenderStrategy({
collectAssistantResponseContentForStreamRenderStrategy({
strategy,
items,
startIndex,
@@ -76,6 +76,19 @@ describe("canonical turn membership", () => {
);
});
it("renders adjacent turns without visible prompts as one response", () => {
const items = [
user("prompt", 1, "turn-1"),
assistant("first reply", 2, "turn-1"),
runningTool("heartbeat-1-tool", 3, "turn-2"),
assistant("heartbeat-1-reply", 4, "turn-2"),
runningTool("heartbeat-2-tool", 5, "turn-3"),
assistant("heartbeat-2-reply", 6, "turn-3"),
];
expect(completedFooterIds(layoutFor(items, false).layout)).toEqual(["heartbeat-2-reply"]);
});
it.each([
[
"Claude",
@@ -12,10 +12,23 @@ export function continuesTurn(previous: StreamItem | null, next: StreamItem | nu
return next.kind !== "user_message";
}
/**
* A visible response can span multiple canonical turns when their prompts are
* system-injected and therefore absent from the Paseo timeline.
*/
export function continuesResponse(previous: StreamItem | null, next: StreamItem | null): boolean {
if (!previous || !next) return false;
return continuesTurn(previous, next) || next.kind !== "user_message";
}
export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean {
return previous !== null && next !== null && !continuesTurn(previous, next);
}
export function isResponseBoundary(previous: StreamItem | null, next: StreamItem | null): boolean {
return previous !== null && next !== null && !continuesResponse(previous, next);
}
/** Whether `item` begins a new chronological turn after `previous`. */
export function startsNewTurn(item: StreamItem, previous: StreamItem | null): boolean {
return previous === null || isTurnBoundary(previous, item);