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
|
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.
|
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
|
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
|
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
|
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", () => {
|
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 priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
|
||||||
const nextTool = toolCall("next-tool", 2, "turn-2");
|
const nextTool = toolCall("next-tool", 2, "turn-2");
|
||||||
const layout = layoutFor({
|
const layout = layoutFor({
|
||||||
@@ -159,14 +159,14 @@ describe("layoutStream", () => {
|
|||||||
timingIds: [priorAssistant.id],
|
timingIds: [priorAssistant.id],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
expect(layout.auxiliaryTurnFooter?.itemId).toBe(priorAssistant.id);
|
||||||
expect(footerOwners(layout)).toEqual([priorAssistant.id]);
|
expect(footerOwners(layout)).toEqual([priorAssistant.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recomputes cached history layout when only the live boundary turn changes", () => {
|
it("recomputes cached history layout when only the live boundary turn changes", () => {
|
||||||
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
|
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
|
||||||
const history = [priorAssistant];
|
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 strategy = strategyFor("web");
|
||||||
const first = layoutStream({
|
const first = layoutStream({
|
||||||
strategy,
|
strategy,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { TurnTiming } from "@/timeline/turn-time";
|
|||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./spacing";
|
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./spacing";
|
||||||
import type { StreamFrameChildOrder, StreamStrategy } from "./strategy";
|
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";
|
export type StreamToolSequence = "single" | "first" | "middle" | "last" | "none";
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ function createTurnFooterHost(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function findLatestAssistantInTurn(input: {
|
function findLatestAssistantInResponse(input: {
|
||||||
strategy: StreamStrategy;
|
strategy: StreamStrategy;
|
||||||
items: StreamItem[];
|
items: StreamItem[];
|
||||||
startIndex: number;
|
startIndex: number;
|
||||||
@@ -97,7 +97,7 @@ function findLatestAssistantInTurn(input: {
|
|||||||
index = input.strategy.getNeighborIndex(index, "above")
|
index = input.strategy.getNeighborIndex(index, "above")
|
||||||
) {
|
) {
|
||||||
const item = items[index];
|
const item = items[index];
|
||||||
if (!item || (laterItem && !continuesTurn(item, laterItem))) {
|
if (!item || (laterItem && !continuesResponse(item, laterItem))) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (item.kind === "assistant_message") {
|
if (item.kind === "assistant_message") {
|
||||||
@@ -132,7 +132,7 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assistant = findLatestAssistantInTurn({
|
const assistant = findLatestAssistantInResponse({
|
||||||
strategy: input.strategy,
|
strategy: input.strategy,
|
||||||
items: footerItems,
|
items: footerItems,
|
||||||
startIndex: latestIndex,
|
startIndex: latestIndex,
|
||||||
@@ -160,11 +160,11 @@ function resolveCompletedFooter(input: {
|
|||||||
boundaryAboveItems: StreamItem[] | null;
|
boundaryAboveItems: StreamItem[] | null;
|
||||||
boundaryAboveIndex: number | null;
|
boundaryAboveIndex: number | null;
|
||||||
}): TurnFooterHost | 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assistant = findLatestAssistantInTurn({
|
const assistant = findLatestAssistantInResponse({
|
||||||
strategy: input.strategy,
|
strategy: input.strategy,
|
||||||
items: input.items,
|
items: input.items,
|
||||||
startIndex: input.index,
|
startIndex: input.index,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import {
|
import {
|
||||||
collectAssistantTurnContentForStreamRenderStrategy,
|
collectAssistantResponseContentForStreamRenderStrategy,
|
||||||
getBottomOffsetForStreamRenderStrategy,
|
getBottomOffsetForStreamRenderStrategy,
|
||||||
getFrameChildOrderForStreamRenderStrategy,
|
getFrameChildOrderForStreamRenderStrategy,
|
||||||
getHistoryLiveBoundaryIndexForStreamRenderStrategy,
|
getHistoryLiveBoundaryIndexForStreamRenderStrategy,
|
||||||
@@ -175,7 +175,7 @@ describe("neighbor and traversal semantics", () => {
|
|||||||
expect(getStreamNeighborIndex({ strategy: inverted, index: 3, relation: "below" })).toBe(2);
|
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[] = [
|
const chronological: StreamItem[] = [
|
||||||
userMessage("u1", "user-1", 1),
|
userMessage("u1", "user-1", 1),
|
||||||
assistantMessage("a1", "assistant-1", 2),
|
assistantMessage("a1", "assistant-1", 2),
|
||||||
@@ -189,7 +189,7 @@ describe("neighbor and traversal semantics", () => {
|
|||||||
});
|
});
|
||||||
const forwardStartIndex = chronological.findIndex((item) => item.id === "a2");
|
const forwardStartIndex = chronological.findIndex((item) => item.id === "a2");
|
||||||
expect(
|
expect(
|
||||||
collectAssistantTurnContentForStreamRenderStrategy({
|
collectAssistantResponseContentForStreamRenderStrategy({
|
||||||
strategy: forward,
|
strategy: forward,
|
||||||
items: chronological,
|
items: chronological,
|
||||||
startIndex: forwardStartIndex,
|
startIndex: forwardStartIndex,
|
||||||
@@ -206,7 +206,7 @@ describe("neighbor and traversal semantics", () => {
|
|||||||
});
|
});
|
||||||
const invertedStartIndex = invertedItems.findIndex((item) => item.id === "a2");
|
const invertedStartIndex = invertedItems.findIndex((item) => item.id === "a2");
|
||||||
expect(
|
expect(
|
||||||
collectAssistantTurnContentForStreamRenderStrategy({
|
collectAssistantResponseContentForStreamRenderStrategy({
|
||||||
strategy: inverted,
|
strategy: inverted,
|
||||||
items: invertedItems,
|
items: invertedItems,
|
||||||
startIndex: invertedStartIndex,
|
startIndex: invertedStartIndex,
|
||||||
@@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => {
|
|||||||
).toBe("assistant-1\n\nassistant-2");
|
).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[] = [
|
const chronological: StreamItem[] = [
|
||||||
{ ...assistantMessage("a1", "first turn", 1), turnId: "turn-1" },
|
{ ...assistantMessage("a1", "first turn", 1), turnId: "turn-1" },
|
||||||
{ ...toolCall("tool", 2), turnId: "turn-2" },
|
{ ...toolCall("tool", 2), turnId: "turn-2" },
|
||||||
@@ -223,12 +223,12 @@ describe("neighbor and traversal semantics", () => {
|
|||||||
const strategy = resolveStreamRenderStrategy({ platform: "web", isMobileBreakpoint: false });
|
const strategy = resolveStreamRenderStrategy({ platform: "web", isMobileBreakpoint: false });
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
collectAssistantTurnContentForStreamRenderStrategy({
|
collectAssistantResponseContentForStreamRenderStrategy({
|
||||||
strategy,
|
strategy,
|
||||||
items: chronological,
|
items: chronological,
|
||||||
startIndex: 2,
|
startIndex: 2,
|
||||||
}),
|
}),
|
||||||
).toBe("second turn");
|
).toBe("first turn\n\nsecond turn");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns undefined neighbor when index would be out of bounds", () => {
|
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 { ComponentType, ReactElement, ReactNode, RefObject } from "react";
|
||||||
import type { StyleProp, ViewStyle } from "react-native";
|
import type { StyleProp, ViewStyle } from "react-native";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import { continuesTurn } from "./turn-membership";
|
import { continuesResponse } from "./turn-membership";
|
||||||
import type { StreamHistoryBoundary, StreamRenderSegments } from "./model";
|
import type { StreamHistoryBoundary, StreamRenderSegments } from "./model";
|
||||||
import type {
|
import type {
|
||||||
BottomAnchorLocalRequest,
|
BottomAnchorLocalRequest,
|
||||||
@@ -99,7 +99,7 @@ export interface StreamStrategy {
|
|||||||
index: number,
|
index: number,
|
||||||
relation: NeighborRelation,
|
relation: NeighborRelation,
|
||||||
) => StreamItem | undefined;
|
) => StreamItem | undefined;
|
||||||
collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string;
|
collectAssistantResponseContent: (items: StreamItem[], startIndex: number) => string;
|
||||||
isNearBottom: (input: StreamNearBottomInput) => boolean;
|
isNearBottom: (input: StreamNearBottomInput) => boolean;
|
||||||
getBottomOffset: (metrics: StreamViewportMetrics) => number;
|
getBottomOffset: (metrics: StreamViewportMetrics) => number;
|
||||||
getEdgeSlotProps: (
|
getEdgeSlotProps: (
|
||||||
@@ -164,7 +164,7 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate
|
|||||||
}
|
}
|
||||||
return items[neighborIndex];
|
return items[neighborIndex];
|
||||||
},
|
},
|
||||||
collectAssistantTurnContent: (items, startIndex) => {
|
collectAssistantResponseContent: (items, startIndex) => {
|
||||||
const messages: string[] = [];
|
const messages: string[] = [];
|
||||||
let laterItem: StreamItem | null = null;
|
let laterItem: StreamItem | null = null;
|
||||||
for (
|
for (
|
||||||
@@ -173,7 +173,7 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate
|
|||||||
index += config.assistantTurnTraversalStep
|
index += config.assistantTurnTraversalStep
|
||||||
) {
|
) {
|
||||||
const currentItem = items[index];
|
const currentItem = items[index];
|
||||||
if (!currentItem || (laterItem && !continuesTurn(currentItem, laterItem))) {
|
if (!currentItem || (laterItem && !continuesResponse(currentItem, laterItem))) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (currentItem.kind === "assistant_message") {
|
if (currentItem.kind === "assistant_message") {
|
||||||
@@ -276,12 +276,12 @@ export function getStreamNeighborItem(params: {
|
|||||||
return params.strategy.getNeighborItem(params.items, params.index, params.relation);
|
return params.strategy.getNeighborItem(params.items, params.index, params.relation);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
|
export function collectAssistantResponseContentForStreamRenderStrategy(params: {
|
||||||
strategy: StreamStrategy;
|
strategy: StreamStrategy;
|
||||||
items: StreamItem[];
|
items: StreamItem[];
|
||||||
startIndex: number;
|
startIndex: number;
|
||||||
}): string {
|
}): string {
|
||||||
return params.strategy.collectAssistantTurnContent(params.items, params.startIndex);
|
return params.strategy.collectAssistantResponseContent(params.items, params.startIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isNearBottomForStreamRenderStrategy(
|
export function isNearBottomForStreamRenderStrategy(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { Theme } from "@/styles/theme";
|
|||||||
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,
|
collectAssistantResponseContentForStreamRenderStrategy,
|
||||||
type StreamStrategy,
|
type StreamStrategy,
|
||||||
} from "./strategy";
|
} from "./strategy";
|
||||||
import { resolveAssistantTurnForkBoundary, type AssistantTurnForkBoundary } from "./turn-boundary";
|
import { resolveAssistantTurnForkBoundary, type AssistantTurnForkBoundary } from "./turn-boundary";
|
||||||
@@ -173,7 +173,7 @@ function CompletedTurnFooter({
|
|||||||
}) {
|
}) {
|
||||||
const getContent = useCallback(
|
const getContent = useCallback(
|
||||||
() =>
|
() =>
|
||||||
collectAssistantTurnContentForStreamRenderStrategy({
|
collectAssistantResponseContentForStreamRenderStrategy({
|
||||||
strategy,
|
strategy,
|
||||||
items,
|
items,
|
||||||
startIndex,
|
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([
|
it.each([
|
||||||
[
|
[
|
||||||
"Claude",
|
"Claude",
|
||||||
|
|||||||
@@ -12,10 +12,23 @@ export function continuesTurn(previous: StreamItem | null, next: StreamItem | nu
|
|||||||
return next.kind !== "user_message";
|
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 {
|
export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean {
|
||||||
return previous !== null && next !== null && !continuesTurn(previous, next);
|
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`. */
|
/** Whether `item` begins a new chronological turn after `previous`. */
|
||||||
export function startsNewTurn(item: StreamItem, previous: StreamItem | null): boolean {
|
export function startsNewTurn(item: StreamItem, previous: StreamItem | null): boolean {
|
||||||
return previous === null || isTurnBoundary(previous, item);
|
return previous === null || isTurnBoundary(previous, item);
|
||||||
|
|||||||
Reference in New Issue
Block a user