From 1701e68ea1aacb535afd005e5b783a1012f052b9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 28 Jul 2026 00:32:51 +0200 Subject: [PATCH] Preserve timeline rows across delayed hydration --- .../timeline/session-stream-reducers.test.ts | 52 ++++++++++++++++++- .../src/timeline/session-stream-reducers.ts | 19 ++++--- packages/app/src/types/stream.ts | 33 ++++++++---- 3 files changed, 82 insertions(+), 22 deletions(-) diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 5c7d7edf2..5ce7ff629 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -660,6 +660,53 @@ describe("processTimelineResponse", () => { expect(getUserTexts(result.head)).toEqual(["remote live prompt"]); }); + it("keeps a provider-acknowledged painted-tail prompt newer than the bootstrap page", () => { + const submitted = makeSubmittedUserMessage("submitted before bootstrap", "client-message-1"); + const live = processAgentStreamEvent({ + ...baseStreamInput, + event: { + type: "timeline", + provider: "claude", + item: { + type: "user_message", + text: "canonical presentation", + clientMessageId: "client-message-1", + messageId: "provider-message-1", + }, + }, + seq: 51, + epoch: "epoch-1", + currentTail: [makeAssistantItem("painted replica"), submitted], + currentCursor: undefined, + hasAuthoritativeBaseline: false, + }); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: live.tail, + currentHead: live.head, + sendingClientMessageIds: [], + isInitializing: true, + hasActiveInitDeferred: true, + hasAuthoritativeBaseline: false, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + startCursor: { seq: 11 }, + endCursor: { seq: 50 }, + entries: [makeTimelineEntry(50, "canonical answer")], + }, + }); + + expect(getUserTexts([...result.tail, ...result.head])).toEqual(["submitted before bootstrap"]); + expect(result.head[0]).toMatchObject({ + kind: "user_message", + clientMessageId: "client-message-1", + messageId: "provider-message-1", + timelineCursor: { epoch: "epoch-1", seq: 51 }, + }); + }); + it("does not duplicate a live row already covered by the bootstrap page", () => { const live = processAgentStreamEvent({ ...baseStreamInput, @@ -2202,7 +2249,10 @@ describe("processTimelineResponse", () => { expect(getAssistantTexts(result.tail)).toEqual(["older chunk newer chunk"]); expect(result.tail[0]).toEqual( - expect.objectContaining({ timelineCursor: { epoch: "epoch-1", seq: 3 } }), + expect.objectContaining({ + id: "assistant-newer", + timelineCursor: { epoch: "epoch-1", seq: 3 }, + }), ); expect(result.cursor).toEqual({ epoch: "epoch-1", diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 8792cfffc..1cd26517f 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -410,16 +410,15 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea return [...olderTail, ...currentTail]; } - return [ - ...olderTail.slice(0, -1), - { - ...olderLast, - text: `${olderLast.text}${currentFirst.text}`, - timestamp: currentFirst.timestamp, - ...(currentFirst.timelineCursor ? { timelineCursor: currentFirst.timelineCursor } : {}), - }, - ...currentTail.slice(1), - ]; + const mergedAssistant: AssistantMessageItem = { + ...currentFirst, + text: `${olderLast.text}${currentFirst.text}`, + }; + if (mergedAssistant.messageId === undefined && olderLast.messageId !== undefined) { + mergedAssistant.messageId = olderLast.messageId; + } + + return [...olderTail.slice(0, -1), mergedAssistant, ...currentTail.slice(1)]; } function replaceLiveAssistantWithProjectedText(params: { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 716a767b9..59d482b85 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -406,6 +406,16 @@ export interface CanonicalStreamReplacementResult { acknowledgedClientMessageIds: string[]; } +function isAfterCanonicalCoverage( + position: TimelinePosition, + coverage: CanonicalStreamReplacementInput["canonicalCoverage"], +): boolean { + return ( + position.epoch === coverage.epoch && + (coverage.endSeq === null || position.seq > coverage.endSeq) + ); +} + function removeUserMessageAt(items: UserMessageItem[], index: number): UserMessageItem[] { return [...items.slice(0, index), ...items.slice(index + 1)]; } @@ -503,9 +513,7 @@ function preserveReplacementHead( ? currentHead.filter( (item) => !item.timelineCursor || - (item.timelineCursor.epoch === canonicalCoverage.epoch && - (canonicalCoverage.endSeq === null || - item.timelineCursor.seq > canonicalCoverage.endSeq)) || + isAfterCanonicalCoverage(item.timelineCursor, canonicalCoverage) || (item.kind === "assistant_message" && canonicalTailAssistant?.kind === "assistant_message" && item.text.startsWith(canonicalTailAssistant.text)), @@ -631,22 +639,25 @@ export function replaceWithCanonicalStream( nextTail.push(item); } + const retainedTailMessages: UserMessageItem[] = []; for (const local of unmatchedTailMessages) { - if (!local.clientMessageId || !sendingClientMessageIds.has(local.clientMessageId)) { - continue; + if (local.clientMessageId && sendingClientMessageIds.has(local.clientMessageId)) { + nextTail.push(local); + } else if ( + input.preserveLiveHead && + local.timelineCursor && + isAfterCanonicalCoverage(local.timelineCursor, input.canonicalCoverage) + ) { + retainedTailMessages.push(local); } - nextTail.push(local); } + nextHead = [...retainedTailMessages, ...nextHead]; nextHead = nextHead.filter((item) => { if (item.kind !== "user_message" || !item.clientMessageId) return true; if (sendingClientMessageIds.has(item.clientMessageId)) return true; if (!input.preserveLiveHead || !item.timelineCursor) return false; - return ( - item.timelineCursor.epoch === input.canonicalCoverage.epoch && - (input.canonicalCoverage.endSeq === null || - item.timelineCursor.seq > input.canonicalCoverage.endSeq) - ); + return isAfterCanonicalCoverage(item.timelineCursor, input.canonicalCoverage); }); const replacement = preserveReplacementHead(