@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.
- package/LICENSE +180 -4
- package/README.md +32 -1
- package/dist/lib/components/conversation/conversation-chat-area.component.d.ts +159 -9
- package/dist/lib/components/conversation/conversation-chat-area.component.d.ts.map +1 -1
- package/dist/lib/components/conversation/conversation-chat-area.component.js +497 -106
- package/dist/lib/components/conversation/conversation-chat-area.component.js.map +1 -1
- package/dist/lib/components/conversation/pinned-messages-panel.component.d.ts +14 -2
- package/dist/lib/components/conversation/pinned-messages-panel.component.d.ts.map +1 -1
- package/dist/lib/components/conversation/pinned-messages-panel.component.js +37 -19
- package/dist/lib/components/conversation/pinned-messages-panel.component.js.map +1 -1
- package/dist/lib/components/message/message-input.component.d.ts +7 -3
- package/dist/lib/components/message/message-input.component.d.ts.map +1 -1
- package/dist/lib/components/message/message-input.component.js +78 -159
- package/dist/lib/components/message/message-input.component.js.map +1 -1
- package/dist/lib/components/message/message-list.component.d.ts +287 -7
- package/dist/lib/components/message/message-list.component.d.ts.map +1 -1
- package/dist/lib/components/message/message-list.component.js +850 -56
- package/dist/lib/components/message/message-list.component.js.map +1 -1
- package/dist/lib/services/conversation-agent.service.d.ts +98 -16
- package/dist/lib/services/conversation-agent.service.d.ts.map +1 -1
- package/dist/lib/services/conversation-agent.service.js +218 -69
- package/dist/lib/services/conversation-agent.service.js.map +1 -1
- package/dist/lib/services/conversation-detail-window.store.d.ts +206 -0
- package/dist/lib/services/conversation-detail-window.store.d.ts.map +1 -0
- package/dist/lib/services/conversation-detail-window.store.js +503 -0
- package/dist/lib/services/conversation-detail-window.store.js.map +1 -0
- package/dist/lib/utils/agent-artifact-summary.d.ts +54 -0
- package/dist/lib/utils/agent-artifact-summary.d.ts.map +1 -0
- package/dist/lib/utils/agent-artifact-summary.js +49 -0
- package/dist/lib/utils/agent-artifact-summary.js.map +1 -0
- package/dist/lib/utils/conversation-detail-window.d.ts +49 -0
- package/dist/lib/utils/conversation-detail-window.d.ts.map +1 -0
- package/dist/lib/utils/conversation-detail-window.js +86 -0
- package/dist/lib/utils/conversation-detail-window.js.map +1 -0
- package/dist/lib/utils/date-jump.d.ts +65 -0
- package/dist/lib/utils/date-jump.d.ts.map +1 -0
- package/dist/lib/utils/date-jump.js +106 -0
- package/dist/lib/utils/date-jump.js.map +1 -0
- 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",
|
|
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",
|
|
24
|
+
i0.ɵɵelement(5, "i", 10);
|
|
20
25
|
i0.ɵɵelementEnd();
|
|
21
|
-
i0.ɵɵelementStart(6, "div",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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
|
|
48
|
-
i0.ɵɵelement(0, "mj-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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 (
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
//
|
|
320
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
629
|
-
i0.ɵɵ
|
|
630
|
-
|
|
631
|
-
i0.ɵɵ
|
|
632
|
-
|
|
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(
|
|
638
|
-
i0.ɵɵconditional(ctx.
|
|
639
|
-
|
|
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:
|
|
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
|