Preserve live timeline state through hydration

This commit is contained in:
Mohamed Boudra
2026-07-27 23:31:38 +02:00
parent 0952550f54
commit 7a2ba7620f
3 changed files with 115 additions and 12 deletions

View File

@@ -331,9 +331,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
settleFrames: HISTORY_START_SETTLE_FRAMES,
requestFrame: (callback) => window.requestAnimationFrame(callback),
cancelFrame: (frame) => window.cancelAnimationFrame(frame),
isSettling: () =>
historyStartPaginationStateRef.current.status === "settling" &&
historyStartPrependAnchorActiveRef.current,
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
isLoading: () => {
const input = getHistoryStartPaginationInput();
return (
@@ -581,11 +579,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
useEffect(() => {
updateScrollMetrics();
evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartPrependSettle();
}
}, [
evaluateHistoryStart,
hasOlderHistory,
isLoadingOlderHistory,
olderHistoryProgressKey,
scheduleHistoryStartPrependSettle,
segments.historyMounted.length,
segments.historyVirtualized.length,
segments.liveHead.length,
@@ -605,6 +607,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const observer = new ResizeObserver(() => {
if (historyStartPrependAnchorActiveRef.current) {
applyHistoryStartPrependAnchor();
}
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartPrependSettle();
}
updateScrollMetrics();

View File

@@ -363,6 +363,59 @@ describe("processTimelineResponse", () => {
expect(result.head).toEqual([liveThought, liveAssistant]);
});
it("keeps a newer live tool completion when bootstrap contains the running call", () => {
const callId = "toolu_live_completion";
const liveCompletion = hydrateStreamState(
[
{
event: {
type: "timeline",
provider: "claude",
item: makeToolCallTimelineEntry(2, callId, "completed", {
type: "read",
filePath: "/tmp/example.ts",
}).item,
} as AgentStreamEventPayload,
timestamp: new Date(2000),
timelineCursor: { epoch: "epoch-1", seq: 2 },
},
],
{ source: "canonical" },
);
const result = processTimelineResponse({
...baseTimelineInput,
currentHead: liveCompletion,
hasAuthoritativeBaseline: false,
isInitializing: true,
hasActiveInitDeferred: true,
payload: {
...baseTimelineInput.payload,
direction: "tail",
reset: false,
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 1 },
entries: [
makeToolCallTimelineEntry(1, callId, "running", {
type: "unknown",
input: { file_path: "/tmp/example.ts" },
output: null,
}),
],
},
});
expect(getAgentToolCalls([...result.tail, ...result.head])).toEqual([
expect.objectContaining({
timelineCursor: { epoch: "epoch-1", seq: 2 },
payload: expect.objectContaining({
data: expect.objectContaining({ callId, status: "completed" }),
}),
}),
]);
});
it("replaces a painted replica with an empty authoritative timeline", () => {
const result = processTimelineResponse({
...baseTimelineInput,

View File

@@ -410,6 +410,44 @@ function removeUserMessageAt(items: UserMessageItem[], index: number): UserMessa
return [...items.slice(0, index), ...items.slice(index + 1)];
}
function reconcileReplacementHeadAgainstTail(
tail: StreamItem[],
retainedHead: StreamItem[],
): { tail: StreamItem[]; head: StreamItem[] } {
let reconciledTail = tail;
const reconciledHeadIndexes = new Set<number>();
for (const [headIndex, item] of retainedHead.entries()) {
if (!isAgentToolCallItem(item) || !item.timelineCursor) {
continue;
}
const tailIndex = findExistingAgentToolCallIndex(reconciledTail, item.payload.data.callId);
const existing = reconciledTail[tailIndex];
if (tailIndex < 0 || !existing || !isAgentToolCallItem(existing)) {
continue;
}
if (reconciledTail === tail) {
reconciledTail = [...tail];
}
reconciledTail[tailIndex] = mergeAgentToolCallItem(
existing,
item.payload.data,
item.timestamp,
item.timelineCursor,
);
reconciledHeadIndexes.add(headIndex);
}
const tailIds = new Set(reconciledTail.map((item) => item.id));
return {
tail: reconciledTail,
head: retainedHead.filter(
(item, index) =>
!reconciledHeadIndexes.has(index) &&
(item.kind === "assistant_message" || !tailIds.has(item.id)),
),
};
}
function preserveReplacementHead(
tail: StreamItem[],
currentHead: StreamItem[],
@@ -435,25 +473,25 @@ function preserveReplacementHead(
item.clientMessageId !== undefined &&
sendingClientMessageIds.has(item.clientMessageId),
);
const tailIds = new Set(tail.map((item) => item.id));
const unreconciledHead = retainedHead.filter(
(item) => item.kind === "assistant_message" || !tailIds.has(item.id),
const { tail: reconciledTail, head: unreconciledHead } = reconcileReplacementHeadAgainstTail(
tail,
retainedHead,
);
const liveAssistantIndex = unreconciledHead.findLastIndex(
(item) => item.kind === "assistant_message",
);
if (liveAssistantIndex < 0) {
return { tail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
}
const liveAssistant = unreconciledHead[liveAssistantIndex];
const tailAssistant = tail.at(-1);
const tailAssistant = reconciledTail.at(-1);
if (
liveAssistant.kind !== "assistant_message" ||
!tailAssistant ||
tailAssistant.kind !== "assistant_message"
) {
return { tail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
}
const isNewerContinuation =
@@ -472,10 +510,14 @@ function preserveReplacementHead(
{ ...liveAssistant, text },
...unreconciledHead.slice(liveAssistantIndex + 1),
];
return { tail: tail.slice(0, -1), head, acknowledgedClientMessageIds: [] };
return {
tail: reconciledTail.slice(0, -1),
head,
acknowledgedClientMessageIds: [],
};
}
if (!liveAssistant.text.startsWith(tailAssistant.text)) {
return { tail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
}
const head = [
@@ -483,7 +525,11 @@ function preserveReplacementHead(
{ ...liveAssistant, text: tailAssistant.text },
...unreconciledHead.slice(liveAssistantIndex + 1),
];
return { tail: tail.slice(0, -1), head, acknowledgedClientMessageIds: [] };
return {
tail: reconciledTail.slice(0, -1),
head,
acknowledgedClientMessageIds: [],
};
}
export function replaceWithCanonicalStream(