main
js 3,488 lines 103 KB
Raw
1 // message actions and components
2 import { store as imageViewerStore } from "../components/modals/image-viewer/image-viewer-store.js";
3 import { marked } from "../vendor/marked/marked.esm.js";
4 import { store as _messageResizeStore } from "/components/messages/resize/message-resize-store.js"; // keep here, required in html
5 import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
6 import { ttsService } from "/js/tts-service.js";
7 import {
8 createActionButton,
9 copyToClipboard,
10 syncActionButtons,
11 } from "/components/messages/action-buttons/simple-action-buttons.js";
12 import { store as stepDetailStore } from "/components/modals/process-step-detail/step-detail-store.js";
13 import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
14 import {
15 formatDateTime,
16 formatDuration,
17 getUserHour12,
18 getUserTimezone,
19 } from "./time-utils.js";
20 import { Scroller, cancelPendingScroll } from "./scroller.js";
21 import {
22 MessageWindow,
23 classifyMessageRenderUnits,
24 getMessageCacheKey,
25 } from "./message-window.js";
26 import { callJsExtensions } from "/js/extensions.js";
27 import { addBlankTargetsToLinks } from "/js/html-links.js";
28 import { sanitizeHtml } from "/js/safe-markdown.js";
29 import { createThreeBubbleLoader } from "/js/loading-indicators.js";
30 import { measureMessageCollapseOverflow } from "./message-collapse.js";
31
32 // Delay before collapsing previous steps when a new step is added
33 const STEP_COLLAPSE_DELAY = {
34 agent: 2000,
35 other: 4000, // tools should stay longer as next gen step is placed quickly
36 };
37 // delay collapse when hovering
38 const STEP_COLLAPSE_HOVER_DELAY_MS = 5000;
39 const PROCESS_GROUP_STEP_PAGE_SIZE = 50;
40 const PROCESS_GROUP_RENDER_INFO = Symbol("processGroupRenderInfo");
41
42 let _messageProcessGroups = new WeakMap();
43 let _messageIsProcessStep = new WeakSet();
44 const _processGroupStepLimits = new Map();
45 let _renderedProcessGroupPages = new Map();
46
47 function getMessageRenderUnitKeys(messages) {
48 _messageProcessGroups = new WeakMap();
49 _messageIsProcessStep = new WeakSet();
50 const units = classifyMessageRenderUnits(messages);
51 units.forEach((unit, index) => {
52 if (!unit.group) return;
53 _messageProcessGroups.set(messages[index], unit.group);
54 if (unit.isStep) _messageIsProcessStep.add(messages[index]);
55 });
56 return units.map((unit) => unit.key);
57 }
58
59 // dom references
60 let _chatHistory = null;
61
62 // state vars
63 let _massRender = false;
64 let _windowedRender = false;
65 let _scrollOnNextProcessGroup = null;
66 const _messageWindow = new MessageWindow({
67 getUnitKeys: getMessageRenderUnitKeys,
68 });
69 let _messageWindowRenderPromise = null;
70 let _messageRenderQueue = Promise.resolve();
71 let _messageRenderGeneration = 0;
72 let _messageWindowHistory = null;
73 let _messageWindowScrollFrame = null;
74 let _lastMessageWindowScrollTop = 0;
75 let _messageWindowFollowTail = true;
76 let _messageWindowLoadingDirection = null;
77 let _messageWindowSuppressScrollEvents = false;
78 let _messageWindowPointerActive = false;
79 let _messageWindowUserScrollUntil = 0;
80 let _messageWindowResizeObserver = null;
81
82 // Leave a small tolerance for fractional scroll positions and the passive
83 // boundary indicator, but do not swap pages while the user is still reading.
84 const MESSAGE_WINDOW_BOUNDARY_TOLERANCE_PX = 48;
85 const MESSAGE_WINDOW_TAIL_TOLERANCE_PX = 80;
86 const MESSAGE_WINDOW_USER_SCROLL_GRACE_MS = 1200;
87 const LAZY_MESSAGE_PREVIEW_CHARS = 6000;
88 const DEFERRED_REPLAY_ENTRY_THRESHOLD = 30;
89 const DEFERRED_REPLAY_TEXT_THRESHOLD = 50000;
90
91 /**
92 * @typedef {object} MessageHandlerArgs
93 * @property {number} [no]
94 * @property {string | number} id
95 * @property {string} type
96 * @property {string | undefined} [heading]
97 * @property {string | undefined} [content]
98 * @property {object | undefined} [kvps]
99 * @property {number | undefined} [timestamp]
100 * @property {number} [agentno]
101 */
102
103 /**
104 * @typedef {{ element: Element } & Record<string, any>} MessageHandlerResult
105 */
106
107 /**
108 * @typedef {object} SetMessageResult
109 * @property {IArguments} args
110 * @property {MessageHandlerResult} result
111 */
112
113 /**
114 * @typedef {(args: MessageHandlerArgs & Record<string, any>) => (MessageHandlerResult|Promise<MessageHandlerResult>)} MessageHandler
115 */
116
117 /**
118 * @typedef {object} ProcessStepArgs
119 * @property {string | number} id
120 * @property {string} title
121 * @property {string} code
122 * @property {string[] | undefined} [classes]
123 * @property {any} [kvps]
124 * @property {string | undefined} [content]
125 * @property {string[] | undefined} [contentClasses]
126 * @property {Element[] | undefined} [actionButtons]
127 * @property {any} log
128 * @property {boolean} [allowCompletedGroup]
129 */
130
131
132 export function scrollOnNextProcessGroup() {
133 _scrollOnNextProcessGroup = "wait";
134 }
135
136 // handlers for log message rendering
137 /**
138 * Returns a message renderer for a given log message type.
139 *
140 * The returned handler has the same input object shape as `setMessage(...)` passes through
141 * and may return a rich object `{ element, actionButtons?, ...additional }`.
142 *
143 * @param {string} type
144 * @returns {Promise<MessageHandler>}
145 */
146 export async function getMessageHandler(type) {
147 switch (type) {
148 case "user":
149 return drawMessageUser;
150 case "agent":
151 return drawMessageAgent;
152 case "response":
153 return drawMessageResponse;
154 case "tool":
155 return drawMessageTool;
156 case "progress":
157 return drawMessageProgress;
158 case "mcp":
159 return drawMessageMcp;
160 case "subagent":
161 return drawMessageSubagent;
162 case "warning":
163 return drawMessageWarning;
164 case "rate_limit":
165 return drawMessageWarning;
166 case "error":
167 return drawMessageError;
168 case "info":
169 return drawMessageInfo;
170 case "util":
171 return drawMessageUtil;
172 case "hint":
173 return drawMessageHint;
174 case "model_setup_gate":
175 return drawMessageModelSetupGate;
176 default:
177 return await getHandlerFromExtensions(type);
178 }
179
180 async function getHandlerFromExtensions(type){
181 const extData = { type: type, handler: undefined }
182 await callJsExtensions("get_message_handler", extData);
183 // return handler from extensions
184 if(typeof extData.handler == "function") return extData.handler;
185 //not set by extensions, return default
186 return drawMessageDefault;
187 }
188 }
189
190
191 // entrypoint called from poll/WS communication, this is how all messages are rendered and updated
192 // input is raw log format
193 export function setMessages(messages) {
194 const generation = _messageRenderGeneration;
195 const task = _messageRenderQueue.then(
196 () => setMessagesNow(messages, generation),
197 () => setMessagesNow(messages, generation),
198 );
199 _messageRenderQueue = task.catch(() => undefined);
200 return task;
201 }
202
203 async function setMessagesNow(messages, generation) {
204 if (generation !== _messageRenderGeneration) return null;
205 messages = normalizeMessages(messages);
206 const history = getChatHistoryEl();
207 const followTail = shouldFollowMessageTail();
208
209 const addedMessageKeys = _messageWindow.merge(messages, { followTail });
210 bindMessageWindow(history);
211 if (_messageWindowRenderPromise) await _messageWindowRenderPromise;
212
213 const initialWindow =
214 _messageWindow.size > 0 && !history?.querySelector(".message-group");
215 if (initialWindow && _messageWindowFollowTail) _messageWindow.showTail();
216 const compactedTail = _messageWindow.compactTailIfNeeded();
217 const windowMessages = _messageWindow.visibleMessages();
218 const cappedProcessGroupUpdate = hasCappedProcessGroupUpdate(
219 messages,
220 windowMessages,
221 addedMessageKeys,
222 );
223 if (initialWindow || compactedTail || cappedProcessGroupUpdate) {
224 return await renderMessageWindow({
225 preserveScroll: !initialWindow && !followTail,
226 generation,
227 });
228 }
229
230 return await renderMessageBatch(messages, {
231 virtualizeOffscreen: true,
232 windowedRender: false,
233 generation,
234 });
235 }
236
237 export function resetMessageRenderState({ clearDom = true } = {}) {
238 _messageRenderGeneration += 1;
239 _messageWindow.reset([]);
240 _massRender = false;
241 _windowedRender = false;
242 _scrollOnNextProcessGroup = null;
243 _messageWindowFollowTail = true;
244 _messageWindowLoadingDirection = null;
245 _messageWindowSuppressScrollEvents = false;
246 _messageWindowPointerActive = false;
247 _messageWindowUserScrollUntil = 0;
248 _messageWindowResizeObserver?.disconnect();
249 _messageWindowResizeObserver = null;
250 _processGroupStepLimits.clear();
251 _renderedProcessGroupPages.clear();
252
253 const history = document.getElementById("chat-history") || getChatHistoryEl();
254 if (history) cancelPendingScroll(history);
255 if (clearDom && history) history.replaceChildren();
256 if (history) {
257 delete history.dataset.messageWindowStart;
258 delete history.dataset.messageWindowEnd;
259 delete history.dataset.messageWindowTotal;
260 }
261 }
262
263 function normalizeMessages(messages) {
264 const normalized = Array.isArray(messages) ? [...messages].filter(Boolean) : [];
265 normalized.sort(
266 (a, b) =>
267 (a.no ?? Number.MAX_SAFE_INTEGER) -
268 (b.no ?? Number.MAX_SAFE_INTEGER),
269 );
270 return normalized;
271 }
272
273 async function renderMessageWindow({
274 preserveScroll = true,
275 generation = _messageRenderGeneration,
276 } = {}) {
277 if (_messageWindowRenderPromise) return await _messageWindowRenderPromise;
278
279 _messageWindowRenderPromise = (async () => {
280 const history = getChatHistoryEl();
281 if (!history) return null;
282 const stagingHistory = createMessageWindowStagingHistory(history);
283
284 _messageWindowSuppressScrollEvents = true;
285 cancelPendingScroll(history);
286 _messageWindowResizeObserver?.disconnect();
287 try {
288 const anchor = preserveScroll
289 ? captureMessageWindowAnchor(history)
290 : null;
291 const expansionState = captureMessageExpansionState(history);
292 _chatHistory = stagingHistory;
293
294 const windowMessages = _messageWindow.visibleMessages();
295 const renderMessages = getProcessGroupRenderMessages(windowMessages);
296 const context = await renderMessageBatch(renderMessages, {
297 forceHistoryEmpty: true,
298 forceMassRender: true,
299 suppressScroll: preserveScroll,
300 windowedRender: shouldDeferReplayDetails(renderMessages),
301 windowRebuild: true,
302 generation,
303 });
304
305 if (generation !== _messageRenderGeneration) {
306 return null;
307 }
308
309 updateProcessGroupPagingControls(stagingHistory);
310 await restoreMessageExpansionState(stagingHistory, expansionState);
311 await nextAnimationFrame();
312
313 if (generation !== _messageRenderGeneration) return null;
314
315 _messageWindowResizeObserver?.disconnect();
316 stagingHistory
317 .querySelectorAll(".message-container")
318 .forEach((element) => element.classList.add("message-window-restored"));
319 const stagedChildren = Array.from(stagingHistory.childNodes);
320 const stagedWindowState = {
321 messageWindowStart: stagingHistory.dataset.messageWindowStart,
322 messageWindowEnd: stagingHistory.dataset.messageWindowEnd,
323 messageWindowTotal: stagingHistory.dataset.messageWindowTotal,
324 detailMode: stagingHistory.dataset.detailMode,
325 };
326 stagingHistory.remove();
327 _chatHistory = history;
328
329 history.replaceChildren(...stagedChildren);
330 copyMessageWindowDataset(history, stagedWindowState);
331 let anchorRestored = anchor
332 ? restoreMessageWindowAnchor(history, anchor)
333 : false;
334 if (!anchorRestored && _messageWindow.isAtTail() && _messageWindowFollowTail) {
335 history.scrollTop = history.scrollHeight;
336 }
337
338 await nextAnimationFrame();
339 refreshCollapsibleMessageOverflow(history);
340 if (anchor) {
341 anchorRestored = restoreMessageWindowAnchor(history, anchor) ||
342 anchorRestored;
343 }
344 if (!anchorRestored && _messageWindow.isAtTail() && _messageWindowFollowTail) {
345 history.scrollTop = history.scrollHeight;
346 }
347
348 context.history = history;
349 context.mainScroller = null;
350 refreshMessageWindowResizeObserver(history);
351 return context;
352 } finally {
353 stagingHistory.remove();
354 _chatHistory = history;
355 _lastMessageWindowScrollTop = history.scrollTop;
356 _messageWindowSuppressScrollEvents = false;
357 }
358 })();
359
360 try {
361 return await _messageWindowRenderPromise;
362 } finally {
363 _messageWindowRenderPromise = null;
364 }
365 }
366
367 function shouldDeferReplayDetails(messages) {
368 if (
369 _messageWindow.hasOlder ||
370 _messageWindow.hasNewer ||
371 messages.length > DEFERRED_REPLAY_ENTRY_THRESHOLD
372 ) {
373 return true;
374 }
375
376 let textSize = 0;
377 for (const message of messages) {
378 textSize += String(message?.heading ?? "").length;
379 textSize += String(message?.content ?? "").length;
380 for (const value of Object.values(message?.kvps || {})) {
381 textSize += typeof value === "string" ? value.length : 500;
382 }
383 if (textSize > DEFERRED_REPLAY_TEXT_THRESHOLD) return true;
384 }
385 return false;
386 }
387
388 async function renderMessageBatch(messages, options = {}) {
389 const generation = options.generation ?? _messageRenderGeneration;
390 if (generation !== _messageRenderGeneration) return null;
391 const history = getChatHistoryEl();
392 const context = {
393 messages: normalizeMessages(messages),
394 history,
395 historyEmpty:
396 options.forceHistoryEmpty ?? !history?.querySelector(".message-group"),
397 isLargeAppend: false,
398 cutoff: 0,
399 massRender: false,
400 windowRebuild: Boolean(options.windowRebuild),
401 messageWindow: getMessageWindowContext(),
402 scrollerOptions: {
403 smooth: true,
404 toleranceRem: 4,
405 reapplyDelayMs: 1000,
406 applyStabilization: true,
407 },
408 /** @type {Scroller | null} */
409 mainScroller: null,
410 /** @type {SetMessageResult[]} */
411 results: [],
412 };
413
414 context.isLargeAppend = !context.historyEmpty && context.messages.length > 10;
415 context.cutoff = context.isLargeAppend
416 ? Math.max(0, context.messages.length - 2)
417 : 0;
418 context.massRender =
419 Boolean(options.forceMassRender) ||
420 context.historyEmpty ||
421 context.isLargeAppend;
422 context.scrollerOptions.smooth = !context.massRender;
423
424 await callJsExtensions("set_messages_before_loop", context);
425 if (generation !== _messageRenderGeneration) {
426 context.history?.replaceChildren();
427 return null;
428 }
429
430 if (context.history) {
431 context.mainScroller = new Scroller(
432 context.history,
433 context.scrollerOptions,
434 );
435 }
436
437 try {
438 for (let i = 0; i < context.messages.length; i++) {
439 if (generation !== _messageRenderGeneration) break;
440 const message = context.messages[i];
441 const messageKey = getMessageCacheKey(message);
442 if (
443 options.virtualizeOffscreen &&
444 messageKey &&
445 !_messageWindow.isKeyVisible(messageKey)
446 ) {
447 context.results.push({
448 args: message,
449 result: { element: null, virtualized: true, dontScroll: true },
450 });
451 continue;
452 }
453 _massRender =
454 Boolean(options.forceMassRender) ||
455 context.historyEmpty ||
456 (context.isLargeAppend && i < context.cutoff);
457 _windowedRender = Boolean(options.windowedRender);
458 const entry = await setMessage(message);
459 if (generation !== _messageRenderGeneration) {
460 context.history?.replaceChildren();
461 break;
462 }
463 context.results.push(entry);
464 }
465
466 if (generation === _messageRenderGeneration) {
467 updateMessageWindowIndicators(context.history);
468 if (
469 context.windowRebuild &&
470 typeof preferencesStore.applyCurrentDetailMode === "function"
471 ) {
472 await preferencesStore.applyCurrentDetailMode(context.history);
473 }
474 refreshMessageWindowResizeObserver(context.history);
475 await callJsExtensions("set_messages_after_loop", context);
476 }
477 } finally {
478 _massRender = false;
479 _windowedRender = false;
480 }
481
482 if (generation !== _messageRenderGeneration) return null;
483
484 const lastResult = context.results[context.results.length - 1]?.result;
485 const shouldScroll =
486 !options.suppressScroll &&
487 (context.historyEmpty || !lastResult?.dontScroll);
488
489 if (shouldScroll) context.mainScroller?.reApplyScroll();
490
491 if (_scrollOnNextProcessGroup === "scroll") {
492 requestAnimationFrame(() => {
493 if (
494 generation !== _messageRenderGeneration ||
495 _scrollOnNextProcessGroup !== "scroll"
496 ) {
497 return;
498 }
499 context.mainScroller?.scrollToBottom();
500 _scrollOnNextProcessGroup = null;
501 });
502 }
503
504 return context;
505 }
506
507 // entrypoint called from poll/WS communication, this is how all messages are rendered and updated
508 // input is raw log format
509 /**
510 * @param {MessageHandlerArgs & Record<string, any>} param0
511 * @returns {Promise<SetMessageResult>}
512 */
513 export async function setMessage({
514 no,
515 id,
516 type,
517 heading,
518 content,
519 kvps,
520 timestamp,
521 agentno,
522 ...additional
523 }) {
524 const rawMessage = arguments[0];
525 const handler = await getMessageHandler(type);
526 // prefer log ID if set to match user message created on frontend with backend updates
527 const handlerArgs = {
528 no,
529 id: id || String(no) || "",
530 type,
531 heading,
532 content,
533 kvps,
534 timestamp,
535 agentno,
536 ...additional,
537 };
538 handlerArgs[PROCESS_GROUP_RENDER_INFO] = _messageProcessGroups.get(rawMessage);
539 const handlerResult = await handler(handlerArgs);
540 const messageKey = getMessageCacheKey(rawMessage);
541
542 if (handlerResult?.element && messageKey) {
543 handlerResult.element.dataset.messageKey = messageKey;
544 }
545 if (handlerResult?.element && no !== undefined && no !== null) {
546 handlerResult.element.dataset.logNo = String(no);
547 }
548
549 if (handlerResult?.step) {
550 handlerResult.step.__renderDetail = async () => {
551 if (!handlerResult.step?.isConnected) return null;
552 return await requestDeferredMessageDetail(rawMessage);
553 };
554 handlerResult.step.__discardDetail = () =>
555 discardProcessStepDetail(handlerResult.step);
556 handlerResult.step.__setExpanded = (expanded) =>
557 toggleStepCollapse(handlerResult.step, expanded);
558 }
559
560 return {
561 args: rawMessage,
562 result: handlerResult,
563 }
564 }
565
566 function getOrCreateMessageContainer(
567 id,
568 position,
569 containerClasses = [],
570 forceNewGroup = false,
571 ) {
572 let container = getChatHistoryElementById(`message-${id}`);
573 if (!container) {
574 container = document.createElement("div");
575 container.id = `message-${id}`;
576 container.classList.add("message-container");
577 }
578
579 if (containerClasses.length) {
580 container.classList.add(...containerClasses);
581 }
582
583 if (!container.parentNode) {
584 appendToMessageGroup(container, position, forceNewGroup);
585 }
586
587 return container;
588 }
589
590 function getChatHistoryEl() {
591 if (!_chatHistory) _chatHistory = document.getElementById("chat-history");
592 return _chatHistory;
593 }
594
595 function getChatHistoryElementById(id) {
596 const history = getChatHistoryEl();
597 if (!history || !id) return null;
598 if (globalThis.CSS?.escape) {
599 return history.querySelector(`#${globalThis.CSS.escape(id)}`);
600 }
601 return Array.from(history.querySelectorAll("[id]")).find(
602 (element) => element.id === id,
603 ) || null;
604 }
605
606 function getLastMessageGroup() {
607 const groups = getChatHistoryEl()?.querySelectorAll(":scope > .message-group");
608 return groups?.[groups.length - 1] || null;
609 }
610
611 function getMessageWindowContext() {
612 return {
613 start: _messageWindow.visibleStart,
614 end: _messageWindow.visibleEnd,
615 total: _messageWindow.size,
616 rendered: _messageWindow.renderedCount,
617 older: _messageWindow.olderCount,
618 newer: _messageWindow.newerCount,
619 hasOlder: _messageWindow.hasOlder,
620 hasNewer: _messageWindow.hasNewer,
621 };
622 }
623
624 function getProcessGroupPageState(messages) {
625 const groups = new Map();
626 for (const message of messages) {
627 const group = _messageProcessGroups.get(message);
628 if (!group || !_messageIsProcessStep.has(message)) continue;
629 let state = groups.get(group.key);
630 if (!state) {
631 state = { group, steps: [] };
632 groups.set(group.key, state);
633 }
634 state.steps.push(message);
635 }
636 return groups;
637 }
638
639 function getProcessGroupRenderMessages(messages) {
640 const groups = getProcessGroupPageState(messages);
641 const hiddenByGroup = new Map();
642 _renderedProcessGroupPages = new Map();
643
644 for (const [key, state] of groups) {
645 const limit = _processGroupStepLimits.get(key) ||
646 PROCESS_GROUP_STEP_PAGE_SIZE;
647 const hidden = Math.max(0, state.steps.length - limit);
648 hiddenByGroup.set(key, hidden);
649 _renderedProcessGroupPages.set(key, {
650 ...state,
651 hidden,
652 visible: state.steps.length - hidden,
653 });
654 }
655
656 const seen = new Map();
657 return messages.filter((message) => {
658 const group = _messageProcessGroups.get(message);
659 if (!group || !_messageIsProcessStep.has(message)) return true;
660 const index = seen.get(group.key) || 0;
661 seen.set(group.key, index + 1);
662 return index >= (hiddenByGroup.get(group.key) || 0);
663 });
664 }
665
666 function hasCappedProcessGroupUpdate(messages, windowMessages, addedMessageKeys) {
667 if (!messages.length) return false;
668 const groupStates = getProcessGroupPageState(windowMessages);
669 return messages.some((message) => {
670 const group = _messageProcessGroups.get(message);
671 if (!group || !_messageIsProcessStep.has(message)) return false;
672 const total = groupStates.get(group.key)?.steps.length || 0;
673 const limit = _processGroupStepLimits.get(group.key) ||
674 PROCESS_GROUP_STEP_PAGE_SIZE;
675 return total > limit && addedMessageKeys.has(getMessageCacheKey(message));
676 });
677 }
678
679 function updateProcessGroupPagingControls(history) {
680 history
681 ?.querySelectorAll(".process-group-show-more")
682 .forEach((element) => element.remove());
683
684 for (const [key, state] of _renderedProcessGroupPages) {
685 const group = Array.from(
686 history?.querySelectorAll(".process-group[data-render-group-key]") || [],
687 ).find((candidate) => candidate.dataset.renderGroupKey === key);
688 if (!group) continue;
689
690 const allSteps = state.steps;
691 const firstTimestamp = allSteps[0]?.timestamp;
692 const lastTimestamp = allSteps.at(-1)?.timestamp;
693 if (firstTimestamp != null) {
694 group.dataset.fullStartTimestamp = String(firstTimestamp);
695 group.setAttribute("data-start-timestamp", String(firstTimestamp));
696 }
697 if (lastTimestamp != null) {
698 group.dataset.fullEndTimestamp = String(lastTimestamp);
699 }
700 group.dataset.fullAgentSteps = String(
701 Math.max(
702 0,
703 allSteps.filter((message) => message?.type === "agent").length - 1,
704 ),
705 );
706 group.dataset.fullWarningSteps = String(
707 allSteps.filter((message) => message?.type === "warning").length,
708 );
709 group.dataset.fullInfoSteps = String(
710 allSteps.filter((message) => message?.type === "info").length,
711 );
712 const lastAgentMessage = allSteps.findLast(
713 (message) => message?.type === "agent",
714 );
715 const fullTitle = cleanStepTitle(lastAgentMessage?.heading, 50);
716 if (fullTitle) {
717 const title = group.querySelector(".process-group-header .group-title");
718 if (title) title.textContent = fullTitle;
719 }
720 updateProcessGroupHeader(group);
721
722 if (state.hidden <= 0) continue;
723 const stepsContainer = group.querySelector(":scope .process-steps");
724 if (!stepsContainer) continue;
725 const button = document.createElement("button");
726 button.type = "button";
727 button.className = "process-group-show-more";
728 button.textContent = "Show more";
729 const nextCount = Math.min(PROCESS_GROUP_STEP_PAGE_SIZE, state.hidden);
730 button.setAttribute("aria-label", `Show ${nextCount} earlier steps`);
731 button.addEventListener("click", () => {
732 void showMoreProcessGroupSteps(key);
733 });
734 stepsContainer.insertBefore(button, stepsContainer.firstChild);
735 }
736 }
737
738 function showMoreProcessGroupSteps(groupKey) {
739 const generation = _messageRenderGeneration;
740 const task = _messageRenderQueue.then(async () => {
741 if (generation !== _messageRenderGeneration) return false;
742 const current = _processGroupStepLimits.get(groupKey) ||
743 PROCESS_GROUP_STEP_PAGE_SIZE;
744 _processGroupStepLimits.set(
745 groupKey,
746 current + PROCESS_GROUP_STEP_PAGE_SIZE,
747 );
748 await renderMessageWindow({ preserveScroll: true, generation });
749 return true;
750 });
751 _messageRenderQueue = task.catch(() => undefined);
752 return task;
753 }
754
755 function shouldFollowMessageTail() {
756 if (_messageWindow.size === 0) return true;
757 return _messageWindowFollowTail && _messageWindow.isAtTail();
758 }
759
760 async function renderDeferredMessageDetail(message) {
761 const entry = await setMessage(message);
762 await callJsExtensions("set_messages_after_loop", {
763 messages: [message],
764 history: getChatHistoryEl(),
765 historyEmpty: true,
766 isLargeAppend: false,
767 cutoff: 0,
768 massRender: false,
769 windowRebuild: false,
770 detailMaterialization: true,
771 messageWindow: getMessageWindowContext(),
772 mainScroller: null,
773 results: [entry],
774 });
775 return entry;
776 }
777
778 function requestDeferredMessageDetail(message) {
779 if (_messageWindowRenderPromise) {
780 return renderDeferredMessageDetail(message);
781 }
782
783 const generation = _messageRenderGeneration;
784 const task = _messageRenderQueue.then(async () => {
785 if (generation !== _messageRenderGeneration) return null;
786 return await renderDeferredMessageDetail(message);
787 });
788 _messageRenderQueue = task.catch(() => undefined);
789 return task;
790 }
791
792 function bindMessageWindow(history) {
793 if (!history || _messageWindowHistory === history) return;
794 _messageWindowHistory = history;
795 _lastMessageWindowScrollTop = history.scrollTop;
796
797 const noteUserScrollIntent = () => {
798 _messageWindowUserScrollUntil =
799 messageWindowNow() + MESSAGE_WINDOW_USER_SCROLL_GRACE_MS;
800 };
801
802 history.addEventListener("wheel", noteUserScrollIntent, { passive: true });
803 history.addEventListener("touchstart", noteUserScrollIntent, {
804 passive: true,
805 });
806 history.addEventListener("pointerdown", () => {
807 _messageWindowPointerActive = true;
808 noteUserScrollIntent();
809 });
810 globalThis.addEventListener("pointerup", () => {
811 _messageWindowPointerActive = false;
812 });
813 globalThis.addEventListener("pointercancel", () => {
814 _messageWindowPointerActive = false;
815 });
816 globalThis.addEventListener("keydown", (event) => {
817 const target = event.target;
818 if (
819 target instanceof Element &&
820 target.closest("input, textarea, select, [contenteditable='true']")
821 ) {
822 return;
823 }
824 if (
825 ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End"].includes(
826 event.key,
827 )
828 ) {
829 noteUserScrollIntent();
830 }
831 });
832
833 history.addEventListener(
834 "scroll",
835 () => {
836 if (_messageWindowScrollFrame != null) return;
837 _messageWindowScrollFrame = requestAnimationFrame(() => {
838 _messageWindowScrollFrame = null;
839 if (_messageWindowRenderPromise) return;
840
841 const previous = _lastMessageWindowScrollTop;
842 const current = history.scrollTop;
843 const direction = current < previous ? "older" : current > previous ? "newer" : null;
844 _lastMessageWindowScrollTop = current;
845
846 const hasUserScrollIntent =
847 _messageWindowPointerActive ||
848 messageWindowNow() <= _messageWindowUserScrollUntil;
849 const bottomDistance =
850 history.scrollHeight - current - history.clientHeight;
851
852 if (hasUserScrollIntent && direction) {
853 _messageWindowFollowTail =
854 _messageWindow.isAtTail() &&
855 bottomDistance <= MESSAGE_WINDOW_TAIL_TOLERANCE_PX;
856 }
857
858 if (
859 _messageWindowSuppressScrollEvents ||
860 _messageWindowRenderPromise ||
861 !hasUserScrollIntent
862 ) {
863 return;
864 }
865
866 if (
867 direction === "older" &&
868 current <= MESSAGE_WINDOW_BOUNDARY_TOLERANCE_PX &&
869 _messageWindow.hasOlder
870 ) {
871 void shiftMessageWindow("older");
872 return;
873 }
874
875 if (
876 direction === "newer" &&
877 bottomDistance <= MESSAGE_WINDOW_BOUNDARY_TOLERANCE_PX &&
878 _messageWindow.hasNewer
879 ) {
880 void shiftMessageWindow("newer");
881 }
882 });
883 },
884 { passive: true },
885 );
886 }
887
888 function messageWindowNow() {
889 return globalThis.performance?.now?.() ?? Date.now();
890 }
891
892 function refreshMessageWindowResizeObserver(history) {
893 if (!history || typeof ResizeObserver === "undefined") return;
894 if (!_messageWindowResizeObserver) {
895 _messageWindowResizeObserver = new ResizeObserver((entries) => {
896 entries.forEach((entry) =>
897 refreshCollapsibleMessageOverflow(entry.target),
898 );
899
900 const liveHistory = getChatHistoryEl();
901 if (
902 _messageWindowSuppressScrollEvents ||
903 _messageWindowRenderPromise ||
904 !_messageWindowFollowTail ||
905 !_messageWindow.isAtTail()
906 ) {
907 return;
908 }
909
910 cancelPendingScroll(liveHistory);
911 liveHistory.scrollTop = liveHistory.scrollHeight;
912 _lastMessageWindowScrollTop = liveHistory.scrollTop;
913 });
914 }
915
916 history
917 .querySelectorAll(":scope > .message-group")
918 .forEach((group) => _messageWindowResizeObserver.observe(group));
919 }
920
921 async function shiftMessageWindow(direction) {
922 await loadAdjacentMessageWindow(direction);
923 }
924
925 export function loadAdjacentMessageWindow(direction) {
926 if (!["older", "newer"].includes(direction)) {
927 return Promise.resolve(false);
928 }
929 if (_messageWindowLoadingDirection) return Promise.resolve(false);
930
931 const generation = _messageRenderGeneration;
932 _messageWindowLoadingDirection = direction;
933 setMessageWindowIndicatorLoading(getChatHistoryEl(), direction, true);
934
935 const renderTask = _messageRenderQueue.then(async () => {
936 if (generation !== _messageRenderGeneration) return false;
937 const shifted =
938 direction === "older"
939 ? _messageWindow.shiftOlder()
940 : _messageWindow.shiftNewer();
941 if (!shifted) return false;
942 await renderMessageWindow({ preserveScroll: true, generation });
943 return true;
944 });
945 const task = renderTask.finally(() => {
946 if (_messageWindowLoadingDirection === direction) {
947 _messageWindowLoadingDirection = null;
948 setMessageWindowIndicatorLoading(getChatHistoryEl(), direction, false);
949 }
950 });
951 _messageRenderQueue = task.catch(() => undefined);
952 return task;
953 }
954
955 export function scrollMessageWindowToEdge(edge) {
956 const generation = _messageRenderGeneration;
957 const task = _messageRenderQueue.then(async () => {
958 if (generation !== _messageRenderGeneration) return false;
959 const history = getChatHistoryEl();
960
961 if (edge === "start") {
962 _messageWindowFollowTail = false;
963 cancelPendingScroll(history);
964 if (!_messageWindow.hasOlder && _messageWindow.start === 0) {
965 history?.scrollTo({ top: 0, behavior: "instant" });
966 return true;
967 }
968 _messageWindow.showHead();
969 await renderMessageWindow({ preserveScroll: false, generation });
970 history?.scrollTo({ top: 0, behavior: "instant" });
971 return true;
972 }
973
974 _messageWindowFollowTail = true;
975 cancelPendingScroll(history);
976 if (_messageWindow.isAtTail()) {
977 if (history) history.scrollTop = history.scrollHeight;
978 return true;
979 }
980 _messageWindow.showTail();
981 await renderMessageWindow({ preserveScroll: false, generation });
982 if (history) history.scrollTop = history.scrollHeight;
983 return true;
984 });
985 _messageRenderQueue = task.catch(() => undefined);
986 return task;
987 }
988
989 export function getMessageWindowState() {
990 return getMessageWindowContext();
991 }
992
993 function updateMessageWindowIndicators(history) {
994 if (!history) return;
995 history
996 .querySelectorAll(":scope > [data-message-window-ui]")
997 .forEach((element) => element.remove());
998
999 history.dataset.messageWindowStart = String(_messageWindow.visibleStart);
1000 history.dataset.messageWindowEnd = String(_messageWindow.visibleEnd);
1001 history.dataset.messageWindowTotal = String(_messageWindow.size);
1002
1003 if (_messageWindow.hasOlder) {
1004 const older = createMessageWindowIndicator("older");
1005 history.insertBefore(older, history.firstChild);
1006 }
1007
1008 if (_messageWindow.hasNewer) {
1009 history.appendChild(createMessageWindowIndicator("newer"));
1010 }
1011 }
1012
1013 function setMessageWindowIndicatorLoading(history, direction, loading) {
1014 if (!history) return;
1015 let indicator = history.querySelector(
1016 `:scope > [data-message-window-ui="${direction}"]`,
1017 );
1018 if (!indicator && loading) {
1019 updateMessageWindowIndicators(history);
1020 indicator = history.querySelector(
1021 `:scope > [data-message-window-ui="${direction}"]`,
1022 );
1023 }
1024 if (!indicator) return;
1025
1026 indicator.classList.toggle("is-loading", loading);
1027 indicator
1028 .querySelector(":scope > .three-bubble-loader")
1029 ?.classList.toggle("is-active", loading);
1030 if (loading) {
1031 const label = direction === "older" ? "earlier" : "newer";
1032 indicator.setAttribute("role", "status");
1033 indicator.setAttribute("aria-live", "polite");
1034 indicator.setAttribute("aria-label", `Loading ${label} messages`);
1035 indicator.removeAttribute("aria-hidden");
1036 } else {
1037 indicator.removeAttribute("role");
1038 indicator.removeAttribute("aria-live");
1039 indicator.removeAttribute("aria-label");
1040 indicator.setAttribute("aria-hidden", "true");
1041 }
1042 }
1043
1044 function createMessageWindowIndicator(direction) {
1045 const indicator = document.createElement("div");
1046 const label = direction === "older" ? "earlier" : "newer";
1047 const isLoading = _messageWindowLoadingDirection === direction;
1048 indicator.className = `message-window-loader message-window-${direction}`;
1049 indicator.classList.toggle("is-loading", isLoading);
1050 indicator.dataset.messageWindowUi = direction;
1051 if (isLoading) {
1052 indicator.setAttribute("role", "status");
1053 indicator.setAttribute("aria-live", "polite");
1054 indicator.setAttribute("aria-label", `Loading ${label} messages`);
1055 } else {
1056 indicator.setAttribute("aria-hidden", "true");
1057 }
1058 indicator.appendChild(createThreeBubbleLoader({ active: isLoading }));
1059 const statusLabel = document.createElement("span");
1060 statusLabel.className = "loading-indicator-label";
1061 statusLabel.textContent = `Loading ${label} messages`;
1062 indicator.appendChild(statusLabel);
1063 return indicator;
1064 }
1065
1066 function createMessageWindowStagingHistory(history) {
1067 const staging = history.cloneNode(false);
1068 const historyRect = history.getBoundingClientRect();
1069 staging.classList.add("message-window-staging");
1070 staging.setAttribute("aria-hidden", "true");
1071 staging.style.position = "fixed";
1072 staging.style.top = "0";
1073 staging.style.left = "-100000px";
1074 staging.style.width = `${historyRect.width}px`;
1075 staging.style.height = `${historyRect.height}px`;
1076 staging.style.visibility = "hidden";
1077 staging.style.pointerEvents = "none";
1078 staging.style.contain = "layout style paint";
1079 delete staging.dataset.scrollerTimeout;
1080 delete staging.dataset.scrollerReapplySnapshot;
1081 delete staging.dataset.scrollingTo;
1082 history.after(staging);
1083 return staging;
1084 }
1085
1086 function copyMessageWindowDataset(history, state) {
1087 for (const [key, value] of Object.entries(state)) {
1088 if (value === undefined) delete history.dataset[key];
1089 else history.dataset[key] = value;
1090 }
1091 }
1092
1093 function getMessageWindowAnchorCandidates(history) {
1094 return Array.from(
1095 history.querySelectorAll(
1096 ".process-group[data-render-group-key], [data-message-key]",
1097 ),
1098 ).filter((element) =>
1099 element.dataset.renderGroupKey || !element.closest(".process-group")
1100 );
1101 }
1102
1103 function getMessageWindowAnchorIdentity(element) {
1104 if (element?.dataset?.renderGroupKey) {
1105 return `group:${element.dataset.renderGroupKey}`;
1106 }
1107 if (element?.dataset?.messageKey) {
1108 return `message:${element.dataset.messageKey}`;
1109 }
1110 return null;
1111 }
1112
1113 function captureMessageWindowAnchor(history) {
1114 const historyRect = history.getBoundingClientRect();
1115 const candidates = getMessageWindowAnchorCandidates(history);
1116 let fallback = null;
1117
1118 for (const element of candidates) {
1119 const rect = element.getBoundingClientRect();
1120 if (rect.height <= 0 || rect.bottom <= historyRect.top) continue;
1121 const anchor = {
1122 identity: getMessageWindowAnchorIdentity(element),
1123 offset: rect.top - historyRect.top,
1124 };
1125 if (rect.top < historyRect.bottom) return anchor;
1126 fallback ||= anchor;
1127 }
1128
1129 return fallback;
1130 }
1131
1132 function restoreMessageWindowAnchor(history, anchor) {
1133 if (!anchor?.identity) return false;
1134 const historyRect = history.getBoundingClientRect();
1135 const element = getMessageWindowAnchorCandidates(history).find(
1136 (candidate) =>
1137 getMessageWindowAnchorIdentity(candidate) === anchor.identity,
1138 );
1139 if (!element) return false;
1140 const nextOffset = element.getBoundingClientRect().top - historyRect.top;
1141 history.scrollTop += nextOffset - anchor.offset;
1142 return true;
1143 }
1144
1145 function captureMessageExpansionState(history) {
1146 const state = new Map();
1147 history
1148 .querySelectorAll(".process-group[id], .process-step[id]")
1149 .forEach((element) => {
1150 const kind = element.classList.contains("process-group")
1151 ? "group"
1152 : "step";
1153 state.set(
1154 `${kind}:${element.id}`,
1155 element.classList.contains("expanded"),
1156 );
1157 });
1158 history
1159 .querySelectorAll("[data-message-key] > .message")
1160 .forEach((element) => {
1161 state.set(
1162 `message:${element.parentElement.dataset.messageKey}`,
1163 element.classList.contains("expanded"),
1164 );
1165 });
1166 return state;
1167 }
1168
1169 async function restoreMessageExpansionState(history, state) {
1170 const pending = [];
1171 for (const [key, expanded] of state) {
1172 let element = null;
1173 if (key.startsWith("group:") || key.startsWith("step:")) {
1174 const separator = key.indexOf(":");
1175 const kind = key.slice(0, separator);
1176 const id = key.slice(separator + 1);
1177 const selector = kind === "group" ? ".process-group[id]" : ".process-step[id]";
1178 element = Array.from(history.querySelectorAll(selector)).find(
1179 (candidate) => candidate.id === id,
1180 );
1181 } else if (key.startsWith("message:")) {
1182 const messageKey = key.slice(8);
1183 const container = Array.from(
1184 history.querySelectorAll("[data-message-key]"),
1185 ).find((candidate) => candidate.dataset.messageKey === messageKey);
1186 element = container?.querySelector(":scope > .message") || null;
1187 }
1188 if (!element || !history.contains(element)) continue;
1189 if (typeof element.__setExpanded === "function") {
1190 pending.push(Promise.resolve(element.__setExpanded(expanded)));
1191 } else {
1192 element.classList.toggle("expanded", expanded);
1193 }
1194 }
1195 await Promise.allSettled(pending);
1196 }
1197
1198 function nextAnimationFrame() {
1199 return new Promise((resolve) => requestAnimationFrame(() => resolve()));
1200 }
1201
1202 function appendToMessageGroup(
1203 messageContainer,
1204 position,
1205 forceNewGroup = false,
1206 ) {
1207 const chatHistoryEl = getChatHistoryEl();
1208 if (!chatHistoryEl) return;
1209
1210 const lastGroup = getLastMessageGroup();
1211 const lastGroupType = lastGroup?.getAttribute("data-group-type");
1212
1213 if (!forceNewGroup && lastGroup && lastGroupType === position) {
1214 lastGroup.appendChild(messageContainer);
1215 } else {
1216 const group = document.createElement("div");
1217 group.classList.add("message-group", `message-group-${position}`);
1218 group.setAttribute("data-group-type", position);
1219 group.appendChild(messageContainer);
1220 const bottomControl = chatHistoryEl.querySelector(
1221 ':scope > [data-message-window-ui="newer"]',
1222 );
1223 chatHistoryEl.insertBefore(group, bottomControl || null);
1224 }
1225 }
1226
1227 function getLastProcessGroup(allowCompleted = true) {
1228 const lastContainer = getLastMessageGroup();
1229 if (!lastContainer) return null;
1230 const groups = lastContainer.querySelectorAll(".process-group");
1231 if (groups.length === 0) return null;
1232 const group = groups[groups.length - 1];
1233 if (!allowCompleted && isProcessGroupComplete(group)) return null;
1234
1235 return group;
1236 }
1237
1238 function getOrCreateProcessGroup(id, allowCompleted = true, renderInfo = null) {
1239 const groupIdentity = renderInfo?.id || id;
1240 // first try direct match by ID
1241 const byId = getChatHistoryElementById(`process-group-${groupIdentity}`);
1242 if (byId) return byId;
1243
1244 // if not found, try to find the last process group
1245 const existing = getLastProcessGroup(allowCompleted);
1246 if (existing) return existing;
1247
1248 // lastly create new
1249 const messageContainer = document.createElement("div");
1250 messageContainer.id = `process-group-${groupIdentity}`;
1251 messageContainer.classList.add(
1252 "message-container",
1253 "ai-container",
1254 "has-process-group",
1255 );
1256
1257 const group = createProcessGroup(groupIdentity);
1258 if (renderInfo?.key) group.dataset.renderGroupKey = renderInfo.key;
1259 group.classList.add("embedded");
1260 messageContainer.appendChild(group);
1261
1262 if (_scrollOnNextProcessGroup === "wait") {
1263 _scrollOnNextProcessGroup = "scroll";
1264 }
1265
1266 appendToMessageGroup(messageContainer, "left");
1267 return group;
1268 }
1269
1270 export function buildDetailPayload(stepData, extras = {}) {
1271 if (!stepData) return null;
1272 return {
1273 ...stepData,
1274 ...extras,
1275 };
1276 }
1277
1278 /**
1279 * @param {ProcessStepArgs & Record<string, any>} param0
1280 * @returns {MessageHandlerResult}
1281 */
1282 export function drawProcessStep({
1283 id,
1284 title,
1285 code,
1286 classes,
1287 kvps,
1288 content,
1289 contentClasses,
1290 actionButtons = [],
1291 log,
1292 allowCompletedGroup = false,
1293 ...additional
1294 }) {
1295 // group and steps DOM elements
1296 const stepId = `process-step-${id}`;
1297 let step = getChatHistoryElementById(stepId);
1298
1299 const renderInfo = log[PROCESS_GROUP_RENDER_INFO];
1300 const group =
1301 getStepProcessGroup(step) ||
1302 getOrCreateProcessGroup(
1303 id,
1304 allowCompletedGroup,
1305 renderInfo,
1306 );
1307 if (renderInfo) {
1308 // A later process step can promote a previously standalone live utility
1309 // into a substantive unit when the full cache is reclassified.
1310 group.classList.remove("utility-only");
1311 } else if (log.type === "util") {
1312 // Standalone utilities are not part of a substantive render unit. Mark
1313 // them directly from the full-log classifier instead of inferring group
1314 // visibility from whichever child steps happen to be mounted so far.
1315 group.classList.add("utility-only");
1316 }
1317 const stepsContainer = group.querySelector(".process-steps");
1318
1319 const isNewStep = !step;
1320 const isGroupComplete = isProcessGroupComplete(group);
1321
1322 // Set start timestamp on group when first step is created
1323 if (
1324 isNewStep &&
1325 !group.hasAttribute("data-start-timestamp") &&
1326 log.timestamp
1327 ) {
1328 group.setAttribute("data-start-timestamp", String(log.timestamp));
1329 }
1330
1331 if (!step) {
1332 // create the base DOM element for the step
1333 step = document.createElement("div");
1334 step.id = stepId;
1335 step.classList.add("process-step");
1336
1337 // set data attributes of the step
1338 step.setAttribute("data-log-type", log.type);
1339 step.setAttribute("data-step-id", String(id));
1340 step.setAttribute("data-agent-number", log.agentno);
1341
1342 // set timestamp attribute (convert to milliseconds for duration calculation)
1343 if (log.timestamp) {
1344 step.setAttribute(
1345 "data-timestamp",
1346 String(Math.round(log.timestamp * 1000)),
1347 );
1348 }
1349
1350 // apply step classes
1351 if (classes) step.classList.add(...classes);
1352
1353 let appendTarget = stepsContainer;
1354
1355 // grouping subordinate chain under the delegation call
1356 // for now disabled, let's keep the UI simple and unified for now
1357 // const parentStep = findParentDelegationStep(group, log.agentno);
1358 // if (parentStep) {
1359 // appendTarget = getNestedContainer(parentStep);
1360 // step.classList.add("nested-step");
1361 // }
1362
1363 // remove any existing shiny-text from group
1364 group
1365 .querySelectorAll(".process-step .step-title.shiny-text")
1366 .forEach((el) => {
1367 el.classList.remove("shiny-text");
1368 });
1369
1370 // insert step
1371 appendTarget.appendChild(step);
1372
1373 // expand all or current step based on settings
1374 const detailMode = preferencesStore.detailMode;
1375 // const isActiveGroup = group.classList.contains("active");
1376
1377 //expand all
1378 if (detailMode === "expanded") {
1379 toggleStepCollapse(step, true);
1380 // expand current step and schedule collapse of previous
1381 } else if (
1382 detailMode === "current" &&
1383 !isMassRender() &&
1384 !isGroupComplete
1385 ) {
1386 stepsContainer
1387 .querySelectorAll(".process-step.expanded")
1388 .forEach((expandedStep) => {
1389 const delay =
1390 STEP_COLLAPSE_DELAY[expandedStep.getAttribute("data-log-type")] ||
1391 STEP_COLLAPSE_DELAY.other;
1392 console.log(
1393 "collapsing",
1394 expandedStep.getAttribute("data-log-type"),
1395 delay,
1396 );
1397 scheduleStepCollapse(expandedStep, delay);
1398 });
1399 toggleStepCollapse(step, true);
1400 }
1401
1402 // create step header
1403 const stepHeader = ensureChild(
1404 step,
1405 ".process-step-header",
1406 "div",
1407 "process-step-header",
1408 );
1409 }
1410
1411 // is step expanded?
1412 const isExpanded = step.classList.contains("expanded");
1413 const shouldRenderDetail =
1414 isExpanded && group.classList.contains("expanded");
1415
1416 // create step header
1417 const stepHeader = ensureChild(
1418 step,
1419 ".process-step-header",
1420 "div",
1421 "process-step-header",
1422 );
1423
1424 // Keep the lightweight detail shell and action hooks mounted for extensions,
1425 // but materialize text-heavy detail content only while the step is expanded.
1426 const stepDetail = ensureChild(
1427 step,
1428 ".process-step-detail",
1429 "div",
1430 "process-step-detail",
1431 );
1432 // set click handlers
1433 setupProcessStepHandlers(step, stepHeader);
1434
1435 // header row - expand icon
1436 ensureChild(stepHeader, ".step-expand-icon", "span", "step-expand-icon");
1437
1438 // header row - status badge
1439 const badge = ensureChild(stepHeader, ".step-badge", "span", "step-badge");
1440
1441 // set code class if changed
1442 const prevCode = step.getAttribute("data-step-code");
1443 if (prevCode !== code) {
1444 if (prevCode) step.classList.remove(prevCode);
1445 step.setAttribute("data-step-code", code);
1446 step.classList.add(code);
1447 badge.innerText = code;
1448 }
1449
1450 // header row - title
1451 const titleEl = ensureChild(stepHeader, ".step-title", "span", "step-title");
1452 titleEl.textContent = title;
1453
1454 // Render action buttons: get/create container, clear, append
1455 const stepActionBtns = ensureChild(
1456 stepDetail,
1457 ".step-detail-actions",
1458 "div",
1459 "step-detail-actions",
1460 "step-action-buttons",
1461 );
1462 syncActionButtons(stepActionBtns, actionButtons);
1463
1464 let detailResult = {
1465 content: undefined,
1466 contentScroller: null,
1467 kvpsTable: null,
1468 };
1469 if (shouldRenderDetail) {
1470 detailResult = renderProcessStepDetail({
1471 stepDetail,
1472 kvps,
1473 content,
1474 contentClasses,
1475 });
1476 } else {
1477 discardProcessStepDetail(step);
1478 }
1479
1480 // update the process grop header by this step
1481 updateProcessGroupHeader(group);
1482
1483 // remove shine from previous steps and add to this one if new and not completed
1484 if (isNewStep && !isGroupComplete) {
1485 group
1486 .querySelectorAll(".step-title.shiny-text")
1487 .forEach((el) => {
1488 el.classList.remove("shiny-text");
1489 });
1490 titleEl.classList.add("shiny-text");
1491 }
1492
1493 // return anything useful
1494 return {
1495 element: step,
1496 actionButtons,
1497 step,
1498 detail: stepDetail,
1499 content: detailResult.content,
1500 contentScroller: detailResult.contentScroller,
1501 kvpsTable: detailResult.kvpsTable,
1502 isExpanded,
1503 detailPending: !shouldRenderDetail,
1504 };
1505 }
1506
1507 function renderProcessStepDetail({
1508 stepDetail,
1509 kvps,
1510 content,
1511 contentClasses,
1512 }) {
1513 let stepDetailScroll = stepDetail.querySelector(
1514 ":scope > .process-step-detail-scroll",
1515 );
1516 if (!stepDetailScroll) {
1517 stepDetailScroll = document.createElement("div");
1518 stepDetailScroll.classList.add("process-step-detail-scroll");
1519 stepDetail.insertBefore(
1520 stepDetailScroll,
1521 stepDetail.querySelector(":scope > .step-detail-actions"),
1522 );
1523 }
1524
1525 const detailScroller = new Scroller(stepDetailScroll, {
1526 smooth: !isMassRender(),
1527 toleranceRem: 4,
1528 });
1529 const kvpsTable = drawKvpsIncremental(stepDetailScroll, kvps);
1530
1531 let stepDetailContent;
1532 if (content) {
1533 stepDetailContent = ensureChild(
1534 stepDetailScroll,
1535 ".process-step-detail-content",
1536 "p",
1537 "process-step-detail-content",
1538 ...(contentClasses || []),
1539 );
1540 stepDetailContent.innerHTML = adjustStepContent(content);
1541 } else {
1542 stepDetailScroll
1543 .querySelector(":scope > .process-step-detail-content")
1544 ?.remove();
1545 }
1546
1547 detailScroller.reApplyScroll();
1548 return {
1549 content: stepDetailContent,
1550 contentScroller: detailScroller,
1551 kvpsTable,
1552 };
1553 }
1554
1555 function discardProcessStepDetail(step, { force = false } = {}) {
1556 if (!step) return;
1557 const remove = () => {
1558 if (!force && step.classList.contains("expanded")) return;
1559 if (
1560 force &&
1561 step.classList.contains("expanded") &&
1562 step.closest(".process-group")?.classList.contains("expanded")
1563 ) {
1564 return;
1565 }
1566 step
1567 .querySelector(":scope > .process-step-detail > .process-step-detail-scroll")
1568 ?.remove();
1569 };
1570
1571 if (isMassRender()) remove();
1572 else setTimeout(remove, 250);
1573 }
1574
1575 function adjustStepContent(content) {
1576 content = escapeHTML(content);
1577 content = convertPathsToLinks(content);
1578 return content;
1579 }
1580
1581 function toggleStepCollapse(step, expanded) {
1582 if (!step) return;
1583
1584 let nextExpanded = expanded;
1585 if (nextExpanded === undefined || nextExpanded === null) {
1586 nextExpanded = !step.classList.contains("expanded");
1587 }
1588 nextExpanded = Boolean(nextExpanded);
1589
1590 step.classList.toggle("expanded", nextExpanded);
1591
1592 if (nextExpanded) {
1593 if (step.querySelector(".process-step-detail-scroll")) return null;
1594 return materializeProcessStepDetail(step);
1595 }
1596
1597 const scroller = step.querySelector(".process-step-detail-scroll");
1598 if (scroller) scroller.scrollTop = 0;
1599 discardProcessStepDetail(step);
1600 }
1601
1602 function materializeProcessStepDetail(step) {
1603 if (!step || typeof step.__renderDetail !== "function") return null;
1604 if (step.__detailRenderPromise) return step.__detailRenderPromise;
1605
1606 step.__detailRenderPromise = Promise.resolve(step.__renderDetail()).finally(
1607 () => {
1608 delete step.__detailRenderPromise;
1609 },
1610 );
1611 return step.__detailRenderPromise;
1612 }
1613
1614 function drawStandaloneMessage({
1615 id,
1616 heading,
1617 content,
1618 position = "mid",
1619 forceNewGroup = false,
1620 containerClasses = [],
1621 mainClass = "",
1622 messageClasses = [],
1623 contentClasses = [],
1624 markdown = false,
1625 latex = false,
1626 kvps = null,
1627 actionButtons = [],
1628 }) {
1629 // end last process group on any standalone messge
1630 completeLastProcessGroup();
1631
1632 const container = getOrCreateMessageContainer(
1633 id,
1634 position,
1635 containerClasses,
1636 forceNewGroup,
1637 );
1638 const messageDiv = _drawMessage({
1639 messageContainer: container,
1640 heading,
1641 content,
1642 kvps,
1643 messageClasses,
1644 contentClasses,
1645 markdown,
1646 latex,
1647 mainClass,
1648 });
1649
1650 // Collapsible with action buttons
1651 setupCollapsible(messageDiv, ".step-action-buttons", false, actionButtons);
1652
1653 return container;
1654 }
1655
1656 // draw a message with a specific type
1657 export function _drawMessage({
1658 messageContainer,
1659 heading,
1660 content,
1661 kvps = null,
1662 messageClasses = [],
1663 contentClasses = [],
1664 markdown = false,
1665 latex = false,
1666 mainClass = "",
1667 smoothStream = false,
1668 }) {
1669 // Find existing message div or create new one
1670 let messageDiv = messageContainer.querySelector(".message");
1671 if (!messageDiv) {
1672 messageDiv = document.createElement("div");
1673 messageDiv.classList.add("message");
1674 messageContainer.appendChild(messageDiv);
1675 }
1676
1677 // Update message classes (preserve collapsible state)
1678 const preserve = ["message-collapsible", "expanded", "has-overflow"]
1679 .filter((c) => messageDiv.classList.contains(c))
1680 .join(" ");
1681 messageDiv.className = `message ${mainClass} ${messageClasses.join(" ")} ${preserve}`;
1682
1683 // Handle heading (important for error/rate_limit messages that show context)
1684 if (heading) {
1685 let headingElement = messageDiv.querySelector(".msg-heading");
1686 if (!headingElement) {
1687 headingElement = document.createElement("div");
1688 headingElement.classList.add("msg-heading");
1689 messageDiv.insertBefore(headingElement, messageDiv.firstChild);
1690 }
1691
1692 let headingH4 = headingElement.querySelector("h4");
1693 if (!headingH4) {
1694 headingH4 = document.createElement("h4");
1695 headingElement.appendChild(headingH4);
1696 }
1697 headingH4.innerHTML = convertIcons(escapeHTML(heading));
1698 } else {
1699 // Remove heading if it exists but heading is null
1700 const existingHeading = messageDiv.querySelector(".msg-heading");
1701 if (existingHeading) {
1702 existingHeading.remove();
1703 }
1704 }
1705
1706 // Find existing body div or create new one
1707 let bodyDiv = messageDiv.querySelector(".message-body");
1708 if (!bodyDiv) {
1709 bodyDiv = document.createElement("div");
1710 bodyDiv.classList.add("message-body");
1711 messageDiv.appendChild(bodyDiv);
1712 }
1713
1714 // reapply scroll position or autoscroll
1715 bodyDiv.dataset.scrollStabilization = "1";
1716 const scroller = new Scroller(bodyDiv, { smooth: !isMassRender() });
1717
1718 const contentText = String(content ?? "");
1719 const lazyContent =
1720 _windowedRender &&
1721 contentText.length + estimateKvpTextSize(kvps) > LAZY_MESSAGE_PREVIEW_CHARS;
1722 const contentOptions = {
1723 bodyDiv,
1724 content: contentText,
1725 kvps,
1726 contentClasses,
1727 markdown,
1728 latex,
1729 smoothStream,
1730 };
1731
1732 if (lazyContent) {
1733 messageDiv.classList.add("lazy-content");
1734 delete messageDiv.__lazyRenderedExpanded;
1735 messageDiv.__renderLazyContent = (expanded) => {
1736 if (messageDiv.__lazyRenderedExpanded === Boolean(expanded)) return;
1737 messageDiv.__lazyRenderedExpanded = Boolean(expanded);
1738 renderStandaloneMessageContent({
1739 ...contentOptions,
1740 content: expanded
1741 ? contentText
1742 : `${contentText.slice(0, LAZY_MESSAGE_PREVIEW_CHARS)}\n\n…`,
1743 kvps: expanded ? kvps : null,
1744 smoothStream: false,
1745 });
1746 };
1747 messageDiv.__renderLazyContent(
1748 messageDiv.classList.contains("expanded"),
1749 );
1750 } else {
1751 messageDiv.classList.remove("lazy-content");
1752 delete messageDiv.__renderLazyContent;
1753 delete messageDiv.__lazyRenderedExpanded;
1754 renderStandaloneMessageContent(contentOptions);
1755 }
1756
1757 // reapply scroll position or reset for collapsed
1758 messageDiv.classList.contains("expanded")
1759 ? scroller.reApplyScroll()
1760 : (bodyDiv.scrollTop = 0);
1761
1762 return messageDiv;
1763 }
1764
1765 function renderStandaloneMessageContent({
1766 bodyDiv,
1767 content,
1768 kvps,
1769 contentClasses,
1770 markdown,
1771 latex,
1772 smoothStream,
1773 }) {
1774 drawKvpsIncremental(bodyDiv, kvps);
1775 if (!content || !content.trim()) {
1776 bodyDiv.querySelector(".msg-content")?.remove();
1777 return;
1778 }
1779
1780 if (markdown) {
1781 let contentDiv = bodyDiv.querySelector(".msg-content");
1782 if (!contentDiv || contentDiv.tagName === "PRE") {
1783 contentDiv?.remove();
1784 contentDiv = document.createElement("div");
1785 bodyDiv.appendChild(contentDiv);
1786 }
1787 contentDiv.className = `msg-content ${contentClasses.join(" ")}`;
1788
1789 let processedContent = content;
1790 if (latex) processedContent = convertLatexDelimiters(processedContent);
1791 processedContent = convertImageTags(processedContent);
1792 processedContent = convertImgFilePaths(processedContent);
1793 processedContent = convertFilePaths(processedContent);
1794 processedContent = marked.parse(processedContent, { breaks: true });
1795 processedContent = sanitizeHtml(processedContent, {
1796 allowDataImages: true,
1797 allowLatex: latex,
1798 });
1799 processedContent = convertPathsToLinks(processedContent);
1800 processedContent = addBlankTargetsToLinks(processedContent);
1801
1802 if (smoothStream) smoothRender(contentDiv, processedContent);
1803 else contentDiv.innerHTML = processedContent;
1804
1805 if (latex) renderLatexElements(contentDiv);
1806 adjustMarkdownRender(contentDiv);
1807 return;
1808 }
1809
1810 let preElement = bodyDiv.querySelector(".msg-content");
1811 if (!preElement || preElement.tagName !== "PRE") {
1812 preElement?.remove();
1813 preElement = document.createElement("pre");
1814 preElement.style.whiteSpace = "pre-wrap";
1815 preElement.style.wordBreak = "break-word";
1816 bodyDiv.appendChild(preElement);
1817 }
1818 preElement.className = `msg-content ${contentClasses.join(" ")}`;
1819
1820 if (smoothStream) smoothRender(preElement, convertHTML(content));
1821 else preElement.innerHTML = convertHTML(content);
1822 }
1823
1824 function estimateKvpTextSize(kvps) {
1825 if (!kvps) return 0;
1826 try {
1827 return JSON.stringify(kvps)?.length || 0;
1828 } catch {
1829 return LAZY_MESSAGE_PREVIEW_CHARS + 1;
1830 }
1831 }
1832
1833 export { addBlankTargetsToLinks };
1834
1835 /**
1836 * @param {MessageHandlerArgs & Record<string, any>} param0
1837 * @returns {MessageHandlerResult}
1838 */
1839 export function drawMessageDefault({
1840 id,
1841 heading,
1842 content,
1843 kvps = null,
1844 ...additional
1845 }) {
1846 const contentText = String(content ?? "");
1847 const actionButtons = contentText.trim()
1848 ? [
1849 createActionButton("copy", "", () => copyToClipboard(contentText)),
1850 createActionButton("speak", "", () => ttsService.speak(contentText)),
1851 ].filter(Boolean)
1852 : [];
1853
1854 const element = drawStandaloneMessage({
1855 id,
1856 heading,
1857 content,
1858 position: "left",
1859 containerClasses: ["ai-container"],
1860 mainClass: "message-default",
1861 messageClasses: ["message-ai"],
1862 contentClasses: ["msg-json"],
1863 kvps,
1864 actionButtons,
1865 });
1866
1867 return { element };
1868 }
1869
1870 /**
1871 * @param {MessageHandlerArgs & Record<string, any>} param0
1872 * @returns {MessageHandlerResult}
1873 */
1874 export function drawMessageAgent({
1875 id,
1876 type,
1877 heading,
1878 content,
1879 kvps = undefined,
1880 timestamp = undefined,
1881 agentno = 0,
1882 ...additional
1883 }) {
1884 const title = cleanStepTitle(heading);
1885 let displayKvps = {};
1886 if (kvps?.thoughts) displayKvps["icon://lightbulb[Thoughts]"] = kvps.thoughts;
1887 if (kvps?.step) displayKvps["icon://step[Step]"] = kvps.step;
1888 const thoughtsText = String(kvps?.thoughts ?? "");
1889 const headerLabels = [
1890 kvps?.tool_name && { label: kvps.tool_name, class: "tool-name-badge" },
1891 ].filter(Boolean);
1892 const actionButtons = [
1893 createActionButton("detail", "", () =>
1894 stepDetailStore.showStepDetail(
1895 buildDetailPayload(arguments[0], { headerLabels }),
1896 ),
1897 ),
1898 ];
1899
1900 if (thoughtsText.trim()) {
1901 actionButtons.push(
1902 createActionButton("copy", "", () => copyToClipboard(thoughtsText)),
1903 );
1904 actionButtons.push(
1905 createActionButton("speak", "", () => ttsService.speak(thoughtsText)),
1906 );
1907 }
1908
1909 const result = drawProcessStep({
1910 id,
1911 title,
1912 code: "GEN",
1913 classes: undefined,
1914 kvps: displayKvps,
1915 actionButtons,
1916 log: arguments[0],
1917 });
1918 if (result.kvpsTable) renderLatexText(result.kvpsTable);
1919 return result;
1920 }
1921
1922 /**
1923 * @param {MessageHandlerArgs & Record<string, any>} param0
1924 * @returns {MessageHandlerResult}
1925 */
1926 export function drawMessageResponse({
1927 id,
1928 type,
1929 heading,
1930 content,
1931 kvps = undefined,
1932 timestamp = undefined,
1933 agentno = 0,
1934 ...additional
1935 }) {
1936 // response of subordinate agent - render as process step
1937 if (agentno && agentno > 0) {
1938 const title = getStepTitle(heading, content, type);
1939 const contentText = String(content ?? "");
1940 const actionButtons = contentText.trim()
1941 ? [
1942 createActionButton("copy", "", () => copyToClipboard(contentText)),
1943 createActionButton("speak", "", () => ttsService.speak(contentText)),
1944 ].filter(Boolean)
1945 : [];
1946 return drawProcessStep({
1947 id,
1948 title,
1949 code: "RES",
1950 kvps: {},
1951 type,
1952 heading,
1953 content,
1954 timestamp,
1955 agentno,
1956 actionButtons,
1957 log: arguments[0],
1958 });
1959 }
1960
1961 // response of agent 0, render as response to user
1962 // get last process group or create new container (if first message)
1963
1964 let group = getLastProcessGroup();
1965 if (group?.classList.contains("utility-only")) {
1966 group.setAttribute("data-group-complete", "true");
1967 updateProcessGroupHeader(group);
1968 group = null;
1969 }
1970 let container = getChatHistoryElementById(`message-${id}`); // first check for already existing message
1971
1972
1973 // if no container found, add to previous process group if exists
1974 if (!container) {
1975 if (group) {
1976 // new response, collapse all previous steps once
1977 if (!group.querySelector(".process-group-response")) {
1978 if (preferencesStore.detailMode == "current")
1979 group.querySelectorAll(".process-step").forEach((step) => {
1980 scheduleStepCollapse(step);
1981 });
1982 }
1983
1984 container = ensureChild(
1985 group,
1986 `#message-${id}.process-group-response`,
1987 "div",
1988 "process-group-response",
1989 );
1990 container.id = `message-${id}`;
1991 }
1992 }
1993
1994 // no container or valid process group, create new container
1995 if (!container) container = getOrCreateMessageContainer(id, "left");
1996
1997 const messageDiv = _drawMessage({
1998 messageContainer: container,
1999 heading: undefined,
2000 content,
2001 kvps: undefined,
2002 messageClasses: [],
2003 contentClasses: [],
2004 markdown: true,
2005 latex: true,
2006 mainClass: "message-agent-response",
2007 smoothStream: false, // smooth render disabled, not reliable yet !isMassRender(), // stream smoothly if not in mass render mode
2008 });
2009
2010 // Collapsible with action buttons
2011 const responseText = String(content ?? "");
2012 const responseActionButtons = responseText.trim()
2013 ? [
2014 createActionButton("copy", "", () => copyToClipboard(responseText)),
2015 createActionButton("speak", "", () => ttsService.speak(responseText)),
2016 ].filter(Boolean)
2017 : [];
2018 setupCollapsible(
2019 messageDiv,
2020 ":scope > .step-action-buttons",
2021 !isMassRender(),
2022 responseActionButtons,
2023 );
2024
2025 if (group) updateProcessGroupHeader(group);
2026
2027 return { element: container };
2028 }
2029
2030 export function drawMessageModelSetupGate({ id }) {
2031 const container = getOrCreateMessageContainer(id, "left");
2032 container.classList.add("model-setup-gate-container");
2033 container.innerHTML = "";
2034
2035 const messageDiv = document.createElement("div");
2036 messageDiv.className = "message message-agent-response model-setup-gate-message";
2037
2038 const component = document.createElement("x-component");
2039 component.setAttribute("path", "chat/model-setup-gate.html");
2040 messageDiv.appendChild(component);
2041 container.appendChild(messageDiv);
2042
2043 return { element: container };
2044 }
2045
2046 /**
2047 * @param {MessageHandlerArgs & Record<string, any>} param0
2048 * @returns {MessageHandlerResult}
2049 */
2050 export function drawMessageUser({
2051 id,
2052 heading,
2053 content,
2054 kvps = null,
2055 ...additional
2056 }) {
2057 // end last process group on any user message
2058 completeLastProcessGroup();
2059
2060 const messageContainer = getOrCreateMessageContainer(
2061 id,
2062 "right",
2063 ["user-container"],
2064 true,
2065 );
2066
2067 // Find existing message div or create new one
2068 let messageDiv = messageContainer.querySelector(".message");
2069 if (!messageDiv) {
2070 messageDiv = document.createElement("div");
2071 messageDiv.classList.add("message", "message-user");
2072 messageContainer.appendChild(messageDiv);
2073 } else {
2074 // Ensure it has the correct classes if it already exists
2075 messageDiv.className = "message message-user";
2076 }
2077
2078 // Handle content
2079 let textDiv = messageDiv.querySelector(".message-text");
2080 if (content && content.trim().length > 0) {
2081 if (!textDiv) {
2082 textDiv = document.createElement("div");
2083 textDiv.classList.add("message-text");
2084 messageDiv.appendChild(textDiv);
2085 }
2086 let spanElement = textDiv.querySelector("pre");
2087 if (!spanElement) {
2088 spanElement = document.createElement("pre");
2089 textDiv.appendChild(spanElement);
2090 }
2091 spanElement.innerHTML = escapeHTML(content);
2092 } else {
2093 if (textDiv) textDiv.remove();
2094 }
2095
2096 // Handle attachments
2097 let attachmentsContainer = messageDiv.querySelector(".attachments-container");
2098 if (kvps && kvps.attachments && kvps.attachments.length > 0) {
2099 if (!attachmentsContainer) {
2100 attachmentsContainer = document.createElement("div");
2101 attachmentsContainer.classList.add("attachments-container");
2102 messageDiv.appendChild(attachmentsContainer);
2103 }
2104 // Important: Clear existing attachments to re-render, preventing duplicates on update
2105 attachmentsContainer.innerHTML = "";
2106
2107 kvps.attachments.forEach((attachment) => {
2108 const attachmentDiv = document.createElement("div");
2109 attachmentDiv.classList.add("attachment-item");
2110
2111 const displayInfo = attachmentsStore.getAttachmentDisplayInfo(attachment);
2112
2113 if (displayInfo.isImage) {
2114 attachmentDiv.classList.add("image-type");
2115
2116 const img = document.createElement("img");
2117 img.src = displayInfo.previewUrl;
2118 img.alt = displayInfo.filename;
2119 img.classList.add("attachment-preview");
2120 img.style.cursor = "pointer";
2121
2122 attachmentDiv.appendChild(img);
2123 } else {
2124 // Render as file tile with title and icon
2125 attachmentDiv.classList.add("file-type");
2126
2127 // File icon
2128 if (
2129 displayInfo.previewUrl &&
2130 displayInfo.previewUrl !== displayInfo.filename
2131 ) {
2132 const iconImg = document.createElement("img");
2133 iconImg.src = displayInfo.previewUrl;
2134 iconImg.alt = `${displayInfo.extension} file`;
2135 iconImg.classList.add("file-icon");
2136 attachmentDiv.appendChild(iconImg);
2137 }
2138
2139 // File title
2140 const fileTitle = document.createElement("div");
2141 fileTitle.classList.add("file-title");
2142 fileTitle.textContent = displayInfo.filename;
2143
2144 attachmentDiv.appendChild(fileTitle);
2145 }
2146
2147 attachmentDiv.addEventListener("click", displayInfo.clickHandler);
2148
2149 // @ts-ignore
2150 attachmentsContainer.appendChild(attachmentDiv);
2151 });
2152 } else {
2153 if (attachmentsContainer) attachmentsContainer.remove();
2154 }
2155
2156 // Render heading below message, if provided
2157 let headingElement = messageDiv.querySelector(".message-user-heading");
2158 if (heading && heading.trim() && heading.trim() !== "User message") {
2159 if (!headingElement) {
2160 headingElement = document.createElement("div");
2161 headingElement.className = "message-user-heading shiny-text";
2162 }
2163 headingElement.textContent = heading;
2164 messageDiv.appendChild(headingElement);
2165 } else if (headingElement) {
2166 headingElement.remove();
2167 }
2168
2169 // Render action buttons: get/create container, clear, append
2170 const userText = String(content ?? "");
2171 const userActionButtons = userText.trim()
2172 ? [
2173 createActionButton("copy", "", () => copyToClipboard(userText)),
2174 createActionButton("speak", "", () => ttsService.speak(userText)),
2175 ].filter(Boolean)
2176 : [];
2177 setupCollapsible(
2178 messageDiv,
2179 ":scope > .step-action-buttons",
2180 false,
2181 userActionButtons,
2182 ":scope > .message-text",
2183 );
2184
2185 return { element: messageContainer };
2186 }
2187
2188 /**
2189 * @param {MessageHandlerArgs & Record<string, any>} param0
2190 * @returns {Promise<MessageHandlerResult>}
2191 */
2192 export async function drawMessageTool({
2193 id,
2194 type,
2195 heading,
2196 content,
2197 kvps,
2198 timestamp,
2199 agentno = 0,
2200 ...additional
2201 }) {
2202 const tool_name = kvps?._tool_name || "";
2203
2204 if (!tool_name) {
2205 return drawMessageToolSimple({ ...arguments[0] });
2206 } else if (kvps._tool_name === "skills_tool") {
2207 const displayKvps = { ...(kvps || {}) };
2208 delete displayKvps._tool_name;
2209 return drawMessageToolSimple({ ...arguments[0], code: "SKL", displayKvps });
2210 } else if (kvps._tool_name === "vision_load") {
2211 return drawMessageToolSimple({ ...arguments[0], code: "EYE" });
2212 } else if (kvps._tool_name === "search_engine") {
2213 return drawMessageToolSimple({ ...arguments[0], code: "WEB" });
2214 } else if (kvps._tool_name.startsWith("memory_")) {
2215 return drawMessageToolSimple({ ...arguments[0], code: "MEM" });
2216 }
2217
2218 /** @type {{ tool_name: string, kvps: any, handler: Function | undefined }} */
2219 const extData = {
2220 tool_name,
2221 kvps,
2222 handler: undefined,
2223 };
2224 await callJsExtensions("get_tool_message_handler", extData);
2225 if (typeof extData.handler === "function") {
2226 return extData.handler(arguments[0]);
2227 }
2228 return drawMessageToolSimple({ ...arguments[0] });
2229 }
2230
2231 /**
2232 * @param {MessageHandlerArgs & Record<string, any>} param0
2233 * @returns {MessageHandlerResult}
2234 */
2235 export function drawMessageToolSimple({
2236 id,
2237 type,
2238 heading,
2239 content,
2240 kvps,
2241 timestamp,
2242 agentno = 0,
2243 code,
2244 displayKvps,
2245 ...additional
2246 }) {
2247 const title = cleanStepTitle(heading);
2248 displayKvps = displayKvps || { ...kvps };
2249 const headerLabels = [
2250 kvps?._tool_name && { label: kvps._tool_name, class: "tool-name-badge" },
2251 ].filter(Boolean);
2252 const contentText = String(content ?? "");
2253 const actionButtons = contentText.trim()
2254 ? [
2255 createActionButton("detail", "", () =>
2256 stepDetailStore.showStepDetail(
2257 buildDetailPayload(arguments[0], { headerLabels }),
2258 ),
2259 ),
2260 createActionButton("copy", "", () => copyToClipboard(contentText)),
2261 createActionButton("speak", "", () => ttsService.speak(contentText)),
2262 ].filter(Boolean)
2263 : [];
2264
2265 return drawProcessStep({
2266 id,
2267 title,
2268 code: code || "USE",
2269 classes: undefined,
2270 kvps: displayKvps,
2271 content,
2272 // contentClasses: [],
2273 actionButtons,
2274 log: arguments[0],
2275 });
2276 }
2277
2278 /**
2279 * @param {MessageHandlerArgs & Record<string, any>} param0
2280 * @returns {MessageHandlerResult}
2281 */
2282 export function drawMessageMcp({
2283 id,
2284 type,
2285 heading,
2286 content,
2287 kvps,
2288 timestamp,
2289 agentno = 0,
2290 ...additional
2291 }) {
2292 const title = cleanStepTitle(heading);
2293 let displayKvps = { ...kvps };
2294 const headerLabels = [
2295 kvps?.tool_name && { label: kvps.tool_name, class: "tool-name-badge" },
2296 ].filter(Boolean);
2297 const contentText = String(content ?? "");
2298 const actionButtons = contentText.trim()
2299 ? [
2300 createActionButton("detail", "", () =>
2301 stepDetailStore.showStepDetail(
2302 buildDetailPayload(arguments[0], { headerLabels }),
2303 ),
2304 ),
2305 createActionButton("copy", "", () => copyToClipboard(contentText)),
2306 createActionButton("speak", "", () => ttsService.speak(contentText)),
2307 ].filter(Boolean)
2308 : [];
2309
2310 return drawProcessStep({
2311 id,
2312 title,
2313 code: "MCP",
2314 classes: undefined,
2315 kvps: displayKvps,
2316 content,
2317 // contentClasses: [],
2318 actionButtons,
2319 log: arguments[0],
2320 });
2321 }
2322
2323 /**
2324 * @param {MessageHandlerArgs & Record<string, any>} param0
2325 * @returns {MessageHandlerResult}
2326 */
2327 export function drawMessageSubagent({
2328 id,
2329 type,
2330 heading,
2331 content,
2332 kvps,
2333 timestamp,
2334 agentno = 0,
2335 ...additional
2336 }) {
2337 const title = cleanStepTitle(heading);
2338 let displayKvps = { ...kvps };
2339 const headerLabels = [
2340 kvps?.tool_name && { label: kvps.tool_name, class: "tool-name-badge" },
2341 ].filter(Boolean);
2342 const contentText = String(content ?? "");
2343 const actionButtons = contentText.trim()
2344 ? [
2345 createActionButton("detail", "", () =>
2346 stepDetailStore.showStepDetail(
2347 buildDetailPayload(arguments[0], { headerLabels }),
2348 ),
2349 ),
2350 createActionButton("copy", "", () => copyToClipboard(contentText)),
2351 createActionButton("speak", "", () => ttsService.speak(contentText)),
2352 ].filter(Boolean)
2353 : [];
2354
2355 return drawProcessStep({
2356 id,
2357 title,
2358 code: "SUB",
2359 classes: undefined,
2360 kvps: displayKvps,
2361 content,
2362 // contentClasses: [],
2363 actionButtons,
2364 log: arguments[0],
2365 });
2366 }
2367
2368 /**
2369 * @param {MessageHandlerArgs & Record<string, any>} param0
2370 * @returns {MessageHandlerResult}
2371 */
2372 export function drawMessageInfo({
2373 id,
2374 heading,
2375 content,
2376 kvps,
2377 ...additional
2378 }) {
2379 const title = cleanStepTitle(heading || content);
2380 let displayKvps = { ...kvps };
2381 delete displayKvps.finished;
2382 const contentText = String(content ?? "");
2383 const actionButtons = contentText.trim()
2384 ? [
2385 createActionButton("copy", "", () => copyToClipboard(contentText)),
2386 createActionButton("speak", "", () => ttsService.speak(contentText)),
2387 ].filter(Boolean)
2388 : [];
2389
2390 const result = drawProcessStep({
2391 id,
2392 title,
2393 code: "INF",
2394 classes: undefined,
2395 kvps: displayKvps,
2396 content,
2397 // contentClasses: [],
2398 actionButtons,
2399 log: arguments[0],
2400 });
2401
2402 if (kvps?.finished) completeLastProcessGroup();
2403 return result;
2404 }
2405
2406 /**
2407 * @param {MessageHandlerArgs & Record<string, any>} param0
2408 * @returns {MessageHandlerResult}
2409 */
2410 export function drawMessageUtil({
2411 id,
2412 type,
2413 heading,
2414 content,
2415 kvps,
2416 timestamp,
2417 agentno = 0,
2418 ...additional
2419 }) {
2420 const title = cleanStepTitle(heading || content);
2421 const contentText = String(content ?? "");
2422 const actionButtons = contentText.trim()
2423 ? [
2424 createActionButton("copy", "", () => copyToClipboard(contentText)),
2425 createActionButton("speak", "", () => ttsService.speak(contentText)),
2426 ].filter(Boolean)
2427 : [];
2428
2429 const result = drawProcessStep({
2430 id,
2431 title,
2432 code: "UTL",
2433 classes: ["message-util"],
2434 kvps,
2435 content,
2436 actionButtons,
2437 log: arguments[0],
2438 allowCompletedGroup: false,
2439 });
2440
2441 result.dontScroll = !preferencesStore.showUtils;
2442 return result;
2443 }
2444
2445 /**
2446 * @param {MessageHandlerArgs & Record<string, any>} param0
2447 * @returns {MessageHandlerResult}
2448 */
2449 export function drawMessageHint({
2450 id,
2451 type,
2452 heading,
2453 content,
2454 kvps,
2455 timestamp,
2456 agentno = 0,
2457 ...additional
2458 }) {
2459 const title = getStepTitle(heading, content, type);
2460 const contentText = String(content ?? "");
2461 const actionButtons = contentText.trim()
2462 ? [
2463 createActionButton("copy", "", () => copyToClipboard(contentText)),
2464 createActionButton("speak", "", () => ttsService.speak(contentText)),
2465 ].filter(Boolean)
2466 : [];
2467
2468 const element = drawStandaloneMessage({
2469 id,
2470 heading: title,
2471 // statusClass,
2472 // statusCode: "HNT",
2473 kvps,
2474 // type,
2475 content,
2476 // timestamp,
2477 // agentno,
2478 actionButtons,
2479 });
2480
2481 return { element };
2482 }
2483
2484 /**
2485 * @param {MessageHandlerArgs & Record<string, any>} param0
2486 * @returns {MessageHandlerResult}
2487 */
2488 export function drawMessageProgress({
2489 id,
2490 type,
2491 heading,
2492 content,
2493 kvps,
2494 timestamp,
2495 agentno = 0,
2496 ...additional
2497 }) {
2498 const title = cleanStepTitle(heading || content);
2499 let displayKvps = { ...kvps };
2500
2501 return drawProcessStep({
2502 id,
2503 title,
2504 code: "HDL",
2505 classes: undefined,
2506 kvps: displayKvps,
2507 content,
2508 // contentClasses: [],
2509 actionButtons: [],
2510 log: arguments[0],
2511 });
2512 }
2513
2514 /**
2515 * @param {MessageHandlerArgs & Record<string, any>} param0
2516 * @returns {MessageHandlerResult}
2517 */
2518 export function drawMessageWarning({
2519 id,
2520 type,
2521 heading,
2522 content,
2523 kvps = null,
2524 ...additional
2525 }) {
2526 const title = getStepTitle(heading, content, type);
2527 let displayKvps = { ...kvps };
2528 const contentText = String(content ?? "");
2529 const actionButtons = contentText.trim()
2530 ? [
2531 createActionButton("copy", "", () => copyToClipboard(contentText)),
2532 createActionButton("speak", "", () => ttsService.speak(contentText)),
2533 ].filter(Boolean)
2534 : [];
2535
2536 // Keep replayed warnings in their classified process group.
2537 if (
2538 arguments[0][PROCESS_GROUP_RENDER_INFO] ||
2539 getLastProcessGroup(false)
2540 ) {
2541 return drawProcessStep({
2542 id,
2543 title,
2544 code: "WRN",
2545 // classes: null,
2546 kvps: displayKvps,
2547 content,
2548 // contentClasses: [],
2549 actionButtons,
2550 log: arguments[0],
2551 });
2552 }
2553
2554 // if no process group is running, draw as standalone
2555 const element = drawStandaloneMessage({
2556 id,
2557 heading: title,
2558 content,
2559 position: "mid",
2560 containerClasses: ["ai-container", "center-container"],
2561 mainClass: "message-warning",
2562 kvps: displayKvps,
2563 actionButtons,
2564 });
2565
2566 return { element };
2567 }
2568
2569 /**
2570 * @param {MessageHandlerArgs & Record<string, any>} param0
2571 * @returns {MessageHandlerResult}
2572 */
2573 export function drawMessageError({
2574 id,
2575 type,
2576 heading,
2577 content,
2578 kvps = null,
2579 ...additional
2580 }) {
2581 const contentText = String(content ?? "");
2582 let title = getStepTitle(heading, content, type);
2583 let displayKvps = { ...kvps };
2584
2585 const actionButtons = [];
2586 actionButtons.push(
2587 createActionButton("detail", "", () =>
2588 stepDetailStore.showStepDetail(
2589 buildDetailPayload(arguments[0], { headerLabels: [] }),
2590 ),
2591 ),
2592 );
2593 if (contentText.trim()) {
2594 actionButtons.push(
2595 createActionButton("copy", "", () => copyToClipboard(contentText)),
2596 );
2597 }
2598
2599 const element = drawStandaloneMessage({
2600 id,
2601 heading: title,
2602 content: contentText,
2603 position: "mid",
2604 containerClasses: ["ai-container", "center-container"],
2605 mainClass: "message-error",
2606 kvps: displayKvps,
2607 actionButtons,
2608 });
2609
2610 return { element };
2611 }
2612
2613 function drawKvpsIncremental(container, kvps) {
2614 // existing KVPS table
2615 let table = container.querySelector(".msg-kvps");
2616 if (kvps) {
2617 // create table if not found
2618 if (!table) {
2619 table = document.createElement("table");
2620 table.classList.add("msg-kvps");
2621 container.insertBefore(table, container.firstChild);
2622 }
2623
2624 // Get all current rows for comparison
2625 let existingRows = table.querySelectorAll(".kvps-row");
2626 // Filter out reasoning
2627 const kvpEntries = Object.entries(kvps).filter(
2628 ([key]) => key !== "reasoning",
2629 );
2630
2631 // Update or create rows as needed
2632 kvpEntries.forEach(([key, value], index) => {
2633 let row = existingRows[index];
2634
2635 if (!row) {
2636 // Create new row if it doesn't exist
2637 row = table.insertRow();
2638 row.classList.add("kvps-row");
2639 }
2640
2641 // Update row classes
2642 row.className = "kvps-row";
2643
2644 // Handle key cell
2645 let th = row.querySelector(".kvps-key");
2646 if (!th) {
2647 th = row.insertCell(0);
2648 th.classList.add("kvps-key");
2649 }
2650 const convertedKey = convertIcons(String(key), "");
2651 if (convertedKey !== String(key)) {
2652 th.innerHTML = convertedKey;
2653 } else {
2654 th.textContent = convertToTitleCase(key);
2655 }
2656
2657 // Handle value cell
2658 let td = row.cells[1];
2659 if (!td) {
2660 td = row.insertCell(1);
2661 td.classList.add("kvps-val");
2662 }
2663
2664 // reapply scroll position or autoscroll
2665 // no inner scrolling for kvps anymore
2666 // const scroller = new Scroller(td);
2667
2668 // Clear and rebuild content (for now - could be optimized further)
2669 td.innerHTML = "";
2670
2671 if (Array.isArray(value)) {
2672 for (const item of value) {
2673 addValue(item, td);
2674 }
2675 } else {
2676 addValue(value, td);
2677 }
2678
2679 // reapply scroll position or autoscroll
2680 // scroller.reApplyScroll();
2681 });
2682
2683 // Remove extra rows if we have fewer kvps now
2684 while (existingRows.length > kvpEntries.length) {
2685 const lastRow = existingRows[existingRows.length - 1];
2686 lastRow.remove();
2687 existingRows = table.querySelectorAll(".kvps-row");
2688 }
2689
2690 function addValue(value, tdiv) {
2691 if (typeof value === "object") value = JSON.stringify(value, null, 2);
2692
2693 if (typeof value === "string" && value.startsWith("img://")) {
2694 const imgElement = document.createElement("img");
2695 imgElement.classList.add("kvps-img");
2696 imgElement.src = value.replace("img://", "/api/image_get?path=");
2697 imgElement.alt = "Image Attachment";
2698 tdiv.appendChild(imgElement);
2699
2700 // Add click handler and cursor change
2701 imgElement.style.cursor = "pointer";
2702 imgElement.addEventListener("click", () => {
2703 imageViewerStore.open(imgElement.src, { refreshInterval: 1000 });
2704 });
2705 } else {
2706 const span = document.createElement("p");
2707 span.innerHTML = convertHTML(value);
2708 tdiv.appendChild(span);
2709 }
2710 }
2711 } else {
2712 // Remove table if kvps is null/empty
2713 if (table) table.remove();
2714 return null;
2715 }
2716 return table;
2717 }
2718
2719 function convertToTitleCase(str) {
2720 return str
2721 .replace(/_/g, " ") // Replace underscores with spaces
2722 .toLowerCase() // Convert the entire string to lowercase
2723 .replace(/\b\w/g, function (match) {
2724 return match.toUpperCase(); // Capitalize the first letter of each word
2725 });
2726 }
2727
2728 function convertImageTags(content) {
2729 // Regular expression to match <image> tags and extract base64 content
2730 const imageTagRegex = /<image>(.*?)<\/image>/g;
2731
2732 // Replace <image> tags with <img> tags with base64 source
2733 const updatedContent = content.replace(
2734 imageTagRegex,
2735 (match, base64Content) => {
2736 return `<img src="data:image/jpeg;base64,${base64Content}" alt="Image Attachment" style="max-width: 250px !important;"/>`;
2737 },
2738 );
2739
2740 return updatedContent;
2741 }
2742
2743 function convertHTML(str) {
2744 if (typeof str !== "string") str = JSON.stringify(str, null, 2);
2745
2746 let result = escapeHTML(str);
2747 result = convertImageTags(result);
2748 result = convertPathsToLinks(result);
2749 return result;
2750 }
2751
2752 function convertLatexDelimiters(content) {
2753 return content.replace(
2754 /(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`)|\\\[([\s\S]*?)\\\]|\\\(([\s\S]*?)\\\)|\$\$([\s\S]*?)\$\$/g,
2755 (match, code, display, inline, dollars) => {
2756 if (code) return code;
2757 const tex = display ?? inline ?? dollars;
2758 const displayAttribute =
2759 display !== undefined || dollars !== undefined
2760 ? ' data-display="true"'
2761 : "";
2762 const encodedTex = Array.from(
2763 tex.trim(),
2764 (char) => `&#${char.codePointAt(0)};`,
2765 ).join("");
2766 return `<latex${displayAttribute}>${encodedTex}</latex>`;
2767 },
2768 );
2769 }
2770
2771 function renderLatexElements(container) {
2772 container.querySelectorAll("latex").forEach((element) => {
2773 globalThis.katex.render(element.textContent, element, {
2774 displayMode: element.dataset.display === "true",
2775 throwOnError: false,
2776 });
2777 });
2778 }
2779
2780 function renderLatexText(container) {
2781 globalThis.renderMathInElement(container, {
2782 throwOnError: false,
2783 errorCallback: () => {},
2784 });
2785 }
2786
2787 function convertImgFilePaths(str) {
2788 return str.replace(/img:\/\//g, "/api/image_get?path=");
2789 }
2790
2791 function convertFilePaths(str) {
2792 return str.replace(/file:\/\//g, "/api/download_work_dir_file?path=");
2793 }
2794
2795 function escapeHTML(str) {
2796 const escapeChars = {
2797 "&": "&amp;",
2798 "<": "&lt;",
2799 ">": "&gt;",
2800 "'": "&#39;",
2801 '"': "&quot;",
2802 };
2803 return str.replace(/[&<>'"]/g, (char) => escapeChars[char]);
2804 }
2805
2806 function convertPathsToLinks(str) {
2807 function generateLinks(match) {
2808 const parts = match.split("/");
2809 if (!parts[0]) parts.shift(); // drop empty element left of first "
2810 let conc = "";
2811 let html = "";
2812 for (const part of parts) {
2813 conc += "/" + part;
2814 html += `/<a href="#" class="path-link" data-path="${conc}" onclick="event.preventDefault(); openFileLink(this.dataset.path);">${part}</a>`;
2815 }
2816 return html;
2817 }
2818
2819 const prefix = `(?:^|[> \`'"\\n]|&#39;|&quot;)`;
2820 const pathPart = `[a-zA-Z0-9_.~@%+=,()\\-]+(?: [a-zA-Z0-9_.~@%+=,()\\-]+)*`;
2821 const spacedFilePath = `\\/(?:${pathPart}\\/)*${pathPart}\\.[a-zA-Z0-9]{1,12}`;
2822 const folder = `[a-zA-Z0-9_\\/.\\-]`;
2823 const file = `[a-zA-Z0-9_\\-\\/]`;
2824 const simplePath = `\\/${folder}*${file}(?<!\\.)`;
2825 const suffix = `(?=$|[\\s.,;:!?\\)\\]\\}]|&#39;|&quot;)`;
2826 const pathRegex = new RegExp(
2827 `(?<=${prefix})(?:${spacedFilePath}|${simplePath})${suffix}`,
2828 "g",
2829 );
2830
2831 // skip paths inside html tags, like <img src="/path/to/image">
2832 const tagRegex = /(<(?:[^<>"']+|"[^"]*"|'[^']*')*>)/g;
2833
2834 return str
2835 .split(tagRegex) // keep tags & text separate
2836 .map((chunk) => {
2837 // if it *starts* with '<', it's a tag -> leave untouched
2838 if (chunk.startsWith("<")) return chunk;
2839 // otherwise run your link-generation
2840 return chunk.replace(pathRegex, generateLinks);
2841 })
2842 .join("");
2843 }
2844
2845 // markdown render helpers //
2846
2847 // wraps an element with a container div
2848 const wrapElement = (el, className) => {
2849 const wrapper = document.createElement("div");
2850 wrapper.className = className;
2851 el.parentNode.insertBefore(wrapper, el);
2852 wrapper.appendChild(el);
2853 return wrapper;
2854 };
2855
2856 // data extractors
2857 const extractTableTSV = (table) =>
2858 [...table.rows]
2859 .map((row) =>
2860 [...row.cells]
2861 .map((cell) =>
2862 cell.textContent.replace(/\t/g, " ").replace(/\n/g, " "),
2863 )
2864 .join("\t"),
2865 )
2866 .join("\n");
2867
2868 function adjustMarkdownRender(element) {
2869 // find all tables in the element
2870 const tables = element.querySelectorAll("table");
2871 tables.forEach((el) => {
2872 const wrapper = wrapElement(el, "message-markdown-table-wrap");
2873 const outerWrapper = wrapElement(wrapper, "markdown-block-wrap");
2874 const actionsDiv = document.createElement("div");
2875 actionsDiv.className = "step-action-buttons";
2876 actionsDiv.appendChild(
2877 createActionButton("copy", "", () =>
2878 copyToClipboard(extractTableTSV(el)),
2879 ),
2880 );
2881 outerWrapper.appendChild(actionsDiv);
2882 });
2883
2884 // find all code blocks
2885 const codeElements = element.querySelectorAll("pre > code");
2886 codeElements.forEach((code) => {
2887 const pre = code.parentNode;
2888 const wrapper = wrapElement(pre, "code-block-wrapper");
2889 const outerWrapper = wrapElement(wrapper, "markdown-block-wrap");
2890 const actionsDiv = document.createElement("div");
2891 actionsDiv.className = "step-action-buttons";
2892 actionsDiv.appendChild(
2893 createActionButton("copy", "", () => copyToClipboard(code.textContent)),
2894 );
2895 outerWrapper.appendChild(actionsDiv);
2896 });
2897
2898 // find all images
2899 const images = element.querySelectorAll("img");
2900
2901 // wrap each image in <a>
2902 images.forEach((img) => {
2903 if (img.parentNode?.tagName === "A") return;
2904 const link = document.createElement("a");
2905 link.className = "message-markdown-image-wrap";
2906 link.href = img.src;
2907 img.parentNode.insertBefore(link, img);
2908 link.appendChild(img);
2909 link.onclick = (e) => (
2910 e.preventDefault(),
2911 imageViewerStore.open(img.src, { name: img.alt || "Image" })
2912 );
2913 });
2914 }
2915
2916 /**
2917 * Create a new collapsible process group
2918 */
2919 function createProcessGroup(id) {
2920 const groupId = `process-group-${id}`;
2921 const group = document.createElement("div");
2922 group.id = groupId;
2923 group.classList.add("process-group");
2924 group.setAttribute("data-group-id", groupId);
2925
2926 // Determine initial expansion state from current detail mode
2927 const initiallyExpanded = preferencesStore.detailMode !== "collapsed";
2928 if (initiallyExpanded) {
2929 group.classList.add("expanded");
2930 }
2931
2932 // Create header
2933 const header = document.createElement("div");
2934 header.classList.add("process-group-header");
2935 header.innerHTML = `
2936 <span class="expand-icon"></span>
2937 <span class="group-title">Processing...</span>
2938 <span class="step-badge GEN">GEN</span>
2939 <span class="group-metrics">
2940 <span class="metric-time" title="Start time"><x-icon name="schedule"></x-icon><span class="metric-value">--:--</span></span>
2941 <span class="metric-steps display-none" title="Steps"><x-icon name="footprint"></x-icon><span class="metric-value">0</span></span>
2942 <span class="metric-notifications" title="Warnings/Info/Hint" hidden><x-icon name="priority_high"></x-icon><span class="metric-value">0</span></span>
2943 <span class="metric-duration display-none" title="Duration"><x-icon name="timer"></x-icon><span class="metric-value">--</span></span>
2944
2945 </span>
2946 `;
2947
2948 group.__setExpanded = (expanded) => {
2949 const nextExpanded = Boolean(expanded);
2950 group.classList.toggle("expanded", nextExpanded);
2951 const steps = group.querySelectorAll(".process-step");
2952 if (nextExpanded) {
2953 steps.forEach((step) => {
2954 if (
2955 step.classList.contains("expanded") &&
2956 !step.querySelector(".process-step-detail-scroll")
2957 ) {
2958 void materializeProcessStepDetail(step);
2959 }
2960 });
2961 } else {
2962 steps.forEach((step) =>
2963 discardProcessStepDetail(step, { force: true }),
2964 );
2965 }
2966 };
2967
2968 // Add click handler for expansion
2969 header.addEventListener("click", () => {
2970 group.__setExpanded(!group.classList.contains("expanded"));
2971 });
2972
2973 group.appendChild(header);
2974
2975 // Create content container
2976 const content = document.createElement("div");
2977 content.classList.add("process-group-content");
2978
2979 // Create steps container
2980 const steps = document.createElement("div");
2981 steps.classList.add("process-steps");
2982 content.appendChild(steps);
2983
2984 group.appendChild(content);
2985
2986 return group;
2987 }
2988
2989 /**
2990 * Create or get nested container within a parent step
2991 */
2992 function getNestedContainer(parentStep) {
2993 let nestedContainer = parentStep.querySelector(".process-nested-container");
2994
2995 if (!nestedContainer) {
2996 // Create new container
2997 nestedContainer = document.createElement("div");
2998 nestedContainer.classList.add("process-nested-container");
2999
3000 // Create inner wrapper for animation support
3001 const innerWrapper = document.createElement("div");
3002 innerWrapper.classList.add("process-nested-inner");
3003 nestedContainer.appendChild(innerWrapper);
3004
3005 parentStep.appendChild(nestedContainer);
3006 parentStep.classList.add("has-nested-steps");
3007 }
3008
3009 // Return the inner wrapper for appending steps
3010 const innerWrapper = nestedContainer.querySelector(".process-nested-inner");
3011 return innerWrapper || nestedContainer; // Fallback to container if wrapper missing
3012 }
3013
3014 /**
3015 * Schedule a step to collapse after a delay
3016 * Automatically handles cancellation on click and reset on hover
3017 */
3018 function scheduleStepCollapse(
3019 stepElement,
3020 delayMs = STEP_COLLAPSE_DELAY.other,
3021 ) {
3022 // skip if any existing timeout for this step
3023 if (stepElement.hasAttribute("data-collapse-timeout-id")) return;
3024 // skip already collapsed steps
3025 if (!stepElement.classList.contains("expanded")) return;
3026
3027 // Schedule the collapse
3028 const timeoutId = setTimeout(() => {
3029 stepElement.removeAttribute("data-collapse-timeout-id");
3030
3031 if (stepElement.dataset.clicked === "true") {
3032 console.log(`Skip clicked collapse: ${stepElement.id}`);
3033 return;
3034 }
3035
3036 if (stepElement.matches(":hover")) {
3037 console.log(`Delay hover collapse: ${stepElement.id}`);
3038 scheduleStepCollapse(stepElement, STEP_COLLAPSE_HOVER_DELAY_MS);
3039 return;
3040 }
3041
3042 console.log(`Collapse step: ${stepElement.id}`);
3043 toggleStepCollapse(stepElement, false);
3044 }, delayMs);
3045
3046 // Store the timeout ID
3047 stepElement.setAttribute("data-collapse-timeout-id", String(timeoutId));
3048 }
3049
3050 function setupProcessStepHandlers(stepElement, stepHeader) {
3051 if (!stepElement.hasAttribute("data-step-handlers")) {
3052 stepElement.setAttribute("data-step-handlers", "true");
3053
3054 stepElement.addEventListener(
3055 "click",
3056 function handler() {
3057 stepElement.dataset.clicked = "true";
3058 console.log(`Step clicked: ${stepElement.id}`);
3059 },
3060 { once: true },
3061 );
3062 }
3063
3064 if (stepHeader && !stepHeader.hasAttribute("data-expand-handler")) {
3065 stepHeader.setAttribute("data-expand-handler", "true");
3066 stepHeader.addEventListener("click", (e) => {
3067 e.stopPropagation();
3068 cancelStepCollapse(stepElement);
3069 stepElement.dataset.clicked = "true";
3070 toggleStepCollapse(stepElement);
3071 });
3072 }
3073 }
3074
3075 /**
3076 * Cancel a scheduled collapse for a step
3077 */
3078 function cancelStepCollapse(stepElement) {
3079 const timeoutIdStr = stepElement.getAttribute("data-collapse-timeout-id");
3080 if (!timeoutIdStr) return;
3081 const timeoutId = Number(timeoutIdStr);
3082 if (!Number.isNaN(timeoutId)) clearTimeout(timeoutId);
3083 stepElement.removeAttribute("data-collapse-timeout-id");
3084 }
3085
3086 /**
3087 * Find parent delegation step for nested agents (DOM-first, reverse scan).
3088 */
3089 function findParentDelegationStep(group, agentno) {
3090 if (!group || !agentno || agentno <= 0) return null;
3091 const steps = group.querySelectorAll(".process-step");
3092 for (let i = steps.length - 1; i >= 0; i -= 1) {
3093 const step = steps[i];
3094 const stepAgent = Number(step.getAttribute("data-agent-number"));
3095 if (
3096 stepAgent === agentno - 1 &&
3097 step.getAttribute("data-log-type") === "subagent" // map to the last tool call of superior agent
3098 ) {
3099 return step;
3100 }
3101 }
3102 return null;
3103 }
3104
3105 /**
3106 * Get a concise title for a process step
3107 */
3108 function getStepTitle(heading, content, type) {
3109 // Try to get a meaningful title from heading or kvps
3110 if (heading && heading.trim()) {
3111 return cleanStepTitle(heading, 60);
3112 }
3113
3114 if (content && content.trim()) {
3115 return cleanStepTitle(content, 60);
3116 }
3117
3118 // Fallback: capitalize type (backend is source of truth)
3119 return type
3120 ? type.charAt(0).toUpperCase() + type.slice(1).replace(/_/g, " ")
3121 : "Process";
3122 }
3123
3124 /**
3125 * Convert icon://name[Optional Tooltip] into a material icon span.
3126 * Tooltip supports escaped brackets inside, e.g. [Tooltip of \[brackets\]].
3127 */
3128 export function convertIcons(html, classes = "") {
3129 if (html == null) return "";
3130
3131 return String(html).replace(
3132 /icon:\/\/([a-zA-Z0-9_]+)(\[(?:\\.|[^\]])*\])?/g,
3133 (match, iconName, tooltipBlock) => {
3134 if (!tooltipBlock) {
3135 return `<x-icon class="icon ${classes}" name="${iconName}"></x-icon>`;
3136 }
3137
3138 const tooltipRaw = tooltipBlock
3139 .slice(1, -1)
3140 .replace(/\\\[/g, "[")
3141 .replace(/\\\]/g, "]")
3142 .replace(/\\\\/g, "\\");
3143
3144 const tooltip = escapeHTML(tooltipRaw);
3145
3146 return `<x-icon class="icon ${classes}" title="${tooltip}" data-bs-placement="top" data-bs-trigger="hover" name="${iconName}"></x-icon>`;
3147 },
3148 );
3149 }
3150
3151 /**
3152 * Clean step title by removing icon:// prefixes and status phrases
3153 * Preserves agent markers (A1:, A2:, etc.) so users can see which subordinate agent is executing
3154 */
3155 export function cleanStepTitle(text, maxLength = 100) {
3156 if (!text) return "";
3157 let cleaned = String(text)
3158 .replace(/icon:\/\/[a-zA-Z0-9_]+(\[(?:\\.|[^\]])*\])?\s*/g, "")
3159 .replace(/\s+/g, " ")
3160 .trim();
3161 return truncateText(cleaned, maxLength);
3162 }
3163
3164 /**
3165 * Update process group header with step count, status, and metrics
3166 */
3167 function updateProcessGroupHeader(group) {
3168 const header = group.querySelector(".process-group-header");
3169 const steps = group.querySelectorAll(".process-step");
3170 const titleEl = header.querySelector(".group-title");
3171 const badgeEl = header.querySelector(".step-badge");
3172 const metricsEl = header.querySelector(".group-metrics");
3173 const isCompleted = isProcessGroupComplete(group);
3174 const notificationsEl = metricsEl?.querySelector(".metric-notifications");
3175
3176 // Update group title with the latest agent step heading
3177 if (titleEl) {
3178 // Find the last "agent" type step
3179 const agentSteps = Array.from(steps).filter(
3180 (step) => step.getAttribute("data-log-type") === "agent",
3181 );
3182 if (agentSteps.length > 0) {
3183 const lastAgentStep = agentSteps[agentSteps.length - 1];
3184 const lastHeading =
3185 lastAgentStep.querySelector(".step-title")?.textContent;
3186 if (lastHeading) {
3187 const cleanTitle = cleanStepTitle(lastHeading, 50);
3188 if (cleanTitle) {
3189 titleEl.textContent = cleanTitle;
3190 }
3191 }
3192 }
3193 }
3194
3195 // If completed, set badge to END
3196 if (isCompleted) {
3197 // set end badge
3198 badgeEl.outerHTML = `<span class="step-badge END">END</span>`;
3199 // remove shine from any steps
3200 group.querySelectorAll(".step-title.shiny-text").forEach((el) => {
3201 el.classList.remove("shiny-text");
3202 });
3203 } else {
3204 // if not complete, clone the last step badge
3205 if (badgeEl && steps.length > 0) {
3206 const lastStep = steps[steps.length - 1];
3207 const code = lastStep.getAttribute("data-step-code");
3208 badgeEl.outerHTML = `<span class="step-badge ${code}">${code}</span>`;
3209 }
3210 }
3211
3212 // Update step count in metrics - All GEN steps from all agents per process group
3213 const stepMetricContainerEl = metricsEl?.querySelector(".metric-steps");
3214 const stepsMetricValEl =
3215 stepMetricContainerEl?.querySelector(".metric-value");
3216 if (stepsMetricValEl) {
3217 let genSteps = Number(group.dataset.fullAgentSteps);
3218 if (!Number.isFinite(genSteps)) {
3219 genSteps = group.querySelectorAll(
3220 '.process-step[data-log-type="agent"]',
3221 ).length;
3222 genSteps -= 1; // don't count response as step
3223 }
3224 stepsMetricValEl.textContent = genSteps.toString();
3225 if (genSteps <= 0)
3226 stepMetricContainerEl.classList.add("display-none"); // hide when no steps
3227 else stepMetricContainerEl.classList.remove("display-none");
3228 }
3229
3230 // Update time metric
3231 const timeMetricContainerEl = metricsEl?.querySelector(".metric-time");
3232 const timeMetricEl = metricsEl?.querySelector(".metric-time .metric-value");
3233 const startTimestamp = group.getAttribute("data-start-timestamp");
3234 if (timeMetricEl && startTimestamp) {
3235 const date = new Date(parseFloat(startTimestamp) * 1000);
3236 const hour12 = getUserHour12();
3237 timeMetricEl.textContent = new Intl.DateTimeFormat(undefined, {
3238 hour: hour12 ? "numeric" : "2-digit",
3239 minute: "2-digit",
3240 hour12,
3241 timeZone: getUserTimezone(),
3242 }).format(date);
3243 if (timeMetricContainerEl) {
3244 const fullDateTime = formatDateTime(date.toISOString(), "short");
3245 timeMetricContainerEl.title =
3246 timeMetricContainerEl.dataset.bsOriginalTitle = fullDateTime;
3247 }
3248 }
3249
3250 const firstTimestampMs = group.dataset.fullStartTimestamp
3251 ? Math.round(Number(group.dataset.fullStartTimestamp) * 1000)
3252 : parseInt(steps[0]?.getAttribute("data-timestamp") || "0", 10);
3253 const lastTimestampMs = group.dataset.fullEndTimestamp
3254 ? Math.round(Number(group.dataset.fullEndTimestamp) * 1000)
3255 : parseInt(
3256 steps[steps.length - 1]?.getAttribute("data-timestamp") || "0",
3257 10,
3258 );
3259 const durationText =
3260 isCompleted &&
3261 metricsEl &&
3262 steps.length > 0 &&
3263 firstTimestampMs > 0 &&
3264 lastTimestampMs > 0 &&
3265 formatDuration(Math.max(0, lastTimestampMs - firstTimestampMs));
3266
3267 const durationMetricContainerEl =
3268 metricsEl?.querySelector(".metric-duration");
3269 const durationMetricValEl =
3270 durationMetricContainerEl?.querySelector(".metric-value");
3271 if (durationMetricContainerEl && durationMetricValEl && durationText) {
3272 durationMetricValEl.textContent = durationText;
3273 durationMetricContainerEl.classList.remove("display-none");
3274 } else if (durationMetricContainerEl) {
3275 durationMetricContainerEl.classList.add("display-none");
3276 }
3277
3278 if (notificationsEl) {
3279 const fullWarningSteps = Number(group.dataset.fullWarningSteps);
3280 const fullInfoSteps = Number(group.dataset.fullInfoSteps);
3281 const counts = Number.isFinite(fullWarningSteps) &&
3282 Number.isFinite(fullInfoSteps)
3283 ? { warning: fullWarningSteps, info: fullInfoSteps }
3284 : { warning: 0, info: 0 };
3285 if (!Number.isFinite(fullWarningSteps) || !Number.isFinite(fullInfoSteps)) {
3286 steps.forEach((step) => {
3287 const stepType = step.getAttribute("data-log-type");
3288 if (Object.prototype.hasOwnProperty.call(counts, stepType)) {
3289 counts[stepType] += 1;
3290 }
3291 });
3292 }
3293
3294 const totalNotifications = counts.warning + counts.info;
3295 const countEl = notificationsEl.querySelector(".metric-value");
3296 notificationsEl.classList.remove("status-wrn", "status-inf");
3297
3298 if (totalNotifications > 0) {
3299 if (countEl) {
3300 countEl.textContent = totalNotifications.toString();
3301 }
3302 if (counts.warning > 0) {
3303 notificationsEl.classList.add("status-wrn");
3304 } else if (counts.info > 0) {
3305 notificationsEl.classList.add("status-inf");
3306 }
3307 notificationsEl.hidden = false;
3308 notificationsEl.title = `Warnings: ${counts.warning}, Info: ${counts.info}`;
3309 } else {
3310 notificationsEl.hidden = true;
3311 }
3312 }
3313 }
3314
3315 function isProcessGroupComplete(group) {
3316 // manually closed group
3317 if (group?.hasAttribute?.("data-group-complete")) return true;
3318 // naturally completed group
3319 const response = group.querySelector(".process-group-response");
3320 return !!response;
3321 }
3322
3323 // manually complete last process group
3324 export function completeLastProcessGroup() {
3325 const group = getLastProcessGroup();
3326 if (!group || isProcessGroupComplete(group)) return;
3327 group.setAttribute("data-group-complete", "true");
3328 updateProcessGroupHeader(group);
3329 }
3330
3331 function getStepProcessGroup(step) {
3332 return step?.closest(".process-group");
3333 }
3334
3335 /**
3336 * Truncate text to a maximum length
3337 */
3338 function truncateText(text, maxLength) {
3339 if (!text) return "";
3340 text = String(text).trim();
3341 if (text.length <= maxLength) return text;
3342 return text.substring(0, maxLength - 3) + "...";
3343 }
3344
3345 // gets or creates a child DOM element
3346 /**
3347 * @param {Element} parent
3348 * @param {string} selector
3349 * @param {string} tagName
3350 * @param {...string} classNames
3351 * @returns {HTMLElement}
3352 */
3353 function ensureChild(parent, selector, tagName, ...classNames) {
3354 /** @type {HTMLElement | null} */
3355 let el = /** @type {any} */ (parent.querySelector(selector));
3356 if (!el) {
3357 el = document.createElement(tagName);
3358 if (classNames.length) el.classList.add(...classNames);
3359 parent.appendChild(el);
3360 }
3361 return el;
3362 }
3363
3364 // Setup collapsible message with expand button and action buttons
3365 function setupCollapsible(
3366 messageDiv,
3367 containerSelector,
3368 initialExpanded,
3369 actionButtons = [],
3370 contentSelector = ":scope > .message-body",
3371 ) {
3372 messageDiv.classList.add("message-collapsible");
3373 messageDiv
3374 .querySelectorAll(":scope > .message-collapse-content")
3375 .forEach((element) => element.classList.remove("message-collapse-content"));
3376 const collapseContent = messageDiv.querySelector(contentSelector);
3377 collapseContent?.classList.add("message-collapse-content");
3378 const initialState =
3379 Boolean(initialExpanded) && !messageDiv.classList.contains("lazy-content");
3380 messageDiv.classList.toggle("expanded", initialState);
3381
3382 const container = ensureChild(
3383 messageDiv,
3384 containerSelector,
3385 "div",
3386 "step-action-buttons",
3387 );
3388 const btn = ensureChild(container, ".expand-btn", "button", "expand-btn");
3389 const syncBtn = () => {
3390 const exp = messageDiv.classList.contains("expanded");
3391 btn.textContent = exp ? "Show less" : "Show more";
3392 btn.classList.toggle("show-less-btn", exp);
3393 btn.classList.toggle("show-more-btn", !exp);
3394 };
3395 const setExpanded = (expanded) => {
3396 const nextExpanded = Boolean(expanded);
3397 messageDiv.classList.toggle("expanded", nextExpanded);
3398 messageDiv.__renderLazyContent?.(nextExpanded);
3399 syncBtn();
3400 if (!nextExpanded) {
3401 if (collapseContent) collapseContent.scrollTop = 0;
3402 }
3403 };
3404 messageDiv.__setExpanded = setExpanded;
3405 setExpanded(initialState);
3406 btn.onclick = () =>
3407 setExpanded(!messageDiv.classList.contains("expanded"));
3408
3409 syncActionButtons(container, actionButtons);
3410
3411 const refreshOverflow = () => {
3412 const hasOverflow = measureMessageCollapseOverflow(collapseContent, {
3413 expanded: messageDiv.classList.contains("expanded"),
3414 lazy: messageDiv.classList.contains("lazy-content"),
3415 });
3416 if (hasOverflow === null) return false;
3417 messageDiv.classList.toggle("has-overflow", hasOverflow);
3418 return true;
3419 };
3420 messageDiv.__refreshCollapseOverflow = refreshOverflow;
3421
3422 // Detect overflow after render. Window replays are measured again after the
3423 // staged DOM has moved into the live, correctly sized chat history.
3424 requestAnimationFrame(() => {
3425 if (messageDiv.__refreshCollapseOverflow === refreshOverflow) {
3426 refreshOverflow();
3427 }
3428 });
3429 }
3430
3431 function refreshCollapsibleMessageOverflow(root) {
3432 if (!root) return;
3433 const messages = root.matches?.(".message-collapsible")
3434 ? [root]
3435 : root.querySelectorAll?.(".message-collapsible") || [];
3436 messages.forEach((message) => {
3437 if (typeof message.__refreshCollapseOverflow === "function") {
3438 message.__refreshCollapseOverflow();
3439 }
3440 });
3441 }
3442
3443 // returns true if this is the initial render of a chat eg. when reloading window, switching chat or catching up after a break
3444 // returns false when already in a rendered chat and adding messages regurarly
3445 function isMassRender() {
3446 return _massRender;
3447 }
3448
3449 // smooth fade in animation for new chunks when streaming
3450 function smoothRender(element, newContent, delay = 350) {
3451 // skip on mass render
3452 if (isMassRender()) {
3453 element.innerHTML = newContent;
3454 return;
3455 }
3456
3457 element.dataset.smoothPendingHtml = newContent;
3458
3459 if (element.dataset.smoothTimeoutId) return;
3460
3461 const timeoutId = window.setTimeout(() => {
3462 const pending = element.dataset.smoothPendingHtml || "";
3463 delete element.dataset.smoothPendingHtml;
3464 delete element.dataset.smoothTimeoutId;
3465
3466 const existing = element.querySelector(
3467 ":scope > div.smooth-render-visible",
3468 );
3469 if (existing) {
3470 existing.classList.remove("smooth-render-visible");
3471 existing.classList.add("smooth-render-invisible");
3472
3473 existing.addEventListener("animationend", () => existing.remove(), {
3474 once: true,
3475 });
3476 }
3477
3478 const nextLayer = document.createElement("div");
3479 nextLayer.className = "smooth-render-visible";
3480 nextLayer.innerHTML = pending;
3481 element.appendChild(nextLayer);
3482
3483 // Keep container height stable while layers are absolute
3484 element.style.height = `${nextLayer.scrollHeight}px`;
3485 }, delay);
3486
3487 element.dataset.smoothTimeoutId = String(timeoutId);
3488 }