| 1 | import type { |
| 2 | OrchestrationV2ProjectedTurnItem, |
| 3 | OrchestrationV2ThreadProjection, |
| 4 | } from "@t3tools/contracts"; |
| 5 | import { isOrchestrationV2TurnItemVisible } from "@t3tools/shared/orchestrationV2Timeline"; |
| 6 | |
| 7 | export 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 | |
| 21 | export 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. */ |
| 31 | export 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 | */ |
| 42 | export 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 | |
| 52 | function projectedItemKey(row: OrchestrationV2ProjectedTurnItem): string { |
| 53 | return `${row.sourceThreadId}:${row.sourceItemId}`; |
| 54 | } |
| 55 | |
| 56 | function 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 | */ |
| 67 | export 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 | |
| 133 | export 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. */ |
| 152 | export function shouldShowLoadEarlierControl(history: ThreadHistoryMeta): boolean { |
| 153 | return history.hasMoreHistory || history.error !== null; |
| 154 | } |