@memberjunction/ng-conversations 6.1.0-edge.2 → 6.1.0-edge.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +180 -4
  2. package/README.md +32 -1
  3. package/dist/lib/components/conversation/conversation-chat-area.component.d.ts +159 -9
  4. package/dist/lib/components/conversation/conversation-chat-area.component.d.ts.map +1 -1
  5. package/dist/lib/components/conversation/conversation-chat-area.component.js +497 -106
  6. package/dist/lib/components/conversation/conversation-chat-area.component.js.map +1 -1
  7. package/dist/lib/components/conversation/pinned-messages-panel.component.d.ts +14 -2
  8. package/dist/lib/components/conversation/pinned-messages-panel.component.d.ts.map +1 -1
  9. package/dist/lib/components/conversation/pinned-messages-panel.component.js +37 -19
  10. package/dist/lib/components/conversation/pinned-messages-panel.component.js.map +1 -1
  11. package/dist/lib/components/message/message-input.component.d.ts +7 -3
  12. package/dist/lib/components/message/message-input.component.d.ts.map +1 -1
  13. package/dist/lib/components/message/message-input.component.js +78 -159
  14. package/dist/lib/components/message/message-input.component.js.map +1 -1
  15. package/dist/lib/components/message/message-list.component.d.ts +287 -7
  16. package/dist/lib/components/message/message-list.component.d.ts.map +1 -1
  17. package/dist/lib/components/message/message-list.component.js +850 -56
  18. package/dist/lib/components/message/message-list.component.js.map +1 -1
  19. package/dist/lib/services/conversation-agent.service.d.ts +98 -16
  20. package/dist/lib/services/conversation-agent.service.d.ts.map +1 -1
  21. package/dist/lib/services/conversation-agent.service.js +218 -69
  22. package/dist/lib/services/conversation-agent.service.js.map +1 -1
  23. package/dist/lib/services/conversation-detail-window.store.d.ts +206 -0
  24. package/dist/lib/services/conversation-detail-window.store.d.ts.map +1 -0
  25. package/dist/lib/services/conversation-detail-window.store.js +503 -0
  26. package/dist/lib/services/conversation-detail-window.store.js.map +1 -0
  27. package/dist/lib/utils/agent-artifact-summary.d.ts +54 -0
  28. package/dist/lib/utils/agent-artifact-summary.d.ts.map +1 -0
  29. package/dist/lib/utils/agent-artifact-summary.js +49 -0
  30. package/dist/lib/utils/agent-artifact-summary.js.map +1 -0
  31. package/dist/lib/utils/conversation-detail-window.d.ts +49 -0
  32. package/dist/lib/utils/conversation-detail-window.d.ts.map +1 -0
  33. package/dist/lib/utils/conversation-detail-window.js +86 -0
  34. package/dist/lib/utils/conversation-detail-window.js.map +1 -0
  35. package/dist/lib/utils/date-jump.d.ts +65 -0
  36. package/dist/lib/utils/date-jump.d.ts.map +1 -0
  37. package/dist/lib/utils/date-jump.js +106 -0
  38. package/dist/lib/utils/date-jump.js.map +1 -0
  39. package/package.json +32 -32
@@ -0,0 +1,503 @@
1
+ import { NormalizeUUID } from '@memberjunction/global';
2
+ import { BuildConversationTimeline } from '../utils/realtime-session-timeline';
3
+ import { SelectLatestTimelinePage, DEFAULT_TRANSCRIPT_PAGE_SIZE, DEFAULT_RAW_OVERREAD, MAX_OVERREAD_ATTEMPTS, OVERREAD_GROWTH_FACTOR, MAX_REFRESH_BACKFILL_PAGES } from '../utils/conversation-detail-window';
4
+ /**
5
+ * Holds ONE conversation's loaded transcript window and the cursors for paging further back.
6
+ *
7
+ * Owned per chat-area instance (not a singleton) so two hosts can never share window state.
8
+ *
9
+ * Contracts:
10
+ * - {@link Reset} bumps a generation counter; every in-flight load checks it after each
11
+ * await and discards its result if the conversation changed underneath.
12
+ * - {@link LoadOlder} is a no-op while a load is in flight or when nothing is above.
13
+ * - Merges dedupe by normalized ID and re-sort by `Sequence` — session expansion can
14
+ * return rows an earlier page already had.
15
+ * - {@link ApplyLocalDetail} never hits the network; it is the send/stream/edit path.
16
+ * - Pins live in their own array. They are NOT unioned into the transcript, because a pin
17
+ * below the window's oldest Sequence would break contiguity and the paging cursor.
18
+ */
19
+ export class ConversationDetailWindowStore {
20
+ loader;
21
+ conversationId = null;
22
+ loadedDetails = [];
23
+ pinnedDetails = [];
24
+ pinnedTotalCount = 0;
25
+ peripherals = emptyPeripherals();
26
+ cursor = emptyCursor();
27
+ generation = 0;
28
+ isLoadingLatest = false;
29
+ isLoadingOlder = false;
30
+ loadFailed = false;
31
+ constructor(loader) {
32
+ this.loader = loader;
33
+ }
34
+ /** Drops all state and invalidates in-flight loads. Call on every conversation switch. */
35
+ Reset(conversationId) {
36
+ this.generation++;
37
+ this.conversationId = conversationId;
38
+ this.loadedDetails = [];
39
+ this.pinnedDetails = [];
40
+ this.pinnedTotalCount = 0;
41
+ this.peripherals = emptyPeripherals();
42
+ this.cursor = emptyCursor();
43
+ this.isLoadingLatest = false;
44
+ this.isLoadingOlder = false;
45
+ this.loadFailed = false;
46
+ }
47
+ /**
48
+ * Fetches a page, WIDENING the raw over-read until it yields a full page of timeline
49
+ * items (or the conversation runs out).
50
+ *
51
+ * Raw rows are not display items: a realtime session folds many stamped rows into ONE
52
+ * timeline card, so the default `3 × pageSize` over-read can come back holding two or
53
+ * three items instead of ten. Taking that at face value gives the reader a stubby page
54
+ * and makes the sentinel fire again immediately — which is worse than one wider read.
55
+ *
56
+ * Bounded by {@link MAX_OVERREAD_ATTEMPTS}: a conversation that is almost entirely one
57
+ * long session would otherwise grow the read until it pulled the whole table.
58
+ */
59
+ async fetchPageFillingTimeline(params, contextUser) {
60
+ let overread = DEFAULT_RAW_OVERREAD;
61
+ let result = await this.loader.LoadDetailWindow({ ...params, RawOverread: overread }, contextUser);
62
+ for (let attempt = 1; attempt < MAX_OVERREAD_ATTEMPTS; attempt++) {
63
+ const itemCount = BuildConversationTimeline(result.Details).length;
64
+ if (itemCount >= DEFAULT_TRANSCRIPT_PAGE_SIZE) {
65
+ break; // already a full page of display items
66
+ }
67
+ if (result.Failed) {
68
+ break; // widening a failed read just fails again
69
+ }
70
+ if (!result.HasMoreAbove) {
71
+ break; // start of the conversation — short IS complete
72
+ }
73
+ // The decisive test: did the read actually HIT its row limit? A fetch that came
74
+ // back under its own `MaxRows` has already returned everything available in that
75
+ // range, so a wider read would return the identical rows. Only a fetch that filled
76
+ // its limit can have been truncated by collapse, and only that is worth re-reading.
77
+ if (result.Details.length < overread) {
78
+ break;
79
+ }
80
+ overread *= OVERREAD_GROWTH_FACTOR;
81
+ const wider = await this.loader.LoadDetailWindow({ ...params, RawOverread: overread }, contextUser);
82
+ // A failed or non-productive retry must not discard the page we already have.
83
+ if (!wider || wider.Details.length <= result.Details.length) {
84
+ break;
85
+ }
86
+ result = wider;
87
+ }
88
+ return result;
89
+ }
90
+ /**
91
+ * First paint: the newest page of a conversation.
92
+ *
93
+ * The loader is not expected to throw — `LoadDetailWindow` logs and returns an empty
94
+ * window on a failed read — but a transport-layer rejection would otherwise strand
95
+ * `isLoadingLatest` at true and wedge the store, so the flag is cleared in `finally`.
96
+ */
97
+ async LoadLatest(conversationId, contextUser) {
98
+ this.Reset(conversationId);
99
+ const generation = this.generation;
100
+ this.isLoadingLatest = true;
101
+ try {
102
+ const result = await this.fetchPageFillingTimeline({ ConversationID: conversationId, PageSize: DEFAULT_TRANSCRIPT_PAGE_SIZE }, contextUser);
103
+ if (generation !== this.generation) {
104
+ return; // user switched conversations mid-flight
105
+ }
106
+ // The engine OVER-READS raw rows (a page of N rows can collapse to one session
107
+ // card), so the fetch returns more than a page's worth. Slice to the newest
108
+ // `pageSize` TIMELINE ITEMS here — this is what actually bounds the transcript.
109
+ const page = SelectLatestTimelinePage(result.Details, DEFAULT_TRANSCRIPT_PAGE_SIZE);
110
+ const droppedOlderRows = page.Page.length < result.Details.length;
111
+ this.mergeDetails(page.Page);
112
+ this.peripherals = peripheralsFrom(result);
113
+ // A failed first read leaves the window empty. `LoadFailed` is what stops that
114
+ // rendering as "this conversation has no messages" — there is no cursor to
115
+ // protect here (an empty window cannot page), so the flag IS the whole remedy.
116
+ this.loadFailed = result.Failed;
117
+ this.cursor = {
118
+ OldestSequence: page.OldestIncluded?.Sequence ?? result.OldestSequence,
119
+ NewestSequence: result.NewestSequence,
120
+ // Rows the slice discarded are older content that IS available — the engine's
121
+ // probe only knows about rows below what it fetched, not below what we kept.
122
+ HasMoreAbove: result.HasMoreAbove || droppedOlderRows
123
+ };
124
+ }
125
+ finally {
126
+ // Guarded: a stale load must not clear a newer load's flag.
127
+ if (generation === this.generation) {
128
+ this.isLoadingLatest = false;
129
+ }
130
+ }
131
+ }
132
+ /**
133
+ * Prepends the next older page. No-op when nothing is above or a load is running.
134
+ *
135
+ * `isLoadingOlder` is cleared in `finally` for the same reason as {@link LoadLatest} —
136
+ * and it matters more here, because a stuck flag makes {@link CanLoadOlder} false
137
+ * forever, which silently kills the transcript's "earlier messages" sentinel.
138
+ */
139
+ async LoadOlder(contextUser) {
140
+ if (!this.CanLoadOlder()) {
141
+ return;
142
+ }
143
+ const generation = this.generation;
144
+ const conversationId = this.conversationId;
145
+ const before = this.cursor.OldestSequence;
146
+ this.isLoadingOlder = true;
147
+ try {
148
+ const result = await this.fetchPageFillingTimeline({ ConversationID: conversationId, BeforeSequence: before, PageSize: DEFAULT_TRANSCRIPT_PAGE_SIZE }, contextUser);
149
+ if (generation !== this.generation) {
150
+ return;
151
+ }
152
+ this.loadFailed = result.Failed;
153
+ if (result.Failed) {
154
+ // Leave the cursor EXACTLY as it was. Folding a failed read's `HasMoreAbove:
155
+ // false` into it would retire the "earlier messages" sentinel permanently —
156
+ // the template unmounts it, the observer disconnects, and no later call ever
157
+ // sets the flag back, so one transport blip costs the reader the rest of the
158
+ // conversation's history with nothing said. The next sentinel fire retries.
159
+ return;
160
+ }
161
+ // Same over-read slice as LoadLatest: keep the newest page of timeline items
162
+ // from what came back, not every raw row.
163
+ const page = SelectLatestTimelinePage(result.Details, DEFAULT_TRANSCRIPT_PAGE_SIZE);
164
+ const droppedOlderRows = page.Page.length < result.Details.length;
165
+ this.mergeDetails(page.Page);
166
+ // Older pages bring their OWN artifacts/ratings/runs — merge, never replace,
167
+ // or paging up would strip the peripherals off the rows already on screen.
168
+ this.mergePeripherals(result);
169
+ // Only the upward bound moves — the tail is whatever we already had.
170
+ this.cursor = {
171
+ OldestSequence: this.loadedDetails[0]?.Sequence ?? this.cursor.OldestSequence,
172
+ NewestSequence: this.cursor.NewestSequence,
173
+ HasMoreAbove: result.HasMoreAbove || droppedOlderRows
174
+ };
175
+ }
176
+ finally {
177
+ if (generation === this.generation) {
178
+ this.isLoadingOlder = false;
179
+ }
180
+ }
181
+ }
182
+ /**
183
+ * Re-fetches the tail and folds it into the existing window.
184
+ *
185
+ * The refresh-in-place counterpart to {@link LoadLatest}: used when something changed at
186
+ * the tail (an agent finished, artifacts were written) and the transcript needs to pick
187
+ * it up. Unlike `LoadLatest` it does NOT `Reset`, so a user who has paged up five times
188
+ * keeps those pages instead of being yanked back to a 10-row tail.
189
+ *
190
+ * Reads via {@link fetchRefreshedTail}, which keeps paging down until it reaches rows the
191
+ * window already holds — otherwise a conversation that grew by more than a page leaves a
192
+ * hole in the middle of the merged set that nothing in the UI can reveal.
193
+ */
194
+ async RefreshLatest(contextUser) {
195
+ const conversationId = this.conversationId;
196
+ if (!conversationId) {
197
+ return;
198
+ }
199
+ const generation = this.generation;
200
+ const previousOldest = this.cursor.OldestSequence;
201
+ const previousNewest = this.cursor.NewestSequence;
202
+ this.isLoadingLatest = true;
203
+ try {
204
+ const tail = await this.fetchRefreshedTail(conversationId, previousNewest, generation, contextUser);
205
+ if (generation !== this.generation) {
206
+ return;
207
+ }
208
+ this.loadFailed = tail.Failed;
209
+ if (tail.Failed) {
210
+ // Apply NOTHING. A bridge only reaches a second page because the first did not
211
+ // join, so a failure part-way through means the fresh rows cannot be merged
212
+ // without opening the very gap this method exists to close — and the fallback
213
+ // for an unbridgeable tail discards loaded history, which is far too
214
+ // destructive a response to a transient read error. Leave the window as it
215
+ // was; the next refresh retries.
216
+ return;
217
+ }
218
+ this.applyRefreshedTail(tail, previousOldest, previousNewest);
219
+ }
220
+ finally {
221
+ if (generation === this.generation) {
222
+ this.isLoadingLatest = false;
223
+ }
224
+ }
225
+ }
226
+ /**
227
+ * Reads the newest page, then keeps paging down until the read reaches back to
228
+ * `previousNewest` — the newest row the window already holds.
229
+ *
230
+ * One page is enough in the normal case: a refresh usually returns rows we already have
231
+ * plus the handful that arrived since. The loop is for the case that is not normal, where
232
+ * the tail has moved further than a page and a single read would land entirely above the
233
+ * loaded set.
234
+ *
235
+ * @returns Every page read, plus whether the reads joined up with the loaded window.
236
+ */
237
+ async fetchRefreshedTail(conversationId, previousNewest, generation, contextUser) {
238
+ const results = [];
239
+ let before = undefined;
240
+ for (let page = 0; page <= MAX_REFRESH_BACKFILL_PAGES; page++) {
241
+ const result = await this.loader.LoadDetailWindow({ ConversationID: conversationId, BeforeSequence: before, PageSize: DEFAULT_TRANSCRIPT_PAGE_SIZE }, contextUser);
242
+ if (generation !== this.generation) {
243
+ return { Results: results, ReachedPreviousTail: false, Failed: false }; // discarded
244
+ }
245
+ // Checked BEFORE tailIsJoined, which reads `HasMoreAbove` — false on a failed read,
246
+ // which would report the bridge as joined and hand back a set with a hole in it.
247
+ if (result.Failed) {
248
+ return { Results: results, ReachedPreviousTail: false, Failed: true };
249
+ }
250
+ results.push(result);
251
+ if (this.tailIsJoined(result, previousNewest)) {
252
+ return { Results: results, ReachedPreviousTail: true, Failed: false };
253
+ }
254
+ before = result.OldestSequence ?? undefined;
255
+ }
256
+ return { Results: results, ReachedPreviousTail: false, Failed: false };
257
+ }
258
+ /** True when this page reaches back into the loaded window, so nothing sits between them. */
259
+ tailIsJoined(result, previousNewest) {
260
+ // Nothing loaded to join up WITH, or the read came back empty — either way, done.
261
+ if (previousNewest === null || result.OldestSequence === null) {
262
+ return true;
263
+ }
264
+ // The page overlaps or abuts what we hold.
265
+ if (result.OldestSequence <= previousNewest) {
266
+ return true;
267
+ }
268
+ // The engine's probe says no row exists below this page at all. The rows we hold are
269
+ // below it, so they have since been deleted — there is no gap left to bridge.
270
+ return !result.HasMoreAbove;
271
+ }
272
+ /**
273
+ * Folds a refreshed tail into the window, choosing what to keep by whether the read
274
+ * joined up with the rows already loaded.
275
+ */
276
+ applyRefreshedTail(tail, previousOldest, previousNewest) {
277
+ const fetched = tail.Results.flatMap(result => result.Details);
278
+ const hasMoreAbove = this.mergeRefreshedRows(tail, fetched, previousOldest, previousNewest);
279
+ for (const result of tail.Results) {
280
+ this.mergePeripherals(result);
281
+ }
282
+ this.cursor = {
283
+ OldestSequence: this.loadedDetails[0]?.Sequence ?? null,
284
+ NewestSequence: this.loadedDetails[this.loadedDetails.length - 1]?.Sequence ?? null,
285
+ HasMoreAbove: hasMoreAbove
286
+ };
287
+ }
288
+ /** Merges the refreshed rows per the three refresh cases. @returns the new `HasMoreAbove`. */
289
+ mergeRefreshedRows(tail, fetched, previousOldest, previousNewest) {
290
+ if (previousNewest === null) {
291
+ // Nothing loaded — this is a first paint wearing a refresh's name, so bound it to
292
+ // a page the way LoadLatest does.
293
+ const page = SelectLatestTimelinePage(fetched, DEFAULT_TRANSCRIPT_PAGE_SIZE);
294
+ this.mergeDetails(page.Page);
295
+ const last = tail.Results[tail.Results.length - 1];
296
+ return (last?.HasMoreAbove ?? false) || page.Page.length < fetched.length;
297
+ }
298
+ if (tail.ReachedPreviousTail) {
299
+ // Keep EVERY fetched row down to the window's existing floor. Slicing to a page
300
+ // here is what opened the hole: the read deliberately spans the distance back to
301
+ // the loaded tail, and discarding its middle throws away the very rows that make
302
+ // the merged set contiguous. Rows below the floor are dropped so a refresh cannot
303
+ // quietly grow the window downward — that is LoadOlder's job.
304
+ this.mergeDetails(previousOldest === null
305
+ ? fetched
306
+ : fetched.filter(detail => detail.Sequence >= previousOldest));
307
+ // The floor did not move, so what is below it is still whatever we last learned.
308
+ return this.cursor.HasMoreAbove;
309
+ }
310
+ // Could not bridge within the page budget. Drop the now-disconnected older rows and
311
+ // keep the fresh tail: a visible gap the sentinel can close beats an invisible one.
312
+ this.loadedDetails = [];
313
+ this.mergeDetails(fetched);
314
+ return true;
315
+ }
316
+ /**
317
+ * Cheap single-value reads for TEMPLATE binding.
318
+ *
319
+ * These exist because the chat area exposes `HasMoreAbove`, `IsLoadingOlder`,
320
+ * `PinnedTotalCount`, and `PinnedDetails` as getters bound in its template, so every
321
+ * change-detection cycle reads all four. Routing those through {@link GetSnapshot} copied
322
+ * the whole loaded window four times per cycle — during streaming, per token — on a
323
+ * component whose entire purpose is to stop doing work proportional to conversation length.
324
+ *
325
+ * {@link PinnedDetails} deliberately returns the internal array BY REFERENCE. Every mutator
326
+ * replaces that array rather than mutating it, so the reference is stable between real
327
+ * changes — which is precisely what keeps the pins panel's `ngOnChanges` from firing on
328
+ * every cycle. Callers must not mutate it.
329
+ */
330
+ get HasMoreAbove() {
331
+ return this.cursor.HasMoreAbove;
332
+ }
333
+ get IsLoadingOlder() {
334
+ return this.isLoadingOlder;
335
+ }
336
+ get IsLoadingLatest() {
337
+ return this.isLoadingLatest;
338
+ }
339
+ get PinnedTotalCount() {
340
+ return this.pinnedTotalCount;
341
+ }
342
+ get PinnedDetails() {
343
+ return this.pinnedDetails;
344
+ }
345
+ /** True when the most recent load failed — an empty transcript vs. a broken one. */
346
+ get LoadFailed() {
347
+ return this.loadFailed;
348
+ }
349
+ /** True when a `LoadOlder` would actually do work. Drives the sentinel's observer. */
350
+ CanLoadOlder() {
351
+ return this.conversationId !== null
352
+ && this.cursor.HasMoreAbove
353
+ && this.cursor.OldestSequence !== null
354
+ && !this.isLoadingOlder
355
+ && !this.isLoadingLatest;
356
+ }
357
+ /**
358
+ * Applies a locally-known row — sent, streamed, edited. Inserts or replaces by ID and
359
+ * re-sorts. Never queries; the caller already has the entity.
360
+ */
361
+ ApplyLocalDetail(detail) {
362
+ this.mergeDetails([detail]);
363
+ const newest = this.loadedDetails[this.loadedDetails.length - 1]?.Sequence ?? null;
364
+ this.cursor = { ...this.cursor, NewestSequence: newest };
365
+ }
366
+ /** Removes a row by ID — the delete path. */
367
+ RemoveDetail(detailId) {
368
+ const key = NormalizeUUID(detailId);
369
+ this.loadedDetails = this.loadedDetails.filter(d => NormalizeUUID(d.ID) !== key);
370
+ const pinnedBefore = this.pinnedDetails.length;
371
+ this.pinnedDetails = this.pinnedDetails.filter(d => NormalizeUUID(d.ID) !== key);
372
+ if (this.pinnedDetails.length < pinnedBefore) {
373
+ this.pinnedTotalCount = Math.max(0, this.pinnedTotalCount - 1);
374
+ }
375
+ }
376
+ /**
377
+ * Records how many pins exist WITHOUT loading them.
378
+ *
379
+ * The conversation-open path calls only this: the chip needs a number, the panel needs
380
+ * nothing until it is opened, and hydrating every pin on open re-imports the cost this
381
+ * windowing work exists to remove.
382
+ */
383
+ SetPinnedCount(total) {
384
+ this.pinnedTotalCount = Math.max(0, total);
385
+ }
386
+ /** Replaces the pins panel's contents. Pins outside the window are expected here. */
387
+ SetPinnedDetails(pins) {
388
+ this.pinnedDetails = [...pins];
389
+ // Hydration is unbounded, so once it lands the loaded set IS the whole set — any
390
+ // drift the local pin/unpin bookkeeping accumulated is reconciled here on purpose.
391
+ this.pinnedTotalCount = pins.length;
392
+ }
393
+ /**
394
+ * Reflects a pin/unpin the user just performed, without re-querying.
395
+ *
396
+ * Newly pinned rows go to the FRONT to match the panel's newest-pin-first order, which
397
+ * the initial `Sequence DESC` fetch also produces.
398
+ */
399
+ ApplyLocalPin(detail) {
400
+ const key = NormalizeUUID(detail.ID);
401
+ const wasPinned = this.pinnedDetails.some(d => NormalizeUUID(d.ID) === key);
402
+ const without = this.pinnedDetails.filter(d => NormalizeUUID(d.ID) !== key);
403
+ this.pinnedDetails = detail.IsPinned ? [detail, ...without] : without;
404
+ // Counted independently of the set: before the panel is opened `pinnedDetails` is
405
+ // deliberately EMPTY, so deriving a count from its length would report zero pins on
406
+ // a conversation that has many. `wasPinned` keeps the two in step — without it an
407
+ // unpin of a row that was never in the set decrements a count it never entered.
408
+ if (detail.IsPinned && !wasPinned) {
409
+ this.pinnedTotalCount++;
410
+ }
411
+ else if (!detail.IsPinned && wasPinned) {
412
+ this.pinnedTotalCount = Math.max(0, this.pinnedTotalCount - 1);
413
+ }
414
+ }
415
+ /**
416
+ * Everything the chat area needs to render, copied. Arrays are fresh so ngOnChanges fires.
417
+ *
418
+ * Call this from imperative paths (a load completing, a jump loop) — NOT from a template
419
+ * getter. It copies the loaded set on every call, so binding it into change detection
420
+ * pays that copy once per checked cycle for state that mostly has not moved. The cheap
421
+ * single-value accessors above exist for exactly that case.
422
+ */
423
+ GetSnapshot() {
424
+ const details = [...this.loadedDetails];
425
+ return {
426
+ ConversationID: this.conversationId,
427
+ Details: details,
428
+ Cursor: { ...this.cursor },
429
+ PinnedDetails: [...this.pinnedDetails],
430
+ PinnedTotalCount: this.pinnedTotalCount,
431
+ // Peripheral maps are handed out by reference — the chat area copies them into
432
+ // its own UI-shaped maps (LazyArtifactInfo etc.) rather than mutating these.
433
+ AgentRunsByDetailId: this.peripherals.AgentRunsByDetailId,
434
+ UserAvatars: this.peripherals.UserAvatars,
435
+ RatingsByDetailId: this.peripherals.RatingsByDetailId,
436
+ ArtifactsByDetailId: this.peripherals.ArtifactsByDetailId,
437
+ IsLoadingLatest: this.isLoadingLatest,
438
+ IsLoadingOlder: this.isLoadingOlder,
439
+ LoadFailed: this.loadFailed
440
+ };
441
+ }
442
+ /**
443
+ * Folds a newly-loaded page's peripherals into the accumulated maps.
444
+ *
445
+ * Later entries win on a key collision, which matters for a window refresh: a re-fetched
446
+ * newest page should replace a row's stale agent run rather than keep the old one.
447
+ */
448
+ mergePeripherals(incoming) {
449
+ for (const [id, run] of incoming.AgentRunsByDetailId) {
450
+ this.peripherals.AgentRunsByDetailId.set(id, run);
451
+ }
452
+ for (const [id, avatar] of incoming.UserAvatars) {
453
+ this.peripherals.UserAvatars.set(id, avatar);
454
+ }
455
+ for (const [id, ratings] of incoming.RatingsByDetailId) {
456
+ this.peripherals.RatingsByDetailId.set(id, ratings);
457
+ }
458
+ for (const [id, artifacts] of incoming.ArtifactsByDetailId) {
459
+ this.peripherals.ArtifactsByDetailId.set(id, artifacts);
460
+ }
461
+ }
462
+ /**
463
+ * Union-merges rows into the loaded set: replace-by-ID, then sort by `Sequence`.
464
+ * Dedupe is required because session expansion can re-return rows a prior page held.
465
+ */
466
+ mergeDetails(incoming) {
467
+ const byId = new Map();
468
+ for (const detail of this.loadedDetails) {
469
+ byId.set(NormalizeUUID(detail.ID), detail);
470
+ }
471
+ for (const detail of incoming) {
472
+ byId.set(NormalizeUUID(detail.ID), detail);
473
+ }
474
+ this.loadedDetails = [...byId.values()].sort((a, b) => a.Sequence - b.Sequence);
475
+ }
476
+ }
477
+ /** Empty peripheral maps — the reset / no-window state. */
478
+ function emptyPeripherals() {
479
+ return {
480
+ AgentRunsByDetailId: new Map(),
481
+ UserAvatars: new Map(),
482
+ RatingsByDetailId: new Map(),
483
+ ArtifactsByDetailId: new Map()
484
+ };
485
+ }
486
+ /** Copies a load result's peripheral maps into fresh maps the store then owns and merges into. */
487
+ function peripheralsFrom(result) {
488
+ return {
489
+ AgentRunsByDetailId: new Map(result.AgentRunsByDetailId),
490
+ UserAvatars: new Map(result.UserAvatars),
491
+ RatingsByDetailId: new Map(result.RatingsByDetailId),
492
+ ArtifactsByDetailId: new Map(result.ArtifactsByDetailId)
493
+ };
494
+ }
495
+ /** The empty-window cursor. */
496
+ function emptyCursor() {
497
+ return { OldestSequence: null, NewestSequence: null, HasMoreAbove: false };
498
+ }
499
+ // NOTE: there is deliberately no `cursorFrom(result)` helper. A cursor can never be copied
500
+ // straight off a load result, because the store slices the over-read page down to `pageSize`
501
+ // timeline items — so both the oldest bound and `HasMoreAbove` depend on what survived the
502
+ // slice, not on what the engine fetched.
503
+ //# sourceMappingURL=conversation-detail-window.store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conversation-detail-window.store.js","sourceRoot":"","sources":["../../../src/lib/services/conversation-detail-window.store.ts"],"names":[],"mappings":"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","sourcesContent":["import { NormalizeUUID } from '@memberjunction/global';\nimport type { UserInfo } from '@memberjunction/core';\nimport type {\n MJConversationDetailEntity,\n LoadDetailWindowParams,\n DetailWindowLoadResult\n} from '@memberjunction/core-entities';\nimport { BuildConversationTimeline } from '../utils/realtime-session-timeline';\nimport {\n ConversationDetailWindowCursor,\n SelectLatestTimelinePage,\n DEFAULT_TRANSCRIPT_PAGE_SIZE,\n DEFAULT_RAW_OVERREAD,\n MAX_OVERREAD_ATTEMPTS,\n OVERREAD_GROWTH_FACTOR,\n MAX_REFRESH_BACKFILL_PAGES\n} from '../utils/conversation-detail-window';\n\n/**\n * The engine surface this store needs. Declared structurally rather than importing\n * ConversationEngine so tests can pass a plain `{ LoadDetailWindow: vi.fn() }` with no\n * TestBed and no engine bootstrap.\n */\nexport interface DetailWindowLoader {\n LoadDetailWindow(params: LoadDetailWindowParams, contextUser: UserInfo): Promise<DetailWindowLoadResult>;\n}\n\n/**\n * Peripheral data for the loaded window, keyed the way the chat area already consumes it.\n *\n * Accumulates across pages: an older page brings its own artifacts and ratings, which merge\n * into these maps rather than replacing them.\n */\nexport type ConversationDetailWindowPeripherals = Pick<\n DetailWindowLoadResult,\n 'AgentRunsByDetailId' | 'UserAvatars' | 'RatingsByDetailId' | 'ArtifactsByDetailId'\n>;\n\n/**\n * What a tail refresh read, and whether it managed to reach back to the rows already loaded.\n *\n * `ReachedPreviousTail: false` is the \"the conversation ran away from us\" case — see\n * {@link MAX_REFRESH_BACKFILL_PAGES}. It is the only signal that the loaded set and the fresh\n * set do not join up, so it must not be dropped between the read and the merge.\n */\ninterface RefreshedTail {\n Results: DetailWindowLoadResult[];\n ReachedPreviousTail: boolean;\n /**\n * True when any read in the bridge failed.\n *\n * Kept separate from `ReachedPreviousTail: false`, which means \"the tail is genuinely\n * further away than the budget allows\" and legitimately discards the disconnected older\n * rows. A FAILED read must never trigger that — throwing away a reader's loaded history\n * because one query blipped is not a recovery, it is the bug wearing a different hat.\n */\n Failed: boolean;\n}\n\n/** What the chat area binds to after any store operation. */\nexport interface ConversationDetailWindowSnapshot extends ConversationDetailWindowPeripherals {\n ConversationID: string | null;\n /** Loaded rows, chronological by Sequence. A NEW array each call, so ngOnChanges fires. */\n Details: MJConversationDetailEntity[];\n Cursor: ConversationDetailWindowCursor;\n /** Pinned rows for the pins panel, INCLUDING pins older than the window. */\n PinnedDetails: MJConversationDetailEntity[];\n /**\n * TRUE total pins in the conversation, which is NOT `PinnedDetails.length` until the\n * panel has been opened — the open path reads only the count so the chip has a number\n * without hydrating anything.\n */\n PinnedTotalCount: number;\n IsLoadingLatest: boolean;\n IsLoadingOlder: boolean;\n /**\n * True when the most recent load FAILED, as opposed to returning nothing.\n *\n * Exists so an empty transcript can be told apart from a broken one. Without it a failed\n * first read renders as a conversation with no messages, which reads to the user as \"my\n * conversation is gone\" — the one failure mode worse than an error message.\n */\n LoadFailed: boolean;\n}\n\n/**\n * Holds ONE conversation's loaded transcript window and the cursors for paging further back.\n *\n * Owned per chat-area instance (not a singleton) so two hosts can never share window state.\n *\n * Contracts:\n * - {@link Reset} bumps a generation counter; every in-flight load checks it after each\n * await and discards its result if the conversation changed underneath.\n * - {@link LoadOlder} is a no-op while a load is in flight or when nothing is above.\n * - Merges dedupe by normalized ID and re-sort by `Sequence` — session expansion can\n * return rows an earlier page already had.\n * - {@link ApplyLocalDetail} never hits the network; it is the send/stream/edit path.\n * - Pins live in their own array. They are NOT unioned into the transcript, because a pin\n * below the window's oldest Sequence would break contiguity and the paging cursor.\n */\nexport class ConversationDetailWindowStore {\n private loader: DetailWindowLoader;\n private conversationId: string | null = null;\n private loadedDetails: MJConversationDetailEntity[] = [];\n private pinnedDetails: MJConversationDetailEntity[] = [];\n private pinnedTotalCount = 0;\n private peripherals: ConversationDetailWindowPeripherals = emptyPeripherals();\n private cursor: ConversationDetailWindowCursor = emptyCursor();\n private generation = 0;\n private isLoadingLatest = false;\n private isLoadingOlder = false;\n private loadFailed = false;\n\n constructor(loader: DetailWindowLoader) {\n this.loader = loader;\n }\n\n /** Drops all state and invalidates in-flight loads. Call on every conversation switch. */\n public Reset(conversationId: string | null): void {\n this.generation++;\n this.conversationId = conversationId;\n this.loadedDetails = [];\n this.pinnedDetails = [];\n this.pinnedTotalCount = 0;\n this.peripherals = emptyPeripherals();\n this.cursor = emptyCursor();\n this.isLoadingLatest = false;\n this.isLoadingOlder = false;\n this.loadFailed = false;\n }\n\n /**\n * Fetches a page, WIDENING the raw over-read until it yields a full page of timeline\n * items (or the conversation runs out).\n *\n * Raw rows are not display items: a realtime session folds many stamped rows into ONE\n * timeline card, so the default `3 × pageSize` over-read can come back holding two or\n * three items instead of ten. Taking that at face value gives the reader a stubby page\n * and makes the sentinel fire again immediately — which is worse than one wider read.\n *\n * Bounded by {@link MAX_OVERREAD_ATTEMPTS}: a conversation that is almost entirely one\n * long session would otherwise grow the read until it pulled the whole table.\n */\n private async fetchPageFillingTimeline(\n params: LoadDetailWindowParams,\n contextUser: UserInfo\n ): Promise<DetailWindowLoadResult> {\n let overread = DEFAULT_RAW_OVERREAD;\n let result = await this.loader.LoadDetailWindow({ ...params, RawOverread: overread }, contextUser);\n\n for (let attempt = 1; attempt < MAX_OVERREAD_ATTEMPTS; attempt++) {\n const itemCount = BuildConversationTimeline(result.Details).length;\n if (itemCount >= DEFAULT_TRANSCRIPT_PAGE_SIZE) {\n break; // already a full page of display items\n }\n if (result.Failed) {\n break; // widening a failed read just fails again\n }\n if (!result.HasMoreAbove) {\n break; // start of the conversation — short IS complete\n }\n // The decisive test: did the read actually HIT its row limit? A fetch that came\n // back under its own `MaxRows` has already returned everything available in that\n // range, so a wider read would return the identical rows. Only a fetch that filled\n // its limit can have been truncated by collapse, and only that is worth re-reading.\n if (result.Details.length < overread) {\n break;\n }\n\n overread *= OVERREAD_GROWTH_FACTOR;\n const wider = await this.loader.LoadDetailWindow({ ...params, RawOverread: overread }, contextUser);\n // A failed or non-productive retry must not discard the page we already have.\n if (!wider || wider.Details.length <= result.Details.length) {\n break;\n }\n result = wider;\n }\n return result;\n }\n\n /**\n * First paint: the newest page of a conversation.\n *\n * The loader is not expected to throw — `LoadDetailWindow` logs and returns an empty\n * window on a failed read — but a transport-layer rejection would otherwise strand\n * `isLoadingLatest` at true and wedge the store, so the flag is cleared in `finally`.\n */\n public async LoadLatest(conversationId: string, contextUser: UserInfo): Promise<void> {\n this.Reset(conversationId);\n const generation = this.generation;\n this.isLoadingLatest = true;\n\n try {\n const result = await this.fetchPageFillingTimeline(\n { ConversationID: conversationId, PageSize: DEFAULT_TRANSCRIPT_PAGE_SIZE },\n contextUser\n );\n\n if (generation !== this.generation) {\n return; // user switched conversations mid-flight\n }\n // The engine OVER-READS raw rows (a page of N rows can collapse to one session\n // card), so the fetch returns more than a page's worth. Slice to the newest\n // `pageSize` TIMELINE ITEMS here — this is what actually bounds the transcript.\n const page = SelectLatestTimelinePage(result.Details, DEFAULT_TRANSCRIPT_PAGE_SIZE);\n const droppedOlderRows = page.Page.length < result.Details.length;\n\n this.mergeDetails(page.Page);\n this.peripherals = peripheralsFrom(result);\n // A failed first read leaves the window empty. `LoadFailed` is what stops that\n // rendering as \"this conversation has no messages\" — there is no cursor to\n // protect here (an empty window cannot page), so the flag IS the whole remedy.\n this.loadFailed = result.Failed;\n this.cursor = {\n OldestSequence: page.OldestIncluded?.Sequence ?? result.OldestSequence,\n NewestSequence: result.NewestSequence,\n // Rows the slice discarded are older content that IS available — the engine's\n // probe only knows about rows below what it fetched, not below what we kept.\n HasMoreAbove: result.HasMoreAbove || droppedOlderRows\n };\n } finally {\n // Guarded: a stale load must not clear a newer load's flag.\n if (generation === this.generation) {\n this.isLoadingLatest = false;\n }\n }\n }\n\n /**\n * Prepends the next older page. No-op when nothing is above or a load is running.\n *\n * `isLoadingOlder` is cleared in `finally` for the same reason as {@link LoadLatest} —\n * and it matters more here, because a stuck flag makes {@link CanLoadOlder} false\n * forever, which silently kills the transcript's \"earlier messages\" sentinel.\n */\n public async LoadOlder(contextUser: UserInfo): Promise<void> {\n if (!this.CanLoadOlder()) {\n return;\n }\n const generation = this.generation;\n const conversationId = this.conversationId as string;\n const before = this.cursor.OldestSequence as number;\n this.isLoadingOlder = true;\n\n try {\n const result = await this.fetchPageFillingTimeline(\n { ConversationID: conversationId, BeforeSequence: before, PageSize: DEFAULT_TRANSCRIPT_PAGE_SIZE },\n contextUser\n );\n\n if (generation !== this.generation) {\n return;\n }\n this.loadFailed = result.Failed;\n if (result.Failed) {\n // Leave the cursor EXACTLY as it was. Folding a failed read's `HasMoreAbove:\n // false` into it would retire the \"earlier messages\" sentinel permanently —\n // the template unmounts it, the observer disconnects, and no later call ever\n // sets the flag back, so one transport blip costs the reader the rest of the\n // conversation's history with nothing said. The next sentinel fire retries.\n return;\n }\n\n // Same over-read slice as LoadLatest: keep the newest page of timeline items\n // from what came back, not every raw row.\n const page = SelectLatestTimelinePage(result.Details, DEFAULT_TRANSCRIPT_PAGE_SIZE);\n const droppedOlderRows = page.Page.length < result.Details.length;\n\n this.mergeDetails(page.Page);\n // Older pages bring their OWN artifacts/ratings/runs — merge, never replace,\n // or paging up would strip the peripherals off the rows already on screen.\n this.mergePeripherals(result);\n // Only the upward bound moves — the tail is whatever we already had.\n this.cursor = {\n OldestSequence: this.loadedDetails[0]?.Sequence ?? this.cursor.OldestSequence,\n NewestSequence: this.cursor.NewestSequence,\n HasMoreAbove: result.HasMoreAbove || droppedOlderRows\n };\n } finally {\n if (generation === this.generation) {\n this.isLoadingOlder = false;\n }\n }\n }\n\n /**\n * Re-fetches the tail and folds it into the existing window.\n *\n * The refresh-in-place counterpart to {@link LoadLatest}: used when something changed at\n * the tail (an agent finished, artifacts were written) and the transcript needs to pick\n * it up. Unlike `LoadLatest` it does NOT `Reset`, so a user who has paged up five times\n * keeps those pages instead of being yanked back to a 10-row tail.\n *\n * Reads via {@link fetchRefreshedTail}, which keeps paging down until it reaches rows the\n * window already holds — otherwise a conversation that grew by more than a page leaves a\n * hole in the middle of the merged set that nothing in the UI can reveal.\n */\n public async RefreshLatest(contextUser: UserInfo): Promise<void> {\n const conversationId = this.conversationId;\n if (!conversationId) {\n return;\n }\n const generation = this.generation;\n const previousOldest = this.cursor.OldestSequence;\n const previousNewest = this.cursor.NewestSequence;\n this.isLoadingLatest = true;\n\n try {\n const tail = await this.fetchRefreshedTail(\n conversationId, previousNewest, generation, contextUser\n );\n if (generation !== this.generation) {\n return;\n }\n this.loadFailed = tail.Failed;\n if (tail.Failed) {\n // Apply NOTHING. A bridge only reaches a second page because the first did not\n // join, so a failure part-way through means the fresh rows cannot be merged\n // without opening the very gap this method exists to close — and the fallback\n // for an unbridgeable tail discards loaded history, which is far too\n // destructive a response to a transient read error. Leave the window as it\n // was; the next refresh retries.\n return;\n }\n this.applyRefreshedTail(tail, previousOldest, previousNewest);\n } finally {\n if (generation === this.generation) {\n this.isLoadingLatest = false;\n }\n }\n }\n\n /**\n * Reads the newest page, then keeps paging down until the read reaches back to\n * `previousNewest` — the newest row the window already holds.\n *\n * One page is enough in the normal case: a refresh usually returns rows we already have\n * plus the handful that arrived since. The loop is for the case that is not normal, where\n * the tail has moved further than a page and a single read would land entirely above the\n * loaded set.\n *\n * @returns Every page read, plus whether the reads joined up with the loaded window.\n */\n private async fetchRefreshedTail(\n conversationId: string,\n previousNewest: number | null,\n generation: number,\n contextUser: UserInfo\n ): Promise<RefreshedTail> {\n const results: DetailWindowLoadResult[] = [];\n let before: number | undefined = undefined;\n\n for (let page = 0; page <= MAX_REFRESH_BACKFILL_PAGES; page++) {\n const result = await this.loader.LoadDetailWindow(\n { ConversationID: conversationId, BeforeSequence: before, PageSize: DEFAULT_TRANSCRIPT_PAGE_SIZE },\n contextUser\n );\n if (generation !== this.generation) {\n return { Results: results, ReachedPreviousTail: false, Failed: false }; // discarded\n }\n // Checked BEFORE tailIsJoined, which reads `HasMoreAbove` — false on a failed read,\n // which would report the bridge as joined and hand back a set with a hole in it.\n if (result.Failed) {\n return { Results: results, ReachedPreviousTail: false, Failed: true };\n }\n results.push(result);\n\n if (this.tailIsJoined(result, previousNewest)) {\n return { Results: results, ReachedPreviousTail: true, Failed: false };\n }\n before = result.OldestSequence ?? undefined;\n }\n return { Results: results, ReachedPreviousTail: false, Failed: false };\n }\n\n /** True when this page reaches back into the loaded window, so nothing sits between them. */\n private tailIsJoined(result: DetailWindowLoadResult, previousNewest: number | null): boolean {\n // Nothing loaded to join up WITH, or the read came back empty — either way, done.\n if (previousNewest === null || result.OldestSequence === null) {\n return true;\n }\n // The page overlaps or abuts what we hold.\n if (result.OldestSequence <= previousNewest) {\n return true;\n }\n // The engine's probe says no row exists below this page at all. The rows we hold are\n // below it, so they have since been deleted — there is no gap left to bridge.\n return !result.HasMoreAbove;\n }\n\n /**\n * Folds a refreshed tail into the window, choosing what to keep by whether the read\n * joined up with the rows already loaded.\n */\n private applyRefreshedTail(\n tail: RefreshedTail,\n previousOldest: number | null,\n previousNewest: number | null\n ): void {\n const fetched = tail.Results.flatMap(result => result.Details);\n const hasMoreAbove = this.mergeRefreshedRows(tail, fetched, previousOldest, previousNewest);\n\n for (const result of tail.Results) {\n this.mergePeripherals(result);\n }\n this.cursor = {\n OldestSequence: this.loadedDetails[0]?.Sequence ?? null,\n NewestSequence: this.loadedDetails[this.loadedDetails.length - 1]?.Sequence ?? null,\n HasMoreAbove: hasMoreAbove\n };\n }\n\n /** Merges the refreshed rows per the three refresh cases. @returns the new `HasMoreAbove`. */\n private mergeRefreshedRows(\n tail: RefreshedTail,\n fetched: MJConversationDetailEntity[],\n previousOldest: number | null,\n previousNewest: number | null\n ): boolean {\n if (previousNewest === null) {\n // Nothing loaded — this is a first paint wearing a refresh's name, so bound it to\n // a page the way LoadLatest does.\n const page = SelectLatestTimelinePage(fetched, DEFAULT_TRANSCRIPT_PAGE_SIZE);\n this.mergeDetails(page.Page);\n const last = tail.Results[tail.Results.length - 1];\n return (last?.HasMoreAbove ?? false) || page.Page.length < fetched.length;\n }\n\n if (tail.ReachedPreviousTail) {\n // Keep EVERY fetched row down to the window's existing floor. Slicing to a page\n // here is what opened the hole: the read deliberately spans the distance back to\n // the loaded tail, and discarding its middle throws away the very rows that make\n // the merged set contiguous. Rows below the floor are dropped so a refresh cannot\n // quietly grow the window downward — that is LoadOlder's job.\n this.mergeDetails(previousOldest === null\n ? fetched\n : fetched.filter(detail => detail.Sequence >= previousOldest));\n // The floor did not move, so what is below it is still whatever we last learned.\n return this.cursor.HasMoreAbove;\n }\n\n // Could not bridge within the page budget. Drop the now-disconnected older rows and\n // keep the fresh tail: a visible gap the sentinel can close beats an invisible one.\n this.loadedDetails = [];\n this.mergeDetails(fetched);\n return true;\n }\n\n /**\n * Cheap single-value reads for TEMPLATE binding.\n *\n * These exist because the chat area exposes `HasMoreAbove`, `IsLoadingOlder`,\n * `PinnedTotalCount`, and `PinnedDetails` as getters bound in its template, so every\n * change-detection cycle reads all four. Routing those through {@link GetSnapshot} copied\n * the whole loaded window four times per cycle — during streaming, per token — on a\n * component whose entire purpose is to stop doing work proportional to conversation length.\n *\n * {@link PinnedDetails} deliberately returns the internal array BY REFERENCE. Every mutator\n * replaces that array rather than mutating it, so the reference is stable between real\n * changes — which is precisely what keeps the pins panel's `ngOnChanges` from firing on\n * every cycle. Callers must not mutate it.\n */\n public get HasMoreAbove(): boolean {\n return this.cursor.HasMoreAbove;\n }\n\n public get IsLoadingOlder(): boolean {\n return this.isLoadingOlder;\n }\n\n public get IsLoadingLatest(): boolean {\n return this.isLoadingLatest;\n }\n\n public get PinnedTotalCount(): number {\n return this.pinnedTotalCount;\n }\n\n public get PinnedDetails(): readonly MJConversationDetailEntity[] {\n return this.pinnedDetails;\n }\n\n /** True when the most recent load failed — an empty transcript vs. a broken one. */\n public get LoadFailed(): boolean {\n return this.loadFailed;\n }\n\n /** True when a `LoadOlder` would actually do work. Drives the sentinel's observer. */\n public CanLoadOlder(): boolean {\n return this.conversationId !== null\n && this.cursor.HasMoreAbove\n && this.cursor.OldestSequence !== null\n && !this.isLoadingOlder\n && !this.isLoadingLatest;\n }\n\n /**\n * Applies a locally-known row — sent, streamed, edited. Inserts or replaces by ID and\n * re-sorts. Never queries; the caller already has the entity.\n */\n public ApplyLocalDetail(detail: MJConversationDetailEntity): void {\n this.mergeDetails([detail]);\n const newest = this.loadedDetails[this.loadedDetails.length - 1]?.Sequence ?? null;\n this.cursor = { ...this.cursor, NewestSequence: newest };\n }\n\n /** Removes a row by ID — the delete path. */\n public RemoveDetail(detailId: string): void {\n const key = NormalizeUUID(detailId);\n this.loadedDetails = this.loadedDetails.filter(d => NormalizeUUID(d.ID) !== key);\n const pinnedBefore = this.pinnedDetails.length;\n this.pinnedDetails = this.pinnedDetails.filter(d => NormalizeUUID(d.ID) !== key);\n if (this.pinnedDetails.length < pinnedBefore) {\n this.pinnedTotalCount = Math.max(0, this.pinnedTotalCount - 1);\n }\n }\n\n /**\n * Records how many pins exist WITHOUT loading them.\n *\n * The conversation-open path calls only this: the chip needs a number, the panel needs\n * nothing until it is opened, and hydrating every pin on open re-imports the cost this\n * windowing work exists to remove.\n */\n public SetPinnedCount(total: number): void {\n this.pinnedTotalCount = Math.max(0, total);\n }\n\n /** Replaces the pins panel's contents. Pins outside the window are expected here. */\n public SetPinnedDetails(pins: MJConversationDetailEntity[]): void {\n this.pinnedDetails = [...pins];\n // Hydration is unbounded, so once it lands the loaded set IS the whole set — any\n // drift the local pin/unpin bookkeeping accumulated is reconciled here on purpose.\n this.pinnedTotalCount = pins.length;\n }\n\n /**\n * Reflects a pin/unpin the user just performed, without re-querying.\n *\n * Newly pinned rows go to the FRONT to match the panel's newest-pin-first order, which\n * the initial `Sequence DESC` fetch also produces.\n */\n public ApplyLocalPin(detail: MJConversationDetailEntity): void {\n const key = NormalizeUUID(detail.ID);\n const wasPinned = this.pinnedDetails.some(d => NormalizeUUID(d.ID) === key);\n const without = this.pinnedDetails.filter(d => NormalizeUUID(d.ID) !== key);\n this.pinnedDetails = detail.IsPinned ? [detail, ...without] : without;\n\n // Counted independently of the set: before the panel is opened `pinnedDetails` is\n // deliberately EMPTY, so deriving a count from its length would report zero pins on\n // a conversation that has many. `wasPinned` keeps the two in step — without it an\n // unpin of a row that was never in the set decrements a count it never entered.\n if (detail.IsPinned && !wasPinned) {\n this.pinnedTotalCount++;\n } else if (!detail.IsPinned && wasPinned) {\n this.pinnedTotalCount = Math.max(0, this.pinnedTotalCount - 1);\n }\n }\n\n /**\n * Everything the chat area needs to render, copied. Arrays are fresh so ngOnChanges fires.\n *\n * Call this from imperative paths (a load completing, a jump loop) — NOT from a template\n * getter. It copies the loaded set on every call, so binding it into change detection\n * pays that copy once per checked cycle for state that mostly has not moved. The cheap\n * single-value accessors above exist for exactly that case.\n */\n public GetSnapshot(): ConversationDetailWindowSnapshot {\n const details = [...this.loadedDetails];\n return {\n ConversationID: this.conversationId,\n Details: details,\n Cursor: { ...this.cursor },\n PinnedDetails: [...this.pinnedDetails],\n PinnedTotalCount: this.pinnedTotalCount,\n // Peripheral maps are handed out by reference — the chat area copies them into\n // its own UI-shaped maps (LazyArtifactInfo etc.) rather than mutating these.\n AgentRunsByDetailId: this.peripherals.AgentRunsByDetailId,\n UserAvatars: this.peripherals.UserAvatars,\n RatingsByDetailId: this.peripherals.RatingsByDetailId,\n ArtifactsByDetailId: this.peripherals.ArtifactsByDetailId,\n IsLoadingLatest: this.isLoadingLatest,\n IsLoadingOlder: this.isLoadingOlder,\n LoadFailed: this.loadFailed\n };\n }\n\n /**\n * Folds a newly-loaded page's peripherals into the accumulated maps.\n *\n * Later entries win on a key collision, which matters for a window refresh: a re-fetched\n * newest page should replace a row's stale agent run rather than keep the old one.\n */\n private mergePeripherals(incoming: ConversationDetailWindowPeripherals): void {\n for (const [id, run] of incoming.AgentRunsByDetailId) {\n this.peripherals.AgentRunsByDetailId.set(id, run);\n }\n for (const [id, avatar] of incoming.UserAvatars) {\n this.peripherals.UserAvatars.set(id, avatar);\n }\n for (const [id, ratings] of incoming.RatingsByDetailId) {\n this.peripherals.RatingsByDetailId.set(id, ratings);\n }\n for (const [id, artifacts] of incoming.ArtifactsByDetailId) {\n this.peripherals.ArtifactsByDetailId.set(id, artifacts);\n }\n }\n\n /**\n * Union-merges rows into the loaded set: replace-by-ID, then sort by `Sequence`.\n * Dedupe is required because session expansion can re-return rows a prior page held.\n */\n private mergeDetails(incoming: MJConversationDetailEntity[]): void {\n const byId = new Map<string, MJConversationDetailEntity>();\n for (const detail of this.loadedDetails) {\n byId.set(NormalizeUUID(detail.ID), detail);\n }\n for (const detail of incoming) {\n byId.set(NormalizeUUID(detail.ID), detail);\n }\n this.loadedDetails = [...byId.values()].sort((a, b) => a.Sequence - b.Sequence);\n }\n}\n\n/** Empty peripheral maps — the reset / no-window state. */\nfunction emptyPeripherals(): ConversationDetailWindowPeripherals {\n return {\n AgentRunsByDetailId: new Map(),\n UserAvatars: new Map(),\n RatingsByDetailId: new Map(),\n ArtifactsByDetailId: new Map()\n };\n}\n\n/** Copies a load result's peripheral maps into fresh maps the store then owns and merges into. */\nfunction peripheralsFrom(result: DetailWindowLoadResult): ConversationDetailWindowPeripherals {\n return {\n AgentRunsByDetailId: new Map(result.AgentRunsByDetailId),\n UserAvatars: new Map(result.UserAvatars),\n RatingsByDetailId: new Map(result.RatingsByDetailId),\n ArtifactsByDetailId: new Map(result.ArtifactsByDetailId)\n };\n}\n\n/** The empty-window cursor. */\nfunction emptyCursor(): ConversationDetailWindowCursor {\n return { OldestSequence: null, NewestSequence: null, HasMoreAbove: false };\n}\n\n// NOTE: there is deliberately no `cursorFrom(result)` helper. A cursor can never be copied\n// straight off a load result, because the store slices the over-read page down to `pageSize`\n// timeline items — so both the oldest bound and `HasMoreAbove` depend on what survived the\n// slice, not on what the engine fetched.\n"]}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Grouping for the "Prior Artifacts Created by This Agent" block fed to the Check Sage Intent
3
+ * prompt.
4
+ *
5
+ * PURE (no Angular, no entities, no provider) so the ordering contract below is unit-testable
6
+ * without the service's DI — same reason `conversation-detail-window.ts` and `date-jump.ts`
7
+ * live out here rather than on their consumers.
8
+ */
9
+ /** The version fields the grouping needs. Structural, so the RunView row type satisfies it. */
10
+ export interface AgentArtifactVersionRow {
11
+ ID: string;
12
+ ArtifactID: string;
13
+ VersionNumber: number;
14
+ Name: string | null;
15
+ }
16
+ /** The artifact fields the prompt prints. */
17
+ export interface AgentArtifactRow {
18
+ ID: string;
19
+ Name: string;
20
+ Type: string;
21
+ }
22
+ /**
23
+ * One artifact an agent produced in this conversation, with its versions newest-first.
24
+ *
25
+ * Deliberately narrower than the array-scan shape it replaces, which also carried `runId`,
26
+ * `artifactDescription`, `versionDescription` and `createdAt` — none of which the intent
27
+ * prompt ever read. Dropping them is what lets the caller resolve this without joining back
28
+ * to agent runs, turning a four-table reconstruction into two reads.
29
+ */
30
+ export interface AgentArtifactSummary {
31
+ artifactId: string;
32
+ artifactName: string;
33
+ artifactType: string;
34
+ /** Newest first, so `versions[0]` is the latest. */
35
+ versions: Array<{
36
+ versionId: string;
37
+ versionNumber: number;
38
+ versionName: string | null;
39
+ }>;
40
+ }
41
+ /**
42
+ * Folds versions into per-artifact summaries.
43
+ *
44
+ * NO SORT, and that is deliberate rather than an omission. The caller reads versions
45
+ * `__mj_CreatedAt DESC`, so inserting in arrival order leaves each artifact's versions
46
+ * newest-first AND leaves the Map ordered by whichever artifact was touched most recently —
47
+ * which is exactly what the explicit sort in the array-scan version used to compute. The
48
+ * assumption is load-bearing and invisible, so it is pinned by tests.
49
+ *
50
+ * @param versions - Artifact versions, newest first
51
+ * @param artifactsById - The artifact rows behind those versions
52
+ */
53
+ export declare function GroupVersionsByArtifact(versions: readonly AgentArtifactVersionRow[], artifactsById: ReadonlyMap<string, AgentArtifactRow>): AgentArtifactSummary[];
54
+ //# sourceMappingURL=agent-artifact-summary.d.ts.map