mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Preserve live timeline state through hydration
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user