diff --git a/python/helpers/log.py b/python/helpers/log.py
index a799666588..a396de0b53 100644
--- a/python/helpers/log.py
+++ b/python/helpers/log.py
@@ -1,5 +1,6 @@
from dataclasses import dataclass, field
import json
+import time
from typing import Any, Literal, Optional, Dict, TypeVar, TYPE_CHECKING
T = TypeVar("T")
@@ -131,9 +132,11 @@ class LogItem:
kvps: Optional[OrderedDict] = None # Use OrderedDict for kvps
id: Optional[str] = None # Add id field
guid: str = ""
+ timestamp: float = 0.0 # Unix timestamp in seconds
def __post_init__(self):
self.guid = self.log.guid
+ self.timestamp = time.time() # Record creation time
def update(
self,
@@ -181,6 +184,7 @@ def output(self):
"content": self.content,
"temp": self.temp,
"kvps": self.kvps,
+ "timestamp": self.timestamp, # Unix timestamp in seconds
}
diff --git a/webui/components/messages/process-group/process-group-store.js b/webui/components/messages/process-group/process-group-store.js
new file mode 100644
index 0000000000..d0f23ef7f7
--- /dev/null
+++ b/webui/components/messages/process-group/process-group-store.js
@@ -0,0 +1,131 @@
+import { createStore } from "/js/AlpineStore.js";
+
+// Process Group Store - manages collapsible process groups in chat
+const model = {
+ // Track which process groups are expanded (by group ID)
+ expandedGroups: {},
+
+ // Track which individual steps are expanded within a group
+ expandedSteps: {},
+
+ // Default collapsed state for new process groups
+ defaultCollapsed: true,
+
+ init() {
+ try {
+ // Load persisted state
+ const stored = localStorage.getItem("processGroupState");
+ if (stored) {
+ const parsed = JSON.parse(stored);
+ this.expandedGroups = parsed.expandedGroups || {};
+ this.expandedSteps = parsed.expandedSteps || {};
+ this.defaultCollapsed = parsed.defaultCollapsed ?? true;
+ }
+ } catch (e) {
+ console.error("Failed to load process group state", e);
+ }
+ },
+
+ _persist() {
+ try {
+ localStorage.setItem("processGroupState", JSON.stringify({
+ expandedGroups: this.expandedGroups,
+ expandedSteps: this.expandedSteps,
+ defaultCollapsed: this.defaultCollapsed
+ }));
+ } catch (e) {
+ console.error("Failed to persist process group state", e);
+ }
+ },
+
+ // Check if a process group is expanded
+ isGroupExpanded(groupId) {
+ if (groupId in this.expandedGroups) {
+ return this.expandedGroups[groupId];
+ }
+ return !this.defaultCollapsed;
+ },
+
+ // Toggle process group expansion
+ toggleGroup(groupId) {
+ const current = this.isGroupExpanded(groupId);
+ this.expandedGroups[groupId] = !current;
+ this._persist();
+ },
+
+ // Expand a specific group
+ expandGroup(groupId) {
+ this.expandedGroups[groupId] = true;
+ this._persist();
+ },
+
+ // Collapse a specific group
+ collapseGroup(groupId) {
+ this.expandedGroups[groupId] = false;
+ this._persist();
+ },
+
+ // Check if a step within a group is expanded
+ isStepExpanded(groupId, stepId) {
+ const key = `${groupId}:${stepId}`;
+ return this.expandedSteps[key] || false;
+ },
+
+ // Toggle step expansion
+ toggleStep(groupId, stepId) {
+ const key = `${groupId}:${stepId}`;
+ this.expandedSteps[key] = !this.expandedSteps[key];
+ this._persist();
+ },
+
+ // Get icon for step type
+ getStepIcon(type) {
+ const icons = {
+ 'agent': 'psychology',
+ 'tool': 'build',
+ 'code_exe': 'terminal',
+ 'browser': 'language',
+ 'info': 'info',
+ 'hint': 'lightbulb',
+ 'util': 'settings',
+ 'warning': 'warning',
+ 'error': 'error'
+ };
+ return icons[type] || 'circle';
+ },
+
+ // Get label for step type
+ getStepLabel(type) {
+ const labels = {
+ 'agent': 'Thinking',
+ 'tool': 'Tool',
+ 'code_exe': 'Code',
+ 'browser': 'Browser',
+ 'info': 'Info',
+ 'hint': 'Hint',
+ 'util': 'Utility',
+ 'warning': 'Warning',
+ 'error': 'Error'
+ };
+ return labels[type] || 'Process';
+ },
+
+ // Clear state for a specific context (when chat is reset)
+ clearContext(contextPrefix) {
+ // Clear groups matching the context
+ for (const key of Object.keys(this.expandedGroups)) {
+ if (key.startsWith(contextPrefix)) {
+ delete this.expandedGroups[key];
+ }
+ }
+ // Clear steps matching the context
+ for (const key of Object.keys(this.expandedSteps)) {
+ if (key.startsWith(contextPrefix)) {
+ delete this.expandedSteps[key];
+ }
+ }
+ this._persist();
+ }
+};
+
+export const store = createStore("processGroup", model);
diff --git a/webui/components/messages/process-group/process-group.css b/webui/components/messages/process-group/process-group.css
new file mode 100644
index 0000000000..1d32679499
--- /dev/null
+++ b/webui/components/messages/process-group/process-group.css
@@ -0,0 +1,451 @@
+.process-group {
+ display: inline-flex;
+ flex-direction: column;
+ position: relative;
+ z-index: 1;
+ margin: var(--spacing-sm) 0;
+ padding: var(--spacing-sm) var(--spacing-md);
+ border-radius: var(--border-radius);
+ background: #1f3c1e;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ min-width: 200px;
+ max-width: 100%;
+ box-sizing: border-box;
+ flex-shrink: 0;
+ width: fit-content;
+}
+
+/* Embedded Process Group inside Response */
+.process-group.embedded {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ margin: 0;
+ border-radius: var(--border-radius) var(--border-radius) 0 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ background: transparent;
+}
+
+/* Message container with embedded process group */
+.message-container.has-process-group {
+ display: inline-flex;
+ flex-direction: column;
+ background: #1f3c1e;
+ border-radius: var(--border-radius);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ padding: 0;
+ overflow: hidden;
+ min-width: 200px;
+ max-width: 100%;
+}
+
+.message-container.has-process-group > .message {
+ border-radius: 0 0 var(--border-radius) var(--border-radius);
+ border: none;
+ background: transparent;
+ margin: 0;
+}
+
+.process-group:hover {
+ border-color: rgba(255, 255, 255, 0.10);
+}
+
+/* Process Group Header */
+.process-group-header {
+ display: flex;
+ align-items: center;
+ padding: 0;
+ cursor: pointer;
+ user-select: none;
+ transition: opacity 0.15s ease;
+ min-height: 24px;
+ gap: var(--spacing-xs);
+ white-space: nowrap;
+}
+
+.process-group-header:hover {
+ opacity: 0.85;
+}
+
+.process-group-header .expand-icon {
+ font-size: 1rem;
+ color: var(--color-text);
+ transition: transform 0.2s ease;
+ opacity: 0.5;
+ flex-shrink: 0;
+}
+
+.process-group.expanded .process-group-header .expand-icon {
+ transform: rotate(90deg);
+}
+
+.process-group-header .group-icon {
+ font-size: 0.95rem;
+ color: var(--color-primary);
+ opacity: 0.7;
+ flex-shrink: 0;
+}
+
+.process-group-header .group-title {
+ flex: 1;
+ font-size: var(--font-size-smaller);
+ font-weight: 400;
+ color: var(--color-text);
+ opacity: 0.7;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.process-group-header .step-count {
+ font-size: 0.7rem;
+ color: var(--color-text);
+ opacity: 0.6;
+ flex-shrink: 0;
+ background-color: rgba(255, 255, 255, 0.06);
+ padding: 1px 6px;
+ border-radius: 8px;
+}
+
+.process-group-header .group-timestamp {
+ font-size: 0.65rem;
+ color: rgba(255, 255, 255, 0.65);
+ flex-shrink: 0;
+ font-family: var(--font-family-code);
+ margin-left: auto;
+}
+
+.process-group-header .group-duration {
+ font-size: 0.7rem;
+ color: #81c784;
+ flex-shrink: 0;
+ font-family: var(--font-family-code);
+ min-width: 40px;
+ text-align: right;
+}
+
+/* Process Group Content - Animated expand/collapse */
+.process-group-content {
+ display: grid;
+ grid-template-rows: 0fr;
+ opacity: 0;
+ margin-top: 0;
+ padding-top: 0;
+ border-top: 1px solid transparent;
+ transition: grid-template-rows 0.25s ease-out,
+ opacity 0.2s ease-out,
+ margin-top 0.25s ease-out,
+ padding-top 0.25s ease-out,
+ border-color 0.2s ease-out;
+ overflow: hidden;
+}
+
+.process-group-content > .process-steps {
+ min-height: 0;
+}
+
+.process-group.expanded .process-group-content {
+ grid-template-rows: 1fr;
+ opacity: 1;
+ margin-top: var(--spacing-sm);
+ padding-top: var(--spacing-sm);
+ border-top-color: rgba(255, 255, 255, 0.06);
+}
+
+/* Process Steps List */
+.process-steps {
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+/* Individual Process Step */
+.process-step {
+ display: flex;
+ flex-direction: column;
+ padding: var(--spacing-xxs) var(--spacing-xs);
+ border-radius: 4px;
+ transition: background-color 0.15s ease;
+}
+
+.process-step:hover {
+ background-color: rgba(255, 255, 255, 0.03);
+}
+
+/* Utility/Info/Hint steps have subtle background tint */
+.process-step[data-type="util"],
+.process-step[data-type="info"],
+.process-step[data-type="hint"] {
+ background-color: rgba(120, 115, 100, 0);
+}
+
+.process-step[data-type="util"]:hover,
+.process-step[data-type="info"]:hover,
+.process-step[data-type="hint"]:hover {
+ background-color: rgba(255, 255, 255, 0.03);
+}
+
+
+.light-mode .process-step[data-type="util"]:hover,
+.light-mode .process-step[data-type="info"]:hover,
+.light-mode .process-step[data-type="hint"]:hover {
+ background-color: rgba(0, 0, 0, 0.04);
+}
+
+/* Step Header (clickable) */
+.process-step-header {
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+ user-select: none;
+ gap: var(--spacing-xs);
+ min-height: 20px;
+}
+
+.process-step-header .step-icon {
+ font-size: 0.85rem;
+ opacity: 0.6;
+ width: 16px;
+ text-align: center;
+}
+
+/* Step type colors */
+.process-step[data-type="agent"] .step-icon { color: #64b5f6; }
+.process-step[data-type="tool"] .step-icon { color: #81c784; }
+.process-step[data-type="code_exe"] .step-icon { color: #ba68c8; }
+.process-step[data-type="browser"] .step-icon { color: #ffb74d; }
+.process-step[data-type="info"] .step-icon { color: #90a4ae; }
+.process-step[data-type="util"] .step-icon { color: #78909c; }
+.process-step[data-type="hint"] .step-icon { color: #aed581; }
+.process-step[data-type="warning"] .step-icon { color: #ffd54f; }
+.process-step[data-type="error"] .step-icon { color: #e57373; }
+
+.process-step-header .step-type {
+ font-size: 0.7rem;
+ font-weight: 500;
+ opacity: 0.5;
+ min-width: 50px;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+.process-step-header .step-title {
+ flex: 1;
+ font-size: 0.75rem;
+ color: var(--color-text);
+ opacity: 0.7;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.process-step-header .step-time {
+ font-size: 0.65rem;
+ color: #81c784;
+ flex-shrink: 0;
+ font-family: var(--font-family-code);
+ min-width: 35px;
+ text-align: right;
+}
+
+.process-step-header .step-expand-icon {
+ font-size: 0.8rem;
+ opacity: 0.4;
+ transition: transform 0.2s ease, opacity 0.15s ease;
+}
+
+.process-step-header:hover .step-expand-icon {
+ opacity: 0.7;
+}
+
+.process-step.step-expanded .step-expand-icon {
+ transform: rotate(180deg);
+}
+
+/* Step Detail Content - Animated expand/collapse */
+.process-step-detail {
+ display: grid;
+ grid-template-rows: 0fr;
+ opacity: 0;
+ transition: grid-template-rows 0.2s ease-out, opacity 0.15s ease-out;
+ overflow: hidden;
+}
+
+.process-step-detail > .process-step-detail-content {
+ min-height: 0;
+}
+
+.process-step.step-expanded .process-step-detail {
+ grid-template-rows: 1fr;
+ opacity: 1;
+ overflow: visible;
+}
+
+.process-step-detail-content {
+ padding: var(--spacing-xs) var(--spacing-sm);
+ margin-top: var(--spacing-xxs);
+ margin-left: 20px; /* Align with icon */
+ background-color: rgba(0, 0, 0, 0.15);
+ border-radius: 4px;
+ font-size: 0.7rem;
+ line-height: 1.5;
+ max-height: 300px;
+ overflow-y: auto;
+ border-left: 2px solid rgba(255, 255, 255, 0.08);
+}
+
+.process-step-detail-content pre {
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: var(--font-family-code);
+ font-size: 0.7rem;
+ color: var(--color-text);
+ opacity: 0.8;
+}
+
+/* KVPs in step detail */
+.process-step-detail-content .step-kvps {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-xxs);
+}
+
+.process-step-detail-content .step-kvp {
+ display: flex;
+ gap: var(--spacing-xs);
+}
+
+.process-step-detail-content .step-kvp-key {
+ color: var(--color-primary);
+ font-weight: 500;
+ min-width: 80px;
+ opacity: 0.8;
+}
+
+.process-step-detail-content .step-kvp-value {
+ flex: 1;
+ color: var(--color-text);
+ opacity: 0.75;
+ word-break: break-word;
+ font-size: 0.7rem;
+}
+
+/* Light mode adjustments */
+.light-mode .process-group {
+ background: rgba(0, 0, 0, 0.03);
+ border-color: rgba(0, 0, 0, 0.08);
+}
+
+.light-mode .process-group.embedded {
+ background: rgba(0, 0, 0, 0.04);
+ border-bottom-color: rgba(0, 0, 0, 0.08);
+}
+
+.light-mode .message-container.has-process-group {
+ background: var(--color-panel);
+ border-color: rgba(0, 0, 0, 0.08);
+}
+
+.light-mode .process-group:hover {
+ border-color: rgba(0, 0, 0, 0.12);
+}
+
+.light-mode .process-group.expanded .process-group-content {
+ border-top-color: rgba(0, 0, 0, 0.06);
+}
+
+.light-mode .process-step:hover {
+ background-color: rgba(0, 0, 0, 0.02);
+}
+
+.light-mode .process-step-detail-content {
+ background-color: rgba(0, 0, 0, 0.03);
+ border-left-color: rgba(0, 0, 0, 0.08);
+}
+
+.light-mode .process-group-header .step-count {
+ background-color: rgba(0, 0, 0, 0.06);
+}
+
+/* Light mode text colors for process group */
+.light-mode .process-group-header .group-title,
+.light-mode .process-group-header .step-count,
+.light-mode .process-step-header .step-title,
+.light-mode .process-step-header .step-type {
+ color: #188216;
+}
+
+.light-mode .process-group-header .group-timestamp {
+ color: rgba(0, 0, 0, 0.5);
+}
+
+.light-mode .process-group-header .group-duration {
+ color: #2e7d32;
+}
+
+.light-mode .process-step-header .step-time {
+ color: #388e3c;
+}
+
+/* Animation for loading state */
+@keyframes pulse-step {
+ 0%, 100% { opacity: 0.5; }
+ 50% { opacity: 0.8; }
+}
+
+.process-step.loading .step-icon {
+ animation: pulse-step 1.2s ease-in-out infinite;
+}
+
+/* Responsive adjustments */
+@media (max-width: 768px) {
+ .process-group {
+ padding: var(--spacing-xs) var(--spacing-sm);
+ }
+
+ .process-step-header .step-type {
+ display: none;
+ }
+
+ .process-step-header .step-title {
+ font-size: 0.7rem;
+ }
+
+ .process-step-detail-content {
+ margin-left: 16px;
+ }
+}
+
+/* ===========================================
+ Preferences Visibility Controls
+ These rules work with preferences-store toggles
+ =========================================== */
+
+/* Utility steps - default hidden (controlled by showUtils) */
+.process-step.message-util {
+ display: none;
+}
+
+.process-step.message-util.show-util {
+ display: flex;
+}
+
+/* Thoughts KVP row - default visible (controlled by showThoughts) */
+.step-kvp.msg-thoughts {
+ display: flex;
+}
+
+.step-kvp.msg-thoughts.hide-thoughts {
+ display: none;
+}
+
+/* JSON pre content - default hidden (controlled by showJson) */
+.process-step-detail-content pre.msg-json {
+ display: none;
+}
+
+.process-step-detail-content pre.msg-json.show-json {
+ display: block;
+}
diff --git a/webui/components/sidebar/bottom/preferences/preferences-store.js b/webui/components/sidebar/bottom/preferences/preferences-store.js
index c34e3980d0..98735ff19e 100644
--- a/webui/components/sidebar/bottom/preferences/preferences-store.js
+++ b/webui/components/sidebar/bottom/preferences/preferences-store.js
@@ -59,6 +59,16 @@ const model = {
},
_showUtils: false,
+ // Process group collapse preference
+ get collapseProcessGroups() {
+ return this._collapseProcessGroups;
+ },
+ set collapseProcessGroups(value) {
+ this._collapseProcessGroups = value;
+ this._applyCollapseProcessGroups(value);
+ },
+ _collapseProcessGroups: true, // Default to collapsed
+
// Initialize preferences and apply current state
init() {
try {
@@ -77,6 +87,14 @@ const model = {
this._speech = false; // Default to speech off if localStorage is unavailable
}
+ // Load collapse process groups preference
+ try {
+ const storedCollapse = localStorage.getItem("collapseProcessGroups");
+ this._collapseProcessGroups = storedCollapse !== "false"; // Default true
+ } catch {
+ this._collapseProcessGroups = true;
+ }
+
// Apply all preferences
this._applyDarkMode(this._darkMode);
this._applyAutoScroll(this._autoScroll);
@@ -84,6 +102,7 @@ const model = {
this._applyShowThoughts(this._showThoughts);
this._applyShowJson(this._showJson);
this._applyShowUtils(this._showUtils);
+ this._applyCollapseProcessGroups(this._collapseProcessGroups);
} catch (e) {
console.error("Failed to initialize preferences store", e);
}
@@ -110,23 +129,51 @@ const model = {
},
_applyShowThoughts(value) {
+ // For original messages
css.toggleCssProperty(
".msg-thoughts",
"display",
value ? undefined : "none"
);
+ // For process steps - toggle class on all existing elements
+ document.querySelectorAll(".step-kvp.msg-thoughts").forEach((el) => {
+ el.classList.toggle("hide-thoughts", !value);
+ });
},
_applyShowJson(value) {
+ // For original messages
css.toggleCssProperty(".msg-json", "display", value ? "block" : "none");
+ // For process steps - toggle class on pre elements with msg-json
+ document.querySelectorAll(".process-step-detail-content pre.msg-json").forEach((el) => {
+ el.classList.toggle("show-json", value);
+ });
},
_applyShowUtils(value) {
+ // For original messages
css.toggleCssProperty(
".message-util",
"display",
value ? undefined : "none"
);
+ // For process steps - toggle class on all existing elements
+ document.querySelectorAll(".process-step.message-util").forEach((el) => {
+ el.classList.toggle("show-util", value);
+ });
+ },
+
+ _applyCollapseProcessGroups(value) {
+ localStorage.setItem("collapseProcessGroups", value);
+ // Update process group store default
+ try {
+ const processGroupStore = window.Alpine?.store("processGroup");
+ if (processGroupStore) {
+ processGroupStore.defaultCollapsed = value;
+ }
+ } catch (e) {
+ // Store may not be initialized yet
+ }
},
};
diff --git a/webui/index.html b/webui/index.html
index 82abf4b591..75ee3a5c49 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -9,6 +9,7 @@
+
diff --git a/webui/index.js b/webui/index.js
index d7db5fc6af..614cd90558 100644
--- a/webui/index.js
+++ b/webui/index.js
@@ -10,6 +10,7 @@ import { store as inputStore } from "/components/chat/input/input-store.js";
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
import { store as tasksStore } from "/components/sidebar/tasks/tasks-store.js";
import { store as chatTopStore } from "/components/chat/top-section/chat-top-store.js";
+import { store as processGroupStore } from "/components/messages/process-group/process-group-store.js";
globalThis.fetchApi = api.fetchApi; // TODO - backward compatibility for non-modular scripts, remove once refactored to alpine
@@ -184,8 +185,8 @@ async function updateUserTime() {
updateUserTime();
setInterval(updateUserTime, 1000);
-function setMessage(id, type, heading, content, temp, kvps = null) {
- const result = msgs.setMessage(id, type, heading, content, temp, kvps);
+function setMessage(id, type, heading, content, temp, kvps = null, timestamp = null) {
+ const result = msgs.setMessage(id, type, heading, content, temp, kvps, timestamp);
const chatHistoryEl = document.getElementById("chat-history");
if (preferencesStore.autoScroll && chatHistoryEl) {
chatHistoryEl.scrollTop = chatHistoryEl.scrollHeight;
@@ -292,6 +293,7 @@ export async function poll() {
if (lastLogGuid != response.log_guid) {
const chatHistoryEl = document.getElementById("chat-history");
if (chatHistoryEl) chatHistoryEl.innerHTML = "";
+ msgs.resetProcessGroups(); // Reset process groups on chat reset
lastLogVersion = 0;
lastLogGuid = response.log_guid;
await poll();
@@ -308,7 +310,8 @@ export async function poll() {
log.heading,
log.content,
log.temp,
- log.kvps
+ log.kvps,
+ log.timestamp
);
}
afterMessagesUpdate(response.logs);
@@ -482,6 +485,9 @@ export const setContext = function (id) {
// Stop speech when switching chats
speechStore.stopAudio();
+ // Reset process groups for new context
+ msgs.resetProcessGroups();
+
// Clear the chat history immediately to avoid showing stale content
const chatHistoryEl = document.getElementById("chat-history");
if (chatHistoryEl) chatHistoryEl.innerHTML = "";
diff --git a/webui/js/messages.js b/webui/js/messages.js
index 70c46bf098..4acea3a2fc 100644
--- a/webui/js/messages.js
+++ b/webui/js/messages.js
@@ -4,22 +4,102 @@ import { marked } from "../vendor/marked/marked.esm.js";
import { store as _messageResizeStore } from "/components/messages/resize/message-resize-store.js"; // keep here, required in html
import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
import { addActionButtonsToElement } from "/components/messages/action-buttons/simple-action-buttons.js";
+import { store as processGroupStore } from "/components/messages/process-group/process-group-store.js";
+import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
const chatHistory = document.getElementById("chat-history");
let messageGroup = null;
+let currentProcessGroup = null; // Track current process group for collapsible UI
+
+// Process types that should be grouped into collapsible sections
+const PROCESS_TYPES = ['agent', 'tool', 'code_exe', 'browser', 'info', 'hint', 'util'];
+// Main types that should always be visible (not collapsed)
+const MAIN_TYPES = ['user', 'response', 'warning', 'error', 'rate_limit'];
+
+/**
+ * Check if a response is from the main agent (A0)
+ * Subordinate agents (A1, A2, ...) responses should be treated as process steps
+ */
+function isMainAgentResponse(heading) {
+ if (!heading) return true; // Default to main agent
+ // Check for subordinate agent patterns like "A1:", "A2:", etc.
+ const match = heading.match(/\bA(\d+):/);
+ if (!match) return true; // No agent marker = main agent
+ return match[1] === "0"; // Only A0 is the main agent
+}
-// Simplified implementation - no complex interactions needed
-
-export function setMessage(id, type, heading, content, temp, kvps = null) {
+export function setMessage(id, type, heading, content, temp, kvps = null, timestamp = null) {
+ // Check if this is a process type message
+ const isProcessType = PROCESS_TYPES.includes(type);
+ const isMainType = MAIN_TYPES.includes(type);
+
// Search for the existing message container by id
let messageContainer = document.getElementById(`message-${id}`);
+ let processStepElement = document.getElementById(`process-step-${id}`);
let isNewMessage = false;
- if (messageContainer) {
- // Don't clear innerHTML - we'll do incremental updates
- // messageContainer.innerHTML = "";
- } else {
+ // For user messages, close current process group FIRST (start fresh for next interaction)
+ if (type === "user") {
+ currentProcessGroup = null;
+ }
+
+ // For process types, check if we should add to process group
+ if (isProcessType) {
+ if (processStepElement) {
+ // Update existing process step
+ updateProcessStep(processStepElement, id, type, heading, content, kvps);
+ return processStepElement;
+ }
+
+ // Create or get process group for current interaction
+ if (!currentProcessGroup || !document.getElementById(currentProcessGroup.id)) {
+ currentProcessGroup = createProcessGroup(id);
+ chatHistory.appendChild(currentProcessGroup);
+ }
+
+ // Add step to current process group
+ processStepElement = addProcessStep(currentProcessGroup, id, type, heading, content, kvps, timestamp);
+ return processStepElement;
+ }
+
+ // For subordinate agent responses (A1, A2, ...), treat as a process step instead of main response
+ if (type === "response" && !isMainAgentResponse(heading)) {
+ if (processStepElement) {
+ updateProcessStep(processStepElement, id, "agent", heading, content, kvps);
+ return processStepElement;
+ }
+
+ // Create or get process group for current interaction
+ if (!currentProcessGroup || !document.getElementById(currentProcessGroup.id)) {
+ currentProcessGroup = createProcessGroup(id);
+ chatHistory.appendChild(currentProcessGroup);
+ }
+
+ // Add subordinate response as a step (type "agent" for appropriate styling)
+ processStepElement = addProcessStep(currentProcessGroup, id, "agent", heading, content, kvps, timestamp);
+ return processStepElement;
+ }
+
+ // For main agent (A0) response, embed the current process group
+ if (type === "response" && currentProcessGroup) {
+ const processGroupToEmbed = currentProcessGroup;
+ // Keep currentProcessGroup reference - subsequent process messages go to same group
+
+ if (!messageContainer) {
+ // Create new container with embedded process group
+ messageContainer = createResponseContainerWithProcessGroup(id, processGroupToEmbed);
+ isNewMessage = true;
+ } else {
+ // Check if already embedded
+ const existingEmbedded = messageContainer.querySelector(".process-group");
+ if (!existingEmbedded && processGroupToEmbed) {
+ embedProcessGroup(messageContainer, processGroupToEmbed);
+ }
+ }
+ }
+
+ if (!messageContainer) {
// Create a new container if not found
isNewMessage = true;
const sender = type === "user" ? "user" : "ai";
@@ -46,7 +126,8 @@ export function setMessage(id, type, heading, content, temp, kvps = null) {
};
//force new group on these types
const groupStart = {
- agent: true,
+ response: true, // response starts a new group
+ user: true, // user message starts a new group (each user message should be separate)
// anything else is false
};
@@ -1007,4 +1088,430 @@ class Scroller {
reApplyScroll() {
if (this.wasAtBottom) this.element.scrollTop = this.element.scrollHeight;
}
-}
\ No newline at end of file
+}
+
+// ============================================
+// Process Group Embedding Functions
+// ============================================
+
+/**
+ * Create a response container with an embedded process group
+ */
+function createResponseContainerWithProcessGroup(id, processGroup) {
+ const messageContainer = document.createElement("div");
+ messageContainer.id = `message-${id}`;
+ messageContainer.classList.add("message-container", "ai-container", "has-process-group");
+
+ // Move process group from chatHistory into the container
+ if (processGroup && processGroup.parentNode) {
+ processGroup.parentNode.removeChild(processGroup);
+ }
+
+ // Process group will be the first child
+ if (processGroup) {
+ processGroup.classList.add("embedded");
+ messageContainer.appendChild(processGroup);
+ }
+
+ return messageContainer;
+}
+
+/**
+ * Embed a process group into an existing message container
+ */
+function embedProcessGroup(messageContainer, processGroup) {
+ if (!messageContainer || !processGroup) return;
+
+ // Remove from current parent
+ if (processGroup.parentNode) {
+ processGroup.parentNode.removeChild(processGroup);
+ }
+
+ // Add embedded class
+ processGroup.classList.add("embedded");
+ messageContainer.classList.add("has-process-group");
+
+ // Insert at the beginning of the container
+ const firstChild = messageContainer.firstChild;
+ if (firstChild) {
+ messageContainer.insertBefore(processGroup, firstChild);
+ } else {
+ messageContainer.appendChild(processGroup);
+ }
+}
+
+// ============================================
+// Process Group Functions
+// ============================================
+
+/**
+ * Create a new collapsible process group
+ */
+function createProcessGroup(id) {
+ const groupId = `process-group-${id}`;
+ const group = document.createElement("div");
+ group.id = groupId;
+ group.classList.add("process-group");
+ group.setAttribute("data-group-id", groupId);
+
+ // Default to collapsed state - don't add 'expanded' class
+ // (Users can expand manually by clicking)
+
+ // Create header
+ const header = document.createElement("div");
+ header.classList.add("process-group-header");
+ header.innerHTML = `
+ chevron_right
+ neurology
+ Processing...
+ 0 steps
+
+ `;
+
+ // Add click handler for expansion
+ header.addEventListener("click", (e) => {
+ processGroupStore.toggleGroup(groupId);
+ const newState = processGroupStore.isGroupExpanded(groupId);
+ group.classList.toggle("expanded", newState);
+ });
+
+ group.appendChild(header);
+
+ // Create content container
+ const content = document.createElement("div");
+ content.classList.add("process-group-content");
+
+ // Create steps container
+ const steps = document.createElement("div");
+ steps.classList.add("process-steps");
+ content.appendChild(steps);
+
+ group.appendChild(content);
+
+ return group;
+}
+
+/**
+ * Add a step to a process group
+ */
+function addProcessStep(group, id, type, heading, content, kvps, timestamp = null) {
+ const groupId = group.getAttribute("data-group-id");
+ const stepsContainer = group.querySelector(".process-steps");
+
+ // Create step element
+ const step = document.createElement("div");
+ step.id = `process-step-${id}`;
+ step.classList.add("process-step");
+ step.setAttribute("data-type", type);
+ step.setAttribute("data-step-id", id);
+
+ // Store timestamp for duration calculation
+ if (timestamp) {
+ step.setAttribute("data-timestamp", timestamp);
+
+ // Set group start time from first step
+ if (!group.getAttribute("data-start-timestamp")) {
+ group.setAttribute("data-start-timestamp", timestamp);
+ // Update header with formatted datetime
+ const timestampEl = group.querySelector(".group-timestamp");
+ if (timestampEl) {
+ timestampEl.textContent = formatDateTime(timestamp);
+ }
+ }
+ }
+
+ // Add message-util class for utility/info types (controlled by showUtils preference)
+ if (type === "util" || type === "info" || type === "hint") {
+ step.classList.add("message-util");
+ // Apply current preference state
+ if (preferencesStore.showUtils) {
+ step.classList.add("show-util");
+ }
+ }
+
+ // Get step info
+ const icon = processGroupStore.getStepIcon(type);
+ const label = processGroupStore.getStepLabel(type);
+ const title = getStepTitle(heading, kvps, type);
+
+ // Check if step should be expanded
+ const isStepExpanded = processGroupStore.isStepExpanded(groupId, id);
+ if (isStepExpanded) {
+ step.classList.add("step-expanded");
+ }
+
+ // Create step header
+ const stepHeader = document.createElement("div");
+ stepHeader.classList.add("process-step-header");
+
+ // Calculate relative time from group start
+ let relativeTimeStr = "";
+ if (timestamp) {
+ const groupStartTime = parseFloat(group.getAttribute("data-start-timestamp") || "0");
+ if (groupStartTime > 0) {
+ const relativeMs = (timestamp - groupStartTime) * 1000;
+ relativeTimeStr = formatRelativeTime(relativeMs);
+ }
+ }
+
+ stepHeader.innerHTML = `
+ ${icon}
+ ${label}
+ ${escapeHTML(title)}
+ ${relativeTimeStr ? `${relativeTimeStr}` : ""}
+ expand_more
+ `;
+
+ // Add click handler for step expansion
+ stepHeader.addEventListener("click", (e) => {
+ e.stopPropagation();
+ processGroupStore.toggleStep(groupId, id);
+ step.classList.toggle("step-expanded", processGroupStore.isStepExpanded(groupId, id));
+ });
+
+ step.appendChild(stepHeader);
+
+ // Create step detail container
+ const detail = document.createElement("div");
+ detail.classList.add("process-step-detail");
+
+ const detailContent = document.createElement("div");
+ detailContent.classList.add("process-step-detail-content");
+
+ // Add content to detail
+ renderStepDetailContent(detailContent, content, kvps);
+
+ detail.appendChild(detailContent);
+ step.appendChild(detail);
+
+ stepsContainer.appendChild(step);
+
+ // Update group header
+ updateProcessGroupHeader(group);
+
+ return step;
+}
+
+/**
+ * Update an existing process step
+ */
+function updateProcessStep(stepElement, id, type, heading, content, kvps) {
+ // Update title
+ const titleEl = stepElement.querySelector(".step-title");
+ if (titleEl) {
+ const title = getStepTitle(heading, kvps, type);
+ titleEl.textContent = title;
+ }
+
+ // Update detail content
+ const detailContent = stepElement.querySelector(".process-step-detail-content");
+ if (detailContent) {
+ renderStepDetailContent(detailContent, content, kvps);
+ }
+
+ // Update parent group header
+ const group = stepElement.closest(".process-group");
+ if (group) {
+ updateProcessGroupHeader(group);
+ }
+}
+
+/**
+ * Get a concise title for a process step
+ */
+function getStepTitle(heading, kvps, type) {
+ // Try to get a meaningful title from heading or kvps
+ if (heading && heading.trim()) {
+ return cleanStepTitle(heading, 80);
+ }
+
+ if (kvps) {
+ // Try common fields for title
+ if (kvps.tool_name) {
+ const headline = kvps.headline ? cleanStepTitle(kvps.headline, 60) : '';
+ return `${kvps.tool_name}${headline ? ': ' + headline : ''}`;
+ }
+ if (kvps.headline) {
+ return cleanStepTitle(kvps.headline, 80);
+ }
+ if (kvps.query) {
+ return truncateText(kvps.query, 80);
+ }
+ if (kvps.thoughts) {
+ return truncateText(String(kvps.thoughts), 80);
+ }
+ }
+
+ return processGroupStore.getStepLabel(type);
+}
+
+/**
+ * Clean step title by removing icon:// prefixes
+ * Preserves agent markers (A0:, A1:, etc.) so users can see which agent is executing
+ */
+function cleanStepTitle(text, maxLength) {
+ if (!text) return "";
+ let cleaned = String(text);
+
+ // Remove icon:// patterns (e.g., "icon://network_intelligence")
+ cleaned = cleaned.replace(/icon:\/\/[a-zA-Z0-9_]+\s*/g, "");
+
+ // Keep agent markers (A0:, A1:, etc.) - users need to see which agent is executing
+ // Only remove "A0:" as it's the main agent (implied)
+ cleaned = cleaned.replace(/^A0:\s*/i, "");
+
+ // Trim whitespace
+ cleaned = cleaned.trim();
+
+ return truncateText(cleaned, maxLength);
+}
+
+/**
+ * Render content for step detail panel
+ */
+function renderStepDetailContent(container, content, kvps) {
+ container.innerHTML = "";
+
+ // Add KVPs if present
+ if (kvps && Object.keys(kvps).length > 0) {
+ const kvpsDiv = document.createElement("div");
+ kvpsDiv.classList.add("step-kvps");
+
+ for (const [key, value] of Object.entries(kvps)) {
+ // Skip internal/display keys
+ if (key === "finished" || key === "attachments") continue;
+
+ const kvpDiv = document.createElement("div");
+ kvpDiv.classList.add("step-kvp");
+
+ // Add msg-thoughts class for thoughts-related keys (controlled by showThoughts preference)
+ const lowerKey = key.toLowerCase();
+
+ if (lowerKey === "thoughts" || lowerKey === "thinking" || lowerKey === "reflection") {
+ kvpDiv.classList.add("msg-thoughts");
+ // Apply current preference state - hide if showThoughts is false
+ if (!preferencesStore.showThoughts) {
+ kvpDiv.classList.add("hide-thoughts");
+ }
+ }
+
+ const keySpan = document.createElement("span");
+ keySpan.classList.add("step-kvp-key");
+ keySpan.textContent = convertToTitleCase(key) + ":";
+
+ const valueSpan = document.createElement("span");
+ valueSpan.classList.add("step-kvp-value");
+
+ let valueText = value;
+ if (typeof value === "object") {
+ valueText = JSON.stringify(value, null, 2);
+ }
+
+ valueSpan.textContent = truncateText(String(valueText), 500);
+
+ kvpDiv.appendChild(keySpan);
+ kvpDiv.appendChild(valueSpan);
+ kvpsDiv.appendChild(kvpDiv);
+ }
+
+ container.appendChild(kvpsDiv);
+ }
+
+ // Add main content if present (JSON content - controlled by showJson preference)
+ if (content && content.trim()) {
+ const pre = document.createElement("pre");
+ pre.classList.add("msg-json");
+ // Apply current preference state
+ if (preferencesStore.showJson) {
+ pre.classList.add("show-json");
+ }
+ pre.textContent = truncateText(content, 1000);
+ container.appendChild(pre);
+ }
+}
+
+/**
+ * Update process group header with step count and status
+ */
+function updateProcessGroupHeader(group) {
+ const steps = group.querySelectorAll(".process-step");
+ const countEl = group.querySelector(".step-count");
+ const titleEl = group.querySelector(".group-title");
+
+ if (countEl) {
+ const count = steps.length;
+ countEl.textContent = `${count} step${count !== 1 ? "s" : ""}`;
+ }
+
+ if (titleEl && steps.length > 0) {
+ // Get the last step's type for the title
+ const lastStep = steps[steps.length - 1];
+ const lastType = lastStep.getAttribute("data-type");
+ const lastTitle = lastStep.querySelector(".step-title")?.textContent || "";
+
+ // Prefer agent type steps for the group title as they contain thinking/planning info
+ if (lastType === "agent" && lastTitle) {
+ titleEl.textContent = cleanStepTitle(lastTitle, 50);
+ } else {
+ // Try to find the most recent agent step for a better title
+ const agentSteps = group.querySelectorAll('.process-step[data-type="agent"]');
+ if (agentSteps.length > 0) {
+ const lastAgentStep = agentSteps[agentSteps.length - 1];
+ const agentTitle = lastAgentStep.querySelector(".step-title")?.textContent || "";
+ if (agentTitle) {
+ titleEl.textContent = cleanStepTitle(agentTitle, 50);
+ return;
+ }
+ }
+ titleEl.textContent = `Processing (${processGroupStore.getStepLabel(lastType)})`;
+ }
+ }
+}
+
+/**
+ * Truncate text to a maximum length
+ */
+function truncateText(text, maxLength) {
+ if (!text) return "";
+ text = String(text).trim();
+ if (text.length <= maxLength) return text;
+ return text.substring(0, maxLength - 3) + "...";
+}
+
+/**
+ * Reset process group state (called on context switch)
+ */
+export function resetProcessGroups() {
+ currentProcessGroup = null;
+ messageGroup = null;
+}
+
+/**
+ * Format Unix timestamp as date-time string (YYYY-MM-DD HH:MM:SS)
+ */
+function formatDateTime(timestamp) {
+ const date = new Date(timestamp * 1000); // Convert seconds to milliseconds
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ const hours = String(date.getHours()).padStart(2, "0");
+ const minutes = String(date.getMinutes()).padStart(2, "0");
+ const seconds = String(date.getSeconds()).padStart(2, "0");
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
+}
+
+/**
+ * Format relative time for steps (e.g., +0.5s, +2.3s)
+ */
+function formatRelativeTime(ms) {
+ if (ms < 100) {
+ return "+0s";
+ }
+ const seconds = ms / 1000;
+ if (seconds < 60) {
+ return `+${seconds.toFixed(1)}s`;
+ }
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = Math.round(seconds % 60);
+ return `+${minutes}m${remainingSeconds}s`;
+}