Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1493,15 +1493,20 @@ function LazyTurnRowBody({
showAssistantMessageActions,
}: LazyTurnRowBodyProps) {
const { getViewRows, threadId } = useTimelineRendererStaticContext();
const { threadId: rowThreadId, turnId: rowTurnId } = row;
const {
sourceSeqEnd,
sourceSeqStart,
threadId: rowThreadId,
turnId: rowTurnId,
} = row;
const identity = useMemo<ThreadTimelineTurnDetailsQueryIdentity>(
() => ({
sourceSeqEnd: row.sourceSeqEnd,
sourceSeqStart: row.sourceSeqStart,
sourceSeqEnd,
sourceSeqStart,
threadId: threadId ?? rowThreadId,
turnId: rowTurnId,
}),
[row.sourceSeqEnd, row.sourceSeqStart, rowThreadId, rowTurnId, threadId],
[rowThreadId, rowTurnId, sourceSeqEnd, sourceSeqStart, threadId],
);
const {
data: detail,
Expand Down
6 changes: 5 additions & 1 deletion apps/app/src/hooks/queries/thread-queries.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,11 @@ describe("useThreadTimelineTurnDetails", () => {

expect(sdk.threads.timelineTurnDetails).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ cursor: "cursor-2" }),
expect.objectContaining({
cursor: "cursor-2",
sourceSeqEnd: "2",
sourceSeqStart: "1",
}),
);
await waitFor(() =>
expect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export function useThreadQueuedMessages(

/**
* Lazy children of one completed-turn summary row. The server owns page
* boundaries; the client cache identifies only the turn.
* boundaries; the client cache identifies the row's semantic source range.
* A history rewrite invalidates every detail page for the thread.
*/
export function useTimelineTurnDetails(
Expand Down
55 changes: 46 additions & 9 deletions apps/server/src/services/threads/timeline-pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,17 +107,23 @@ function isTimelineSegmentAnchorRow(row: TimelineRow): boolean {

function buildTimelineLogicalSegment(
rows: TimelineRow[],
cursorRow: TimelineRow | null = null,
): TimelineLogicalSegment {
const anchorRow = rows[0];
if (!anchorRow) {
const firstRow = rows[0];
if (!firstRow) {
throw new Error("Cannot build a timeline segment without rows");
}
const segmentCursorRow = cursorRow ?? firstRow;

return {
cursor: {
anchorSeq: anchorRow.sourceSeqStart,
anchorId: anchorRow.id,
anchorSeq: segmentCursorRow.sourceSeqStart,
anchorId: segmentCursorRow.id,
},
// Projection order is semantic, not always source order: completed-turn
// summaries precede their terminal assistant message even when the
// summary's derived source bounds begin later. Sorting a segment by source
// here moves that assistant to the wrong side of "Worked for...".
rows,
};
}
Expand All @@ -127,23 +133,54 @@ function buildTimelineLogicalSegments(
): TimelineLogicalSegment[] {
const segments: TimelineLogicalSegment[] = [];
let currentRows: TimelineRow[] = [];
let currentCursorRow: TimelineRow | null = null;

for (const row of rows) {
if (
isTimelineSegmentAnchorRow(row) &&
currentRows.length > 0 &&
currentRows[0]?.sourceSeqStart !== row.sourceSeqStart
(currentCursorRow?.sourceSeqStart ?? currentRows[0]?.sourceSeqStart) !==
row.sourceSeqStart
) {
segments.push(buildTimelineLogicalSegment(currentRows));
currentRows = [row];
// Projection groups thread-scoped messages separately from turn rows.
// A thread event can therefore appear immediately before the accepted
// user row that precedes it in source order. Do not leave that trailing
// event in the older segment: a one-segment page would trim it away.
// Move the anchor ahead of every source-newer trailing row so the
// segment also has the user row as its cursor. A row that starts before
// the anchor stays with that earlier segment even if its lifecycle ends
// after the anchor; the server closes that row with targeted context.
const trailingRowIndex = currentRows.findIndex(
(currentRow) => currentRow.sourceSeqStart >= row.sourceSeqStart,
);
if (trailingRowIndex === -1) {
segments.push(
buildTimelineLogicalSegment(currentRows, currentCursorRow),
);
currentRows = [row];
} else {
const olderRows = currentRows.slice(0, trailingRowIndex);
if (olderRows.length > 0) {
segments.push(
buildTimelineLogicalSegment(olderRows, currentCursorRow),
);
}
currentRows = [...currentRows.slice(trailingRowIndex), row].sort(
(left, right) => left.sourceSeqStart - right.sourceSeqStart,
);
}
currentCursorRow = row;
continue;
}

if (isTimelineSegmentAnchorRow(row) && currentCursorRow === null) {
currentCursorRow = row;
}
currentRows.push(row);
}

if (currentRows.length > 0) {
segments.push(buildTimelineLogicalSegment(currentRows));
segments.push(buildTimelineLogicalSegment(currentRows, currentCursorRow));
}

return segments;
Expand Down Expand Up @@ -188,7 +225,7 @@ export function paginateTimelineRows(
// cursor was read and none has to be trimmed off here.
const selectedSegments = segments.slice(-page.segmentLimit);
const hasOlderRows =
knownHasOlderSegments ?? segments.length > selectedSegments.length;
knownHasOlderSegments === true || segments.length > selectedSegments.length;
const oldestSelectedSegment = selectedSegments[0];

return {
Expand Down
Loading
Loading