1import type {
2 OrchestrationV2ProjectedTurnItem,
3 OrchestrationV2ThreadProjection,
4} from "@t3tools/contracts";
5import { isOrchestrationV2TurnItemVisible } from "@t3tools/shared/orchestrationV2Timeline";
6
7export type ThreadHistoryMeta = {
8 readonly historyCursor: string | null;
9 readonly hasMoreHistory: boolean;
10 readonly loading: boolean;
11 readonly error: string | null;
12 /** True after at least one older page was merged; skip monolithic cache growth. */
13 readonly expanded: boolean;
14 /**
15 * Max local turn ordinal known for partial progressive windows. Null when
16 * unknown (legacy cache) or when history meta is cleared for a full snapshot.
17 */
18 readonly latestLocalTurnOrdinal: number | null;
19};
20
21export const EMPTY_THREAD_HISTORY_META: ThreadHistoryMeta = {
22 historyCursor: null,
23 hasMoreHistory: false,
24 loading: false,
25 error: null,
26 expanded: false,
27 latestLocalTurnOrdinal: null,
28};
29
30/** Whether a history-page response still matches the cursor that started it. */
31export function isActiveHistoryRequestCursor(
32 requestCursor: string,
33 history: { readonly historyCursor: string | null },
34): boolean {
35 return history.historyCursor === requestCursor;
36}
37
38/**
39 * Clear history loading after an interrupted load-earlier only when the
40 * request cursor is still active. Does not touch stream/status/error.
41 */
42export function clearActiveHistoryLoading(
43 requestCursor: string,
44 history: ThreadHistoryMeta,
45): ThreadHistoryMeta {
46 if (!isActiveHistoryRequestCursor(requestCursor, history) || !history.loading) {
47 return history;
48 }
49 return { ...history, loading: false };
50}
51
52function projectedItemKey(row: OrchestrationV2ProjectedTurnItem): string {
53 return `${row.sourceThreadId}:${row.sourceItemId}`;
54}
55
56function renumberVisible(
57 rows: ReadonlyArray<OrchestrationV2ProjectedTurnItem>,
58): OrchestrationV2ProjectedTurnItem[] {
59 return rows.map((row, position) => (row.position === position ? row : { ...row, position }));
60}
61
62/**
63 * Merge an older history page into the live projection. Dedupes by
64 * sourceThreadId + sourceItemId, keeps chronological order (older first), and
65 * preserves newer live rows already present in state.
66 */
67export function mergeOlderHistoryIntoProjection(
68 projection: OrchestrationV2ThreadProjection,
69 olderItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem>,
70): OrchestrationV2ThreadProjection {
71 if (olderItems.length === 0) {
72 return projection;
73 }
74
75 const currentByKey = new Map(
76 projection.visibleTurnItems.map((row) => [projectedItemKey(row), row]),
77 );
78 const turnItemById = new Map(projection.turnItems.map((item) => [String(item.id), item]));
79 const pageKeys = new Set<string>();
80 const page: OrchestrationV2ProjectedTurnItem[] = [];
81 for (const pageRow of olderItems) {
82 const key = projectedItemKey(pageRow);
83 if (pageKeys.has(key)) continue;
84 let row = currentByKey.get(key) ?? pageRow;
85 if (
86 !currentByKey.has(key) &&
87 (row.visibility === "local" || row.sourceThreadId === projection.thread.id)
88 ) {
89 const currentItem = turnItemById.get(String(row.sourceItemId));
90 // Hidden live items stay hidden. Cold snapshots also retain interrupt
91 // requests outside the visible window; paging may reveal those requests.
92 if (currentItem !== undefined) {
93 if (
94 currentItem.type !== "run_interrupt_request" ||
95 !isOrchestrationV2TurnItemVisible({
96 item: currentItem,
97 runs: projection.runs,
98 attempts: projection.attempts,
99 items: projection.turnItems,
100 })
101 )
102 continue;
103 row = { ...row, item: currentItem };
104 }
105 }
106 pageKeys.add(key);
107 page.push(row);
108 if (
109 (row.visibility === "local" || row.sourceThreadId === projection.thread.id) &&
110 !turnItemById.has(String(row.sourceItemId))
111 )
112 turnItemById.set(String(row.sourceItemId), row.item);
113 }
114 if (page.length === 0) return projection;
115 // Server page order fills gaps between rows from a partial conversation load.
116 // Existing rows supply live values; rows outside this range keep their order.
117 const visible = [
118 ...page,
119 ...projection.visibleTurnItems.filter((row) => !pageKeys.has(projectedItemKey(row))),
120 ];
121 if (
122 visible.length === projection.visibleTurnItems.length &&
123 visible.every((row, index) => row === projection.visibleTurnItems[index])
124 )
125 return projection;
126 return {
127 ...projection,
128 turnItems: [...turnItemById.values()],
129 visibleTurnItems: renumberVisible(visible),
130 };
131}
132
133export function applyHistoryPageMeta(
134 current: ThreadHistoryMeta,
135 page: {
136 readonly nextCursor: string | null;
137 readonly hasMoreHistory: boolean;
138 },
139): ThreadHistoryMeta {
140 return {
141 historyCursor: page.nextCursor,
142 hasMoreHistory: page.hasMoreHistory,
143 loading: false,
144 error: null,
145 expanded: true,
146 // Preserve the partial-timeline watermark across load-earlier pages.
147 latestLocalTurnOrdinal: current.latestLocalTurnOrdinal,
148 };
149}
150
151/** Whether a client timeline should render its load-earlier control. */
152export function shouldShowLoadEarlierControl(history: ThreadHistoryMeta): boolean {
153 return history.hasMoreHistory || history.error !== null;
154}