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`; +}