@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
@@ -1,39 +1,44 @@
1
- import { Component, Input, Output, EventEmitter, ViewChild, ViewContainerRef } from '@angular/core';
2
- import { NormalizeUUID } from '@memberjunction/global';
1
+ import { Component, Input, Output, HostBinding, EventEmitter, ViewChild, ViewContainerRef } from '@angular/core';
2
+ import { NormalizeUUID, UUIDsEqual } from '@memberjunction/global';
3
3
  import { BaseAngularComponent } from '@memberjunction/ng-base-types';
4
4
  import { MessageItemComponent } from './message-item.component';
5
5
  import { RealtimeSessionTimelineCardComponent } from '../realtime/realtime-session-timeline-card.component';
6
6
  import { selectDistinctLatestArtifacts } from '../../utils/distinct-artifacts';
7
7
  import { BuildConversationTimeline } from '../../utils/realtime-session-timeline';
8
+ import { DEFAULT_TRANSCRIPT_PAGE_SIZE } from '../../utils/conversation-detail-window';
9
+ import { ResolveDateJumpTarget } from '../../utils/date-jump';
8
10
  import * as i0 from "@angular/core";
9
11
  import * as i1 from "@memberjunction/ng-ui-components";
12
+ import * as i2 from "@memberjunction/ng-shared-generic";
10
13
  const _c0 = ["messageContainer"];
11
14
  const _c1 = ["scrollContainer"];
15
+ const _c2 = ["olderSentinel"];
16
+ const _c3 = ["spacerTemplate"];
12
17
  function MessageListComponent_Conditional_2_Template(rf, ctx) { if (rf & 1) {
13
18
  const _r1 = i0.ɵɵgetCurrentView();
14
- i0.ɵɵelementStart(0, "div", 3)(1, "div", 5)(2, "button", 6);
19
+ i0.ɵɵelementStart(0, "div", 5)(1, "div", 8)(2, "button", 9);
15
20
  i0.ɵɵlistener("click", function MessageListComponent_Conditional_2_Template_button_click_2_listener() { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.toggleDateNav()); });
16
21
  i0.ɵɵelementStart(3, "span");
17
22
  i0.ɵɵtext(4);
18
23
  i0.ɵɵelementEnd();
19
- i0.ɵɵelement(5, "i", 7);
24
+ i0.ɵɵelement(5, "i", 10);
20
25
  i0.ɵɵelementEnd();
21
- i0.ɵɵelementStart(6, "div", 8)(7, "div", 9);
26
+ i0.ɵɵelementStart(6, "div", 11)(7, "div", 12);
22
27
  i0.ɵɵtext(8, "Jump to...");
23
28
  i0.ɵɵelementEnd();
24
- i0.ɵɵelementStart(9, "div", 10);
29
+ i0.ɵɵelementStart(9, "div", 13);
25
30
  i0.ɵɵlistener("click", function MessageListComponent_Conditional_2_Template_div_click_9_listener() { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.jumpToDate("today")); });
26
31
  i0.ɵɵtext(10, "Today");
27
32
  i0.ɵɵelementEnd();
28
- i0.ɵɵelementStart(11, "div", 10);
33
+ i0.ɵɵelementStart(11, "div", 13);
29
34
  i0.ɵɵlistener("click", function MessageListComponent_Conditional_2_Template_div_click_11_listener() { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.jumpToDate("yesterday")); });
30
35
  i0.ɵɵtext(12, "Yesterday");
31
36
  i0.ɵɵelementEnd();
32
- i0.ɵɵelementStart(13, "div", 10);
37
+ i0.ɵɵelementStart(13, "div", 13);
33
38
  i0.ɵɵlistener("click", function MessageListComponent_Conditional_2_Template_div_click_13_listener() { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.jumpToDate("last-week")); });
34
39
  i0.ɵɵtext(14, "Last week");
35
40
  i0.ɵɵelementEnd();
36
- i0.ɵɵelementStart(15, "div", 10);
41
+ i0.ɵɵelementStart(15, "div", 13);
37
42
  i0.ɵɵlistener("click", function MessageListComponent_Conditional_2_Template_div_click_15_listener() { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.jumpToDate("last-month")); });
38
43
  i0.ɵɵtext(16, "Last month");
39
44
  i0.ɵɵelementEnd()()()();
@@ -44,16 +49,51 @@ function MessageListComponent_Conditional_2_Template(rf, ctx) { if (rf & 1) {
44
49
  i0.ɵɵadvance(2);
45
50
  i0.ɵɵclassProp("show", ctx_r1.showDateNav);
46
51
  } }
47
- function MessageListComponent_Conditional_5_Template(rf, ctx) { if (rf & 1) {
48
- i0.ɵɵelement(0, "mj-empty-state", 4);
52
+ function MessageListComponent_Conditional_3_Conditional_2_Template(rf, ctx) { if (rf & 1) {
53
+ i0.ɵɵelement(0, "mj-loading", 14);
54
+ } }
55
+ function MessageListComponent_Conditional_3_Conditional_3_Template(rf, ctx) { if (rf & 1) {
56
+ i0.ɵɵelementStart(0, "span", 15);
57
+ i0.ɵɵtext(1, "Earlier messages");
58
+ i0.ɵɵelementEnd();
59
+ } }
60
+ function MessageListComponent_Conditional_3_Template(rf, ctx) { if (rf & 1) {
61
+ i0.ɵɵelementStart(0, "div", 6, 3);
62
+ i0.ɵɵconditionalCreate(2, MessageListComponent_Conditional_3_Conditional_2_Template, 1, 0, "mj-loading", 14)(3, MessageListComponent_Conditional_3_Conditional_3_Template, 2, 0, "span", 15);
63
+ i0.ɵɵelementEnd();
64
+ } if (rf & 2) {
65
+ const ctx_r1 = i0.ɵɵnextContext();
66
+ i0.ɵɵadvance(2);
67
+ i0.ɵɵconditional(ctx_r1.IsLoadingOlder ? 2 : 3);
68
+ } }
69
+ function MessageListComponent_ng_template_4_Template(rf, ctx) { if (rf & 1) {
70
+ i0.ɵɵelement(0, "div", 16);
71
+ } if (rf & 2) {
72
+ const height_r3 = ctx.height;
73
+ i0.ɵɵstyleProp("height", height_r3, "px");
74
+ } }
75
+ function MessageListComponent_Conditional_8_Template(rf, ctx) { if (rf & 1) {
76
+ i0.ɵɵelement(0, "mj-empty-state", 7);
49
77
  } }
50
78
  /**
51
79
  * Container component for displaying a list of messages
52
80
  * Uses dynamic component creation (like skip-chat) to avoid Angular binding overhead
53
81
  * This dramatically improves performance when messages are added/removed
54
82
  */
83
+ /** Dropdown label per period. Kept beside the guard so a new period cannot render blank. */
84
+ const DATE_JUMP_LABELS = {
85
+ 'today': 'Today',
86
+ 'yesterday': 'Yesterday',
87
+ 'last-week': 'Last week',
88
+ 'last-month': 'Last month'
89
+ };
90
+ /** Narrows the template's string to the period union — the template cannot type its own literals. */
91
+ function isDateJumpPeriod(value) {
92
+ return Object.prototype.hasOwnProperty.call(DATE_JUMP_LABELS, value);
93
+ }
55
94
  export class MessageListComponent extends BaseAngularComponent {
56
95
  cdRef;
96
+ hostRef;
57
97
  messages = [];
58
98
  conversation;
59
99
  currentUser;
@@ -70,6 +110,78 @@ export class MessageListComponent extends BaseAngularComponent {
70
110
  allowPinning = true;
71
111
  allowMessageEdit = true;
72
112
  allowMessageDelete = true;
113
+ // ── Windowed-transcript paging state ────────────────────────────────────────
114
+ // The list renders only the LOADED window, not the whole conversation. These two
115
+ // describe what lies above it. Unused until the Phase 5 sentinel lands; wired now so
116
+ // the host binding is in place and the component's contract is stable.
117
+ /**
118
+ * True when older pages remain above the loaded window.
119
+ *
120
+ * Setter rather than a plain input: flipping this creates or destroys the sentinel via
121
+ * `@if`, and the observer has to be re-pointed at the new element. Relying on an ambient
122
+ * `ngAfterViewChecked` tick to notice is fragile — once the list settles there may not be
123
+ * another one.
124
+ */
125
+ set HasMoreAbove(value) {
126
+ if (value === this._hasMoreAbove) {
127
+ return;
128
+ }
129
+ this._hasMoreAbove = value;
130
+ // Gates the date navigator too — see updateDateFilterVisibility. Without this the button
131
+ // would only re-evaluate on a `messages` change, so exhausting history would leave it up.
132
+ this.updateDateFilterVisibility();
133
+ // Deferred: the `@if` has not rendered the sentinel yet at set time.
134
+ Promise.resolve().then(() => this.syncOlderObserver());
135
+ }
136
+ get HasMoreAbove() {
137
+ return this._hasMoreAbove;
138
+ }
139
+ _hasMoreAbove = false;
140
+ /** True while an older page is in flight. */
141
+ IsLoadingOlder = false;
142
+ /**
143
+ * The host's scrolling element, used as the sentinel observer's root.
144
+ *
145
+ * This component's own container does NOT scroll — hosts wrap it in their own scroller
146
+ * (in the chat area, `.chat-messages-container`, which carries the `min-height: 0` a flex
147
+ * child needs). Passing it in is deterministic; discovering it by walking the DOM depends
148
+ * on layout having settled, which is not knowable from in here.
149
+ *
150
+ * Optional: when omitted the component falls back to walking its ancestors.
151
+ */
152
+ set ScrollRoot(value) {
153
+ const next = value ?? null;
154
+ if (next === this._scrollRoot) {
155
+ return;
156
+ }
157
+ this._scrollRoot = next;
158
+ this._scrollParent = next;
159
+ // The root changed, so any live observer is pointed at the wrong element.
160
+ Promise.resolve().then(() => this.syncOlderObserver());
161
+ }
162
+ get ScrollRoot() {
163
+ return this._scrollRoot;
164
+ }
165
+ _scrollRoot = null;
166
+ /**
167
+ * Marks the host as "someone else owns the scrolling", which drops `overflow-y` on this
168
+ * component's own container.
169
+ *
170
+ * `position: sticky` pins to the nearest ancestor with `overflow-y: auto|scroll`. When a
171
+ * host supplies a scroller, this component's container still declared `overflow-y: auto`
172
+ * but never actually scrolled (its content grows to fit), so the sticky date header bound
173
+ * to a container whose `scrollTop` is permanently 0 — it rendered, then rode out of view
174
+ * with the messages and could not be clicked.
175
+ *
176
+ * Driven off `_scrollRoot` rather than `resolveScrollParent()` on purpose: the DOM walk is
177
+ * lazy and layout-dependent, and dropping `overflow-y` for a consumer that turned out to
178
+ * have NO scrolling ancestor would leave the transcript unable to scroll or page at all.
179
+ * The explicit input is the only signal that is safe here — a host that passes it has, by
180
+ * definition, a scroller of its own.
181
+ */
182
+ get HostSuppliesScroller() {
183
+ return this._scrollRoot !== null;
184
+ }
73
185
  // ── Assistant identity overrides — static host config forwarded to every message
74
186
  // item (null = engine identity). Setters (not ngOnChanges) so an imperative
75
187
  // host — `@ViewChild(MessageListComponent).assistantDisplayName = …` — restamps
@@ -152,8 +264,23 @@ export class MessageListComponent extends BaseAngularComponent {
152
264
  beforeResponseFormSubmitted = new EventEmitter();
153
265
  /** Forwarded from MessageItemComponent — see its docs. */
154
266
  afterResponseFormSubmitted = new EventEmitter();
267
+ /**
268
+ * Asks the host to load the next older page. Fired when the "earlier messages"
269
+ * sentinel scrolls into view — never on a raw scroll event, so a fast scroll costs
270
+ * one emit rather than one per pixel.
271
+ */
272
+ OlderRequested = new EventEmitter();
273
+ /**
274
+ * Asks the host to page older history far enough back to satisfy a date jump, then scroll.
275
+ * Emitted instead of handled locally because paging belongs to the window store.
276
+ */
277
+ DateJumpRequested = new EventEmitter();
155
278
  messageContainerRef;
156
279
  scrollContainer;
280
+ /** Only present while `HasMoreAbove` is true — the `@if` creates and destroys it. */
281
+ olderSentinel;
282
+ /** Template rendered in place of an unmounted timeline item. */
283
+ spacerTemplate;
157
284
  /**
158
285
  * Per-message rendered entries — see `RenderedMessageEntry` for the 3-way
159
286
  * union (default MessageItemComponent / consumer messageRenderer slot
@@ -162,35 +289,165 @@ export class MessageListComponent extends BaseAngularComponent {
162
289
  _renderedMessages = new Map();
163
290
  _shouldScrollToBottom = false;
164
291
  _previousMessageCount = 0; // Track previous count to detect new messages
292
+ /** Watches the "earlier messages" sentinel. Rebuilt whenever the sentinel comes or goes. */
293
+ _olderObserver;
294
+ /**
295
+ * Set when the newest render PREPENDED older content, so `ngAfterViewChecked` can hold
296
+ * the user's reading position instead of letting the browser's pixel-based `scrollTop`
297
+ * slide them to unfamiliar content.
298
+ */
299
+ _restoreScrollAfterPrepend = false;
300
+ /** `scrollHeight` captured immediately before a prepend render. */
301
+ _heightBeforePrepend = 0;
302
+ /** Memoized result of {@link resolveScrollParent} — the host's scroller, not ours. */
303
+ _scrollParent = null;
304
+ /** What the live observer is currently watching, so a stale pairing can be detected. */
305
+ _observedSentinel = null;
306
+ _observedRoot = null;
307
+ /** Guards the no-scroll-parent warning so it fires once, not every checked cycle. */
308
+ _warnedNoScrollParent = false;
309
+ /** Guards the missing-spacer-template warning the same way. */
310
+ _warnedNoSpacerTemplate = false;
311
+ // ── DOM unmount (bounded transcript) ────────────────────────────────────────
312
+ // Paging up 20 times would otherwise leave 200 live MessageItemComponents mounted.
313
+ // Items far from the viewport are destroyed and replaced by a height-holding spacer,
314
+ // then remounted from `messages` (already in memory — no network) on scroll-back.
315
+ /** Rendered pixel height per timeline key, captured just before an item is unmounted. */
316
+ _measuredHeights = new Map();
317
+ /** Watches spacers so an item remounts as the user scrolls back toward it. */
318
+ _spacerObserver;
319
+ /** Which spacers the live observer is watching, so it is only rebuilt when the set changes. */
320
+ _observedSpacerKeys = '';
321
+ /** Scroller the window listener is attached to, and its coalescing frame handle. */
322
+ _scrollListenerTarget = null;
323
+ _onScroll = null;
324
+ _scrollFrame = null;
325
+ /** Items kept mounted beyond the visible page, above and below. */
326
+ static MOUNT_BUFFER = 5;
327
+ /**
328
+ * Height used for an item unmounted before it was ever measured. Only a first-render
329
+ * fallback — a real measurement replaces it and is preferred forever after.
330
+ */
331
+ static ESTIMATED_MESSAGE_HEIGHT = 72;
332
+ static ESTIMATED_SESSION_HEIGHT = 88;
333
+ /** Frames spent waiting for a scroller to appear, and the pending rAF handle. */
334
+ _scrollParentRetries = 0;
335
+ _scrollParentRetryHandle = null;
336
+ /** ~1s at 60fps — long enough for layout to settle, short enough to report a real fault. */
337
+ static MAX_SCROLL_PARENT_RETRIES = 60;
338
+ /**
339
+ * Render key of the FIRST timeline item last time we rendered. A change here means older
340
+ * content arrived at the head — the only reliable way to tell a prepend from an append,
341
+ * since both grow `messages.length`.
342
+ */
343
+ _previousFirstKey = null;
165
344
  currentDateDisplay = 'Today';
166
345
  showDateNav = false;
167
346
  shouldShowDateFilter = false;
168
- constructor(cdRef) {
347
+ constructor(cdRef, hostRef) {
169
348
  super();
170
349
  this.cdRef = cdRef;
350
+ this.hostRef = hostRef;
171
351
  }
172
352
  toggleDateNav() {
173
353
  this.showDateNav = !this.showDateNav;
174
354
  }
355
+ /**
356
+ * Handles a date-navigator selection.
357
+ *
358
+ * The list cannot page history itself — it is a Generic widget with no store — so it asks
359
+ * the host, which owns the window and runs the older-page loop, then calls back into
360
+ * {@link ScrollToDateTarget}. The label updates immediately so the dropdown feels responsive
361
+ * while paging runs.
362
+ */
175
363
  jumpToDate(period) {
176
- // TODO: Implement date jumping logic
177
- console.log('Jump to date:', period);
178
364
  this.showDateNav = false;
179
- // Update display based on period
180
- switch (period) {
181
- case 'today':
182
- this.currentDateDisplay = 'Today';
183
- break;
184
- case 'yesterday':
185
- this.currentDateDisplay = 'Yesterday';
186
- break;
187
- case 'last-week':
188
- this.currentDateDisplay = 'Last week';
189
- break;
190
- case 'last-month':
191
- this.currentDateDisplay = 'Last month';
192
- break;
365
+ if (!isDateJumpPeriod(period)) {
366
+ return;
193
367
  }
368
+ this.currentDateDisplay = DATE_JUMP_LABELS[period];
369
+ this.DateJumpRequested.emit(period);
370
+ }
371
+ /**
372
+ * Scrolls to the start of `period` within the currently loaded messages.
373
+ *
374
+ * Called by the host AFTER it has paged as far back as it intends to, so whatever is loaded
375
+ * now is the best answer available. Returns the outcome rather than failing silently — the
376
+ * plan's explicit requirement for this path.
377
+ */
378
+ ScrollToDateTarget(period) {
379
+ if (!this.messages || this.messages.length === 0) {
380
+ return 'empty';
381
+ }
382
+ const { Detail } = ResolveDateJumpTarget(this.messages, period, new Date());
383
+ // A miss lands the user on the oldest loaded message — where paging left them — rather
384
+ // than leaving the viewport wherever it happened to be.
385
+ const target = Detail ?? this.messages[0];
386
+ const scrolled = this.scrollToDetail(target);
387
+ if (!scrolled) {
388
+ return 'empty';
389
+ }
390
+ return Detail ? 'reached' : 'oldest';
391
+ }
392
+ /**
393
+ * Scrolls a loaded detail into view, mounted or not.
394
+ *
395
+ * Resolves through the TIMELINE KEY rather than querying `[data-message-id]`, because the
396
+ * jump target is by definition far above the viewport — exactly the region this component
397
+ * unmounts and replaces with height-holding spacers. A DOM query for the message id misses
398
+ * every unmounted target, which made a legitimate jump report "nothing loaded to jump to".
399
+ *
400
+ * Spacers carry the same timeline key as the item they stand in for, so scrolling to the
401
+ * key lands on the right place either way; the spacer observer then remounts the real item
402
+ * as it enters the viewport.
403
+ */
404
+ scrollToDetail(detail) {
405
+ return this.scrollToTimelineEntry(detail.ID, detail.AgentSessionID, 'start');
406
+ }
407
+ /**
408
+ * Scrolls a LOADED message into view by id, mounted or not, and returns whether it landed.
409
+ *
410
+ * Public because the pins panel's "Jump to message" lives on the host: a pin can be far
411
+ * above the viewport, which is exactly the region this component unmounts into spacers, so
412
+ * the host's own `[data-message-id]` query found nothing and the button silently did
413
+ * nothing. Resolution goes through the timeline key instead — see
414
+ * {@link scrollToTimelineEntry}.
415
+ *
416
+ * Returns false when the message is not in the loaded window at all; the host decides
417
+ * whether to page back for it or tell the user.
418
+ */
419
+ ScrollToMessage(messageId) {
420
+ const detail = this.messages?.find(m => UUIDsEqual(m.ID, messageId));
421
+ if (!detail) {
422
+ return false;
423
+ }
424
+ return this.scrollToTimelineEntry(detail.ID, detail.AgentSessionID, 'center');
425
+ }
426
+ /**
427
+ * Shared resolution for both jump paths: timeline key -> rendered node.
428
+ *
429
+ * Two things a `[data-message-id]` query cannot do, and both matter here:
430
+ * - A session-stamped row folds into its session CARD, so the row itself never has a
431
+ * node of its own; the card is what is on screen.
432
+ * - An item far from the viewport is unmounted and replaced by a height-holding spacer,
433
+ * which carries the SAME timeline key — so scrolling to the key lands in the right
434
+ * place either way, and the spacer observer remounts the real item on arrival.
435
+ */
436
+ scrollToTimelineEntry(detailId, agentSessionId, block) {
437
+ const stampedSessionId = agentSessionId?.trim() || null;
438
+ const timeline = BuildConversationTimeline(this.messages);
439
+ const item = timeline.find(entry => entry.Kind === 'session'
440
+ ? stampedSessionId !== null && UUIDsEqual(entry.Group.SessionID, stampedSessionId)
441
+ : UUIDsEqual(entry.Detail.ID, detailId));
442
+ if (!item) {
443
+ return false;
444
+ }
445
+ const node = this.nodeForKey(this.getTimelineKey(item));
446
+ if (!node) {
447
+ return false;
448
+ }
449
+ node.scrollIntoView({ behavior: 'smooth', block });
450
+ return true;
194
451
  }
195
452
  // Track whether initial render has happened
196
453
  _initialRenderComplete = false;
@@ -211,6 +468,10 @@ export class MessageListComponent extends BaseAngularComponent {
211
468
  // In that case, ngAfterViewInit will handle the initial render
212
469
  if (changes['messages'] && this.messages && this.messageContainerRef) {
213
470
  this._initialRenderComplete = true;
471
+ // Capture the pre-render height so a prepend can be corrected for. Must happen
472
+ // BEFORE updateMessages — afterwards the new rows are already in the layout — and
473
+ // must read the HOST's scroller, which is the element whose height actually changes.
474
+ this._heightBeforePrepend = this.resolveScrollParent()?.scrollHeight ?? 0;
214
475
  this.updateMessages(this.messages);
215
476
  this.updateDateFilterVisibility();
216
477
  }
@@ -251,12 +512,237 @@ export class MessageListComponent extends BaseAngularComponent {
251
512
  }
252
513
  }
253
514
  ngAfterViewChecked() {
254
- if (this._shouldScrollToBottom) {
515
+ // Mutually exclusive on purpose: scrolling to the bottom would undo a prepend restore.
516
+ if (this._restoreScrollAfterPrepend) {
517
+ this.restoreScrollAfterPrepend();
518
+ this._restoreScrollAfterPrepend = false;
519
+ }
520
+ else if (this._shouldScrollToBottom) {
255
521
  this.scrollToBottom();
256
522
  this._shouldScrollToBottom = false;
257
523
  }
524
+ // Measure while things are on screen, so an item has a real height recorded before it
525
+ // ever needs a spacer — the plan's "measure offsetHeight the first time an item is
526
+ // mounted and remember it by key".
527
+ this.measureMountedItems();
528
+ this.syncScrollListener();
529
+ this.syncOlderObserver();
530
+ this.syncSpacerObserver();
531
+ }
532
+ /** The rendered sentinel, read from the DOM so it does not depend on view-query timing. */
533
+ findSentinelElement() {
534
+ return this.hostRef.nativeElement.querySelector('.transcript-older-sentinel');
535
+ }
536
+ /**
537
+ * Whether this instance is actually on screen.
538
+ *
539
+ * `offsetParent` is null for any element in a `display: none` subtree — which is how the
540
+ * chat area parks the message lists of conversations you are not currently looking at.
541
+ */
542
+ isHostVisible() {
543
+ const host = this.hostRef.nativeElement;
544
+ return host.offsetParent !== null || getComputedStyle(host).position === 'fixed';
545
+ }
546
+ /**
547
+ * Retries {@link syncOlderObserver} on animation frames until a scroller appears.
548
+ *
549
+ * Bounded: after {@link MAX_SCROLL_PARENT_RETRIES} frames it gives up and logs the DOM
550
+ * chain it walked, so a genuine host-layout problem names itself instead of presenting
551
+ * as "paging silently doesn't work".
552
+ */
553
+ scheduleScrollParentRetry() {
554
+ if (this._scrollParentRetryHandle !== null) {
555
+ return; // one retry loop at a time
556
+ }
557
+ // A HIDDEN instance can never resolve a scroller: the chat area keeps one message list
558
+ // alive per visited conversation in a DOM cache, and a display:none subtree reports
559
+ // every height as 0, so `scrollHeight > clientHeight` is false all the way up. Keep
560
+ // waiting (it may be shown later) but never burn the retry budget or warn — that noise
561
+ // would point at the wrong instance entirely.
562
+ if (!this.isHostVisible()) {
563
+ this._scrollParentRetryHandle = requestAnimationFrame(() => {
564
+ this._scrollParentRetryHandle = null;
565
+ this.syncOlderObserver();
566
+ });
567
+ return;
568
+ }
569
+ if (this._scrollParentRetries >= MessageListComponent.MAX_SCROLL_PARENT_RETRIES) {
570
+ this.warnNoScrollParentOnce();
571
+ return;
572
+ }
573
+ this._scrollParentRetries++;
574
+ this._scrollParentRetryHandle = requestAnimationFrame(() => {
575
+ this._scrollParentRetryHandle = null;
576
+ this.syncOlderObserver();
577
+ });
578
+ }
579
+ /** One-time diagnostic dump of the ancestor chain, so the failure is self-explaining. */
580
+ warnNoScrollParentOnce() {
581
+ if (this._warnedNoScrollParent) {
582
+ return;
583
+ }
584
+ this._warnedNoScrollParent = true;
585
+ const chain = [];
586
+ let el = this.findSentinelElement() ?? this.scrollContainer?.nativeElement ?? null;
587
+ while (el && chain.length < 20) {
588
+ const style = getComputedStyle(el);
589
+ chain.push({
590
+ el: el.className || el.tagName,
591
+ overflowY: style.overflowY,
592
+ scrollHeight: el.scrollHeight,
593
+ clientHeight: el.clientHeight
594
+ });
595
+ el = el.parentElement;
596
+ }
597
+ console.warn('[MessageList] "Earlier messages" is showing but no scrolling ancestor was found, so '
598
+ + 'older pages cannot auto-load. The host must give the transcript a scrollable container '
599
+ + '(overflow-y: auto AND a bounded height, e.g. min-height: 0 on a flex child). '
600
+ + 'Walked from '
601
+ + (this.olderSentinel ? 'the sentinel' : this.scrollContainer ? 'the list container' : 'NOTHING — both view children are undefined')
602
+ + ':', chain);
603
+ }
604
+ /**
605
+ * The element that ACTUALLY scrolls the transcript.
606
+ *
607
+ * This component's own `.message-list-container` does not scroll: the chat area wraps it
608
+ * in `.chat-messages-container`, and that outer div is the one carrying `min-height: 0`
609
+ * alongside `overflow-y: auto` — the pair a flex child needs before it will scroll instead
610
+ * of growing to fit its content. Targeting the inner div means writing `scrollTop` on an
611
+ * element whose `scrollHeight === clientHeight`, which silently does nothing, and giving
612
+ * an IntersectionObserver a root that never scrolls.
613
+ *
614
+ * Walking up keeps this component agnostic about the host's markup — any consumer that
615
+ * wraps it in its own scroller works the same way.
616
+ */
617
+ resolveScrollParent() {
618
+ // Host-supplied root wins — no discovery, no timing dependency.
619
+ if (this._scrollRoot?.isConnected) {
620
+ return this._scrollRoot;
621
+ }
622
+ if (this._scrollParent && this._scrollParent.isConnected) {
623
+ return this._scrollParent;
624
+ }
625
+ // Start from the sentinel when it exists: it is the element being observed, so it is
626
+ // guaranteed present exactly when the walk matters. `@ViewChild('scrollContainer')`
627
+ // resolves on Angular's own schedule and can still be undefined here, which would end
628
+ // the walk before it began.
629
+ let el = this.findSentinelElement() ?? this.scrollContainer?.nativeElement ?? null;
630
+ while (el) {
631
+ const overflowY = getComputedStyle(el).overflowY;
632
+ if ((overflowY === 'auto' || overflowY === 'scroll') && el.scrollHeight > el.clientHeight) {
633
+ this._scrollParent = el;
634
+ return el;
635
+ }
636
+ el = el.parentElement;
637
+ }
638
+ return null; // nothing overflows yet — short conversation, nothing to scroll
639
+ }
640
+ /**
641
+ * Holds the user's reading position after older content is spliced in above.
642
+ *
643
+ * `scrollTop` is a pixel offset from the top, so inserting content above silently shifts
644
+ * everything down — the user ends up looking at messages they never scrolled to. Adding
645
+ * the height delta puts the same message back under their eyes.
646
+ */
647
+ restoreScrollAfterPrepend() {
648
+ const el = this.resolveScrollParent();
649
+ if (!el) {
650
+ return;
651
+ }
652
+ const delta = el.scrollHeight - this._heightBeforePrepend;
653
+ if (delta > 0) {
654
+ el.scrollTop = el.scrollTop + delta;
655
+ }
656
+ }
657
+ /**
658
+ * True when the environment provides `IntersectionObserver`.
659
+ *
660
+ * False under SSR and in the unit-test projects. Both observer syncs are reached a
661
+ * microtask late (see the `HasMoreAbove` setter), so a spec that stubs the global and
662
+ * restores it synchronously has already restored it by the time the sync runs — the
663
+ * constructor call then throws inside a `.then()` with no catch, which surfaces as an
664
+ * unhandled rejection rather than a test failure and fails the run despite every
665
+ * assertion passing.
666
+ */
667
+ get canObserve() {
668
+ return typeof IntersectionObserver !== 'undefined';
669
+ }
670
+ /**
671
+ * Keeps the IntersectionObserver attached to the current sentinel element.
672
+ *
673
+ * The sentinel lives inside `@if (HasMoreAbove)`, so it is created and destroyed as the
674
+ * user reaches the top of the loaded window and as older pages arrive. Runs every checked
675
+ * cycle and is a no-op once attached.
676
+ *
677
+ * Observer-on-sentinel rather than a scroll listener: a fast scroll costs one callback,
678
+ * not one per frame.
679
+ */
680
+ syncOlderObserver() {
681
+ if (!this.canObserve) {
682
+ return;
683
+ }
684
+ if (!this.HasMoreAbove) {
685
+ this._olderObserver?.disconnect();
686
+ this._olderObserver = undefined;
687
+ this._observedSentinel = null;
688
+ this._observedRoot = null;
689
+ return;
690
+ }
691
+ // Query the DOM rather than reading `@ViewChild('olderSentinel')`.
692
+ //
693
+ // The sentinel lives inside `@if`, so the view query resolves on Angular's own
694
+ // schedule — and `updateMessages` detaches/reattaches this component's change detector
695
+ // around every render, which makes that schedule hard to reason about. Reading the host
696
+ // element directly is timing-independent: if the div is on the page, we find it.
697
+ const el = this.findSentinelElement();
698
+ if (!el) {
699
+ this.scheduleScrollParentRetry(); // not rendered yet — look again next frame
700
+ return;
701
+ }
702
+ // The scroll parent is not resolvable until something actually overflows, which is not
703
+ // true on the first checked cycle. Bailing (rather than observing with a null root)
704
+ // matters: a viewport-rooted observer never sees a sentinel clipped inside a scrolled
705
+ // container, so it would silently never fire.
706
+ const root = this.resolveScrollParent();
707
+ if (!root) {
708
+ // A scroller only becomes findable once its content overflows, which is not true on
709
+ // the tick the sentinel first renders. Retry on animation frames rather than waiting
710
+ // for another change-detection pass — once the list settles there may not be one.
711
+ this.scheduleScrollParentRetry();
712
+ return;
713
+ }
714
+ this._scrollParentRetries = 0;
715
+ // Rebuild whenever EITHER end of the relationship changes — `@if` swaps the sentinel
716
+ // element as HasMoreAbove toggles, and the root only becomes known once content
717
+ // overflows. Observing a stale element or a stale root is silent, not loud.
718
+ if (this._olderObserver && this._observedSentinel === el && this._observedRoot === root) {
719
+ return;
720
+ }
721
+ this._olderObserver?.disconnect();
722
+ this._olderObserver = new IntersectionObserver(entries => {
723
+ // Re-check the flags at fire time: the observer can fire while a load is already
724
+ // running, and LoadOlder's own guard shouldn't be the only thing standing between
725
+ // a fast scroll and a burst of duplicate requests.
726
+ if (entries.some(e => e.isIntersecting) && this.HasMoreAbove && !this.IsLoadingOlder) {
727
+ this.OlderRequested.emit();
728
+ }
729
+ },
730
+ // Root must be the REAL scroller, not this component's own container — an observer
731
+ // rooted on a non-scrolling ancestor reports the sentinel as permanently visible.
732
+ { root, threshold: 0.01 });
733
+ this._olderObserver.observe(el);
734
+ this._observedSentinel = el;
735
+ this._observedRoot = root;
258
736
  }
259
737
  ngOnDestroy() {
738
+ this._olderObserver?.disconnect();
739
+ this._olderObserver = undefined;
740
+ this._spacerObserver?.disconnect();
741
+ this._spacerObserver = undefined;
742
+ if (this._scrollParentRetryHandle !== null) {
743
+ cancelAnimationFrame(this._scrollParentRetryHandle);
744
+ this._scrollParentRetryHandle = null;
745
+ }
260
746
  // Clean up all dynamically created components AND embedded views (both have destroy()).
261
747
  this._renderedMessages.forEach((entry) => {
262
748
  if (entry) {
@@ -307,20 +793,45 @@ export class MessageListComponent extends BaseAngularComponent {
307
793
  // internally branches on the slot template (messageRenderer) vs the
308
794
  // default MessageItemComponent; `renderSessionBlock` always creates a
309
795
  // RealtimeSessionTimelineCardComponent.
796
+ // The timeline index is passed through so newly created views land at the RIGHT
797
+ // position. `createComponent` appends by default, which would put prepended older
798
+ // messages at the bottom of the DOM despite being the oldest content.
799
+ // Items far from the viewport are UNMOUNTED and replaced by a height-holding spacer,
800
+ // so paging a long way up leaves a bounded DOM rather than hundreds of live message
801
+ // components. The tail and any in-progress message are always kept.
802
+ const range = this.computeMountedRange(timeline);
310
803
  const lastMessageKey = this.findLastMessageKey(timeline);
311
- for (const item of timeline) {
312
- if (item.Kind === 'session') {
313
- this.renderSessionBlock(item.Group);
804
+ for (let i = 0; i < timeline.length; i++) {
805
+ const item = timeline[i];
806
+ const mounted = (i >= range.start && i <= range.end)
807
+ || this.mustStayMounted(item, i, timeline.length);
808
+ if (!mounted) {
809
+ this.ensureSpacer(this.getTimelineKey(item), item, i);
810
+ }
811
+ else if (item.Kind === 'session') {
812
+ this.renderSessionBlock(item.Group, i);
314
813
  }
315
814
  else {
316
- this.renderMessageItem(item.Detail, messages, this.getMessageKey(item.Detail) === lastMessageKey);
815
+ this.renderMessageItem(item.Detail, messages, this.getMessageKey(item.Detail) === lastMessageKey, i);
317
816
  }
318
817
  }
319
- // Only scroll to bottom if new messages were added (not just updates)
320
- // This prevents scrolling when the message list is merely refreshed (e.g., during agent run timer)
818
+ // Decide where the viewport should end up.
819
+ //
820
+ // A raw `length > previousCount` check cannot tell an append from a prepend — paging
821
+ // older history also grows the array, and treating that as "someone sent a message"
822
+ // would snap the user to the newest message every time they scrolled up. Compare the
823
+ // FIRST timeline key instead: if it changed, older content arrived at the head.
321
824
  const previousCount = this._previousMessageCount;
322
825
  this._previousMessageCount = messages.length;
323
- if (messages.length > previousCount) {
826
+ const firstKey = timeline.length > 0 ? this.getTimelineKey(timeline[0]) : null;
827
+ const prepended = this._previousFirstKey !== null
828
+ && firstKey !== null
829
+ && firstKey !== this._previousFirstKey;
830
+ this._previousFirstKey = firstKey;
831
+ if (prepended) {
832
+ this._restoreScrollAfterPrepend = true;
833
+ }
834
+ else if (messages.length > previousCount) {
324
835
  this._shouldScrollToBottom = true;
325
836
  }
326
837
  }
@@ -330,6 +841,253 @@ export class MessageListComponent extends BaseAngularComponent {
330
841
  this.cdRef.detectChanges();
331
842
  }
332
843
  }
844
+ /**
845
+ * The span of timeline indices that stay mounted.
846
+ *
847
+ * Follows the tail on first paint and whenever nothing measurable is on screen. Once items
848
+ * are rendered, the span is centred on what the reader is actually looking at — measured
849
+ * from scroll position, plus {@link MessageListComponent.MOUNT_BUFFER} either side — so
850
+ * scrolling back does not collapse the window to the newest messages on the next render.
851
+ *
852
+ * (An earlier design pinned the top of the span with a remembered key instead. It was
853
+ * replaced by the measurement below for the reason that comment explains, and the key no
854
+ * longer exists.)
855
+ */
856
+ computeMountedRange(timeline) {
857
+ const lastIndex = timeline.length - 1;
858
+ const span = DEFAULT_TRANSCRIPT_PAGE_SIZE + MessageListComponent.MOUNT_BUFFER * 2;
859
+ const tailRange = { start: Math.max(0, timeline.length - span), end: lastIndex };
860
+ const root = this.resolveScrollParent();
861
+ if (!root || this._renderedMessages.size === 0) {
862
+ return tailRange; // first paint — follow the tail
863
+ }
864
+ // Derived from SCROLL POSITION, which nothing in the render loop feeds back into.
865
+ //
866
+ // Reacting to spacer intersections instead is circular: a spacer's height is an
867
+ // estimate, so where it sits decides what is visible, which decides which spacers fire,
868
+ // which changes what is mounted, which changes heights. That loop is what made the
869
+ // transcript replay the same region; constraining it to one direction only traded the
870
+ // ping-pong for dead zones that never remounted.
871
+ const rootTop = root.getBoundingClientRect().top;
872
+ const viewportHeight = root.clientHeight;
873
+ let firstVisible = -1;
874
+ let lastVisible = -1;
875
+ for (let i = 0; i < timeline.length; i++) {
876
+ const node = this.nodeForKey(this.getTimelineKey(timeline[i]));
877
+ if (!node) {
878
+ continue;
879
+ }
880
+ const rect = node.getBoundingClientRect();
881
+ const top = rect.top - rootTop;
882
+ const bottom = top + rect.height;
883
+ if (bottom > 0 && firstVisible < 0) {
884
+ firstVisible = i;
885
+ }
886
+ if (top < viewportHeight) {
887
+ lastVisible = i;
888
+ }
889
+ }
890
+ if (firstVisible < 0 || lastVisible < 0) {
891
+ return tailRange;
892
+ }
893
+ return {
894
+ start: Math.max(0, firstVisible - MessageListComponent.MOUNT_BUFFER),
895
+ end: Math.min(lastIndex, lastVisible + MessageListComponent.MOUNT_BUFFER)
896
+ };
897
+ }
898
+ /** The rendered DOM node for a timeline key, whatever kind of entry backs it. */
899
+ nodeForKey(key) {
900
+ const entry = this._renderedMessages.get(key);
901
+ if (!entry) {
902
+ return undefined;
903
+ }
904
+ return entry.kind === 'embedded' || entry.kind === 'spacer'
905
+ ? entry.ref.rootNodes[0]
906
+ : entry.ref.location.nativeElement;
907
+ }
908
+ /**
909
+ * Items that must never be unmounted regardless of position.
910
+ *
911
+ * The tail carries streaming output, `isLastMessage` affordances and suggested
912
+ * responses; an in-progress message is mid-stream and would lose its live state.
913
+ */
914
+ mustStayMounted(item, index, timelineLength) {
915
+ if (index === timelineLength - 1) {
916
+ return true;
917
+ }
918
+ return item.Kind === 'message' && item.Detail.Status === 'In-Progress';
919
+ }
920
+ /**
921
+ * Records what an entry occupied on screen, so its spacer holds exactly that much space.
922
+ *
923
+ * Called immediately BEFORE destroying — afterwards the node is gone. A wrong height here
924
+ * is the failure mode that makes scrolling jump, which is the whole thing spacers exist
925
+ * to prevent.
926
+ */
927
+ rememberHeight(key, entry) {
928
+ const node = entry.kind === 'embedded' || entry.kind === 'spacer'
929
+ ? entry.ref.rootNodes[0]
930
+ : entry.ref.location.nativeElement;
931
+ const height = node?.offsetHeight ?? 0;
932
+ if (height > 0) {
933
+ this._measuredHeights.set(key, height);
934
+ }
935
+ }
936
+ /** Measured height when we have one, else a kind-appropriate estimate. */
937
+ heightFor(key, item) {
938
+ const measured = this._measuredHeights.get(key);
939
+ if (measured !== undefined) {
940
+ return measured;
941
+ }
942
+ return item.Kind === 'session'
943
+ ? MessageListComponent.ESTIMATED_SESSION_HEIGHT
944
+ : MessageListComponent.ESTIMATED_MESSAGE_HEIGHT;
945
+ }
946
+ /**
947
+ * Measures every currently-mounted item.
948
+ *
949
+ * Runs each checked cycle so an item has a real height recorded BEFORE it scrolls far
950
+ * enough to be unmounted — otherwise its first spacer is always a guess.
951
+ */
952
+ measureMountedItems() {
953
+ this._renderedMessages.forEach((entry, key) => {
954
+ if (entry.kind === 'spacer') {
955
+ return;
956
+ }
957
+ const node = entry.kind === 'embedded'
958
+ ? entry.ref.rootNodes[0]
959
+ : entry.ref.location.nativeElement;
960
+ const height = node?.offsetHeight ?? 0;
961
+ if (height > 0) {
962
+ this._measuredHeights.set(key, height);
963
+ }
964
+ });
965
+ }
966
+ /**
967
+ * Replaces a mounted item with a height-holding spacer, or leaves an existing spacer
968
+ * alone. Stored under the item's own timeline key so {@link updateMessages}'s
969
+ * remove-stale pass and the remount path both find it.
970
+ */
971
+ ensureSpacer(key, item, timelineIndex) {
972
+ const existing = this._renderedMessages.get(key);
973
+ if (existing?.kind === 'spacer') {
974
+ return;
975
+ }
976
+ if (!this.spacerTemplate) {
977
+ // Staying mounted is the safe choice, but silently doing so means the transcript
978
+ // grows without bound and nothing says why. Report it once.
979
+ if (!this._warnedNoSpacerTemplate) {
980
+ this._warnedNoSpacerTemplate = true;
981
+ console.warn('[MessageList] spacerTemplate did not resolve, so off-screen messages cannot be '
982
+ + 'unmounted and the DOM will grow with every page loaded.');
983
+ }
984
+ return;
985
+ }
986
+ if (existing) {
987
+ this.rememberHeight(key, existing);
988
+ existing.ref.destroy();
989
+ this._renderedMessages.delete(key);
990
+ }
991
+ const viewRef = this.messageContainerRef.createEmbeddedView(this.spacerTemplate, { height: this.heightFor(key, item) }, { index: timelineIndex });
992
+ this._renderedMessages.set(key, { kind: 'spacer', ref: viewRef });
993
+ }
994
+ /**
995
+ * Re-evaluates the mounted window as the user scrolls.
996
+ *
997
+ * Attached once to the host's scroller. Coalesced onto an animation frame so a fast scroll
998
+ * costs one re-render per frame rather than one per event, and skipped entirely when the
999
+ * window has not actually moved.
1000
+ */
1001
+ syncScrollListener() {
1002
+ const root = this.resolveScrollParent();
1003
+ if (!root || root === this._scrollListenerTarget) {
1004
+ return;
1005
+ }
1006
+ this.detachScrollListener();
1007
+ this._scrollListenerTarget = root;
1008
+ this._onScroll = () => {
1009
+ if (this._scrollFrame !== null) {
1010
+ return;
1011
+ }
1012
+ this._scrollFrame = requestAnimationFrame(() => {
1013
+ this._scrollFrame = null;
1014
+ if (this.messages?.length) {
1015
+ this.updateMessages(this.messages);
1016
+ }
1017
+ });
1018
+ };
1019
+ root.addEventListener('scroll', this._onScroll, { passive: true });
1020
+ }
1021
+ detachScrollListener() {
1022
+ if (this._scrollListenerTarget && this._onScroll) {
1023
+ this._scrollListenerTarget.removeEventListener('scroll', this._onScroll);
1024
+ }
1025
+ this._scrollListenerTarget = null;
1026
+ this._onScroll = null;
1027
+ if (this._scrollFrame !== null) {
1028
+ cancelAnimationFrame(this._scrollFrame);
1029
+ this._scrollFrame = null;
1030
+ }
1031
+ }
1032
+ /**
1033
+ * Watches the current spacers so scrolling back toward one remounts it.
1034
+ *
1035
+ * Rebuilt on every render because spacers come and go. Remounting reads from `messages`,
1036
+ * which is already in memory — this never triggers a fetch.
1037
+ */
1038
+ syncSpacerObserver() {
1039
+ if (!this.canObserve) {
1040
+ return;
1041
+ }
1042
+ const root = this.resolveScrollParent();
1043
+ if (!root) {
1044
+ this._spacerObserver?.disconnect();
1045
+ this._spacerObserver = undefined;
1046
+ this._observedSpacerKeys = '';
1047
+ return;
1048
+ }
1049
+ const spacers = [];
1050
+ this._renderedMessages.forEach((entry, key) => {
1051
+ if (entry.kind === 'spacer') {
1052
+ const el = entry.ref.rootNodes[0];
1053
+ if (el) {
1054
+ spacers.push({ key, el });
1055
+ }
1056
+ }
1057
+ });
1058
+ if (spacers.length === 0) {
1059
+ this._spacerObserver?.disconnect();
1060
+ this._spacerObserver = undefined;
1061
+ this._observedSpacerKeys = '';
1062
+ return;
1063
+ }
1064
+ // Rebuild ONLY when the spacer set actually changes.
1065
+ //
1066
+ // This runs every checked cycle, and a fresh IntersectionObserver delivers an initial
1067
+ // callback for everything it observes. Rebuilding unconditionally therefore re-fired
1068
+ // remountAround on every change-detection pass, which re-rendered, which triggered
1069
+ // another pass — the transcript visibly stuck, replaying the same region.
1070
+ const signature = spacers.map(s => s.key).sort().join('|');
1071
+ if (this._spacerObserver && signature === this._observedSpacerKeys) {
1072
+ return;
1073
+ }
1074
+ this._spacerObserver?.disconnect();
1075
+ this._observedSpacerKeys = signature;
1076
+ this._spacerObserver = new IntersectionObserver(entries => {
1077
+ const hit = entries.find(e => e.isIntersecting);
1078
+ if (!hit) {
1079
+ return;
1080
+ }
1081
+ // The range is computed from scroll position; a spacer coming into view just means
1082
+ // it is time to recompute. No key is threaded through, so there is no feedback loop.
1083
+ if (this.messages?.length) {
1084
+ this.updateMessages(this.messages);
1085
+ }
1086
+ }, { root, threshold: 0.01 });
1087
+ for (const s of spacers) {
1088
+ this._spacerObserver.observe(s.el);
1089
+ }
1090
+ }
333
1091
  /** Stable render key for a timeline item — message ID, or a prefixed session key for session blocks. */
334
1092
  getTimelineKey(item) {
335
1093
  return item.Kind === 'session' ? this.getSessionKey(item.Group.SessionID) : this.getMessageKey(item.Detail);
@@ -353,7 +1111,7 @@ export class MessageListComponent extends BaseAngularComponent {
353
1111
  * Click/Open on the card bubbles up via {@link realtimeSessionOpenRequested} so the
354
1112
  * chat area can host the SESSION REVIEW overlay for it.
355
1113
  */
356
- renderSessionBlock(group) {
1114
+ renderSessionBlock(group, timelineIndex) {
357
1115
  const key = this.getSessionKey(group.SessionID);
358
1116
  const meta = this.sessionMetaMap.get(NormalizeUUID(group.SessionID)) ?? null;
359
1117
  const existing = this._renderedMessages.get(key);
@@ -370,7 +1128,7 @@ export class MessageListComponent extends BaseAngularComponent {
370
1128
  existing.ref.destroy();
371
1129
  this._renderedMessages.delete(key);
372
1130
  }
373
- const componentRef = this.messageContainerRef.createComponent(RealtimeSessionTimelineCardComponent);
1131
+ const componentRef = this.messageContainerRef.createComponent(RealtimeSessionTimelineCardComponent, { index: timelineIndex });
374
1132
  componentRef.instance.Group = group;
375
1133
  componentRef.instance.Meta = meta;
376
1134
  componentRef.instance.UserName = this.currentUser?.Name || 'You';
@@ -384,7 +1142,7 @@ export class MessageListComponent extends BaseAngularComponent {
384
1142
  * dynamic-component path. Both paths flow through the shared
385
1143
  * `createRenderedEntry` / `updateMessageItemInstance` helpers.
386
1144
  */
387
- renderMessageItem(message, messages, isLastMessage) {
1145
+ renderMessageItem(message, messages, isLastMessage, timelineIndex) {
388
1146
  const key = this.getMessageKey(message);
389
1147
  // `index` is only used for the `isLastMessage` heuristic inside
390
1148
  // updateMessageItemInstance — synthesize a value that produces the right
@@ -411,7 +1169,7 @@ export class MessageListComponent extends BaseAngularComponent {
411
1169
  existing.ref.destroy();
412
1170
  this._renderedMessages.delete(key);
413
1171
  }
414
- this.createRenderedEntry(message, messages, index, key, useCustomRenderer);
1172
+ this.createRenderedEntry(message, messages, index, key, useCustomRenderer, timelineIndex);
415
1173
  }
416
1174
  /**
417
1175
  * Updates an existing `MessageItemComponent` in place — used on the default
@@ -459,18 +1217,18 @@ export class MessageListComponent extends BaseAngularComponent {
459
1217
  * Stores the entry in `_renderedMessages` and stamps a back-reference on the
460
1218
  * message entity.
461
1219
  */
462
- createRenderedEntry(message, messages, index, key, useCustomRenderer) {
1220
+ createRenderedEntry(message, messages, index, key, useCustomRenderer, timelineIndex) {
463
1221
  if (useCustomRenderer && this.messageRendererTemplate) {
464
1222
  // The slot directive carries TemplateRef<unknown>; assert the contract here
465
1223
  // (consumers' `let-message` bindings consume the message context shape below).
466
1224
  const template = this.messageRendererTemplate;
467
- const viewRef = this.messageContainerRef.createEmbeddedView(template, { $implicit: message, message });
1225
+ const viewRef = this.messageContainerRef.createEmbeddedView(template, { $implicit: message, message }, { index: timelineIndex });
468
1226
  this._renderedMessages.set(key, { kind: 'embedded', ref: viewRef });
469
1227
  // Stamp back-ref for parity with the component path.
470
1228
  message._viewRef = viewRef;
471
1229
  return;
472
1230
  }
473
- const componentRef = this.messageContainerRef.createComponent(MessageItemComponent);
1231
+ const componentRef = this.messageContainerRef.createComponent(MessageItemComponent, { index: timelineIndex });
474
1232
  const instance = componentRef.instance;
475
1233
  instance.message = message;
476
1234
  instance.conversation = this.conversation;
@@ -577,6 +1335,15 @@ export class MessageListComponent extends BaseAngularComponent {
577
1335
  * Only show if conversation is long and spans multiple days
578
1336
  */
579
1337
  updateDateFilterVisibility() {
1338
+ // `messages` is the loaded WINDOW, not the conversation. The 20-message / 3-day heuristic
1339
+ // below reads it as if it were full history, so under windowing a long multi-week thread
1340
+ // opens with ~10 items, fails the length check, and never offers date navigation at all —
1341
+ // the one case where it is most useful. `HasMoreAbove` is the only windowing-safe signal
1342
+ // that more conversation exists above, so it short-circuits the heuristic.
1343
+ if (this.HasMoreAbove) {
1344
+ this.shouldShowDateFilter = true;
1345
+ return;
1346
+ }
580
1347
  if (!this.messages || this.messages.length < 20) {
581
1348
  this.shouldShowDateFilter = false;
582
1349
  return;
@@ -618,30 +1385,38 @@ export class MessageListComponent extends BaseAngularComponent {
618
1385
  this._renderedMessages.delete(key);
619
1386
  }
620
1387
  }
621
- static ɵfac = function MessageListComponent_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || MessageListComponent)(i0.ɵɵdirectiveInject(i0.ChangeDetectorRef)); };
1388
+ static ɵfac = function MessageListComponent_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || MessageListComponent)(i0.ɵɵdirectiveInject(i0.ChangeDetectorRef), i0.ɵɵdirectiveInject(i0.ElementRef)); };
622
1389
  static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: MessageListComponent, selectors: [["mj-conversation-message-list"]], viewQuery: function MessageListComponent_Query(rf, ctx) { if (rf & 1) {
623
- i0.ɵɵviewQuery(_c0, 5, ViewContainerRef)(_c1, 5);
1390
+ i0.ɵɵviewQuery(_c0, 5, ViewContainerRef)(_c1, 5)(_c2, 5)(_c3, 5);
624
1391
  } if (rf & 2) {
625
1392
  let _t;
626
1393
  i0.ɵɵqueryRefresh(_t = i0.ɵɵloadQuery()) && (ctx.messageContainerRef = _t.first);
627
1394
  i0.ɵɵqueryRefresh(_t = i0.ɵɵloadQuery()) && (ctx.scrollContainer = _t.first);
628
- } }, inputs: { messages: "messages", conversation: "conversation", currentUser: "currentUser", isProcessing: "isProcessing", showEmptyFill: "showEmptyFill", showDateNavigation: "showDateNavigation", showAgentRunDetails: "showAgentRunDetails", showReactions: "showReactions", showMessageRating: "showMessageRating", allowPinning: "allowPinning", allowMessageEdit: "allowMessageEdit", allowMessageDelete: "allowMessageDelete", assistantDisplayName: "assistantDisplayName", assistantAvatarUrl: "assistantAvatarUrl", artifactMap: "artifactMap", agentRunMap: "agentRunMap", ratingsMap: "ratingsMap", userAvatarMap: "userAvatarMap", attachmentsMap: "attachmentsMap", sessionMetaMap: "sessionMetaMap", messageRendererTemplate: "messageRendererTemplate", messageExtraTemplate: "messageExtraTemplate", messagesUpdate: "messagesUpdate" }, outputs: { editMessage: "editMessage", deleteMessage: "deleteMessage", retryMessage: "retryMessage", testFeedbackMessage: "testFeedbackMessage", artifactClicked: "artifactClicked", replyInThread: "replyInThread", viewThread: "viewThread", messageEdited: "messageEdited", openEntityRecord: "openEntityRecord", suggestedResponseSelected: "suggestedResponseSelected", attachmentClicked: "attachmentClicked", diagnosticRequested: "diagnosticRequested", messagePinToggled: "messagePinToggled", realtimeSessionOpenRequested: "realtimeSessionOpenRequested", beforeResponseFormSubmitted: "beforeResponseFormSubmitted", afterResponseFormSubmitted: "afterResponseFormSubmitted" }, standalone: false, features: [i0.ɵɵInheritDefinitionFeature, i0.ɵɵNgOnChangesFeature], decls: 6, vars: 2, consts: [["scrollContainer", ""], ["messageContainer", ""], [1, "message-list-container"], [1, "sticky-date-header"], ["Icon", "fa-solid fa-comments", "Title", "No messages yet. Start a conversation!", 1, "messages-empty-fill"], [1, "sticky-date-wrapper"], [1, "sticky-date-button", 3, "click"], [1, "fas", "fa-chevron-down"], [1, "date-nav-dropdown"], [1, "date-nav-option", "jump-to"], [1, "date-nav-option", 3, "click"]], template: function MessageListComponent_Template(rf, ctx) { if (rf & 1) {
629
- i0.ɵɵelementStart(0, "div", 2, 0);
630
- i0.ɵɵconditionalCreate(2, MessageListComponent_Conditional_2_Template, 17, 3, "div", 3);
631
- i0.ɵɵelementContainer(3, null, 1);
632
- i0.ɵɵconditionalCreate(5, MessageListComponent_Conditional_5_Template, 1, 0, "mj-empty-state", 4);
1395
+ i0.ɵɵqueryRefresh(_t = i0.ɵɵloadQuery()) && (ctx.olderSentinel = _t.first);
1396
+ i0.ɵɵqueryRefresh(_t = i0.ɵɵloadQuery()) && (ctx.spacerTemplate = _t.first);
1397
+ } }, hostVars: 2, hostBindings: function MessageListComponent_HostBindings(rf, ctx) { if (rf & 2) {
1398
+ i0.ɵɵclassProp("mj-list-host-scrolled", ctx.HostSuppliesScroller);
1399
+ } }, inputs: { messages: "messages", conversation: "conversation", currentUser: "currentUser", isProcessing: "isProcessing", showEmptyFill: "showEmptyFill", showDateNavigation: "showDateNavigation", showAgentRunDetails: "showAgentRunDetails", showReactions: "showReactions", showMessageRating: "showMessageRating", allowPinning: "allowPinning", allowMessageEdit: "allowMessageEdit", allowMessageDelete: "allowMessageDelete", HasMoreAbove: "HasMoreAbove", IsLoadingOlder: "IsLoadingOlder", ScrollRoot: "ScrollRoot", assistantDisplayName: "assistantDisplayName", assistantAvatarUrl: "assistantAvatarUrl", artifactMap: "artifactMap", agentRunMap: "agentRunMap", ratingsMap: "ratingsMap", userAvatarMap: "userAvatarMap", attachmentsMap: "attachmentsMap", sessionMetaMap: "sessionMetaMap", messageRendererTemplate: "messageRendererTemplate", messageExtraTemplate: "messageExtraTemplate", messagesUpdate: "messagesUpdate" }, outputs: { editMessage: "editMessage", deleteMessage: "deleteMessage", retryMessage: "retryMessage", testFeedbackMessage: "testFeedbackMessage", artifactClicked: "artifactClicked", replyInThread: "replyInThread", viewThread: "viewThread", messageEdited: "messageEdited", openEntityRecord: "openEntityRecord", suggestedResponseSelected: "suggestedResponseSelected", attachmentClicked: "attachmentClicked", diagnosticRequested: "diagnosticRequested", messagePinToggled: "messagePinToggled", realtimeSessionOpenRequested: "realtimeSessionOpenRequested", beforeResponseFormSubmitted: "beforeResponseFormSubmitted", afterResponseFormSubmitted: "afterResponseFormSubmitted", OlderRequested: "OlderRequested", DateJumpRequested: "DateJumpRequested" }, standalone: false, features: [i0.ɵɵInheritDefinitionFeature, i0.ɵɵNgOnChangesFeature], decls: 9, vars: 3, consts: [["scrollContainer", ""], ["spacerTemplate", ""], ["messageContainer", ""], ["olderSentinel", ""], [1, "message-list-container"], [1, "sticky-date-header"], [1, "transcript-older-sentinel"], ["Icon", "fa-solid fa-comments", "Title", "No messages yet. Start a conversation!", 1, "messages-empty-fill"], [1, "sticky-date-wrapper"], [1, "sticky-date-button", 3, "click"], [1, "fas", "fa-chevron-down"], [1, "date-nav-dropdown"], [1, "date-nav-option", "jump-to"], [1, "date-nav-option", 3, "click"], ["text", "Loading earlier messages\u2026", "size", "small"], [1, "transcript-older-label"], ["aria-hidden", "true", 1, "transcript-spacer"]], template: function MessageListComponent_Template(rf, ctx) { if (rf & 1) {
1400
+ i0.ɵɵelementStart(0, "div", 4, 0);
1401
+ i0.ɵɵconditionalCreate(2, MessageListComponent_Conditional_2_Template, 17, 3, "div", 5);
1402
+ i0.ɵɵconditionalCreate(3, MessageListComponent_Conditional_3_Template, 4, 1, "div", 6);
1403
+ i0.ɵɵtemplate(4, MessageListComponent_ng_template_4_Template, 1, 2, "ng-template", null, 1, i0.ɵɵtemplateRefExtractor);
1404
+ i0.ɵɵelementContainer(6, null, 2);
1405
+ i0.ɵɵconditionalCreate(8, MessageListComponent_Conditional_8_Template, 1, 0, "mj-empty-state", 7);
633
1406
  i0.ɵɵelementEnd();
634
1407
  } if (rf & 2) {
635
1408
  i0.ɵɵadvance(2);
636
1409
  i0.ɵɵconditional(ctx.showDateNavigation && ctx.shouldShowDateFilter && ctx.messages.length > 0 ? 2 : -1);
637
- i0.ɵɵadvance(3);
638
- i0.ɵɵconditional(ctx.messages.length === 0 && ctx.showEmptyFill ? 5 : -1);
639
- } }, dependencies: [i1.MJEmptyStateComponent], styles: [".message-list-container[_ngcontent-%COMP%] {\n flex: 1;\n overflow-y: auto;\n overflow-x: hidden;\n background-color: var(--mj-bg-surface-card);\n position: relative;\n}\n\n\n\n.messages-empty-fill[_ngcontent-%COMP%] {\n height: 100%;\n}\n\n\n\n.sticky-date-header[_ngcontent-%COMP%] {\n position: sticky;\n top: 12px;\n z-index: 100;\n display: flex;\n justify-content: center;\n align-items: center;\n padding: 0;\n margin: 0;\n pointer-events: none;\n}\n\n.sticky-date-wrapper[_ngcontent-%COMP%] {\n pointer-events: auto;\n position: relative;\n}\n\n.sticky-date-button[_ngcontent-%COMP%] {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 6px 12px;\n border: 1px solid var(--mj-border-default);\n border-radius: 16px;\n background: var(--mj-bg-surface-card);\n font-size: 13px;\n font-weight: 600;\n color: var(--mj-text-primary);\n cursor: pointer;\n transition: all 0.2s;\n box-shadow: var(--mj-shadow-sm);\n}\n\n.sticky-date-button[_ngcontent-%COMP%]:hover {\n background: var(--mj-bg-surface-sunken);\n box-shadow: var(--mj-shadow-md);\n}\n\n.sticky-date-button[_ngcontent-%COMP%] i[_ngcontent-%COMP%] {\n font-size: 10px;\n color: var(--mj-text-secondary);\n}\n\n\n\n.date-nav-dropdown[_ngcontent-%COMP%] {\n position: absolute;\n top: 100%;\n left: 50%;\n transform: translateX(-50%);\n margin-top: 8px;\n background: var(--mj-bg-surface-card);\n border: 1px solid var(--mj-border-default);\n border-radius: 8px;\n box-shadow: var(--mj-shadow-lg);\n min-width: 200px;\n display: none;\n z-index: 101;\n}\n\n.date-nav-dropdown.show[_ngcontent-%COMP%] {\n display: block;\n}\n\n.date-nav-option[_ngcontent-%COMP%] {\n padding: 10px 16px;\n cursor: pointer;\n transition: background 0.2s;\n font-size: 14px;\n color: var(--mj-text-primary);\n}\n\n.date-nav-option[_ngcontent-%COMP%]:hover {\n background: var(--mj-bg-surface-sunken);\n}\n\n.date-nav-option.jump-to[_ngcontent-%COMP%] {\n font-weight: 600;\n color: var(--mj-text-secondary);\n font-size: 12px;\n cursor: default;\n padding: 8px 16px 4px 16px;\n}\n\n.date-nav-option.jump-to[_ngcontent-%COMP%]:hover {\n background: none;\n}"] });
1410
+ i0.ɵɵadvance();
1411
+ i0.ɵɵconditional(ctx.HasMoreAbove ? 3 : -1);
1412
+ i0.ɵɵadvance(5);
1413
+ i0.ɵɵconditional(ctx.messages.length === 0 && ctx.showEmptyFill ? 8 : -1);
1414
+ } }, dependencies: [i1.MJEmptyStateComponent, i2.LoadingComponent], styles: ["\n\n\n\n\n.message-list-container[_ngcontent-%COMP%] {\n flex: 1;\n overflow-y: auto;\n overflow-x: hidden;\n background-color: var(--mj-bg-surface-card);\n position: relative;\n}\n\n\n\n.messages-empty-fill[_ngcontent-%COMP%] {\n height: 100%;\n}\n\n\n\n.sticky-date-header[_ngcontent-%COMP%] {\n position: sticky;\n top: 12px;\n z-index: 100;\n display: flex;\n justify-content: center;\n align-items: center;\n padding: 0;\n margin: 0;\n pointer-events: none;\n}\n\n.sticky-date-wrapper[_ngcontent-%COMP%] {\n pointer-events: auto;\n position: relative;\n}\n\n.sticky-date-button[_ngcontent-%COMP%] {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 6px 12px;\n border: 1px solid var(--mj-border-default);\n border-radius: 16px;\n background: var(--mj-bg-surface-card);\n font-size: 13px;\n font-weight: 600;\n color: var(--mj-text-primary);\n cursor: pointer;\n transition: all 0.2s;\n box-shadow: var(--mj-shadow-sm);\n}\n\n.sticky-date-button[_ngcontent-%COMP%]:hover {\n background: var(--mj-bg-surface-sunken);\n box-shadow: var(--mj-shadow-md);\n}\n\n.sticky-date-button[_ngcontent-%COMP%] i[_ngcontent-%COMP%] {\n font-size: 10px;\n color: var(--mj-text-secondary);\n}\n\n\n\n.date-nav-dropdown[_ngcontent-%COMP%] {\n position: absolute;\n top: 100%;\n left: 50%;\n transform: translateX(-50%);\n margin-top: 8px;\n background: var(--mj-bg-surface-card);\n border: 1px solid var(--mj-border-default);\n border-radius: 8px;\n box-shadow: var(--mj-shadow-lg);\n min-width: 200px;\n display: none;\n z-index: 101;\n}\n\n.date-nav-dropdown.show[_ngcontent-%COMP%] {\n display: block;\n}\n\n.date-nav-option[_ngcontent-%COMP%] {\n padding: 10px 16px;\n cursor: pointer;\n transition: background 0.2s;\n font-size: 14px;\n color: var(--mj-text-primary);\n}\n\n.date-nav-option[_ngcontent-%COMP%]:hover {\n background: var(--mj-bg-surface-sunken);\n}\n\n.date-nav-option.jump-to[_ngcontent-%COMP%] {\n font-weight: 600;\n color: var(--mj-text-secondary);\n font-size: 12px;\n cursor: default;\n padding: 8px 16px 4px 16px;\n}\n\n.date-nav-option.jump-to[_ngcontent-%COMP%]:hover {\n background: none;\n}\n\n\n\n\n\n.transcript-older-sentinel[_ngcontent-%COMP%] {\n display: flex;\n align-items: center;\n justify-content: center;\n min-height: 2.5rem;\n padding: 0.75rem 1rem;\n}\n\n.transcript-older-label[_ngcontent-%COMP%] {\n font-size: 0.8125rem;\n color: var(--mj-text-secondary);\n}\n\n\n\n\n.transcript-spacer[_ngcontent-%COMP%] {\n width: 100%;\n flex-shrink: 0;\n}\n\n\n\n\n\n\n\n\n\n\n.mj-list-host-scrolled[_nghost-%COMP%] .message-list-container[_ngcontent-%COMP%] {\n overflow: visible;\n}"] });
640
1415
  }
641
1416
  (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(MessageListComponent, [{
642
1417
  type: Component,
643
- args: [{ standalone: false, selector: 'mj-conversation-message-list', template: "<div class=\"message-list-container\" #scrollContainer>\n <!-- Sticky Date Header (only show for long conversations spanning multiple days) -->\n @if (showDateNavigation && shouldShowDateFilter && messages.length > 0) {\n <div class=\"sticky-date-header\">\n <div class=\"sticky-date-wrapper\">\n <button class=\"sticky-date-button\" (click)=\"toggleDateNav()\">\n <span>{{ currentDateDisplay }}</span>\n <i class=\"fas fa-chevron-down\"></i>\n </button>\n <div class=\"date-nav-dropdown\" [class.show]=\"showDateNav\">\n <div class=\"date-nav-option jump-to\">Jump to...</div>\n <div class=\"date-nav-option\" (click)=\"jumpToDate('today')\">Today</div>\n <div class=\"date-nav-option\" (click)=\"jumpToDate('yesterday')\">Yesterday</div>\n <div class=\"date-nav-option\" (click)=\"jumpToDate('last-week')\">Last week</div>\n <div class=\"date-nav-option\" (click)=\"jumpToDate('last-month')\">Last month</div>\n </div>\n </div>\n </div>\n }\n\n <ng-container #messageContainer></ng-container>\n\n @if (messages.length === 0 && showEmptyFill) {\n <mj-empty-state\n class=\"messages-empty-fill\"\n Icon=\"fa-solid fa-comments\"\n Title=\"No messages yet. Start a conversation!\" />\n }\n</div>", styles: [".message-list-container {\n flex: 1;\n overflow-y: auto;\n overflow-x: hidden;\n background-color: var(--mj-bg-surface-card);\n position: relative;\n}\n\n/* Empty-state fills the message pane vertically */\n.messages-empty-fill {\n height: 100%;\n}\n\n/* Sticky Date Header */\n.sticky-date-header {\n position: sticky;\n top: 12px;\n z-index: 100;\n display: flex;\n justify-content: center;\n align-items: center;\n padding: 0;\n margin: 0;\n pointer-events: none;\n}\n\n.sticky-date-wrapper {\n pointer-events: auto;\n position: relative;\n}\n\n.sticky-date-button {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 6px 12px;\n border: 1px solid var(--mj-border-default);\n border-radius: 16px;\n background: var(--mj-bg-surface-card);\n font-size: 13px;\n font-weight: 600;\n color: var(--mj-text-primary);\n cursor: pointer;\n transition: all 0.2s;\n box-shadow: var(--mj-shadow-sm);\n}\n\n.sticky-date-button:hover {\n background: var(--mj-bg-surface-sunken);\n box-shadow: var(--mj-shadow-md);\n}\n\n.sticky-date-button i {\n font-size: 10px;\n color: var(--mj-text-secondary);\n}\n\n/* Date Navigation Dropdown */\n.date-nav-dropdown {\n position: absolute;\n top: 100%;\n left: 50%;\n transform: translateX(-50%);\n margin-top: 8px;\n background: var(--mj-bg-surface-card);\n border: 1px solid var(--mj-border-default);\n border-radius: 8px;\n box-shadow: var(--mj-shadow-lg);\n min-width: 200px;\n display: none;\n z-index: 101;\n}\n\n.date-nav-dropdown.show {\n display: block;\n}\n\n.date-nav-option {\n padding: 10px 16px;\n cursor: pointer;\n transition: background 0.2s;\n font-size: 14px;\n color: var(--mj-text-primary);\n}\n\n.date-nav-option:hover {\n background: var(--mj-bg-surface-sunken);\n}\n\n.date-nav-option.jump-to {\n font-weight: 600;\n color: var(--mj-text-secondary);\n font-size: 12px;\n cursor: default;\n padding: 8px 16px 4px 16px;\n}\n\n.date-nav-option.jump-to:hover {\n background: none;\n}\n"] }]
644
- }], () => [{ type: i0.ChangeDetectorRef }], { messages: [{
1418
+ args: [{ standalone: false, selector: 'mj-conversation-message-list', template: "<div class=\"message-list-container\" #scrollContainer>\n <!-- Sticky Date Header (only show for long conversations spanning multiple days) -->\n @if (showDateNavigation && shouldShowDateFilter && messages.length > 0) {\n <div class=\"sticky-date-header\">\n <div class=\"sticky-date-wrapper\">\n <button class=\"sticky-date-button\" (click)=\"toggleDateNav()\">\n <span>{{ currentDateDisplay }}</span>\n <i class=\"fas fa-chevron-down\"></i>\n </button>\n <div class=\"date-nav-dropdown\" [class.show]=\"showDateNav\">\n <div class=\"date-nav-option jump-to\">Jump to...</div>\n <div class=\"date-nav-option\" (click)=\"jumpToDate('today')\">Today</div>\n <div class=\"date-nav-option\" (click)=\"jumpToDate('yesterday')\">Yesterday</div>\n <div class=\"date-nav-option\" (click)=\"jumpToDate('last-week')\">Last week</div>\n <div class=\"date-nav-option\" (click)=\"jumpToDate('last-month')\">Last month</div>\n </div>\n </div>\n </div>\n }\n\n <!-- Windowed transcript: older pages load when this scrolls into view (IntersectionObserver\n in the component, not a scroll listener). Absent entirely once the top is reached. -->\n @if (HasMoreAbove) {\n <div #olderSentinel class=\"transcript-older-sentinel\">\n @if (IsLoadingOlder) {\n <mj-loading text=\"Loading earlier messages\u2026\" size=\"small\"></mj-loading>\n } @else {\n <span class=\"transcript-older-label\">Earlier messages</span>\n }\n </div>\n }\n\n <!-- Height-holding stand-in for a timeline item that has been unmounted to keep the DOM\n bounded. Declared here but rendered into #messageContainer at the item's own index,\n so scroll geometry is unchanged. -->\n <ng-template #spacerTemplate let-height=\"height\">\n <div class=\"transcript-spacer\" [style.height.px]=\"height\" aria-hidden=\"true\"></div>\n </ng-template>\n\n <ng-container #messageContainer></ng-container>\n\n @if (messages.length === 0 && showEmptyFill) {\n <mj-empty-state\n class=\"messages-empty-fill\"\n Icon=\"fa-solid fa-comments\"\n Title=\"No messages yet. Start a conversation!\" />\n }\n</div>", styles: ["/* Scrolls itself ONLY when no host scroller was supplied \u2014 see the `mj-list-host-scrolled`\n host binding. `overflow-y` here makes this element the nearest scrolling ancestor, which is\n what `position: sticky` binds to; when the host owns the scrolling this container never\n moves, so anything sticky inside it silently scrolls away instead of pinning. */\n.message-list-container {\n flex: 1;\n overflow-y: auto;\n overflow-x: hidden;\n background-color: var(--mj-bg-surface-card);\n position: relative;\n}\n\n/* Empty-state fills the message pane vertically */\n.messages-empty-fill {\n height: 100%;\n}\n\n/* Sticky Date Header */\n.sticky-date-header {\n position: sticky;\n top: 12px;\n z-index: 100;\n display: flex;\n justify-content: center;\n align-items: center;\n padding: 0;\n margin: 0;\n pointer-events: none;\n}\n\n.sticky-date-wrapper {\n pointer-events: auto;\n position: relative;\n}\n\n.sticky-date-button {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 6px 12px;\n border: 1px solid var(--mj-border-default);\n border-radius: 16px;\n background: var(--mj-bg-surface-card);\n font-size: 13px;\n font-weight: 600;\n color: var(--mj-text-primary);\n cursor: pointer;\n transition: all 0.2s;\n box-shadow: var(--mj-shadow-sm);\n}\n\n.sticky-date-button:hover {\n background: var(--mj-bg-surface-sunken);\n box-shadow: var(--mj-shadow-md);\n}\n\n.sticky-date-button i {\n font-size: 10px;\n color: var(--mj-text-secondary);\n}\n\n/* Date Navigation Dropdown */\n.date-nav-dropdown {\n position: absolute;\n top: 100%;\n left: 50%;\n transform: translateX(-50%);\n margin-top: 8px;\n background: var(--mj-bg-surface-card);\n border: 1px solid var(--mj-border-default);\n border-radius: 8px;\n box-shadow: var(--mj-shadow-lg);\n min-width: 200px;\n display: none;\n z-index: 101;\n}\n\n.date-nav-dropdown.show {\n display: block;\n}\n\n.date-nav-option {\n padding: 10px 16px;\n cursor: pointer;\n transition: background 0.2s;\n font-size: 14px;\n color: var(--mj-text-primary);\n}\n\n.date-nav-option:hover {\n background: var(--mj-bg-surface-sunken);\n}\n\n.date-nav-option.jump-to {\n font-weight: 600;\n color: var(--mj-text-secondary);\n font-size: 12px;\n cursor: default;\n padding: 8px 16px 4px 16px;\n}\n\n.date-nav-option.jump-to:hover {\n background: none;\n}\n\n/* \u2500\u2500 Windowed transcript sentinel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n Sits above the first loaded message while older pages remain. Needs real height\n so the IntersectionObserver has something to observe. */\n.transcript-older-sentinel {\n display: flex;\n align-items: center;\n justify-content: center;\n min-height: 2.5rem;\n padding: 0.75rem 1rem;\n}\n\n.transcript-older-label {\n font-size: 0.8125rem;\n color: var(--mj-text-secondary);\n}\n\n/* Stands in for an unmounted timeline item so the scroll range never changes when the\n DOM is trimmed. Height comes from the item's last measured size. */\n.transcript-spacer {\n width: 100%;\n flex-shrink: 0;\n}\n\n/* Host owns the scrolling: stop creating a scroll context here so `position: sticky`\n descendants (the date navigator) bind to the host's real scroller and actually pin.\n\n BOTH axes, not just `overflow-y`. Per CSS Overflow, when one axis is `visible` and the\n other is not, the `visible` one computes to `auto` \u2014 so leaving `overflow-x: hidden` in\n place silently kept this element a scroll container and the header kept scrolling away.\n Horizontal clipping is not lost: the host's own scroller (`.chat-messages-container`)\n already carries `overflow-x: hidden`. */\n:host(.mj-list-host-scrolled) .message-list-container {\n overflow: visible;\n}\n"] }]
1419
+ }], () => [{ type: i0.ChangeDetectorRef }, { type: i0.ElementRef }], { messages: [{
645
1420
  type: Input
646
1421
  }], conversation: [{
647
1422
  type: Input
@@ -665,6 +1440,15 @@ export class MessageListComponent extends BaseAngularComponent {
665
1440
  type: Input
666
1441
  }], allowMessageDelete: [{
667
1442
  type: Input
1443
+ }], HasMoreAbove: [{
1444
+ type: Input
1445
+ }], IsLoadingOlder: [{
1446
+ type: Input
1447
+ }], ScrollRoot: [{
1448
+ type: Input
1449
+ }], HostSuppliesScroller: [{
1450
+ type: HostBinding,
1451
+ args: ['class.mj-list-host-scrolled']
668
1452
  }], assistantDisplayName: [{
669
1453
  type: Input
670
1454
  }], assistantAvatarUrl: [{
@@ -717,14 +1501,24 @@ export class MessageListComponent extends BaseAngularComponent {
717
1501
  type: Output
718
1502
  }], afterResponseFormSubmitted: [{
719
1503
  type: Output
1504
+ }], OlderRequested: [{
1505
+ type: Output
1506
+ }], DateJumpRequested: [{
1507
+ type: Output
720
1508
  }], messageContainerRef: [{
721
1509
  type: ViewChild,
722
1510
  args: ['messageContainer', { read: ViewContainerRef }]
723
1511
  }], scrollContainer: [{
724
1512
  type: ViewChild,
725
1513
  args: ['scrollContainer']
1514
+ }], olderSentinel: [{
1515
+ type: ViewChild,
1516
+ args: ['olderSentinel']
1517
+ }], spacerTemplate: [{
1518
+ type: ViewChild,
1519
+ args: ['spacerTemplate']
726
1520
  }], messagesUpdate: [{
727
1521
  type: Input
728
1522
  }] }); })();
729
- (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(MessageListComponent, { className: "MessageListComponent", filePath: "src/lib/components/message/message-list.component.ts", lineNumber: 77 }); })();
1523
+ (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(MessageListComponent, { className: "MessageListComponent", filePath: "src/lib/components/message/message-list.component.ts", lineNumber: 108 }); })();
730
1524
  //# sourceMappingURL=message-list.component.js.map