16
getUserHour12,
17
getUserTimezone,
18
} from "./time-utils.js";
19
-import { Scroller } from "./scroller.js";
19
+import { Scroller, cancelPendingScroll } from "./scroller.js";
20
+import {
21
+ MessageWindow,
22
+ classifyMessageRenderUnits,
23
+ getMessageCacheKey,
24
+} from "./message-window.js";
25
import { callJsExtensions } from "/js/extensions.js";
26
import { addBlankTargetsToLinks } from "/js/html-links.js";
27
import { sanitizeHtml } from "/js/safe-markdown.js";
33
};
34
// delay collapse when hovering
35
const STEP_COLLAPSE_HOVER_DELAY_MS = 5000;
36
+const PROCESS_GROUP_STEP_PAGE_SIZE = 50;
37
+const PROCESS_GROUP_RENDER_INFO = Symbol("processGroupRenderInfo");
38
+
39
+let _messageProcessGroups = new WeakMap();
40
+let _messageIsProcessStep = new WeakSet();
41
+const _processGroupStepLimits = new Map();
42
+let _renderedProcessGroupPages = new Map();
43
+
44
+function getMessageRenderUnitKeys(messages) {
45
+ _messageProcessGroups = new WeakMap();
46
+ _messageIsProcessStep = new WeakSet();
47
+ const units = classifyMessageRenderUnits(messages);
48
+ units.forEach((unit, index) => {
49
+ if (!unit.group) return;
50
+ _messageProcessGroups.set(messages[index], unit.group);
51
+ if (unit.isStep) _messageIsProcessStep.add(messages[index]);
52
+ });
53
+ return units.map((unit) => unit.key);
54
+}
55
56
// dom references
57
let _chatHistory = null;
58
59
// state vars
60
let _massRender = false;
61
+let _windowedRender = false;
62
let _scrollOnNextProcessGroup = null;
63
+const _messageWindow = new MessageWindow({
64
+ getUnitKeys: getMessageRenderUnitKeys,
65
+});
66
+let _messageWindowRenderPromise = null;
67
+let _messageRenderQueue = Promise.resolve();
68
+let _messageRenderGeneration = 0;
69
+let _messageWindowHistory = null;
70
+let _messageWindowScrollFrame = null;
71
+let _lastMessageWindowScrollTop = 0;
72
+let _messageWindowFollowTail = true;
73
+let _messageWindowLoadingDirection = null;
74
+let _messageWindowSuppressScrollEvents = false;
75
+let _messageWindowPointerActive = false;
76
+let _messageWindowUserScrollUntil = 0;
77
+let _messageWindowResizeObserver = null;
78
+
79
+// Leave a small tolerance for fractional scroll positions and the passive
80
+// boundary indicator, but do not swap pages while the user is still reading.
81
+const MESSAGE_WINDOW_BOUNDARY_TOLERANCE_PX = 48;
82
+const MESSAGE_WINDOW_TAIL_TOLERANCE_PX = 80;
83
+const MESSAGE_WINDOW_USER_SCROLL_GRACE_MS = 1200;
84
+const LAZY_MESSAGE_PREVIEW_CHARS = 6000;
85
+const DEFERRED_REPLAY_ENTRY_THRESHOLD = 30;
86
+const DEFERRED_REPLAY_TEXT_THRESHOLD = 50000;
87
88
/**
89
* @typedef {object} MessageHandlerArgs
187
188
// entrypoint called from poll/WS communication, this is how all messages are rendered and updated
189
// input is raw log format
141
-export async function setMessages(messages) {
142
- messages = Array.isArray(messages) ? [...messages].filter(Boolean) : [];
143
- messages.sort((a, b) => (a.no ?? Number.MAX_SAFE_INTEGER) - (b.no ?? Number.MAX_SAFE_INTEGER));
190
+export function setMessages(messages) {
191
+ const generation = _messageRenderGeneration;
192
+ const task = _messageRenderQueue.then(
193
+ () => setMessagesNow(messages, generation),
194
+ () => setMessagesNow(messages, generation),
195
+ );
196
+ _messageRenderQueue = task.catch(() => undefined);
197
+ return task;
198
+}
199
145
- const context = {
200
+async function setMessagesNow(messages, generation) {
201
+ if (generation !== _messageRenderGeneration) return null;
202
+ messages = normalizeMessages(messages);
203
+ const history = getChatHistoryEl();
204
+ const followTail = shouldFollowMessageTail();
205
+
206
+ _messageWindow.merge(messages, { followTail });
207
+ bindMessageWindow(history);
208
+ if (_messageWindowRenderPromise) await _messageWindowRenderPromise;
209
+
210
+ const initialWindow =
211
+ _messageWindow.size > 0 && !history?.querySelector(".message-group");
212
+ if (initialWindow && _messageWindowFollowTail) _messageWindow.showTail();
213
+ const compactedTail = _messageWindow.compactTailIfNeeded();
214
+ const windowMessages = _messageWindow.visibleMessages();
215
+ const cappedProcessGroupUpdate = hasCappedProcessGroupUpdate(
216
messages,
147
- history: getChatHistoryEl(),
148
- historyEmpty: false,
217
+ windowMessages,
218
+ );
219
+ if (initialWindow || compactedTail || cappedProcessGroupUpdate) {
220
+ return await renderMessageWindow({
221
+ preserveScroll: !initialWindow && !followTail,
222
+ generation,
223
+ });
224
+ }
225
+
226
+ return await renderMessageBatch(messages, {
227
+ virtualizeOffscreen: true,
228
+ windowedRender: false,
229
+ generation,
230
+ });
231
+}
232
+
233
+export function resetMessageRenderState({ clearDom = true } = {}) {
234
+ _messageRenderGeneration += 1;
235
+ _messageWindow.reset([]);
236
+ _massRender = false;
237
+ _windowedRender = false;
238
+ _scrollOnNextProcessGroup = null;
239
+ _messageWindowFollowTail = true;
240
+ _messageWindowLoadingDirection = null;
241
+ _messageWindowSuppressScrollEvents = false;
242
+ _messageWindowPointerActive = false;
243
+ _messageWindowUserScrollUntil = 0;
244
+ _messageWindowResizeObserver?.disconnect();
245
+ _messageWindowResizeObserver = null;
246
+ _processGroupStepLimits.clear();
247
+ _renderedProcessGroupPages.clear();
248
+
249
+ const history = document.getElementById("chat-history") || getChatHistoryEl();
250
+ if (history) cancelPendingScroll(history);
251
+ if (clearDom && history) history.replaceChildren();
252
+ if (history) {
253
+ delete history.dataset.messageWindowStart;
254
+ delete history.dataset.messageWindowEnd;
255
+ delete history.dataset.messageWindowTotal;
256
+ }
257
+}
258
+
259
+function normalizeMessages(messages) {
260
+ const normalized = Array.isArray(messages) ? [...messages].filter(Boolean) : [];
261
+ normalized.sort(
262
+ (a, b) =>
263
+ (a.no ?? Number.MAX_SAFE_INTEGER) -
264
+ (b.no ?? Number.MAX_SAFE_INTEGER),
265
+ );
266
+ return normalized;
267
+}
268
+
269
+async function renderMessageWindow({
270
+ preserveScroll = true,
271
+ generation = _messageRenderGeneration,
272
+} = {}) {
273
+ if (_messageWindowRenderPromise) return await _messageWindowRenderPromise;
274
+
275
+ _messageWindowRenderPromise = (async () => {
276
+ const history = getChatHistoryEl();
277
+ if (!history) return null;
278
+ const stagingHistory = createMessageWindowStagingHistory(history);
279
+
280
+ _messageWindowSuppressScrollEvents = true;
281
+ cancelPendingScroll(history);
282
+ _messageWindowResizeObserver?.disconnect();
283
+ try {
284
+ const anchor = preserveScroll
285
+ ? captureMessageWindowAnchor(history)
286
+ : null;
287
+ const expansionState = captureMessageExpansionState(history);
288
+ _chatHistory = stagingHistory;
289
+
290
+ const windowMessages = _messageWindow.visibleMessages();
291
+ const renderMessages = getProcessGroupRenderMessages(windowMessages);
292
+ const context = await renderMessageBatch(renderMessages, {
293
+ forceHistoryEmpty: true,
294
+ forceMassRender: true,
295
+ suppressScroll: preserveScroll,
296
+ windowedRender: shouldDeferReplayDetails(renderMessages),
297
+ windowRebuild: true,
298
+ generation,
299
+ });
300
+
301
+ if (generation !== _messageRenderGeneration) {
302
+ return null;
303
+ }
304
+
305
+ updateProcessGroupPagingControls(stagingHistory);
306
+ await restoreMessageExpansionState(stagingHistory, expansionState);
307
+ await nextAnimationFrame();
308
+
309
+ if (generation !== _messageRenderGeneration) return null;
310
+
311
+ _messageWindowResizeObserver?.disconnect();
312
+ stagingHistory
313
+ .querySelectorAll(".message-container")
314
+ .forEach((element) => element.classList.add("message-window-restored"));
315
+ const stagedChildren = Array.from(stagingHistory.childNodes);
316
+ const stagedWindowState = {
317
+ messageWindowStart: stagingHistory.dataset.messageWindowStart,
318
+ messageWindowEnd: stagingHistory.dataset.messageWindowEnd,
319
+ messageWindowTotal: stagingHistory.dataset.messageWindowTotal,
320
+ detailMode: stagingHistory.dataset.detailMode,
321
+ };
322
+ stagingHistory.remove();
323
+ _chatHistory = history;
324
+
325
+ history.replaceChildren(...stagedChildren);
326
+ copyMessageWindowDataset(history, stagedWindowState);
327
+ let anchorRestored = anchor
328
+ ? restoreMessageWindowAnchor(history, anchor)
329
+ : false;
330
+ if (!anchorRestored && _messageWindow.isAtTail() && _messageWindowFollowTail) {
331
+ history.scrollTop = history.scrollHeight;
332
+ }
333
+
334
+ await nextAnimationFrame();
335
+ if (anchor) {
336
+ anchorRestored = restoreMessageWindowAnchor(history, anchor) ||
337
+ anchorRestored;
338
+ }
339
+ if (!anchorRestored && _messageWindow.isAtTail() && _messageWindowFollowTail) {
340
+ history.scrollTop = history.scrollHeight;
341
+ }
342
+
343
+ context.history = history;
344
+ context.mainScroller = null;
345
+ refreshMessageWindowResizeObserver(history);
346
+ return context;
347
+ } finally {
348
+ stagingHistory.remove();
349
+ _chatHistory = history;
350
+ _lastMessageWindowScrollTop = history.scrollTop;
351
+ _messageWindowSuppressScrollEvents = false;
352
+ }
353
+ })();
354
+
355
+ try {
356
+ return await _messageWindowRenderPromise;
357
+ } finally {
358
+ _messageWindowRenderPromise = null;
359
+ }
360
+}
361
+
362
+function shouldDeferReplayDetails(messages) {
363
+ if (
364
+ _messageWindow.hasOlder ||
365
+ _messageWindow.hasNewer ||
366
+ messages.length > DEFERRED_REPLAY_ENTRY_THRESHOLD
367
+ ) {
368
+ return true;
369
+ }
370
+
371
+ let textSize = 0;
372
+ for (const message of messages) {
373
+ textSize += String(message?.heading ?? "").length;
374
+ textSize += String(message?.content ?? "").length;
375
+ for (const value of Object.values(message?.kvps || {})) {
376
+ textSize += typeof value === "string" ? value.length : 500;
377
+ }
378
+ if (textSize > DEFERRED_REPLAY_TEXT_THRESHOLD) return true;
379
+ }
380
+ return false;
381
+}
382
+
383
+async function renderMessageBatch(messages, options = {}) {
384
+ const generation = options.generation ?? _messageRenderGeneration;
385
+ if (generation !== _messageRenderGeneration) return null;
386
+ const history = getChatHistoryEl();
387
+ const context = {
388
+ messages: normalizeMessages(messages),
389
+ history,
390
+ historyEmpty:
391
+ options.forceHistoryEmpty ?? !history?.querySelector(".message-group"),
392
isLargeAppend: false,
393
cutoff: 0,
394
massRender: false,
395
+ windowRebuild: Boolean(options.windowRebuild),
396
+ messageWindow: getMessageWindowContext(),
397
scrollerOptions: {
398
smooth: true,
399
toleranceRem: 4,
406
results: [],
407
};
408
164
- context.historyEmpty = !context.history || context.history.childElementCount === 0;
409
context.isLargeAppend = !context.historyEmpty && context.messages.length > 10;
166
- context.cutoff = context.isLargeAppend ? Math.max(0, context.messages.length - 2) : 0;
167
- context.massRender = context.historyEmpty || context.isLargeAppend;
410
+ context.cutoff = context.isLargeAppend
411
+ ? Math.max(0, context.messages.length - 2)
412
+ : 0;
413
+ context.massRender =
414
+ Boolean(options.forceMassRender) ||
415
+ context.historyEmpty ||
416
+ context.isLargeAppend;
417
context.scrollerOptions.smooth = !context.massRender;
418
419
await callJsExtensions("set_messages_before_loop", context);
420
+ if (generation !== _messageRenderGeneration) {
421
+ context.history?.replaceChildren();
422
+ return null;
423
+ }
424
172
- //@ts-ignore
173
- context.mainScroller = new Scroller(context.history, context.scrollerOptions);
174
-
175
- // process messages
176
- for (let i = 0; i < context.messages.length; i++) {
177
- _massRender = context.historyEmpty || (context.isLargeAppend && i < context.cutoff);
178
- context.results.push(await setMessage(context.messages[i]));
425
+ if (context.history) {
426
+ context.mainScroller = new Scroller(
427
+ context.history,
428
+ context.scrollerOptions,
429
+ );
430
}
431
181
- await callJsExtensions("set_messages_after_loop", context);
432
+ try {
433
+ for (let i = 0; i < context.messages.length; i++) {
434
+ if (generation !== _messageRenderGeneration) break;
435
+ const message = context.messages[i];
436
+ const messageKey = getMessageCacheKey(message);
437
+ if (
438
+ options.virtualizeOffscreen &&
439
+ messageKey &&
440
+ !_messageWindow.isKeyVisible(messageKey)
441
+ ) {
442
+ context.results.push({
443
+ args: message,
444
+ result: { element: null, virtualized: true, dontScroll: true },
445
+ });
446
+ continue;
447
+ }
448
+ _massRender =
449
+ Boolean(options.forceMassRender) ||
450
+ context.historyEmpty ||
451
+ (context.isLargeAppend && i < context.cutoff);
452
+ _windowedRender = Boolean(options.windowedRender);
453
+ const entry = await setMessage(message);
454
+ if (generation !== _messageRenderGeneration) {
455
+ context.history?.replaceChildren();
456
+ break;
457
+ }
458
+ context.results.push(entry);
459
+ }
460
183
- // reset _massRender flag
184
- _massRender = false;
461
+ if (generation === _messageRenderGeneration) {
462
+ updateMessageWindowIndicators(context.history);
463
+ if (
464
+ context.windowRebuild &&
465
+ typeof preferencesStore.applyCurrentDetailMode === "function"
466
+ ) {
467
+ await preferencesStore.applyCurrentDetailMode(context.history);
468
+ }
469
+ refreshMessageWindowResizeObserver(context.history);
470
+ await callJsExtensions("set_messages_after_loop", context);
471
+ }
472
+ } finally {
473
+ _massRender = false;
474
+ _windowedRender = false;
475
+ }
476
+
477
+ if (generation !== _messageRenderGeneration) return null;
478
186
- const shouldScroll = context.historyEmpty || !context.results[context.results.length - 1]?.result?.dontScroll;
479
+ const lastResult = context.results[context.results.length - 1]?.result;
480
+ const shouldScroll =
481
+ !options.suppressScroll &&
482
+ (context.historyEmpty || !lastResult?.dontScroll);
483
484
if (shouldScroll) context.mainScroller?.reApplyScroll();
485
486
if (_scrollOnNextProcessGroup === "scroll") {
487
requestAnimationFrame(() => {
488
+ if (
489
+ generation !== _messageRenderGeneration ||
490
+ _scrollOnNextProcessGroup !== "scroll"
491
+ ) {
492
+ return;
493
+ }
494
context.mainScroller?.scrollToBottom();
495
_scrollOnNextProcessGroup = null;
496
});
497
}
498
+
499
+ return context;
500
}
501
502
// entrypoint called from poll/WS communication, this is how all messages are rendered and updated
516
agentno,
517
...additional
518
}) {
519
+ const rawMessage = arguments[0];
520
const handler = await getMessageHandler(type);
521
// prefer log ID if set to match user message created on frontend with backend updates
217
- const handlerResult = await handler({
522
+ const handlerArgs = {
523
no,
524
id: id || String(no) || "",
525
type,
529
timestamp,
530
agentno,
531
...additional,
227
- });
532
+ };
533
+ handlerArgs[PROCESS_GROUP_RENDER_INFO] = _messageProcessGroups.get(rawMessage);
534
+ const handlerResult = await handler(handlerArgs);
535
+ const messageKey = getMessageCacheKey(rawMessage);
536
+
537
+ if (handlerResult?.element && messageKey) {
538
+ handlerResult.element.dataset.messageKey = messageKey;
539
+ }
540
+ if (handlerResult?.element && no !== undefined && no !== null) {
541
+ handlerResult.element.dataset.logNo = String(no);
542
+ }
543
+
544
+ if (handlerResult?.step) {
545
+ handlerResult.step.__renderDetail = async () => {
546
+ if (!handlerResult.step?.isConnected) return null;
547
+ return await requestDeferredMessageDetail(rawMessage);
548
+ };
549
+ handlerResult.step.__discardDetail = () =>
550
+ discardProcessStepDetail(handlerResult.step);
551
+ handlerResult.step.__setExpanded = (expanded) =>
552
+ toggleStepCollapse(handlerResult.step, expanded);
553
+ }
554
+
555
return {
229
- args: arguments[0],
556
+ args: rawMessage,
557
result: handlerResult,
558
}
559
}
564
containerClasses = [],
565
forceNewGroup = false,
566
) {
240
- let container = document.getElementById(`message-${id}`);
567
+ let container = getChatHistoryElementById(`message-${id}`);
568
if (!container) {
569
container = document.createElement("div");
570
container.id = `message-${id}`;
587
return _chatHistory;
588
}
589
590
+function getChatHistoryElementById(id) {
591
+ const history = getChatHistoryEl();
592
+ if (!history || !id) return null;
593
+ if (globalThis.CSS?.escape) {
594
+ return history.querySelector(`#${globalThis.CSS.escape(id)}`);
595
+ }
596
+ return Array.from(history.querySelectorAll("[id]")).find(
597
+ (element) => element.id === id,
598
+ ) || null;
599
+}
600
+
601
function getLastMessageGroup() {
264
- return getChatHistoryEl()?.lastElementChild;
602
+ const groups = getChatHistoryEl()?.querySelectorAll(":scope > .message-group");
603
+ return groups?.[groups.length - 1] || null;
604
+}
605
+
606
+function getMessageWindowContext() {
607
+ return {
608
+ start: _messageWindow.visibleStart,
609
+ end: _messageWindow.visibleEnd,
610
+ total: _messageWindow.size,
611
+ rendered: _messageWindow.renderedCount,
612
+ older: _messageWindow.olderCount,
613
+ newer: _messageWindow.newerCount,
614
+ hasOlder: _messageWindow.hasOlder,
615
+ hasNewer: _messageWindow.hasNewer,
616
+ };
617
+}
618
+
619
+function getProcessGroupPageState(messages) {
620
+ const groups = new Map();
621
+ for (const message of messages) {
622
+ const group = _messageProcessGroups.get(message);
623
+ if (!group || !_messageIsProcessStep.has(message)) continue;
624
+ let state = groups.get(group.key);
625
+ if (!state) {
626
+ state = { group, steps: [] };
627
+ groups.set(group.key, state);
628
+ }
629
+ state.steps.push(message);
630
+ }
631
+ return groups;
632
+}
633
+
634
+function getProcessGroupRenderMessages(messages) {
635
+ const groups = getProcessGroupPageState(messages);
636
+ const hiddenByGroup = new Map();
637
+ _renderedProcessGroupPages = new Map();
638
+
639
+ for (const [key, state] of groups) {
640
+ const limit = _processGroupStepLimits.get(key) ||
641
+ PROCESS_GROUP_STEP_PAGE_SIZE;
642
+ const hidden = Math.max(0, state.steps.length - limit);
643
+ hiddenByGroup.set(key, hidden);
644
+ _renderedProcessGroupPages.set(key, {
645
+ ...state,
646
+ hidden,
647
+ visible: state.steps.length - hidden,
648
+ });
649
+ }
650
+
651
+ const seen = new Map();
652
+ return messages.filter((message) => {
653
+ const group = _messageProcessGroups.get(message);
654
+ if (!group || !_messageIsProcessStep.has(message)) return true;
655
+ const index = seen.get(group.key) || 0;
656
+ seen.set(group.key, index + 1);
657
+ return index >= (hiddenByGroup.get(group.key) || 0);
658
+ });
659
+}
660
+
661
+function hasCappedProcessGroupUpdate(messages, windowMessages) {
662
+ if (!messages.length) return false;
663
+ const groupStates = getProcessGroupPageState(windowMessages);
664
+ return messages.some((message) => {
665
+ const group = _messageProcessGroups.get(message);
666
+ if (!group || !_messageIsProcessStep.has(message)) return false;
667
+ const total = groupStates.get(group.key)?.steps.length || 0;
668
+ const limit = _processGroupStepLimits.get(group.key) ||
669
+ PROCESS_GROUP_STEP_PAGE_SIZE;
670
+ return total > limit;
671
+ });
672
+}
673
+
674
+function updateProcessGroupPagingControls(history) {
675
+ history
676
+ ?.querySelectorAll(".process-group-show-more")
677
+ .forEach((element) => element.remove());
678
+
679
+ for (const [key, state] of _renderedProcessGroupPages) {
680
+ const group = Array.from(
681
+ history?.querySelectorAll(".process-group[data-render-group-key]") || [],
682
+ ).find((candidate) => candidate.dataset.renderGroupKey === key);
683
+ if (!group) continue;
684
+
685
+ const allSteps = state.steps;
686
+ const firstTimestamp = allSteps[0]?.timestamp;
687
+ const lastTimestamp = allSteps.at(-1)?.timestamp;
688
+ if (firstTimestamp != null) {
689
+ group.dataset.fullStartTimestamp = String(firstTimestamp);
690
+ group.setAttribute("data-start-timestamp", String(firstTimestamp));
691
+ }
692
+ if (lastTimestamp != null) {
693
+ group.dataset.fullEndTimestamp = String(lastTimestamp);
694
+ }
695
+ group.dataset.fullAgentSteps = String(
696
+ Math.max(
697
+ 0,
698
+ allSteps.filter((message) => message?.type === "agent").length - 1,
699
+ ),
700
+ );
701
+ group.dataset.fullWarningSteps = String(
702
+ allSteps.filter((message) => message?.type === "warning").length,
703
+ );
704
+ group.dataset.fullInfoSteps = String(
705
+ allSteps.filter((message) => message?.type === "info").length,
706
+ );
707
+ const lastAgentMessage = allSteps.findLast(
708
+ (message) => message?.type === "agent",
709
+ );
710
+ const fullTitle = cleanStepTitle(lastAgentMessage?.heading, 50);
711
+ if (fullTitle) {
712
+ const title = group.querySelector(".process-group-header .group-title");
713
+ if (title) title.textContent = fullTitle;
714
+ }
715
+ updateProcessGroupHeader(group);
716
+
717
+ if (state.hidden <= 0) continue;
718
+ const stepsContainer = group.querySelector(":scope .process-steps");
719
+ if (!stepsContainer) continue;
720
+ const button = document.createElement("button");
721
+ button.type = "button";
722
+ button.className = "process-group-show-more";
723
+ button.textContent = "Show more";
724
+ const nextCount = Math.min(PROCESS_GROUP_STEP_PAGE_SIZE, state.hidden);
725
+ button.setAttribute("aria-label", `Show ${nextCount} earlier steps`);
726
+ button.addEventListener("click", () => {
727
+ void showMoreProcessGroupSteps(key);
728
+ });
729
+ stepsContainer.insertBefore(button, stepsContainer.firstChild);
730
+ }
731
+}
732
+
733
+function showMoreProcessGroupSteps(groupKey) {
734
+ const generation = _messageRenderGeneration;
735
+ const task = _messageRenderQueue.then(async () => {
736
+ if (generation !== _messageRenderGeneration) return false;
737
+ const current = _processGroupStepLimits.get(groupKey) ||
738
+ PROCESS_GROUP_STEP_PAGE_SIZE;
739
+ _processGroupStepLimits.set(
740
+ groupKey,
741
+ current + PROCESS_GROUP_STEP_PAGE_SIZE,
742
+ );
743
+ await renderMessageWindow({ preserveScroll: true, generation });
744
+ return true;
745
+ });
746
+ _messageRenderQueue = task.catch(() => undefined);
747
+ return task;
748
+}
749
+
750
+function shouldFollowMessageTail() {
751
+ if (_messageWindow.size === 0) return true;
752
+ return _messageWindowFollowTail && _messageWindow.isAtTail();
753
+}
754
+
755
+async function renderDeferredMessageDetail(message) {
756
+ const entry = await setMessage(message);
757
+ await callJsExtensions("set_messages_after_loop", {
758
+ messages: [message],
759
+ history: getChatHistoryEl(),
760
+ historyEmpty: true,
761
+ isLargeAppend: false,
762
+ cutoff: 0,
763
+ massRender: false,
764
+ windowRebuild: false,
765
+ detailMaterialization: true,
766
+ messageWindow: getMessageWindowContext(),
767
+ mainScroller: null,
768
+ results: [entry],
769
+ });
770
+ return entry;
771
+}
772
+
773
+function requestDeferredMessageDetail(message) {
774
+ if (_messageWindowRenderPromise) {
775
+ return renderDeferredMessageDetail(message);
776
+ }
777
+
778
+ const generation = _messageRenderGeneration;
779
+ const task = _messageRenderQueue.then(async () => {
780
+ if (generation !== _messageRenderGeneration) return null;
781
+ return await renderDeferredMessageDetail(message);
782
+ });
783
+ _messageRenderQueue = task.catch(() => undefined);
784
+ return task;
785
+}
786
+
787
+function bindMessageWindow(history) {
788
+ if (!history || _messageWindowHistory === history) return;
789
+ _messageWindowHistory = history;
790
+ _lastMessageWindowScrollTop = history.scrollTop;
791
+
792
+ const noteUserScrollIntent = () => {
793
+ _messageWindowUserScrollUntil =
794
+ messageWindowNow() + MESSAGE_WINDOW_USER_SCROLL_GRACE_MS;
795
+ };
796
+
797
+ history.addEventListener("wheel", noteUserScrollIntent, { passive: true });
798
+ history.addEventListener("touchstart", noteUserScrollIntent, {
799
+ passive: true,
800
+ });
801
+ history.addEventListener("pointerdown", () => {
802
+ _messageWindowPointerActive = true;
803
+ noteUserScrollIntent();
804
+ });
805
+ globalThis.addEventListener("pointerup", () => {
806
+ _messageWindowPointerActive = false;
807
+ });
808
+ globalThis.addEventListener("pointercancel", () => {
809
+ _messageWindowPointerActive = false;
810
+ });
811
+ globalThis.addEventListener("keydown", (event) => {
812
+ const target = event.target;
813
+ if (
814
+ target instanceof Element &&
815
+ target.closest("input, textarea, select, [contenteditable='true']")
816
+ ) {
817
+ return;
818
+ }
819
+ if (
820
+ ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End"].includes(
821
+ event.key,
822
+ )
823
+ ) {
824
+ noteUserScrollIntent();
825
+ }
826
+ });
827
+
828
+ history.addEventListener(
829
+ "scroll",
830
+ () => {
831
+ if (_messageWindowScrollFrame != null) return;
832
+ _messageWindowScrollFrame = requestAnimationFrame(() => {
833
+ _messageWindowScrollFrame = null;
834
+ if (_messageWindowRenderPromise) return;
835
+
836
+ const previous = _lastMessageWindowScrollTop;
837
+ const current = history.scrollTop;
838
+ const direction = current < previous ? "older" : current > previous ? "newer" : null;
839
+ _lastMessageWindowScrollTop = current;
840
+
841
+ const hasUserScrollIntent =
842
+ _messageWindowPointerActive ||
843
+ messageWindowNow() <= _messageWindowUserScrollUntil;
844
+ const bottomDistance =
845
+ history.scrollHeight - current - history.clientHeight;
846
+
847
+ if (hasUserScrollIntent && direction) {
848
+ _messageWindowFollowTail =
849
+ _messageWindow.isAtTail() &&
850
+ bottomDistance <= MESSAGE_WINDOW_TAIL_TOLERANCE_PX;
851
+ }
852
+
853
+ if (
854
+ _messageWindowSuppressScrollEvents ||
855
+ _messageWindowRenderPromise ||
856
+ !hasUserScrollIntent
857
+ ) {
858
+ return;
859
+ }
860
+
861
+ if (
862
+ direction === "older" &&
863
+ current <= MESSAGE_WINDOW_BOUNDARY_TOLERANCE_PX &&
864
+ _messageWindow.hasOlder
865
+ ) {
866
+ void shiftMessageWindow("older");
867
+ return;
868
+ }
869
+
870
+ if (
871
+ direction === "newer" &&
872
+ bottomDistance <= MESSAGE_WINDOW_BOUNDARY_TOLERANCE_PX &&
873
+ _messageWindow.hasNewer
874
+ ) {
875
+ void shiftMessageWindow("newer");
876
+ }
877
+ });
878
+ },
879
+ { passive: true },
880
+ );
881
+}
882
+
883
+function messageWindowNow() {
884
+ return globalThis.performance?.now?.() ?? Date.now();
885
+}
886
+
887
+function refreshMessageWindowResizeObserver(history) {
888
+ if (!history || typeof ResizeObserver === "undefined") return;
889
+ if (!_messageWindowResizeObserver) {
890
+ _messageWindowResizeObserver = new ResizeObserver(() => {
891
+ if (
892
+ _messageWindowSuppressScrollEvents ||
893
+ _messageWindowRenderPromise ||
894
+ !_messageWindowFollowTail ||
895
+ !_messageWindow.isAtTail()
896
+ ) {
897
+ return;
898
+ }
899
+
900
+ cancelPendingScroll(history);
901
+ history.scrollTop = history.scrollHeight;
902
+ _lastMessageWindowScrollTop = history.scrollTop;
903
+ });
904
+ }
905
+
906
+ history
907
+ .querySelectorAll(":scope > .message-group")
908
+ .forEach((group) => _messageWindowResizeObserver.observe(group));
909
+}
910
+
911
+async function shiftMessageWindow(direction) {
912
+ await loadAdjacentMessageWindow(direction);
913
+}
914
+
915
+export function loadAdjacentMessageWindow(direction) {
916
+ if (!["older", "newer"].includes(direction)) {
917
+ return Promise.resolve(false);
918
+ }
919
+ if (_messageWindowLoadingDirection) return Promise.resolve(false);
920
+
921
+ const generation = _messageRenderGeneration;
922
+ _messageWindowLoadingDirection = direction;
923
+ setMessageWindowIndicatorLoading(getChatHistoryEl(), direction, true);
924
+
925
+ const renderTask = _messageRenderQueue.then(async () => {
926
+ if (generation !== _messageRenderGeneration) return false;
927
+ const shifted =
928
+ direction === "older"
929
+ ? _messageWindow.shiftOlder()
930
+ : _messageWindow.shiftNewer();
931
+ if (!shifted) return false;
932
+ await renderMessageWindow({ preserveScroll: true, generation });
933
+ return true;
934
+ });
935
+ const task = renderTask.finally(() => {
936
+ if (_messageWindowLoadingDirection === direction) {
937
+ _messageWindowLoadingDirection = null;
938
+ setMessageWindowIndicatorLoading(getChatHistoryEl(), direction, false);
939
+ }
940
+ });
941
+ _messageRenderQueue = task.catch(() => undefined);
942
+ return task;
943
+}
944
+
945
+export function scrollMessageWindowToEdge(edge) {
946
+ const generation = _messageRenderGeneration;
947
+ const task = _messageRenderQueue.then(async () => {
948
+ if (generation !== _messageRenderGeneration) return false;
949
+ const history = getChatHistoryEl();
950
+
951
+ if (edge === "start") {
952
+ _messageWindowFollowTail = false;
953
+ cancelPendingScroll(history);
954
+ if (!_messageWindow.hasOlder && _messageWindow.start === 0) {
955
+ history?.scrollTo({ top: 0, behavior: "instant" });
956
+ return true;
957
+ }
958
+ _messageWindow.showHead();
959
+ await renderMessageWindow({ preserveScroll: false, generation });
960
+ history?.scrollTo({ top: 0, behavior: "instant" });
961
+ return true;
962
+ }
963
+
964
+ _messageWindowFollowTail = true;
965
+ cancelPendingScroll(history);
966
+ if (_messageWindow.isAtTail()) {
967
+ if (history) history.scrollTop = history.scrollHeight;
968
+ return true;
969
+ }
970
+ _messageWindow.showTail();
971
+ await renderMessageWindow({ preserveScroll: false, generation });
972
+ if (history) history.scrollTop = history.scrollHeight;
973
+ return true;
974
+ });
975
+ _messageRenderQueue = task.catch(() => undefined);
976
+ return task;
977
+}
978
+
979
+export function getMessageWindowState() {
980
+ return getMessageWindowContext();
981
+}
982
+
983
+function updateMessageWindowIndicators(history) {
984
+ if (!history) return;
985
+ history
986
+ .querySelectorAll(":scope > [data-message-window-ui]")
987
+ .forEach((element) => element.remove());
988
+
989
+ history.dataset.messageWindowStart = String(_messageWindow.visibleStart);
990
+ history.dataset.messageWindowEnd = String(_messageWindow.visibleEnd);
991
+ history.dataset.messageWindowTotal = String(_messageWindow.size);
992
+
993
+ if (_messageWindow.hasOlder) {
994
+ const older = createMessageWindowIndicator("older");
995
+ history.insertBefore(older, history.firstChild);
996
+ }
997
+
998
+ if (_messageWindow.hasNewer) {
999
+ history.appendChild(createMessageWindowIndicator("newer"));
1000
+ }
1001
+}
1002
+
1003
+function setMessageWindowIndicatorLoading(history, direction, loading) {
1004
+ if (!history) return;
1005
+ let indicator = history.querySelector(
1006
+ `:scope > [data-message-window-ui="${direction}"]`,
1007
+ );
1008
+ if (!indicator && loading) {
1009
+ updateMessageWindowIndicators(history);
1010
+ indicator = history.querySelector(
1011
+ `:scope > [data-message-window-ui="${direction}"]`,
1012
+ );
1013
+ }
1014
+ if (!indicator) return;
1015
+
1016
+ indicator.classList.toggle("is-loading", loading);
1017
+ if (loading) {
1018
+ const label = direction === "older" ? "earlier" : "newer";
1019
+ indicator.setAttribute("role", "status");
1020
+ indicator.setAttribute("aria-live", "polite");
1021
+ indicator.setAttribute("aria-label", `Loading ${label} messages`);
1022
+ indicator.removeAttribute("aria-hidden");
1023
+ } else {
1024
+ indicator.removeAttribute("role");
1025
+ indicator.removeAttribute("aria-live");
1026
+ indicator.removeAttribute("aria-label");
1027
+ indicator.setAttribute("aria-hidden", "true");
1028
+ }
1029
+}
1030
+
1031
+function createMessageWindowIndicator(direction) {
1032
+ const indicator = document.createElement("div");
1033
+ const label = direction === "older" ? "earlier" : "newer";
1034
+ const isLoading = _messageWindowLoadingDirection === direction;
1035
+ indicator.className = `message-window-loader message-window-${direction}`;
1036
+ indicator.classList.toggle("is-loading", isLoading);
1037
+ indicator.dataset.messageWindowUi = direction;
1038
+ if (isLoading) {
1039
+ indicator.setAttribute("role", "status");
1040
+ indicator.setAttribute("aria-live", "polite");
1041
+ indicator.setAttribute("aria-label", `Loading ${label} messages`);
1042
+ } else {
1043
+ indicator.setAttribute("aria-hidden", "true");
1044
+ }
1045
+ indicator.innerHTML = `
1046
+ <span class="message-window-loader-bubble" aria-hidden="true">
1047
+ <span></span><span></span><span></span>
1048
+ </span>
1049
+ <span class="message-window-loader-label">Loading ${label} messages</span>
1050
+ `;
1051
+ return indicator;
1052
+}
1053
+
1054
+function createMessageWindowStagingHistory(history) {
1055
+ const staging = history.cloneNode(false);
1056
+ const historyRect = history.getBoundingClientRect();
1057
+ staging.classList.add("message-window-staging");
1058
+ staging.setAttribute("aria-hidden", "true");
1059
+ staging.style.position = "fixed";
1060
+ staging.style.top = "0";
1061
+ staging.style.left = "-100000px";
1062
+ staging.style.width = `${historyRect.width}px`;
1063
+ staging.style.height = `${historyRect.height}px`;
1064
+ staging.style.visibility = "hidden";
1065
+ staging.style.pointerEvents = "none";
1066
+ staging.style.contain = "layout style paint";
1067
+ delete staging.dataset.scrollerTimeout;
1068
+ delete staging.dataset.scrollerReapplySnapshot;
1069
+ delete staging.dataset.scrollingTo;
1070
+ history.after(staging);
1071
+ return staging;
1072
+}
1073
+
1074
+function copyMessageWindowDataset(history, state) {
1075
+ for (const [key, value] of Object.entries(state)) {
1076
+ if (value === undefined) delete history.dataset[key];
1077
+ else history.dataset[key] = value;
1078
+ }
1079
+}
1080
+
1081
+function getMessageWindowAnchorCandidates(history) {
1082
+ return Array.from(
1083
+ history.querySelectorAll(
1084
+ ".process-group[data-render-group-key], [data-message-key]",
1085
+ ),
1086
+ ).filter((element) =>
1087
+ element.dataset.renderGroupKey || !element.closest(".process-group")
1088
+ );
1089
+}
1090
+
1091
+function getMessageWindowAnchorIdentity(element) {
1092
+ if (element?.dataset?.renderGroupKey) {
1093
+ return `group:${element.dataset.renderGroupKey}`;
1094
+ }
1095
+ if (element?.dataset?.messageKey) {
1096
+ return `message:${element.dataset.messageKey}`;
1097
+ }
1098
+ return null;
1099
+}
1100
+
1101
+function captureMessageWindowAnchor(history) {
1102
+ const historyRect = history.getBoundingClientRect();
1103
+ const candidates = getMessageWindowAnchorCandidates(history);
1104
+ let fallback = null;
1105
+
1106
+ for (const element of candidates) {
1107
+ const rect = element.getBoundingClientRect();
1108
+ if (rect.height <= 0 || rect.bottom <= historyRect.top) continue;
1109
+ const anchor = {
1110
+ identity: getMessageWindowAnchorIdentity(element),
1111
+ offset: rect.top - historyRect.top,
1112
+ };
1113
+ if (rect.top < historyRect.bottom) return anchor;
1114
+ fallback ||= anchor;
1115
+ }
1116
+
1117
+ return fallback;
1118
+}
1119
+
1120
+function restoreMessageWindowAnchor(history, anchor) {
1121
+ if (!anchor?.identity) return false;
1122
+ const historyRect = history.getBoundingClientRect();
1123
+ const element = getMessageWindowAnchorCandidates(history).find(
1124
+ (candidate) =>
1125
+ getMessageWindowAnchorIdentity(candidate) === anchor.identity,
1126
+ );
1127
+ if (!element) return false;
1128
+ const nextOffset = element.getBoundingClientRect().top - historyRect.top;
1129
+ history.scrollTop += nextOffset - anchor.offset;
1130
+ return true;
1131
+}
1132
+
1133
+function captureMessageExpansionState(history) {
1134
+ const state = new Map();
1135
+ history
1136
+ .querySelectorAll(".process-group[id], .process-step[id]")
1137
+ .forEach((element) => {
1138
+ const kind = element.classList.contains("process-group")
1139
+ ? "group"
1140
+ : "step";
1141
+ state.set(
1142
+ `${kind}:${element.id}`,
1143
+ element.classList.contains("expanded"),
1144
+ );
1145
+ });
1146
+ history
1147
+ .querySelectorAll("[data-message-key] > .message")
1148
+ .forEach((element) => {
1149
+ state.set(
1150
+ `message:${element.parentElement.dataset.messageKey}`,
1151
+ element.classList.contains("expanded"),
1152
+ );
1153
+ });
1154
+ return state;
1155
+}
1156
+
1157
+async function restoreMessageExpansionState(history, state) {
1158
+ const pending = [];
1159
+ for (const [key, expanded] of state) {
1160
+ let element = null;
1161
+ if (key.startsWith("group:") || key.startsWith("step:")) {
1162
+ const separator = key.indexOf(":");
1163
+ const kind = key.slice(0, separator);
1164
+ const id = key.slice(separator + 1);
1165
+ const selector = kind === "group" ? ".process-group[id]" : ".process-step[id]";
1166
+ element = Array.from(history.querySelectorAll(selector)).find(
1167
+ (candidate) => candidate.id === id,
1168
+ );
1169
+ } else if (key.startsWith("message:")) {
1170
+ const messageKey = key.slice(8);
1171
+ const container = Array.from(
1172
+ history.querySelectorAll("[data-message-key]"),
1173
+ ).find((candidate) => candidate.dataset.messageKey === messageKey);
1174
+ element = container?.querySelector(":scope > .message") || null;
1175
+ }
1176
+ if (!element || !history.contains(element)) continue;
1177
+ if (typeof element.__setExpanded === "function") {
1178
+ pending.push(Promise.resolve(element.__setExpanded(expanded)));
1179
+ } else {
1180
+ element.classList.toggle("expanded", expanded);
1181
+ }
1182
+ }
1183
+ await Promise.allSettled(pending);
1184
+}
1185
+
1186
+function nextAnimationFrame() {
1187
+ return new Promise((resolve) => requestAnimationFrame(() => resolve()));
1188
}
1189
1190
function appendToMessageGroup(
1195
const chatHistoryEl = getChatHistoryEl();
1196
if (!chatHistoryEl) return;
1197
275
- const lastGroup = chatHistoryEl.lastElementChild;
1198
+ const lastGroup = getLastMessageGroup();
1199
const lastGroupType = lastGroup?.getAttribute("data-group-type");
1200
1201
if (!forceNewGroup && lastGroup && lastGroupType === position) {
1205
group.classList.add("message-group", `message-group-${position}`);
1206
group.setAttribute("data-group-type", position);
1207
group.appendChild(messageContainer);
285
- chatHistoryEl.appendChild(group);
1208
+ const bottomControl = chatHistoryEl.querySelector(
1209
+ ':scope > [data-message-window-ui="newer"]',
1210
+ );
1211
+ chatHistoryEl.insertBefore(group, bottomControl || null);
1212
}
1213
}
1214
1223
return group;
1224
}
1225
300
-function getOrCreateProcessGroup(id, allowCompleted = true) {
1226
+function isUtilityOnlyProcessGroup(group) {
1227
+ const steps = group?.querySelectorAll?.(".process-step") || [];
1228
+ return steps.length > 0 &&
1229
+ !group.querySelector(".process-step:not(.message-util)");
1230
+}
1231
+
1232
+function updateUtilityOnlyProcessGroup(group) {
1233
+ if (!group) return;
1234
+ const utilityOnly = isUtilityOnlyProcessGroup(group);
1235
+ group.classList.toggle("utility-only", utilityOnly);
1236
+ group.hidden = utilityOnly && !preferencesStore.showUtils;
1237
+}
1238
+
1239
+function getOrCreateProcessGroup(id, allowCompleted = true, renderInfo = null) {
1240
+ const groupIdentity = renderInfo?.id || id;
1241
// first try direct match by ID
302
- const byId = document.getElementById(`process-group-${id}`);
1242
+ const byId = getChatHistoryElementById(`process-group-${groupIdentity}`);
1243
if (byId) return byId;
1244
1245
// if not found, try to find the last process group
1248
1249
// lastly create new
1250
const messageContainer = document.createElement("div");
311
- messageContainer.id = `process-group-${id}`;
1251
+ messageContainer.id = `process-group-${groupIdentity}`;
1252
messageContainer.classList.add(
1253
"message-container",
1254
"ai-container",
1255
"has-process-group",
1256
);
1257
318
- const group = createProcessGroup(id);
1258
+ const group = createProcessGroup(groupIdentity);
1259
+ if (renderInfo?.key) group.dataset.renderGroupKey = renderInfo.key;
1260
group.classList.add("embedded");
1261
messageContainer.appendChild(group);
1262
1295
}) {
1296
// group and steps DOM elements
1297
const stepId = `process-step-${id}`;
357
- let step = document.getElementById(stepId);
1298
+ let step = getChatHistoryElementById(stepId);
1299
1300
const group =
1301
getStepProcessGroup(step) ||
361
- getOrCreateProcessGroup(id, allowCompletedGroup);
1302
+ getOrCreateProcessGroup(
1303
+ id,
1304
+ allowCompletedGroup,
1305
+ log[PROCESS_GROUP_RENDER_INFO],
1306
+ );
1307
const stepsContainer = group.querySelector(".process-steps");
1308
1309
const isNewStep = !step;
1400
1401
// is step expanded?
1402
const isExpanded = step.classList.contains("expanded");
1403
+ const shouldRenderDetail =
1404
+ isExpanded && group.classList.contains("expanded");
1405
1406
// create step header
1407
const stepHeader = ensureChild(
1411
"process-step-header",
1412
);
1413
467
- // create step detail
1414
+ // Keep the lightweight detail shell and action hooks mounted for extensions,
1415
+ // but materialize text-heavy detail content only while the step is expanded.
1416
const stepDetail = ensureChild(
1417
step,
1418
".process-step-detail",
1419
"div",
1420
"process-step-detail",
1421
);
474
- const stepDetailScroll = ensureChild(
475
- stepDetail,
476
- ".process-step-detail-scroll",
477
- "div",
478
- "process-step-detail-scroll",
479
- );
480
-
1422
// set click handlers
1423
setupProcessStepHandlers(step, stepHeader);
1424
1441
const titleEl = ensureChild(stepHeader, ".step-title", "span", "step-title");
1442
titleEl.textContent = title;
1443
503
- // auto-scroller of the step detail
504
- const detailScroller = new Scroller(stepDetailScroll, {
505
- smooth: !isMassRender(),
506
- toleranceRem: 4,
507
- }); // scroller for step detail content
508
-
509
- // update KVPs of the step detail
510
- const kvpsTable = drawKvpsIncremental(stepDetailScroll, kvps);
511
-
512
- // update content
513
- let stepDetailContent;
514
- if(content){
515
- stepDetailContent = ensureChild(
516
- stepDetailScroll,
517
- ".process-step-detail-content",
518
- "p",
519
- "process-step-detail-content",
520
- ...(contentClasses || []),
521
- );
522
- const adjustedContent = adjustStepContent(content)
523
- stepDetailContent.innerHTML = adjustedContent;
524
- }
525
-
526
- // reapply scroll position (autoscroll if bottom) - only when expanded already and not mass rendering
527
- if (isExpanded) detailScroller.reApplyScroll();
528
-
1444
// Render action buttons: get/create container, clear, append
1445
const stepActionBtns = ensureChild(
1446
stepDetail,
1454
.filter(Boolean)
1455
.forEach((button) => stepActionBtns.appendChild(button));
1456
1457
+ let detailResult = {
1458
+ content: undefined,
1459
+ contentScroller: null,
1460
+ kvpsTable: null,
1461
+ };
1462
+ if (shouldRenderDetail) {
1463
+ detailResult = renderProcessStepDetail({
1464
+ stepDetail,
1465
+ kvps,
1466
+ content,
1467
+ contentClasses,
1468
+ });
1469
+ } else {
1470
+ discardProcessStepDetail(step);
1471
+ }
1472
+
1473
// update the process grop header by this step
1474
updateProcessGroupHeader(group);
1475
+ updateUtilityOnlyProcessGroup(group);
1476
1477
// remove shine from previous steps and add to this one if new and not completed
1478
if (isNewStep && !isGroupComplete) {
547
- stepDetailScroll
1479
+ group
1480
.querySelectorAll(".step-title.shiny-text")
1481
.forEach((el) => {
1482
el.classList.remove("shiny-text");
1490
actionButtons,
1491
step,
1492
detail: stepDetail,
1493
+ content: detailResult.content,
1494
+ contentScroller: detailResult.contentScroller,
1495
+ kvpsTable: detailResult.kvpsTable,
1496
+ isExpanded,
1497
+ detailPending: !shouldRenderDetail,
1498
+ };
1499
+}
1500
+
1501
+function renderProcessStepDetail({
1502
+ stepDetail,
1503
+ kvps,
1504
+ content,
1505
+ contentClasses,
1506
+}) {
1507
+ let stepDetailScroll = stepDetail.querySelector(
1508
+ ":scope > .process-step-detail-scroll",
1509
+ );
1510
+ if (!stepDetailScroll) {
1511
+ stepDetailScroll = document.createElement("div");
1512
+ stepDetailScroll.classList.add("process-step-detail-scroll");
1513
+ stepDetail.insertBefore(
1514
+ stepDetailScroll,
1515
+ stepDetail.querySelector(":scope > .step-detail-actions"),
1516
+ );
1517
+ }
1518
+
1519
+ const detailScroller = new Scroller(stepDetailScroll, {
1520
+ smooth: !isMassRender(),
1521
+ toleranceRem: 4,
1522
+ });
1523
+ const kvpsTable = drawKvpsIncremental(stepDetailScroll, kvps);
1524
+
1525
+ let stepDetailContent;
1526
+ if (content) {
1527
+ stepDetailContent = ensureChild(
1528
+ stepDetailScroll,
1529
+ ".process-step-detail-content",
1530
+ "p",
1531
+ "process-step-detail-content",
1532
+ ...(contentClasses || []),
1533
+ );
1534
+ stepDetailContent.innerHTML = adjustStepContent(content);
1535
+ } else {
1536
+ stepDetailScroll
1537
+ .querySelector(":scope > .process-step-detail-content")
1538
+ ?.remove();
1539
+ }
1540
+
1541
+ detailScroller.reApplyScroll();
1542
+ return {
1543
content: stepDetailContent,
1544
contentScroller: detailScroller,
1545
kvpsTable,
564
- isExpanded,
1546
};
1547
}
1548
1549
+function discardProcessStepDetail(step, { force = false } = {}) {
1550
+ if (!step) return;
1551
+ const remove = () => {
1552
+ if (!force && step.classList.contains("expanded")) return;
1553
+ if (
1554
+ force &&
1555
+ step.classList.contains("expanded") &&
1556
+ step.closest(".process-group")?.classList.contains("expanded")
1557
+ ) {
1558
+ return;
1559
+ }
1560
+ step
1561
+ .querySelector(":scope > .process-step-detail > .process-step-detail-scroll")
1562
+ ?.remove();
1563
+ };
1564
+
1565
+ if (isMassRender()) remove();
1566
+ else setTimeout(remove, 250);
1567
+}
1568
+
1569
function adjustStepContent(content) {
1570
content = escapeHTML(content);
1571
content = convertPathsToLinks(content);
1581
}
1582
nextExpanded = Boolean(nextExpanded);
1583
583
- // scroll to top when collapsing
584
- if (!nextExpanded) {
585
- setTimeout(() => {
586
- const scroller = step.querySelector(".process-step-detail-scroll");
587
- if (scroller) scroller.scrollTop = 0;
588
- }, 100);
1584
+ step.classList.toggle("expanded", nextExpanded);
1585
+
1586
+ if (nextExpanded) {
1587
+ if (step.querySelector(".process-step-detail-scroll")) return null;
1588
+ return materializeProcessStepDetail(step);
1589
}
1590
591
- step.classList.toggle("expanded", nextExpanded);
1591
+ const scroller = step.querySelector(".process-step-detail-scroll");
1592
+ if (scroller) scroller.scrollTop = 0;
1593
+ discardProcessStepDetail(step);
1594
+}
1595
+
1596
+function materializeProcessStepDetail(step) {
1597
+ if (!step || typeof step.__renderDetail !== "function") return null;
1598
+ if (step.__detailRenderPromise) return step.__detailRenderPromise;
1599
+
1600
+ step.__detailRenderPromise = Promise.resolve(step.__renderDetail()).finally(
1601
+ () => {
1602
+ delete step.__detailRenderPromise;
1603
+ },
1604
+ );
1605
+ return step.__detailRenderPromise;
1606
}
1607
1608
function drawStandaloneMessage({
1709
bodyDiv.dataset.scrollStabilization = "1";
1710
const scroller = new Scroller(bodyDiv, { smooth: !isMassRender() });
1711
698
- // Handle KVPs incrementally
699
- drawKvpsIncremental(bodyDiv, kvps);
1712
+ const contentText = String(content ?? "");
1713
+ const lazyContent =
1714
+ _windowedRender &&
1715
+ contentText.length + estimateKvpTextSize(kvps) > LAZY_MESSAGE_PREVIEW_CHARS;
1716
+ const contentOptions = {
1717
+ bodyDiv,
1718
+ content: contentText,
1719
+ kvps,
1720
+ contentClasses,
1721
+ markdown,
1722
+ latex,
1723
+ smoothStream,
1724
+ };
1725
701
- // Handle content
702
- if (content && content.trim().length > 0) {
703
- if (markdown) {
704
- let contentDiv = bodyDiv.querySelector(".msg-content");
705
- if (!contentDiv) {
706
- contentDiv = document.createElement("div");
707
- bodyDiv.appendChild(contentDiv);
708
- }
709
- contentDiv.className = `msg-content ${contentClasses.join(" ")}`;
710
-
711
- // let spanElement = contentDiv.querySelector("span");
712
- // if (!spanElement) {
713
- // spanElement = document.createElement("span");
714
- // contentDiv.appendChild(spanElement);
715
- // }
716
-
717
- let processedContent = content;
718
- if (latex) processedContent = convertLatexDelimiters(processedContent);
719
- processedContent = convertImageTags(processedContent);
720
- processedContent = convertImgFilePaths(processedContent);
721
- processedContent = convertFilePaths(processedContent);
722
- processedContent = marked.parse(processedContent, { breaks: true });
723
- processedContent = sanitizeHtml(processedContent, {
724
- allowDataImages: true,
725
- allowLatex: latex,
1726
+ if (lazyContent) {
1727
+ messageDiv.classList.add("lazy-content");
1728
+ delete messageDiv.__lazyRenderedExpanded;
1729
+ messageDiv.__renderLazyContent = (expanded) => {
1730
+ if (messageDiv.__lazyRenderedExpanded === Boolean(expanded)) return;
1731
+ messageDiv.__lazyRenderedExpanded = Boolean(expanded);
1732
+ renderStandaloneMessageContent({
1733
+ ...contentOptions,
1734
+ content: expanded
1735
+ ? contentText
1736
+ : `${contentText.slice(0, LAZY_MESSAGE_PREVIEW_CHARS)}\n\n…`,
1737
+ kvps: expanded ? kvps : null,
1738
+ smoothStream: false,
1739
});
727
- processedContent = convertPathsToLinks(processedContent);
728
- processedContent = addBlankTargetsToLinks(processedContent);
729
-
730
- // do a smooth stream if requested
731
- if (smoothStream) smoothRender(contentDiv, processedContent);
732
- else contentDiv.innerHTML = processedContent;
733
-
734
- // KaTeX rendering for markdown
735
- if (latex) {
736
- renderLatexElements(contentDiv);
737
- }
738
-
739
- adjustMarkdownRender(contentDiv);
740
- } else {
741
- let preElement = bodyDiv.querySelector(".msg-content");
742
- if (!preElement) {
743
- preElement = document.createElement("pre");
744
- preElement.classList.add("msg-content", ...contentClasses);
745
- preElement.style.whiteSpace = "pre-wrap";
746
- preElement.style.wordBreak = "break-word";
747
- bodyDiv.appendChild(preElement);
748
- } else {
749
- // Update classes
750
- preElement.className = `msg-content ${contentClasses.join(" ")}`;
751
- }
752
-
753
- // let spanElement = preElement.querySelector("span");
754
- // if (!spanElement) {
755
- // spanElement = document.createElement("span");
756
- // preElement.appendChild(spanElement);
757
- // }
758
-
759
- if (smoothStream) smoothRender(preElement, convertHTML(content));
760
- else preElement.innerHTML = convertHTML(content);
761
- }
1740
+ };
1741
+ messageDiv.__renderLazyContent(
1742
+ messageDiv.classList.contains("expanded"),
1743
+ );
1744
} else {
763
- // Remove content if it exists but content is empty
764
- const existingContent = bodyDiv.querySelector(".msg-content");
765
- if (existingContent) {
766
- existingContent.remove();
767
- }
1745
+ messageDiv.classList.remove("lazy-content");
1746
+ delete messageDiv.__renderLazyContent;
1747
+ delete messageDiv.__lazyRenderedExpanded;
1748
+ renderStandaloneMessageContent(contentOptions);
1749
}
1750
1751
// reapply scroll position or reset for collapsed
1756
return messageDiv;
1757
}
1758
1759
+function renderStandaloneMessageContent({
1760
+ bodyDiv,
1761
+ content,
1762
+ kvps,
1763
+ contentClasses,
1764
+ markdown,
1765
+ latex,
1766
+ smoothStream,
1767
+}) {
1768
+ drawKvpsIncremental(bodyDiv, kvps);
1769
+ if (!content || !content.trim()) {
1770
+ bodyDiv.querySelector(".msg-content")?.remove();
1771
+ return;
1772
+ }
1773
+
1774
+ if (markdown) {
1775
+ let contentDiv = bodyDiv.querySelector(".msg-content");
1776
+ if (!contentDiv || contentDiv.tagName === "PRE") {
1777
+ contentDiv?.remove();
1778
+ contentDiv = document.createElement("div");
1779
+ bodyDiv.appendChild(contentDiv);
1780
+ }
1781
+ contentDiv.className = `msg-content ${contentClasses.join(" ")}`;
1782
+
1783
+ let processedContent = content;
1784
+ if (latex) processedContent = convertLatexDelimiters(processedContent);
1785
+ processedContent = convertImageTags(processedContent);
1786
+ processedContent = convertImgFilePaths(processedContent);
1787
+ processedContent = convertFilePaths(processedContent);
1788
+ processedContent = marked.parse(processedContent, { breaks: true });
1789
+ processedContent = sanitizeHtml(processedContent, {
1790
+ allowDataImages: true,
1791
+ allowLatex: latex,
1792
+ });
1793
+ processedContent = convertPathsToLinks(processedContent);
1794
+ processedContent = addBlankTargetsToLinks(processedContent);
1795
+
1796
+ if (smoothStream) smoothRender(contentDiv, processedContent);
1797
+ else contentDiv.innerHTML = processedContent;
1798
+
1799
+ if (latex) renderLatexElements(contentDiv);
1800
+ adjustMarkdownRender(contentDiv);
1801
+ return;
1802
+ }
1803
+
1804
+ let preElement = bodyDiv.querySelector(".msg-content");
1805
+ if (!preElement || preElement.tagName !== "PRE") {
1806
+ preElement?.remove();
1807
+ preElement = document.createElement("pre");
1808
+ preElement.style.whiteSpace = "pre-wrap";
1809
+ preElement.style.wordBreak = "break-word";
1810
+ bodyDiv.appendChild(preElement);
1811
+ }
1812
+ preElement.className = `msg-content ${contentClasses.join(" ")}`;
1813
+
1814
+ if (smoothStream) smoothRender(preElement, convertHTML(content));
1815
+ else preElement.innerHTML = convertHTML(content);
1816
+}
1817
+
1818
+function estimateKvpTextSize(kvps) {
1819
+ if (!kvps) return 0;
1820
+ try {
1821
+ return JSON.stringify(kvps)?.length || 0;
1822
+ } catch {
1823
+ return LAZY_MESSAGE_PREVIEW_CHARS + 1;
1824
+ }
1825
+}
1826
+
1827
export { addBlankTargetsToLinks };
1828
1829
/**
1840
const contentText = String(content ?? "");
1841
const actionButtons = contentText.trim()
1842
? [
794
- createActionButton("speak", "", () => ttsService.speak(contentText)),
1843
createActionButton("copy", "", () => copyToClipboard(contentText)),
1844
+ createActionButton("speak", "", () => ttsService.speak(contentText)),
1845
].filter(Boolean)
1846
: [];
1847
1893
1894
if (thoughtsText.trim()) {
1895
actionButtons.push(
847
- createActionButton("speak", "", () => ttsService.speak(thoughtsText)),
1896
+ createActionButton("copy", "", () => copyToClipboard(thoughtsText)),
1897
);
1898
actionButtons.push(
850
- createActionButton("copy", "", () => copyToClipboard(thoughtsText)),
1899
+ createActionButton("speak", "", () => ttsService.speak(thoughtsText)),
1900
);
1901
}
1902
1933
const contentText = String(content ?? "");
1934
const actionButtons = contentText.trim()
1935
? [
887
- createActionButton("speak", "", () => ttsService.speak(contentText)),
1936
createActionButton("copy", "", () => copyToClipboard(contentText)),
1937
+ createActionButton("speak", "", () => ttsService.speak(contentText)),
1938
].filter(Boolean)
1939
: [];
1940
return drawProcessStep({
1955
// response of agent 0, render as response to user
1956
// get last process group or create new container (if first message)
1957
909
- const group = getLastProcessGroup();
910
- let container = document.getElementById(`message-${id}`); // first check for already existing message
1958
+ let group = getLastProcessGroup();
1959
+ if (isUtilityOnlyProcessGroup(group)) {
1960
+ group.setAttribute("data-group-complete", "true");
1961
+ updateProcessGroupHeader(group);
1962
+ updateUtilityOnlyProcessGroup(group);
1963
+ group = null;
1964
+ }
1965
+ let container = getChatHistoryElementById(`message-${id}`); // first check for already existing message
1966
1967
1968
// if no container found, add to previous process group if exists
2006
const responseText = String(content ?? "");
2007
const responseActionButtons = responseText.trim()
2008
? [
954
- createActionButton("speak", "", () => ttsService.speak(responseText)),
2009
createActionButton("copy", "", () => copyToClipboard(responseText)),
2010
+ createActionButton("speak", "", () => ttsService.speak(responseText)),
2011
].filter(Boolean)
2012
: [];
2013
setupCollapsible(
2165
const userText = String(content ?? "");
2166
const userActionButtons = userText.trim()
2167
? [
1113
- createActionButton("speak", "", () => ttsService.speak(userText)),
2168
createActionButton("copy", "", () => copyToClipboard(userText)),
2169
+ createActionButton("speak", "", () => ttsService.speak(userText)),
2170
].filter(Boolean)
2171
: [];
1117
- const actionButtonsContainer = ensureChild(
2172
+ setupCollapsible(
2173
messageDiv,
1119
- ".step-action-buttons",
1120
- "div",
1121
- "step-action-buttons",
1122
- );
1123
- actionButtonsContainer.textContent = "";
1124
- userActionButtons.forEach((button) =>
1125
- actionButtonsContainer.appendChild(button),
2174
+ ":scope > .step-action-buttons",
2175
+ false,
2176
+ userActionButtons,
2177
+ ":scope > .message-text",
2178
);
2179
2180
return { element: messageContainer };
2252
buildDetailPayload(arguments[0], { headerLabels }),
2253
),
2254
),
1203
- createActionButton("speak", "", () => ttsService.speak(contentText)),
2255
createActionButton("copy", "", () => copyToClipboard(contentText)),
2256
+ createActionButton("speak", "", () => ttsService.speak(contentText)),
2257
].filter(Boolean)
2258
: [];
2259
2297
buildDetailPayload(arguments[0], { headerLabels }),
2298
),
2299
),
1248
- createActionButton("speak", "", () => ttsService.speak(contentText)),
2300
createActionButton("copy", "", () => copyToClipboard(contentText)),
2301
+ createActionButton("speak", "", () => ttsService.speak(contentText)),
2302
].filter(Boolean)
2303
: [];
2304
2342
buildDetailPayload(arguments[0], { headerLabels }),
2343
),
2344
),
1293
- createActionButton("speak", "", () => ttsService.speak(contentText)),
2345
createActionButton("copy", "", () => copyToClipboard(contentText)),
2346
+ createActionButton("speak", "", () => ttsService.speak(contentText)),
2347
].filter(Boolean)
2348
: [];
2349
2377
const contentText = String(content ?? "");
2378
const actionButtons = contentText.trim()
2379
? [
1328
- createActionButton("speak", "", () => ttsService.speak(contentText)),
2380
createActionButton("copy", "", () => copyToClipboard(contentText)),
2381
+ createActionButton("speak", "", () => ttsService.speak(contentText)),
2382
].filter(Boolean)
2383
: [];
2384
2416
const contentText = String(content ?? "");
2417
const actionButtons = contentText.trim()
2418
? [
1367
- createActionButton("speak", "", () => ttsService.speak(contentText)),
2419
createActionButton("copy", "", () => copyToClipboard(contentText)),
2420
+ createActionButton("speak", "", () => ttsService.speak(contentText)),
2421
].filter(Boolean)
2422
: [];
2423
2430
content,
2431
actionButtons,
2432
log: arguments[0],
1381
- allowCompletedGroup: true,
2433
+ allowCompletedGroup: false,
2434
});
2435
2436
result.dontScroll = !preferencesStore.showUtils;
2455
const contentText = String(content ?? "");
2456
const actionButtons = contentText.trim()
2457
? [
1406
- createActionButton("speak", "", () => ttsService.speak(contentText)),
2458
createActionButton("copy", "", () => copyToClipboard(contentText)),
2459
+ createActionButton("speak", "", () => ttsService.speak(contentText)),
2460
].filter(Boolean)
2461
: [];
2462
2523
const contentText = String(content ?? "");
2524
const actionButtons = contentText.trim()
2525
? [
1474
- createActionButton("speak", "", () => ttsService.speak(contentText)),
2526
createActionButton("copy", "", () => copyToClipboard(contentText)),
2527
+ createActionButton("speak", "", () => ttsService.speak(contentText)),
2528
].filter(Boolean)
2529
: [];
2530
2611
if (!table) {
2612
table = document.createElement("table");
2613
table.classList.add("msg-kvps");
1562
- container.appendChild(table);
2614
+ container.insertBefore(table, container.firstChild);
2615
}
2616
2617
// Get all current rows for comparison
2938
</span>
2939
`;
2940
2941
+ group.__setExpanded = (expanded) => {
2942
+ const nextExpanded = Boolean(expanded);
2943
+ group.classList.toggle("expanded", nextExpanded);
2944
+ const steps = group.querySelectorAll(".process-step");
2945
+ if (nextExpanded) {
2946
+ steps.forEach((step) => {
2947
+ if (
2948
+ step.classList.contains("expanded") &&
2949
+ !step.querySelector(".process-step-detail-scroll")
2950
+ ) {
2951
+ void materializeProcessStepDetail(step);
2952
+ }
2953
+ });
2954
+ } else {
2955
+ steps.forEach((step) =>
2956
+ discardProcessStepDetail(step, { force: true }),
2957
+ );
2958
+ }
2959
+ };
2960
+
2961
// Add click handler for expansion
2962
header.addEventListener("click", () => {
1891
- group.classList.toggle("expanded");
2963
+ group.__setExpanded(!group.classList.contains("expanded"));
2964
});
2965
2966
group.appendChild(header);
3207
const stepsMetricValEl =
3208
stepMetricContainerEl?.querySelector(".metric-value");
3209
if (stepsMetricValEl) {
2138
- let genSteps = group.querySelectorAll(
2139
- '.process-step[data-log-type="agent"]',
2140
- ).length;
2141
- genSteps -= 1; // don't count response as step
3210
+ let genSteps = Number(group.dataset.fullAgentSteps);
3211
+ if (!Number.isFinite(genSteps)) {
3212
+ genSteps = group.querySelectorAll(
3213
+ '.process-step[data-log-type="agent"]',
3214
+ ).length;
3215
+ genSteps -= 1; // don't count response as step
3216
+ }
3217
stepsMetricValEl.textContent = genSteps.toString();
3218
if (genSteps <= 0)
3219
stepMetricContainerEl.classList.add("display-none"); // hide when no steps
3240
}
3241
}
3242
2168
- const firstTimestampMs = parseInt(
2169
- steps[0]?.getAttribute("data-timestamp") || "0",
2170
- 10,
2171
- );
2172
- const lastTimestampMs = parseInt(
2173
- steps[steps.length - 1]?.getAttribute("data-timestamp") || "0",
2174
- 10,
2175
- );
3243
+ const firstTimestampMs = group.dataset.fullStartTimestamp
3244
+ ? Math.round(Number(group.dataset.fullStartTimestamp) * 1000)
3245
+ : parseInt(steps[0]?.getAttribute("data-timestamp") || "0", 10);
3246
+ const lastTimestampMs = group.dataset.fullEndTimestamp
3247
+ ? Math.round(Number(group.dataset.fullEndTimestamp) * 1000)
3248
+ : parseInt(
3249
+ steps[steps.length - 1]?.getAttribute("data-timestamp") || "0",
3250
+ 10,
3251
+ );
3252
const durationText =
3253
isCompleted &&
3254
metricsEl &&
3269
}
3270
3271
if (notificationsEl) {
2196
- const counts = { warning: 0, info: 0 };
2197
- steps.forEach((step) => {
2198
- const stepType = step.getAttribute("data-log-type");
2199
- if (Object.prototype.hasOwnProperty.call(counts, stepType)) {
2200
- counts[stepType] += 1;
2201
- }
2202
- });
3272
+ const fullWarningSteps = Number(group.dataset.fullWarningSteps);
3273
+ const fullInfoSteps = Number(group.dataset.fullInfoSteps);
3274
+ const counts = Number.isFinite(fullWarningSteps) &&
3275
+ Number.isFinite(fullInfoSteps)
3276
+ ? { warning: fullWarningSteps, info: fullInfoSteps }
3277
+ : { warning: 0, info: 0 };
3278
+ if (!Number.isFinite(fullWarningSteps) || !Number.isFinite(fullInfoSteps)) {
3279
+ steps.forEach((step) => {
3280
+ const stepType = step.getAttribute("data-log-type");
3281
+ if (Object.prototype.hasOwnProperty.call(counts, stepType)) {
3282
+ counts[stepType] += 1;
3283
+ }
3284
+ });
3285
+ }
3286
3287
const totalNotifications = counts.warning + counts.info;
3288
const countEl = notificationsEl.querySelector(".metric-value");
3360
containerSelector,
3361
initialExpanded,
3362
actionButtons = [],
3363
+ contentSelector = ":scope > .message-body",
3364
) {
3365
messageDiv.classList.add("message-collapsible");
2282
- messageDiv.classList.toggle("expanded", initialExpanded);
3366
+ messageDiv
3367
+ .querySelectorAll(":scope > .message-collapse-content")
3368
+ .forEach((element) => element.classList.remove("message-collapse-content"));
3369
+ const collapseContent = messageDiv.querySelector(contentSelector);
3370
+ collapseContent?.classList.add("message-collapse-content");
3371
+ const initialState =
3372
+ Boolean(initialExpanded) && !messageDiv.classList.contains("lazy-content");
3373
+ messageDiv.classList.toggle("expanded", initialState);
3374
3375
const container = ensureChild(
3376
messageDiv,
3387
btn.classList.toggle("show-less-btn", exp);
3388
btn.classList.toggle("show-more-btn", !exp);
3389
};
2299
- syncBtn();
2300
- btn.onclick = () => {
2301
- messageDiv.classList.toggle("expanded");
3390
+ const setExpanded = (expanded) => {
3391
+ const nextExpanded = Boolean(expanded);
3392
+ messageDiv.classList.toggle("expanded", nextExpanded);
3393
+ messageDiv.__renderLazyContent?.(nextExpanded);
3394
syncBtn();
2303
- messageDiv.classList.contains("expanded") ||
2304
- (messageDiv.querySelector(".message-body").scrollTop = 0);
3395
+ if (!nextExpanded) {
3396
+ if (collapseContent) collapseContent.scrollTop = 0;
3397
+ }
3398
};
3399
+ messageDiv.__setExpanded = setExpanded;
3400
+ setExpanded(initialState);
3401
+ btn.onclick = () =>
3402
+ setExpanded(!messageDiv.classList.contains("expanded"));
3403
3404
actionButtons.filter(Boolean).forEach((b) => container.appendChild(b));
3405
3406
// Detect overflow after render
3407
requestAnimationFrame(() => {
2311
- const body = messageDiv.querySelector(".message-body");
3408
const fontSize = parseFloat(
2313
- getComputedStyle(body || document.documentElement).fontSize || "16",
3409
+ getComputedStyle(collapseContent || document.documentElement).fontSize ||
3410
+ "16",
3411
);
3412
const maxHeight = messageDiv.classList.contains("expanded")
3413
? fontSize * 15
2317
- : body?.clientHeight || 0;
3414
+ : collapseContent?.clientHeight || 0;
3415
messageDiv.classList.toggle(
3416
"has-overflow",
2320
- (body?.scrollHeight || 0) > maxHeight,
3417
+ messageDiv.classList.contains("lazy-content") ||
3418
+ (collapseContent?.scrollHeight || 0) > maxHeight,
3419
);
3420
});
3421
}