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
+5
View File
@@ -173,6 +173,11 @@ foreground control ownership remains a separate daemon concern. Cancellation req
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
remain a separate pre-turn registry and retire on canonical acknowledgement.
Canonical turns and visible responses are different boundaries. System-injected prompts are absent from
the Paseo timeline, so one visible response can span several canonical turns without a user message
between them. Layout and copy group that response together; lifecycle, timing, tool sequences, and exact
fork positions retain the canonical `turnId` boundaries.
The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an
anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app
does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a
+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);