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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user