| 1 | import type { TranscriptOutlineEntry } from "./transcriptProtocol"; |
| 2 | |
| 3 | /** The identities a loaded user turn exposes to the rail. */ |
| 4 | export interface LoadedTurnNode { |
| 5 | /** The item's own id, which is also its DOM anchor key. */ |
| 6 | readonly id: string; |
| 7 | readonly messageId?: string; |
| 8 | } |
| 9 | |
| 10 | /** Item keys the transcript gives a history record that has no message id. */ |
| 11 | const RECORD_ITEM_PREFIX = "record:"; |
| 12 | |
| 13 | /** |
| 14 | * The transcript record identity behind a loaded user item, or undefined when |
| 15 | * it does not have one yet. |
| 16 | * |
| 17 | * `historyItems` and `transcriptStore` key a history record without a message |
| 18 | * id as `record:<recordId>` while the outline carries the bare `<recordId>` |
| 19 | * (`m:<messageId>` for everything the snapshot protocol emitted). Comparing the |
| 20 | * two item keys directly therefore never matches for older history, which is |
| 21 | * why this conversion lives here rather than being re-derived at each call |
| 22 | * site. |
| 23 | */ |
| 24 | export function recordIdOf(item: LoadedTurnNode): string | undefined { |
| 25 | if (item.id.startsWith(RECORD_ITEM_PREFIX)) return item.id.slice(RECORD_ITEM_PREFIX.length); |
| 26 | // A committed row already carries the outline's own form. |
| 27 | if (item.id.startsWith("m:")) return item.id; |
| 28 | if (item.messageId) return `m:${item.messageId}`; |
| 29 | // `he:<entryId>` belongs to the windowed legacy store, whose ids live in a |
| 30 | // different key space and which never has an outline: a tab on that path |
| 31 | // never installs a snapshot, so the rail stays in its loaded-turn mode. |
| 32 | // Reporting no identity is correct — guessing one could match the wrong turn. |
| 33 | return undefined; |
| 34 | } |
| 35 | |
| 36 | /** Mounted user turns indexed by both stable identities they can be found by. */ |
| 37 | export interface LoadedTurnIndex { |
| 38 | readonly byMessageId: ReadonlyMap<string, string>; |
| 39 | readonly byRecordId: ReadonlyMap<string, string>; |
| 40 | } |
| 41 | |
| 42 | /** |
| 43 | * One pass over the mounted order. The merge must not scan the mounted set per |
| 44 | * outline entry: at ten thousand turns against tens of thousands of nodes that |
| 45 | * becomes the dominant cost of a render. |
| 46 | */ |
| 47 | export function indexLoadedTurns(order: readonly string[], read: (key: string) => LoadedTurnNode | undefined): LoadedTurnIndex { |
| 48 | const byMessageId = new Map<string, string>(); |
| 49 | const byRecordId = new Map<string, string>(); |
| 50 | for (const key of order) { |
| 51 | const node = read(key); |
| 52 | if (node === undefined) continue; |
| 53 | if (node.messageId !== undefined && !byMessageId.has(node.messageId)) byMessageId.set(node.messageId, key); |
| 54 | const recordId = recordIdOf(node); |
| 55 | if (recordId !== undefined && !byRecordId.has(recordId)) byRecordId.set(recordId, key); |
| 56 | } |
| 57 | return { byMessageId, byRecordId }; |
| 58 | } |
| 59 | |
| 60 | /** |
| 61 | * The mounted node that answers for one outline entry, or undefined while that |
| 62 | * turn is still unloaded. |
| 63 | * |
| 64 | * Matches on identity, never on the shape of the anchor key: a question |
| 65 | * submitted in this app session keeps its optimistic `u<seq>` id after the |
| 66 | * authoritative message settles and only gains a `messageId`. A message ID is |
| 67 | * the identity that survives settlement, so it wins; the record ID is the |
| 68 | * stable fallback that also covers a question that has not been committed yet. |
| 69 | * |
| 70 | * Shared by the rail and by the jump transaction so both agree on when a target |
| 71 | * has really mounted. |
| 72 | */ |
| 73 | export function findLoadedTurn(index: LoadedTurnIndex, entry: TranscriptOutlineEntry): string | undefined { |
| 74 | if (entry.messageId !== undefined) { |
| 75 | const byMessage = index.byMessageId.get(entry.messageId); |
| 76 | if (byMessage !== undefined) return byMessage; |
| 77 | } |
| 78 | return index.byRecordId.get(entry.id); |
| 79 | } |
| 80 | |
| 81 | /** Ordered, de-duplicated outline entries. */ |
| 82 | export function alignOutlineEntries(entries: readonly TranscriptOutlineEntry[]): TranscriptOutlineEntry[] { |
| 83 | const seen = new Set<string>(); |
| 84 | const aligned: TranscriptOutlineEntry[] = []; |
| 85 | for (const entry of entries) { |
| 86 | if (seen.has(entry.id)) continue; |
| 87 | seen.add(entry.id); |
| 88 | aligned.push(entry); |
| 89 | } |
| 90 | aligned.sort((left, right) => left.order - right.order); |
| 91 | return aligned; |
| 92 | } |
| 93 |