diff --git a/python/helpers/log.py b/python/helpers/log.py index a396de0b5..d2cdcc40e 100644 --- a/python/helpers/log.py +++ b/python/helpers/log.py @@ -133,6 +133,9 @@ class LogItem: id: Optional[str] = None # Add id field guid: str = "" timestamp: float = 0.0 # Unix timestamp in seconds + duration_ms: Optional[int] = None # Duration until next step (set by Log.log) + tokens_in: int = 0 # Input tokens consumed + tokens_out: int = 0 # Output tokens generated def __post_init__(self): self.guid = self.log.guid @@ -146,6 +149,8 @@ class LogItem: kvps: dict | None = None, temp: bool | None = None, update_progress: ProgressUpdate | None = None, + tokens_in: int | None = None, + tokens_out: int | None = None, **kwargs, ): if self.guid == self.log.guid: @@ -157,9 +162,18 @@ class LogItem: kvps=kvps, temp=temp, update_progress=update_progress, + tokens_in=tokens_in, + tokens_out=tokens_out, **kwargs, ) + def add_tokens(self, tokens_in: int = 0, tokens_out: int = 0): + """Add tokens to this log item (accumulative).""" + if self.guid == self.log.guid: + self.tokens_in += tokens_in + self.tokens_out += tokens_out + self.log.updates += [self.no] + def stream( self, heading: str | None = None, @@ -185,6 +199,9 @@ class LogItem: "temp": self.temp, "kvps": self.kvps, "timestamp": self.timestamp, # Unix timestamp in seconds + "duration_ms": self.duration_ms, # Duration until next step + "tokens_in": self.tokens_in, # Input tokens + "tokens_out": self.tokens_out, # Output tokens } @@ -215,6 +232,11 @@ class Log: no=len(self.logs), type=type, ) + # Set duration on previous item and mark it as updated + if self.logs: + prev = self.logs[-1] + prev.duration_ms = int((item.timestamp - prev.timestamp) * 1000) + self.updates += [prev.no] self.logs.append(item) # and update it (to have just one implementation) @@ -241,6 +263,8 @@ class Log: temp: bool | None = None, update_progress: ProgressUpdate | None = None, id: Optional[str] = None, + tokens_in: int | None = None, + tokens_out: int | None = None, **kwargs, ): item = self.logs[no] @@ -257,6 +281,12 @@ class Log: if update_progress is not None: item.update_progress = update_progress + if tokens_in is not None: + item.tokens_in = tokens_in + + if tokens_out is not None: + item.tokens_out = tokens_out + # adjust all content before processing if heading is not None: diff --git a/webui/components/messages/process-group/process-group-store.js b/webui/components/messages/process-group/process-group-store.js index d0f23ef7f..9382e5629 100644 --- a/webui/components/messages/process-group/process-group-store.js +++ b/webui/components/messages/process-group/process-group-store.js @@ -78,10 +78,10 @@ const model = { this._persist(); }, - // Get icon for step type + // Get icon for step type (Material Symbols) getStepIcon(type) { const icons = { - 'agent': 'psychology', + 'agent': 'neurology', 'tool': 'build', 'code_exe': 'terminal', 'browser': 'language', @@ -89,23 +89,95 @@ const model = { 'hint': 'lightbulb', 'util': 'settings', 'warning': 'warning', - 'error': 'error' + 'error': 'error', + 'mcp': 'api', + 'memory': 'memory', + 'done': 'check_circle', + 'subagent': 'group' }; return icons[type] || 'circle'; }, - // Get label for step type + // Get 3-letter status code for step type + getStepCode(type) { + const codes = { + 'agent': 'GEN', + 'tool': 'MCP', + 'code_exe': 'EXE', + 'browser': 'EXE', + 'info': 'INF', + 'hint': 'INF', + 'util': 'UTL', + 'warning': 'WRN', + 'error': 'ERR', + 'mcp': 'MCP', + 'memory': 'MEM', + 'done': 'END', + 'response': 'END', + 'subagent': 'SUB' + }; + return codes[type] || 'GEN'; + }, + + // Get color class for status code + getStatusColorClass(type) { + const colors = { + 'agent': 'status-gen', + 'tool': 'status-mcp', + 'code_exe': 'status-exe', + 'browser': 'status-exe', + 'info': 'status-inf', + 'hint': 'status-inf', + 'util': 'status-utl', + 'warning': 'status-wrn', + 'error': 'status-err', + 'mcp': 'status-mcp', + 'memory': 'status-mem', + 'done': 'status-end', + 'response': 'status-end', + 'subagent': 'status-sub' + }; + return colors[type] || 'status-gen'; + }, + + // Get icon for badge display (Material Symbols) + getStepBadgeIcon(type) { + const icons = { + 'agent': 'public', // Globe for GEN (network intelligence) + 'tool': 'build', // Wrench for MCP + 'code_exe': 'terminal', // Terminal for EXE + 'browser': 'language', // Globe for browser + 'info': 'info', + 'hint': 'lightbulb', + 'util': 'settings', + 'warning': 'warning', + 'error': 'error', + 'mcp': 'build', // Wrench for MCP + 'memory': 'memory', + 'done': 'check_circle', + 'response': 'check_circle', + 'subagent': 'group' + }; + return icons[type] || 'circle'; + }, + + // Get label for step type (longer description) getStepLabel(type) { const labels = { - 'agent': 'Thinking', - 'tool': 'Tool', - 'code_exe': 'Code', + 'agent': 'Generating', + 'tool': 'MCP Call', + 'code_exe': 'Executing', 'browser': 'Browser', 'info': 'Info', 'hint': 'Hint', 'util': 'Utility', 'warning': 'Warning', - 'error': 'Error' + 'error': 'Error', + 'mcp': 'MCP', + 'memory': 'Memory', + 'done': 'Done', + 'response': 'Response', + 'subagent': 'Subagent' }; return labels[type] || 'Process'; }, diff --git a/webui/components/messages/process-group/process-group.css b/webui/components/messages/process-group/process-group.css index 1d3267949..d7621f820 100644 --- a/webui/components/messages/process-group/process-group.css +++ b/webui/components/messages/process-group/process-group.css @@ -3,11 +3,8 @@ 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); + margin: var(--spacing-xs) 0; + padding: var(--spacing-xs) 0; min-width: 200px; max-width: 100%; box-sizing: border-box; @@ -30,9 +27,6 @@ .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; @@ -40,16 +34,11 @@ } .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; @@ -58,8 +47,8 @@ cursor: pointer; user-select: none; transition: opacity 0.15s ease; - min-height: 24px; - gap: var(--spacing-xs); + min-height: 22px; + gap: 6px; white-space: nowrap; } @@ -67,52 +56,240 @@ opacity: 0.85; } +/* Expand/collapse triangle - CSS-only (no Material Icons) */ .process-group-header .expand-icon { - font-size: 1rem; - color: var(--color-text); - transition: transform 0.2s ease; + display: inline-block; + width: 0; + height: 0; + border-style: solid; + border-width: 4px 0 4px 6px; + border-color: transparent transparent transparent var(--color-text); opacity: 0.5; + transition: transform 0.2s ease, opacity 0.15s ease; flex-shrink: 0; + font-size: 0; /* Hide any text content */ + margin-right: 2px; +} + +.process-group-header:hover .expand-icon { + opacity: 0.8; } .process-group.expanded .process-group-header .expand-icon { transform: rotate(90deg); } +/* Group icon removed - using status badges */ .process-group-header .group-icon { - font-size: 0.95rem; - color: var(--color-primary); - opacity: 0.7; - flex-shrink: 0; + display: none; } +/* Group title - prominent display */ .process-group-header .group-title { - flex: 1; + flex: 0 1 auto; font-size: var(--font-size-smaller); - font-weight: 400; + font-weight: 500; color: var(--color-text); - opacity: 0.7; + opacity: 0.9; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + max-width: 350px; } +/* Step count badge */ .process-group-header .step-count { + font-size: 0.65rem; + color: var(--color-text); + opacity: 0.5; + flex-shrink: 0; + padding: 1px 6px; + font-family: var(--font-family-code); +} + +/* =========================================== + 3-Letter Status Code Badges + =========================================== */ + +/* Base status badge */ +.status-badge { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 6px; + border-radius: 3px; + font-size: 0.65rem; + font-weight: 600; + font-family: var(--font-family-code); + text-transform: uppercase; + letter-spacing: 0.5px; + flex-shrink: 0; + line-height: 1; +} + +/* Status badge with icon */ +.status-badge .material-symbols-outlined { + font-size: 0.8rem; + line-height: 1; +} + +/* Badge icon styling */ +.status-badge .badge-icon { font-size: 0.7rem; + margin-right: 2px; + opacity: 0.9; +} + +/* GEN - Generating (blue/cyan) */ +.status-gen { + background-color: rgba(56, 189, 248, 0.15); + color: #38bdf8; + border: 1px solid rgba(56, 189, 248, 0.3); +} + +/* EXE - Executing (magenta/purple) */ +.status-exe { + background-color: rgba(186, 104, 200, 0.15); + color: #ba68c8; + border: 1px solid rgba(186, 104, 200, 0.3); +} + +/* MCP - MCP Tool Call (amber/yellow) */ +.status-mcp { + background-color: rgba(251, 191, 36, 0.15); + color: #fbbf24; + border: 1px solid rgba(251, 191, 36, 0.3); +} + +/* MEM - Memory (blue) */ +.status-mem { + background-color: rgba(96, 165, 250, 0.15); + color: #60a5fa; + border: 1px solid rgba(96, 165, 250, 0.3); +} + +/* END - Done (green) */ +.status-end { + background-color: rgba(34, 197, 94, 0.15); + color: #22c55e; + border: 1px solid rgba(34, 197, 94, 0.3); +} + +/* ERR - Error (red) */ +.status-err { + background-color: rgba(239, 68, 68, 0.15); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.3); +} + +/* WRN - Warning (orange) */ +.status-wrn { + background-color: rgba(249, 115, 22, 0.15); + color: #f97316; + border: 1px solid rgba(249, 115, 22, 0.3); +} + +/* SUB - Subagent (teal) */ +.status-sub { + background-color: rgba(20, 184, 166, 0.15); + color: #14b8a6; + border: 1px solid rgba(20, 184, 166, 0.3); +} + +/* INF - Info (gray) */ +.status-inf { + background-color: rgba(148, 163, 184, 0.15); + color: #94a3b8; + border: 1px solid rgba(148, 163, 184, 0.3); +} + +/* UTL - Utility (gray-blue) */ +.status-utl { + background-color: rgba(100, 116, 139, 0.15); + color: #64748b; + border: 1px solid rgba(100, 116, 139, 0.3); +} + +/* Animated spinner for active status (CSS-only, no Material Icons) */ +.status-badge.status-active::after { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + border: 1.5px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-left: 4px; + flex-shrink: 0; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Don't show spinner pseudo-element on END/ERR (they have their own indicators) */ +.status-badge.status-end::after, +.status-badge.status-err::after { + display: none; +} + +/* END status with checkmark icon (like sketch) - use ::before only */ +.status-end::before { + content: "✓"; + margin-right: 2px; + font-weight: bold; +} + +/* Completed process group styling */ +.process-group-completed { + opacity: 0.95; +} + +.process-group-completed .group-status { + font-weight: 700; +} + +/* Subtle completion indicator on the expand icon */ +.process-group-completed .expand-icon { + color: #22c55e; +} + +/* Group status badge in header */ +.process-group-header .group-status { + margin-left: var(--spacing-xs); +} + +/* Metrics row */ +.process-group-header .group-metrics { + display: flex; + align-items: center; + gap: var(--spacing-sm); + margin-left: auto; + font-size: 0.65rem; + font-family: var(--font-family-code); 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-metrics span[class^="metric-"] { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.process-group-header .group-metrics .metric-value { + opacity: 0.8; +} + +/* Legacy timestamp/duration (for backwards compatibility) */ .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; + display: none; /* Hidden, replaced by metrics */ } .process-group-header .group-duration { @@ -122,6 +299,7 @@ font-family: var(--font-family-code); min-width: 40px; text-align: right; + display: none; /* Hidden, replaced by metrics */ } /* Process Group Content - Animated expand/collapse */ @@ -147,8 +325,8 @@ .process-group.expanded .process-group-content { grid-template-rows: 1fr; opacity: 1; - margin-top: var(--spacing-sm); - padding-top: var(--spacing-sm); + margin-top: var(--spacing-xs); + padding-top: var(--spacing-xs); border-top-color: rgba(255, 255, 255, 0.06); } @@ -164,15 +342,11 @@ .process-step { display: flex; flex-direction: column; - padding: var(--spacing-xxs) var(--spacing-xs); + padding: 1px 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"], @@ -199,60 +373,45 @@ align-items: center; cursor: pointer; user-select: none; - gap: var(--spacing-xs); - min-height: 20px; + gap: 4px; + min-height: 18px; } +/* Step icon removed - using status badges instead */ .process-step-header .step-icon { - font-size: 0.85rem; - opacity: 0.6; - width: 16px; - text-align: center; + display: none; } -/* 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; } - +/* Step type label removed - using status badges instead */ .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; + display: none; } +/* Step title */ .process-step-header .step-title { flex: 1; font-size: 0.75rem; color: var(--color-text); - opacity: 0.7; + opacity: 0.85; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + margin-left: var(--spacing-xs); } -.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; -} - +/* Step expand icon - CSS triangle (no Material Icons) */ .process-step-header .step-expand-icon { - font-size: 0.8rem; + display: inline-block; + width: 0; + height: 0; + border-style: solid; + border-width: 3px 0 3px 5px; + border-color: transparent transparent transparent var(--color-text); opacity: 0.4; transition: transform 0.2s ease, opacity 0.15s ease; + flex-shrink: 0; + font-size: 0; /* Hide any text content */ + margin-right: 2px; } .process-step-header:hover .step-expand-icon { @@ -260,7 +419,7 @@ } .process-step.step-expanded .step-expand-icon { - transform: rotate(180deg); + transform: rotate(90deg); } /* Step Detail Content - Animated expand/collapse */ @@ -283,16 +442,14 @@ } .process-step-detail-content { - padding: var(--spacing-xs) var(--spacing-sm); + padding: var(--spacing-xs) 0; margin-top: var(--spacing-xxs); margin-left: 20px; /* Align with icon */ - background-color: rgba(0, 0, 0, 0.15); - border-radius: 4px; + background-color: transparent; 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 { @@ -332,10 +489,126 @@ font-size: 0.7rem; } +/* Thoughts styling - single icon with plain text */ +.process-step-detail-content .step-thoughts { + display: flex; + align-items: flex-start; + gap: 6px; + margin: var(--spacing-xs) 0; +} + +.process-step-detail-content .thought-icon { + font-size: 0.85rem; + color: #fbbf24; + flex-shrink: 0; + margin-top: 2px; +} + +.process-step-detail-content .thought-text { + font-size: 0.72rem; + color: var(--color-text); + opacity: 0.8; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +/* Light mode thoughts */ +.light-mode .process-step-detail-content .thought-icon { + color: #b45309; +} + +/* Tool arguments structured display - clean label:value format without boxes */ +.process-step-detail-content .step-tool-args { + display: flex; + flex-direction: column; + gap: 4px; + margin: var(--spacing-xs) 0; + padding: 0; + background: transparent; + border-radius: 0; + border-left: none; +} + +.process-step-detail-content .tool-arg-row { + display: flex; + gap: 8px; + align-items: baseline; +} + +.process-step-detail-content .tool-arg-label { + font-size: 0.68rem; + font-weight: 600; + color: var(--color-primary); + min-width: 70px; + flex-shrink: 0; +} + +.process-step-detail-content .tool-arg-value { + font-size: 0.7rem; + color: var(--color-text); + opacity: 0.85; + word-break: break-word; + font-family: var(--font-family-code); +} + +/* Light mode tool args - transparent like dark mode */ +.light-mode .process-step-detail-content .step-tool-args { + background: transparent; + border-left-color: transparent; +} + +/* Terminal-style output for code_exe */ +.process-step-detail-content .step-terminal { + margin: var(--spacing-xs) 0; + font-family: var(--font-family-code); + font-size: 0.72rem; +} + +.process-step-detail-content .terminal-cmd { + display: flex; + align-items: baseline; + gap: 4px; + color: var(--color-text); + opacity: 0.9; +} + +.process-step-detail-content .terminal-prompt { + color: #7ee787; + font-weight: 600; +} + +.process-step-detail-content .terminal-code { + color: var(--color-text); + opacity: 0.85; +} + +.process-step-detail-content .terminal-output { + margin: var(--spacing-xs) 0 0 0; + padding: var(--spacing-xs); + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + color: #c9d1d9; + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow-y: auto; +} + +/* Light mode terminal */ +.light-mode .process-step-detail-content .terminal-prompt { + color: #1a7f37; +} + +.light-mode .process-step-detail-content .terminal-output { + background: rgba(0, 0, 0, 0.05); + color: #24292f; +} + /* Light mode adjustments */ .light-mode .process-group { background: rgba(0, 0, 0, 0.03); - border-color: rgba(0, 0, 0, 0.08); + border: none; } .light-mode .process-group.embedded { @@ -361,8 +634,7 @@ } .light-mode .process-step-detail-content { - background-color: rgba(0, 0, 0, 0.03); - border-left-color: rgba(0, 0, 0, 0.08); + background-color: transparent; } .light-mode .process-group-header .step-count { @@ -371,22 +643,73 @@ /* 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-step-header .step-title { + color: var(--color-text); } -.light-mode .process-group-header .group-timestamp { - color: rgba(0, 0, 0, 0.5); +.light-mode .process-group-header .group-metrics { + color: var(--color-text); } -.light-mode .process-group-header .group-duration { - color: #2e7d32; +/* Light mode status badge adjustments for better contrast */ +.light-mode .status-gen { + background-color: rgba(14, 165, 233, 0.12); + color: #0284c7; + border-color: rgba(14, 165, 233, 0.25); } -.light-mode .process-step-header .step-time { - color: #388e3c; +.light-mode .status-exe { + background-color: rgba(147, 51, 234, 0.12); + color: #7c3aed; + border-color: rgba(147, 51, 234, 0.25); +} + +.light-mode .status-mcp { + background-color: rgba(217, 119, 6, 0.12); + color: #b45309; + border-color: rgba(217, 119, 6, 0.25); +} + +.light-mode .status-mem { + background-color: rgba(37, 99, 235, 0.12); + color: #1d4ed8; + border-color: rgba(37, 99, 235, 0.25); +} + +.light-mode .status-end { + background-color: rgba(22, 163, 74, 0.12); + color: #15803d; + border-color: rgba(22, 163, 74, 0.25); +} + +.light-mode .status-err { + background-color: rgba(220, 38, 38, 0.12); + color: #b91c1c; + border-color: rgba(220, 38, 38, 0.25); +} + +.light-mode .status-wrn { + background-color: rgba(234, 88, 12, 0.12); + color: #c2410c; + border-color: rgba(234, 88, 12, 0.25); +} + +.light-mode .status-sub { + background-color: rgba(13, 148, 136, 0.12); + color: #0f766e; + border-color: rgba(13, 148, 136, 0.25); +} + +.light-mode .status-inf { + background-color: rgba(100, 116, 139, 0.12); + color: #475569; + border-color: rgba(100, 116, 139, 0.25); +} + +.light-mode .status-utl { + background-color: rgba(71, 85, 105, 0.12); + color: #334155; + border-color: rgba(71, 85, 105, 0.25); } /* Animation for loading state */ diff --git a/webui/css/messages.css b/webui/css/messages.css index 2ee0dd3e6..76cfd2ad9 100644 --- a/webui/css/messages.css +++ b/webui/css/messages.css @@ -79,9 +79,12 @@ .message-user { background-color: #4a4a4a; + border-radius: var(--border-radius) !important; /* border-bottom-right-radius: var(--spacing-xxs); */ /* min-width: 195px; */ text-align: end; + box-shadow: inset 0 2rem 2rem -2rem rgba(0, 0, 0, 0.3), + inset 0 -2rem 2rem -2rem rgba(0, 0, 0, 0.1); } .message-user > div { @@ -116,7 +119,7 @@ } .message-followup .message { - border-radius: 1.125em; /* 18px */ + border-radius: 0; /* border-top-left-radius: var(--spacing-xxs); */ } @@ -137,31 +140,108 @@ .message-warning, .message-error { color: #e0e0e0; + background-color: transparent; + border-radius: 0; + box-shadow: none; } .message-default { - background-color: #1a242f; + background-color: transparent; } .message-agent { - background-color: #34506b; + background-color: transparent; } .message-agent-response { min-width: 255px; - background-color: #1f3c1e; + background-color: transparent; } .message-agent-delegation { - background-color: #12685e; + background-color: transparent; } .message-tool { - background-color: #2a4170; + background-color: transparent; } .message-code-exe { - background-color: #4b3a69; + background-color: transparent; +} + +/* Terminal-style code execution block */ +.message-code-exe .message-body { + background: #0d1117; + border: 1px solid rgba(48, 54, 61, 0.8); + border-radius: 6px; + padding: 0; + margin-top: 0.5em; + overflow: hidden; +} + +.message-code-exe .msg-heading { + background: linear-gradient(180deg, #21262d 0%, #161b22 100%); + padding: 6px 12px; + border-bottom: 1px solid rgba(48, 54, 61, 0.8); + display: flex; + align-items: center; + gap: 8px; +} + +.message-code-exe .msg-heading h4 { + font-family: var(--font-family-code); + font-size: 0.75rem; + color: #8b949e; + margin: 0; + display: flex; + align-items: center; + gap: 6px; +} + +.message-code-exe .msg-heading h4::before { + content: "$"; + color: #7ee787; + font-weight: 600; +} + +.message-code-exe .msg-content { + padding: 12px; + font-family: var(--font-family-code); + font-size: 0.72rem; + color: #c9d1d9; + line-height: 1.5; + max-height: 300px; + overflow-y: auto; +} + +.message-code-exe .msg-content pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; +} + +/* Light mode terminal */ +.light-mode .message-code-exe .message-body { + background: #f6f8fa; + border-color: #d0d7de; +} + +.light-mode .message-code-exe .msg-heading { + background: linear-gradient(180deg, #f6f8fa 0%, #eaeef2 100%); + border-bottom-color: #d0d7de; +} + +.light-mode .message-code-exe .msg-heading h4 { + color: #57606a; +} + +.light-mode .message-code-exe .msg-heading h4::before { + color: #1a7f37; +} + +.light-mode .message-code-exe .msg-content { + color: #24292f; } .message-body .message-markdown-table-wrap { @@ -181,40 +261,33 @@ white-space: break-spaces; } -.light-mode .message-code-exe .message-body { - border: 1px solid var(--color-border); -} +/* Light mode code-exe styling moved to main terminal block above */ .message-browser { - background-color: #4b3a69; + background-color: transparent; } .message-info { - background-color: var(--color-panel); + background-color: transparent; } .message-util { - background-color: #23211a; + background-color: transparent; display: none; } .message-warning { - background-color: #bc8036; + background-color: transparent; } .message-error { - background-color: #af2222; + background-color: rgba(180, 40, 40, 0.25); + border: 1px solid rgba(220, 60, 60, 0.5); + border-radius: 8px; + padding: 12px; } -.message-code-exe .message-body { - min-height: 5em; - width: 100%; - background-color: var(--color-panel); - border-radius: 0.5em; - margin-top: 0.5em; - padding: 0.3em; - font-family: var(--font-family-code); -} +/* Terminal styling moved to new terminal block above */ /* Agent and AI Info */ .agent-start { @@ -254,7 +327,7 @@ } .msg-kvps tr { - border-bottom: 1px solid rgba(255, 255, 255, 0.15); + border-bottom: none; } .msg-heading { @@ -415,67 +488,71 @@ } .light-mode .msg-kvps tr { - border-bottom: 1px solid rgb(192 192 192 / 50%); + border-bottom: none; } .light-mode .message-default { - background-color: var(--color-panel); + background-color: transparent; color: #1a242f; } .light-mode .message-agent { - background-color: var(--color-panel); + background-color: transparent; color: #356ca3; } .light-mode .message-agent-response { - background-color: var(--color-panel); + background-color: transparent; color: #188216; } .light-mode .message-agent-delegation { - background-color: var(--color-panel); + background-color: transparent; color: #12685e; } .light-mode .message-tool { - background-color: var(--color-panel); + background-color: transparent; color: #1c3c88; } .light-mode .message-code-exe { - background-color: var(--color-panel); + background-color: transparent; color: #6c43b0; } .light-mode .message-browser { - background-color: var(--color-panel); + background-color: transparent; color: #6c43b0; } .light-mode .message-info { - background-color: var(--color-panel); + background-color: transparent; color: #3f3f3f; } .light-mode .message-util { - background-color: var(--color-panel); + background-color: transparent; color: #5b5540; } .light-mode .message-warning { - background-color: var(--color-panel); + background-color: transparent; color: #8f4800; } .light-mode .message-error { - background-color: var(--color-panel); + background-color: rgba(220, 60, 60, 0.15); + border: 1px solid rgba(180, 40, 40, 0.4); color: #8f1010; } .light-mode .message-user { background-color: var(--color-panel); color: #4e4e4e; + border-radius: var(--border-radius); + box-shadow: inset 0 2rem 2rem -2rem rgba(0, 0, 0, 0.15), + inset 0 -2rem 2rem -2rem rgba(0, 0, 0, 0.05); } /* Markdown in messages */ @@ -604,18 +681,18 @@ } /* 1. FIRST child’s .message – clear ONLY bottom corners */ -.message-group > *:first-child:not(:last-child) > .message { +.message-group > *:first-child:not(:last-child) > .message-user { border-bottom-left-radius: var(--spacing-xxs); border-bottom-right-radius: var(--spacing-xxs); } /* 2. MIDDLE children’s .message – clear ALL corners */ -.message-group > *:not(:first-child):not(:last-child) > .message { +.message-group > *:not(:first-child):not(:last-child) > .message-user { border-radius: var(--spacing-xxs); } /* 3. LAST child’s .message – clear ONLY top corners */ -.message-group > *:last-child:not(:first-child) > .message { +.message-group > *:last-child:not(:first-child) > .message-user { border-top-left-radius: var(--spacing-xxs); border-top-right-radius: var(--spacing-xxs); } @@ -630,7 +707,7 @@ .message { /* background-color: var(--color-message-bg); */ - border-radius: var(--border-radius); + border-radius: 0; padding: 0.9rem var(--spacing-sm) 0.7rem var(--spacing-sm); overflow-x: auto; width: auto; @@ -639,10 +716,10 @@ /* display: block; */ word-break: break-word; overflow-wrap: anywhere; + box-shadow: none; } /* shades */ .dark-mode .message { - box-shadow: inset 0 2rem 2rem -2rem rgba(0, 0, 0, 0.3), - inset 0 -2rem 2rem -2rem rgba(0, 0, 0, 0.1); + box-shadow: none; } diff --git a/webui/index.css b/webui/index.css index 341954dce..3b58bac09 100644 --- a/webui/index.css +++ b/webui/index.css @@ -196,14 +196,19 @@ div#right-panel::-webkit-scrollbar-thumb:hover { #time-date { color: var(--color-text); - font-size: var(--font-size-normal); + font-size: 1.5rem; + font-weight: 500; text-align: right; - line-height: 1.1; + line-height: 1.2; + font-family: var(--font-family-code); } #user-date { - font-size: var(--font-size-small); - opacity: 0.6; + font-size: 0.7rem; + opacity: 0.5; + font-weight: 400; + display: block; + margin-top: 2px; } /* Typography */ diff --git a/webui/index.js b/webui/index.js index 614cd9055..3cb695496 100644 --- a/webui/index.js +++ b/webui/index.js @@ -185,8 +185,8 @@ async function updateUserTime() { updateUserTime(); setInterval(updateUserTime, 1000); -function setMessage(id, type, heading, content, temp, kvps = null, timestamp = null) { - const result = msgs.setMessage(id, type, heading, content, temp, kvps, timestamp); +function setMessage(id, type, heading, content, temp, kvps = null, timestamp = null, durationMs = null, tokensIn = 0, tokensOut = 0) { + const result = msgs.setMessage(id, type, heading, content, temp, kvps, timestamp, durationMs, tokensIn, tokensOut); const chatHistoryEl = document.getElementById("chat-history"); if (preferencesStore.autoScroll && chatHistoryEl) { chatHistoryEl.scrollTop = chatHistoryEl.scrollHeight; @@ -311,7 +311,10 @@ export async function poll() { log.content, log.temp, log.kvps, - log.timestamp + log.timestamp, + log.duration_ms, + log.tokens_in, + log.tokens_out ); } afterMessagesUpdate(response.logs); diff --git a/webui/js/messages.js b/webui/js/messages.js index 4acea3a2f..7bb19eb88 100644 --- a/webui/js/messages.js +++ b/webui/js/messages.js @@ -13,9 +13,9 @@ 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']; +const PROCESS_TYPES = ['agent', 'tool', 'code_exe', 'browser', 'info', 'hint', 'util', 'warning']; // Main types that should always be visible (not collapsed) -const MAIN_TYPES = ['user', 'response', 'warning', 'error', 'rate_limit']; +const MAIN_TYPES = ['user', 'response', 'error', 'rate_limit']; /** * Check if a response is from the main agent (A0) @@ -29,7 +29,7 @@ function isMainAgentResponse(heading) { return match[1] === "0"; // Only A0 is the main agent } -export function setMessage(id, type, heading, content, temp, kvps = null, timestamp = null) { +export function setMessage(id, type, heading, content, temp, kvps = null, timestamp = null, durationMs = null, tokensIn = 0, tokensOut = 0) { // Check if this is a process type message const isProcessType = PROCESS_TYPES.includes(type); const isMainType = MAIN_TYPES.includes(type); @@ -48,7 +48,7 @@ export function setMessage(id, type, heading, content, temp, kvps = null, timest if (isProcessType) { if (processStepElement) { // Update existing process step - updateProcessStep(processStepElement, id, type, heading, content, kvps); + updateProcessStep(processStepElement, id, type, heading, content, kvps, durationMs, tokensIn, tokensOut); return processStepElement; } @@ -59,14 +59,14 @@ export function setMessage(id, type, heading, content, temp, kvps = null, timest } // Add step to current process group - processStepElement = addProcessStep(currentProcessGroup, id, type, heading, content, kvps, timestamp); + processStepElement = addProcessStep(currentProcessGroup, id, type, heading, content, kvps, timestamp, durationMs, tokensIn, tokensOut); 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); + updateProcessStep(processStepElement, id, "agent", heading, content, kvps, durationMs, tokensIn, tokensOut); return processStepElement; } @@ -77,15 +77,18 @@ export function setMessage(id, type, heading, content, temp, kvps = null, timest } // Add subordinate response as a step (type "agent" for appropriate styling) - processStepElement = addProcessStep(currentProcessGroup, id, "agent", heading, content, kvps, timestamp); + processStepElement = addProcessStep(currentProcessGroup, id, "agent", heading, content, kvps, timestamp, durationMs, tokensIn, tokensOut); return processStepElement; } - // For main agent (A0) response, embed the current process group + // For main agent (A0) response, embed the current process group and mark as complete if (type === "response" && currentProcessGroup) { const processGroupToEmbed = currentProcessGroup; // Keep currentProcessGroup reference - subsequent process messages go to same group + // Mark process group as complete (END state) + markProcessGroupComplete(processGroupToEmbed, heading); + if (!messageContainer) { // Create new container with embedded process group messageContainer = createResponseContainerWithProcessGroup(id, processGroupToEmbed); @@ -494,14 +497,11 @@ export function drawMessageUser( messageDiv.className = "message message-user"; } - // Handle heading + // Remove heading element if it exists (user messages no longer show label per target design) let headingElement = messageDiv.querySelector(".msg-heading"); - if (!headingElement) { - headingElement = document.createElement("h4"); - headingElement.classList.add("msg-heading"); - messageDiv.insertBefore(headingElement, messageDiv.firstChild); + if (headingElement) { + headingElement.remove(); } - headingElement.innerHTML = `${heading} person`; // Handle content let textDiv = messageDiv.querySelector(".message-text"); @@ -1161,11 +1161,15 @@ function createProcessGroup(id) { const header = document.createElement("div"); header.classList.add("process-group-header"); header.innerHTML = ` - chevron_right - neurology + + publicGEN Processing... - 0 steps - + + --:-- + 0 + 0s + -- + `; // Add click handler for expansion @@ -1194,7 +1198,7 @@ function createProcessGroup(id) { /** * Add a step to a process group */ -function addProcessStep(group, id, type, heading, content, kvps, timestamp = null) { +function addProcessStep(group, id, type, heading, content, kvps, timestamp = null, durationMs = null, tokensIn = 0, tokensOut = 0) { const groupId = group.getAttribute("data-group-id"); const stepsContainer = group.querySelector(".process-steps"); @@ -1204,6 +1208,8 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul step.classList.add("process-step"); step.setAttribute("data-type", type); step.setAttribute("data-step-id", id); + step.setAttribute("data-tokens-in", tokensIn || 0); + step.setAttribute("data-tokens-out", tokensOut || 0); // Store timestamp for duration calculation if (timestamp) { @@ -1244,22 +1250,17 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul 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); - } - } + // Get 3-letter status code and color class + const statusCode = processGroupStore.getStepCode(type); + const statusColorClass = processGroupStore.getStatusColorClass(type); + + // Get icon for step type + const stepIcon = processGroupStore.getStepBadgeIcon(type); stepHeader.innerHTML = ` - ${icon} - ${label} + + ${stepIcon}${statusCode} ${escapeHTML(title)} - ${relativeTimeStr ? `${relativeTimeStr}` : ""} - expand_more `; // Add click handler for step expansion @@ -1279,11 +1280,15 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul detailContent.classList.add("process-step-detail-content"); // Add content to detail - renderStepDetailContent(detailContent, content, kvps); + renderStepDetailContent(detailContent, content, kvps, type); detail.appendChild(detailContent); step.appendChild(detail); + // Remove status-active from all previous steps (only the current step is active) + const prevSteps = stepsContainer.querySelectorAll(".process-step .status-badge.status-active"); + prevSteps.forEach(badge => badge.classList.remove("status-active")); + stepsContainer.appendChild(step); // Update group header @@ -1295,7 +1300,7 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul /** * Update an existing process step */ -function updateProcessStep(stepElement, id, type, heading, content, kvps) { +function updateProcessStep(stepElement, id, type, heading, content, kvps, durationMs = null, tokensIn = 0, tokensOut = 0) { // Update title const titleEl = stepElement.querySelector(".step-title"); if (titleEl) { @@ -1303,10 +1308,18 @@ function updateProcessStep(stepElement, id, type, heading, content, kvps) { titleEl.textContent = title; } + // Update token data (use the latest values) + if (tokensIn > 0) { + stepElement.setAttribute("data-tokens-in", tokensIn); + } + if (tokensOut > 0) { + stepElement.setAttribute("data-tokens-out", tokensOut); + } + // Update detail content const detailContent = stepElement.querySelector(".process-step-detail-content"); if (detailContent) { - renderStepDetailContent(detailContent, content, kvps); + renderStepDetailContent(detailContent, content, kvps, type); } // Update parent group header @@ -1369,9 +1382,48 @@ function cleanStepTitle(text, maxLength) { /** * Render content for step detail panel */ -function renderStepDetailContent(container, content, kvps) { +function renderStepDetailContent(container, content, kvps, type = null) { container.innerHTML = ""; + // Special handling for code_exe type - render as terminal-style output + if (type === "code_exe" && kvps) { + const runtime = kvps.runtime || kvps.Runtime || "bash"; + const code = kvps.code || kvps.Code || ""; + const output = content || ""; + + if (code || output) { + const terminalDiv = document.createElement("div"); + terminalDiv.classList.add("step-terminal"); + + // Show command line + if (code) { + const cmdLine = document.createElement("div"); + cmdLine.classList.add("terminal-cmd"); + cmdLine.innerHTML = `${escapeHTML(runtime)}> ${escapeHTML(code)}`; + terminalDiv.appendChild(cmdLine); + } + + // Show output if present + if (output && output.trim()) { + const outputPre = document.createElement("pre"); + outputPre.classList.add("terminal-output"); + outputPre.textContent = truncateText(output, 1000); + terminalDiv.appendChild(outputPre); + } + + container.appendChild(terminalDiv); + } + + // Still render thoughts if present + if (kvps.thoughts || kvps.thinking || kvps.reasoning) { + const thoughtKey = kvps.thoughts ? "thoughts" : (kvps.thinking ? "thinking" : "reasoning"); + const thoughtValue = kvps[thoughtKey]; + renderThoughts(container, thoughtValue); + } + + return; + } + // Add KVPs if present if (kvps && Object.keys(kvps).length > 0) { const kvpsDiv = document.createElement("div"); @@ -1381,20 +1433,79 @@ function renderStepDetailContent(container, content, kvps) { // Skip internal/display keys if (key === "finished" || key === "attachments") continue; - const kvpDiv = document.createElement("div"); - kvpDiv.classList.add("step-kvp"); + // Skip code_exe specific keys that we handle specially above + if (type === "code_exe" && (key.toLowerCase() === "runtime" || key.toLowerCase() === "session" || key.toLowerCase() === "code")) { + continue; + } - // 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"); + // Special handling for thoughts/reasoning - render with single lightbulb icon + if (lowerKey === "thoughts" || lowerKey === "thinking" || lowerKey === "reflection" || lowerKey === "reasoning") { + const thoughtsDiv = document.createElement("div"); + thoughtsDiv.classList.add("step-thoughts", "msg-thoughts"); + // Apply current preference state - hide if showThoughts is false if (!preferencesStore.showThoughts) { - kvpDiv.classList.add("hide-thoughts"); + thoughtsDiv.classList.add("hide-thoughts"); } + + let thoughtText = value; + if (typeof value === "object") { + // Handle array of thoughts + if (Array.isArray(value)) { + thoughtText = value.filter(t => t && String(t).trim() && String(t).trim() !== "[" && String(t).trim() !== "]").join("\n"); + } else { + thoughtText = JSON.stringify(value, null, 2); + } + } + + // Clean up the text - remove standalone brackets + thoughtText = String(thoughtText).replace(/^\s*[\[\]]\s*$/gm, "").trim(); + + if (thoughtText) { + // Single icon + text block + thoughtsDiv.innerHTML = `lightbulb${escapeHTML(thoughtText)}`; + } + + kvpsDiv.appendChild(thoughtsDiv); + continue; } + // Special handling for tool_args - render as structured labeled parameters + if (lowerKey === "tool_args" && typeof value === "object" && value !== null) { + const argsDiv = document.createElement("div"); + argsDiv.classList.add("step-tool-args"); + + for (const [argKey, argValue] of Object.entries(value)) { + const argRow = document.createElement("div"); + argRow.classList.add("tool-arg-row"); + + const argLabel = document.createElement("span"); + argLabel.classList.add("tool-arg-label"); + argLabel.textContent = convertToTitleCase(argKey) + ":"; + + const argVal = document.createElement("span"); + argVal.classList.add("tool-arg-value"); + + let argText = argValue; + if (typeof argValue === "object") { + argText = JSON.stringify(argValue, null, 2); + } + argVal.textContent = truncateText(String(argText), 300); + + argRow.appendChild(argLabel); + argRow.appendChild(argVal); + argsDiv.appendChild(argRow); + } + + kvpsDiv.appendChild(argsDiv); + continue; + } + + const kvpDiv = document.createElement("div"); + kvpDiv.classList.add("step-kvp"); + const keySpan = document.createElement("span"); keySpan.classList.add("step-kvp-key"); keySpan.textContent = convertToTitleCase(key) + ":"; @@ -1431,39 +1542,124 @@ function renderStepDetailContent(container, content, kvps) { } /** - * Update process group header with step count and status + * Helper to render thoughts/reasoning with lightbulb icon + */ +function renderThoughts(container, value) { + const thoughtsDiv = document.createElement("div"); + thoughtsDiv.classList.add("step-thoughts", "msg-thoughts"); + + // Apply current preference state - hide if showThoughts is false + if (!preferencesStore.showThoughts) { + thoughtsDiv.classList.add("hide-thoughts"); + } + + let thoughtText = value; + if (typeof value === "object") { + if (Array.isArray(value)) { + thoughtText = value.filter(t => t && String(t).trim() && String(t).trim() !== "[" && String(t).trim() !== "]").join("\n"); + } else { + thoughtText = JSON.stringify(value, null, 2); + } + } + + thoughtText = String(thoughtText).replace(/^\s*[\[\]]\s*$/gm, "").trim(); + + if (thoughtText) { + thoughtsDiv.innerHTML = `lightbulb${escapeHTML(thoughtText)}`; + container.appendChild(thoughtsDiv); + } +} + +/** + * Update process group header with step count, status, and metrics */ function updateProcessGroupHeader(group) { const steps = group.querySelectorAll(".process-step"); - const countEl = group.querySelector(".step-count"); const titleEl = group.querySelector(".group-title"); + const statusEl = group.querySelector(".group-status"); + const metricsEl = group.querySelector(".group-metrics"); - if (countEl) { - const count = steps.length; - countEl.textContent = `${count} step${count !== 1 ? "s" : ""}`; + // Update step count in metrics + const stepsMetricEl = metricsEl?.querySelector(".metric-steps .metric-value"); + if (stepsMetricEl) { + stepsMetricEl.textContent = steps.length.toString(); } - if (titleEl && steps.length > 0) { - // Get the last step's type for the title + // Update time metric + const timeMetricEl = metricsEl?.querySelector(".metric-time .metric-value"); + const startTimestamp = group.getAttribute("data-start-timestamp"); + if (timeMetricEl && startTimestamp) { + const date = new Date(parseFloat(startTimestamp) * 1000); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + timeMetricEl.textContent = `${hours}:${minutes}`; + } + + // Update duration metric (elapsed time since start) + const durationMetricEl = metricsEl?.querySelector(".metric-duration .metric-value"); + if (durationMetricEl && startTimestamp) { + const startMs = parseFloat(startTimestamp) * 1000; + const elapsedMs = Date.now() - startMs; + if (elapsedMs < 60000) { + durationMetricEl.textContent = `${Math.round(elapsedMs / 1000)}s`; + } else { + const mins = Math.floor(elapsedMs / 60000); + const secs = Math.round((elapsedMs % 60000) / 1000); + durationMetricEl.textContent = `${mins}m${secs}s`; + } + } + + // Update tokens metric (aggregate from all steps) + const tokensMetricEl = metricsEl?.querySelector(".metric-tokens .metric-value"); + if (tokensMetricEl) { + let totalTokensIn = 0; + let totalTokensOut = 0; + steps.forEach(step => { + totalTokensIn += parseInt(step.getAttribute("data-tokens-in") || 0, 10); + totalTokensOut += parseInt(step.getAttribute("data-tokens-out") || 0, 10); + }); + const totalTokens = totalTokensIn + totalTokensOut; + if (totalTokens > 0) { + // Format as compact notation (e.g., 20k/3k for input/output) + tokensMetricEl.textContent = formatTokenCount(totalTokensIn, totalTokensOut); + } else { + tokensMetricEl.textContent = "--"; + } + } + + if (steps.length > 0) { + // Get the last step's type for status 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; + // Update status badge with icon (keep status-active during execution) + if (statusEl) { + const statusCode = processGroupStore.getStepCode(lastType); + const statusColorClass = processGroupStore.getStatusColorClass(lastType); + const badgeIcon = processGroupStore.getStepBadgeIcon(lastType); + statusEl.innerHTML = `${badgeIcon}${statusCode}`; + statusEl.className = `status-badge ${statusColorClass} status-active group-status`; + } + + // Update title + if (titleEl) { + // 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 = cleanStepTitle(lastTitle, 50) || `Processing...`; } - titleEl.textContent = `Processing (${processGroupStore.getStepLabel(lastType)})`; } } } @@ -1478,6 +1674,52 @@ function truncateText(text, maxLength) { return text.substring(0, maxLength - 3) + "..."; } +/** + * Mark a process group as complete (END state) + */ +function markProcessGroupComplete(group, responseTitle) { + if (!group) return; + + // Update status badge to END (remove status-active) + const statusEl = group.querySelector(".group-status"); + if (statusEl) { + statusEl.textContent = "END"; + statusEl.className = "status-badge status-end group-status"; // No status-active + } + + // Remove status-active from all step badges (stop spinners) + const stepBadges = group.querySelectorAll(".process-step .status-badge.status-active"); + stepBadges.forEach(badge => badge.classList.remove("status-active")); + + // Update title if response title is available + const titleEl = group.querySelector(".group-title"); + if (titleEl && responseTitle) { + const cleanTitle = cleanStepTitle(responseTitle, 50); + if (cleanTitle) { + titleEl.textContent = cleanTitle; + } + } + + // Add completed class to group + group.classList.add("process-group-completed"); + + // Update duration to final value + const metricsEl = group.querySelector(".group-metrics"); + const durationMetricEl = metricsEl?.querySelector(".metric-duration .metric-value"); + const startTimestamp = group.getAttribute("data-start-timestamp"); + if (durationMetricEl && startTimestamp) { + const startMs = parseFloat(startTimestamp) * 1000; + const elapsedMs = Date.now() - startMs; + if (elapsedMs < 60000) { + durationMetricEl.textContent = `${Math.round(elapsedMs / 1000)}s`; + } else { + const mins = Math.floor(elapsedMs / 60000); + const secs = Math.round((elapsedMs % 60000) / 1000); + durationMetricEl.textContent = `${mins}m${secs}s`; + } + } +} + /** * Reset process group state (called on context switch) */ @@ -1486,6 +1728,26 @@ export function resetProcessGroups() { messageGroup = null; } +/** + * Format token counts in compact notation (e.g., "12k/3k" for input/output) + */ +function formatTokenCount(tokensIn, tokensOut) { + const formatCompact = (n) => { + if (n >= 1000000) return `${(n / 1000000).toFixed(1)}m`; + if (n >= 1000) return `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k`; + return n.toString(); + }; + + if (tokensIn > 0 && tokensOut > 0) { + return `${formatCompact(tokensIn)}/${formatCompact(tokensOut)}`; + } else if (tokensIn > 0) { + return `${formatCompact(tokensIn)}↓`; + } else if (tokensOut > 0) { + return `${formatCompact(tokensOut)}↑`; + } + return "--"; +} + /** * Format Unix timestamp as date-time string (YYYY-MM-DD HH:MM:SS) */ @@ -1500,18 +1762,3 @@ function formatDateTime(timestamp) { 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`; -}