Merge branch 'pr/907' into development

This commit is contained in:
frdel 2026-01-20 16:41:02 +01:00
commit e81f24ec99
11 changed files with 1537 additions and 263 deletions

View file

@ -101,7 +101,8 @@
/* .kvps-row:hover .action-buttons, */
.message-text:hover .action-buttons,
.kvps-val:hover .action-buttons,
.message-body:hover > .action-buttons {
.message-body:hover > .action-buttons,
.error-content-inner:hover .action-buttons {
display: flex;
animation: fadeInAfterDelay 0.3s ease-in-out;
animation-delay: 0.3s;

View file

@ -175,6 +175,68 @@ const model = {
}
}
this._persist();
},
// Get current detail mode from preferences
_getDetailMode() {
return window.Alpine?.store("preferences")?.detailMode || "current";
},
expandGroup(groupId, isActiveAndGenerating = false) {
const mode = this._getDetailMode();
if (mode === "collapsed") {
// Only expand if generating, not for completed groups
return isActiveAndGenerating;
}
if (mode === "current" || mode === "expanded") return true;
return !this.defaultCollapsed;
},
expandStep(groupId, stepId, isActive = false) {
const mode = this._getDetailMode();
if (mode === "collapsed") return false;
if (mode === "expanded") return true;
if (mode === "current") return isActive;
return this.isStepExpanded(groupId, stepId);
},
// Apply current mode to all existing DOM elements
applyModeSteps() {
const mode = this._getDetailMode();
const showUtils = window.Alpine?.store("preferences")?.showUtils || false;
const allGroups = document.querySelectorAll(".process-group");
// Find the last VISIBLE step using targeted selector
const stepSelector = showUtils ? ".process-step" : ".process-step:not(.message-util)";
const visibleSteps = document.querySelectorAll(stepSelector);
const lastStep = visibleSteps.length > 0 ? visibleSteps[visibleSteps.length - 1] : null;
// Get all steps for applying expansion
const allSteps = document.querySelectorAll(".process-step");
// Apply to groups
allGroups.forEach(group => {
group.classList.toggle("expanded", mode !== "collapsed");
});
// Apply to steps
allSteps.forEach(step => {
let shouldExpand = false;
if (mode === "expanded") {
shouldExpand = true;
} else if (mode === "current") {
// Expand the last step and any parent steps containing it (for nested subordinate steps)
shouldExpand = step === lastStep || step.contains(lastStep);
}
step.classList.toggle("step-expanded", shouldExpand);
});
// Apply to error groups
const allErrorGroups = document.querySelectorAll(".error-group");
allErrorGroups.forEach(errorGroup => {
const shouldExpand = mode === "current" || mode === "expanded";
errorGroup.classList.toggle("expanded", shouldExpand);
});
}
};

View file

@ -210,7 +210,7 @@
/* ERR - error type (red) */
.status-err {
--step-accent: #ef4444;
--step-accent: var(--color-accent);
color: var(--step-accent);
}
@ -226,33 +226,6 @@
color: var(--step-accent);
}
/* 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 (icon added via JS, no ::before needed) */
/* Completed process group styling */
.process-group-completed {
opacity: 0.95;
@ -295,6 +268,18 @@
gap: 2px;
}
.process-group-header .group-metrics .metric-notifications {
font-weight: 600;
}
.process-group-header .group-metrics .metric-notifications[hidden] {
display: none;
}
.process-group-header .group-metrics .metric-notifications .material-symbols-outlined {
opacity: 0.85;
}
/* Legacy timestamp/duration (for backwards compatibility) */
.process-group-header .group-timestamp {
font-size: 0.65rem;
@ -388,16 +373,6 @@
min-height: 18px;
}
/* Step icon removed - using status badges instead */
.process-step-header .step-icon {
display: none;
}
/* Step type label removed - using status badges instead */
.process-step-header .step-type {
display: none;
}
/* Step title */
.process-step-header .step-title {
flex: 1;
@ -767,7 +742,7 @@
}
.light-mode .status-err {
--step-accent: #b91c1c;
--step-accent: var(--color-accent);
color: var(--step-accent);
}
@ -787,9 +762,8 @@
50% { opacity: 0.8; }
}
.process-step.loading .step-icon {
animation: pulse-step 1.2s ease-in-out infinite;
}
.step-title.shiny-text { color: transparent !important; -webkit-background-clip: text; background-clip: text; animation: shine 1s linear infinite; }
@keyframes shine { to { background-position: -100% center; } }
/* Responsive adjustments */
@media (max-width: 768px) {
@ -797,10 +771,6 @@
padding: var(--spacing-xs) var(--spacing-sm);
}
.process-step-header .step-type {
display: none;
}
.process-step-header .step-title {
font-size: 0.7rem;
}
@ -923,3 +893,17 @@
.light-mode .process-step-detail-content .screenshot-img:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* View Details button in step detail */
.step-detail-actions {
display: flex;
padding-left: 18px;
min-height: 0; /* Required for grid collapse animation */
}
/* Hide View Details button when step is collapsed */
.process-step:not(.step-expanded) > .process-step-detail > .step-detail-actions {
display: none;
}

View file

@ -0,0 +1,603 @@
<html>
<head>
<title>Step Details</title>
<script type="module">
import { store } from "/components/modals/process-step-detail/step-detail-store.js";
</script>
</head>
<body>
<div x-data="{ showRaw: false }"
x-effect="if (showRaw) { $nextTick(() => $store.stepDetail.initRawEditor()) } else { $store.stepDetail.destroyRawEditor() }">
<template x-if="$store.stepDetail && $store.stepDetail.selectedStepForDetail">
<div class="step-detail-container">
<!-- Compact Modal Header -->
<div class="modal-header modal-header-compact">
<div class="header-info">
<span class="status-badge"
:class="$store.processGroup.getStatusColorClass($store.stepDetail.selectedStepForDetail?.type, $store.stepDetail.selectedStepForDetail?.toolName || $store.stepDetail.selectedStepForDetail?.kvps?.tool_name)"
x-text="$store.processGroup.getStepCode($store.stepDetail.selectedStepForDetail?.type, $store.stepDetail.selectedStepForDetail?.toolName || $store.stepDetail.selectedStepForDetail?.kvps?.tool_name)"></span>
<span class="step-type-label" x-text="$store.stepDetail.formatStepType($store.stepDetail.selectedStepForDetail?.type)"></span>
<template x-if="$store.stepDetail.selectedStepForDetail?.toolName || $store.stepDetail.selectedStepForDetail?.kvps?.tool_name">
<span class="tool-name-badge" x-text="$store.stepDetail.selectedStepForDetail?.toolName || $store.stepDetail.selectedStepForDetail?.kvps?.tool_name"></span>
</template>
<template x-if="$store.stepDetail.selectedStepForDetail?.timestamp">
<span class="timestamp-text" x-text="$store.stepDetail.formatTimestamp($store.stepDetail.selectedStepForDetail?.timestamp)"></span>
</template>
<template x-if="$store.stepDetail.selectedStepForDetail?.durationMs">
<span class="duration-badge" x-text="$store.stepDetail.formatDuration($store.stepDetail.selectedStepForDetail?.durationMs)"></span>
</template>
</div>
<div class="header-actions">
<button class="btn btn-action-header copy-all"
@click="$store.stepDetail.copyToClipboard($store.stepDetail.formatStepForCopy($store.stepDetail.selectedStepForDetail))"
title="Copy Complete Step with Metadata">
<span class="material-symbols-outlined">copy_all</span> All
</button>
<button class="btn btn-action-header copy-content"
@click="$store.stepDetail.copyToClipboard($store.stepDetail.getStepPrimaryContent($store.stepDetail.selectedStepForDetail))"
title="Copy Primary Content Only">
<span class="material-symbols-outlined">content_copy</span> Content
</button>
<button class="btn btn-action-header toggle-raw"
@click="showRaw = !showRaw"
:class="{ 'active': showRaw }"
title="Toggle Raw JSON View">
<span class="material-symbols-outlined">data_object</span>
</button>
</div>
</div>
<!-- Modal Body -->
<div class="modal-body">
<!-- Raw JSON View with ACE Editor -->
<div class="raw-json-section" x-show="showRaw">
<div id="step-detail-raw-editor"></div>
</div>
<!-- Formatted View -->
<div class="formatted-content" x-show="!showRaw">
<!-- Heading/Title Section -->
<template x-if="$store.stepDetail.selectedStepForDetail?.heading">
<div class="content-block">
<h4>Heading</h4>
<div class="content-text" x-text="$store.stepDetail.cleanHeading($store.stepDetail.selectedStepForDetail?.heading)"></div>
</div>
</template>
<!-- GEN (agent) Type: Thoughts and Reasoning -->
<template x-if="$store.stepDetail.selectedStepForDetail?.type === 'agent'">
<div class="type-specific-content">
<!-- Reasoning (native model thinking) -->
<template x-if="$store.stepDetail.selectedStepForDetail?.kvps?.reasoning">
<div class="content-block reasoning-block">
<h4><span class="material-symbols-outlined">psychology</span> Reasoning</h4>
<div class="content-text reasoning-content" x-text="$store.stepDetail.cleanTextValue($store.stepDetail.selectedStepForDetail?.kvps?.reasoning)"></div>
</div>
</template>
<!-- Thoughts -->
<template x-if="$store.stepDetail.selectedStepForDetail?.kvps?.thoughts">
<div class="content-block thoughts-block">
<h4><span class="material-symbols-outlined">lightbulb</span> Thoughts</h4>
<div class="content-text thoughts-content" x-text="$store.stepDetail.cleanTextValue($store.stepDetail.selectedStepForDetail?.kvps?.thoughts)"></div>
</div>
</template>
<!-- Tool Call Preview -->
<template x-if="$store.stepDetail.selectedStepForDetail?.kvps?.tool_name || $store.stepDetail.selectedStepForDetail?.kvps?.tool_args">
<div class="content-block tool-call-block">
<h4>
<span class="material-symbols-outlined">build</span>
<span>Tool Call</span>
<template x-if="$store.stepDetail.selectedStepForDetail?.toolName || $store.stepDetail.selectedStepForDetail?.kvps?.tool_name">
<span class="tool-name-badge" x-text="$store.stepDetail.selectedStepForDetail?.toolName || $store.stepDetail.selectedStepForDetail?.kvps?.tool_name"></span>
</template>
</h4>
<template x-if="$store.stepDetail.selectedStepForDetail?.kvps?.tool_args">
<div class="tool-args-container">
<template x-for="[key, value] in Object.entries($store.stepDetail.selectedStepForDetail?.kvps?.tool_args || {})" :key="key">
<div class="tool-arg-row">
<span class="arg-key" x-text="key"></span>
<span class="arg-value" x-text="$store.stepDetail.formatValue(value)"></span>
</div>
</template>
</div>
</template>
</div>
</template>
</div>
</template>
<!-- EXE (code_exe) Type: Terminal Output -->
<template x-if="$store.stepDetail.selectedStepForDetail?.type === 'code_exe'">
<div class="type-specific-content">
<!-- Command Info -->
<template x-if="$store.stepDetail.selectedStepForDetail?.kvps?.code">
<div class="content-block command-block">
<h4><span class="material-symbols-outlined">terminal</span> Command</h4>
<div class="command-info">
<template x-if="$store.stepDetail.selectedStepForDetail?.kvps?.runtime">
<span class="runtime-badge" x-text="$store.stepDetail.selectedStepForDetail?.kvps?.runtime"></span>
</template>
<template x-if="$store.stepDetail.selectedStepForDetail?.kvps?.session != null">
<span class="session-badge" x-text="'Session ' + $store.stepDetail.selectedStepForDetail?.kvps?.session"></span>
</template>
</div>
<pre class="command-text" x-text="$store.stepDetail.selectedStepForDetail?.kvps?.code"></pre>
</div>
</template>
<!-- Terminal Output -->
<template x-if="$store.stepDetail.selectedStepForDetail?.content">
<div class="content-block terminal-block">
<h4><span class="material-symbols-outlined">output</span> Output</h4>
<pre class="terminal-output terminal-output-full" x-text="$store.stepDetail.selectedStepForDetail?.content"></pre>
</div>
</template>
</div>
</template>
<!-- Tool/MCP Type: Arguments and Result -->
<template x-if="$store.stepDetail.selectedStepForDetail?.type === 'tool' || $store.stepDetail.selectedStepForDetail?.type === 'mcp'">
<div class="type-specific-content">
<!-- Tool Arguments -->
<template x-if="$store.stepDetail.selectedStepForDetail?.kvps?.tool_args">
<div class="content-block">
<h4><span class="material-symbols-outlined">build</span> Arguments</h4>
<div class="tool-args-grid">
<template x-for="[key, value] in Object.entries($store.stepDetail.selectedStepForDetail?.kvps?.tool_args || {})" :key="key">
<div class="tool-arg-item result-content">
<span class="arg-key" x-text="key"></span>
<span class="arg-value" x-text="$store.stepDetail.formatValue(value)"></span>
</div>
</template>
</div>
</div>
</template>
<!-- Result -->
<template x-if="$store.stepDetail.selectedStepForDetail?.kvps?.result || $store.stepDetail.selectedStepForDetail?.content">
<div class="content-block">
<h4><span class="material-symbols-outlined">output</span> Result</h4>
<pre class="result-content" x-text="$store.stepDetail.selectedStepForDetail?.kvps?.result || $store.stepDetail.selectedStepForDetail?.content"></pre>
</div>
</template>
</div>
</template>
<!-- Browser Type: Screenshot -->
<template x-if="$store.stepDetail.selectedStepForDetail?.type === 'browser'">
<div class="type-specific-content">
<template x-if="$store.stepDetail.selectedStepForDetail?.kvps?.screenshot">
<div class="content-block">
<h4><span class="material-symbols-outlined">screenshot_monitor</span> Screenshot</h4>
<img class="screenshot-full"
:src="$store.stepDetail.selectedStepForDetail?.kvps?.screenshot?.replace('img://', '/image_get?path=')"
alt="Browser Screenshot" />
</div>
</template>
</div>
</template>
<!-- Generic KVPs for other types -->
<template x-if="!['agent', 'code_exe', 'tool', 'mcp', 'browser'].includes($store.stepDetail.selectedStepForDetail?.type)">
<div class="type-specific-content">
<template x-if="$store.stepDetail.selectedStepForDetail?.kvps">
<div class="content-block">
<h4>Details</h4>
<div class="kvps-display">
<template x-for="[key, value] in Object.entries($store.stepDetail.selectedStepForDetail?.kvps || {}).filter(([k]) => k !== 'reasoning')" :key="key">
<div class="kvp-item">
<span class="kvp-key" x-text="$store.stepDetail.formatKey(key)"></span>
<span class="kvp-value" x-text="$store.stepDetail.formatValue(value)"></span>
</div>
</template>
</div>
</div>
</template>
<!-- Content -->
<template x-if="$store.stepDetail.selectedStepForDetail?.content">
<div class="content-block">
<h4>Content</h4>
<pre class="tool-arg-item" x-text="$store.stepDetail.selectedStepForDetail?.content"></pre>
</div>
</template>
</div>
</template>
</div>
</div>
</div>
</template>
</div>
<style>
.step-detail-container {
display: flex;
flex-direction: column;
height: 100%;
max-height: 80vh;
}
.modal-header-compact {
background: linear-gradient(135deg, var(--color-panel) 0%, var(--color-background) 100%);
position: sticky;
padding: 0.75rem 1rem;
border-radius: 6px 6px 0 0;
top: 0;
z-index: 100;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.header-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.status-badge {
padding: 0.25rem 0.5rem;
border-radius: 8px;
font-size: 0.75rem;
font-weight: 600;
font-family: var(--font-mono);
}
.step-type-label {
font-weight: 500;
color: var(--color-text);
}
.tool-name-badge {
background: var(--color-message-bg);
color: var(--color-text);
padding: 0.2rem 0.5rem;
border-radius: 8px;
font-size: 0.75rem;
font-family: var(--font-mono);
border: 1px solid var(--color-border);
}
.timestamp-text,
.duration-badge {
color: var(--color-text);
opacity: 0.7;
padding: 0.2rem 0.5rem;
border-radius: var(--border-radius);
font-size: 0.75rem;
border: 1px solid var(--color-border);
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.btn-action-header {
padding: 0.4rem 0.6rem;
display: flex;
align-items: center;
gap: 0.25rem;
background: var(--color-message-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.15s ease;
}
.btn-action-header .material-symbols-outlined {
font-size: 18px;
}
.btn-action-header:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-action-header.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.raw-json-section {
height: 100%;
display: flex;
flex-direction: column;
}
#step-detail-raw-editor {
width: 100%;
height: 60vh;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--color-border);
}
#step-detail-raw-editor .ace_scrollbar-v {
overflow-y: auto;
}
#step-detail-raw-editor::-webkit-scrollbar {
width: 0;
}
.formatted-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.content-block {
background: var(--color-panel);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 1rem;
}
.content-block h4 {
margin: 0 0 0.75rem 0;
color: var(--color-text);
font-size: 0.9rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
opacity: 0.8;
}
.content-block h4 .material-symbols-outlined {
font-size: 18px;
opacity: 0.7;
}
.content-block:last-child {
margin-top: 1rem;
}
.content-text {
color: var(--color-text);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.thoughts-block {
margin-bottom: 1rem;
}
.thoughts-content {
opacity: 0.8;
}
.reasoning-block {
margin-bottom: 1rem;
}
.reasoning-content {
opacity: 0.75;
font-style: italic;
}
.tool-call-block {
margin-bottom: 1rem;
}
.tool-name-inline {
background: var(--color-primary);
color: white;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-family: var(--font-mono);
font-weight: 500;
margin-left: 0.5rem;
}
.command-block {
margin-bottom: 1rem;
}
.command-info {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.runtime-badge,
.session-badge {
background: var(--color-message-bg);
padding: 0.2rem 0.5rem;
border-radius: 8px;
font-size: 0.75rem;
font-family: var(--font-mono);
border: 1px solid var(--color-border);
}
.command-text {
background: var(--color-background);
padding: 0.75rem;
border-radius: 8px;
font-size: 0.85rem;
margin: 0;
overflow-x: auto;
}
.terminal-output-full {
background: rgba(0, 0, 0, 0.8);
color: var(--color-text);
padding: 1rem;
border-radius: 8px;
font-size: 0.85rem;
margin: 0;
max-height: 40vh;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.light-mode .terminal-output-full {
background: rgba(0, 0, 0, 0.05);
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
.tool-args-grid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tool-arg-item {
display: flex;
gap: 0.75rem;
padding: 0.5rem;
background: var(--color-background);
border-radius: 8px;
border: 1px solid var(--color-border);
}
/* GEN step tool call - single container with KV rows */
.tool-args-container {
background: var(--color-background);
border-radius: 8px;
border: 1px solid var(--color-border);
padding: 0.5rem;
display: grid;
grid-template-columns: auto 1fr;
gap: 0;
}
.tool-arg-row {
display: contents;
}
.tool-arg-row .arg-key,
.tool-arg-row .arg-value {
padding: 0.5rem;
}
.arg-key {
font-weight: 600;
color: var(--color-primary);
font-size: 0.85rem;
flex-shrink: 0;
}
.arg-value {
color: var(--color-text);
font-family: var(--font-mono);
font-size: 0.85rem;
white-space: pre-wrap;
word-break: break-word;
flex: 1;
}
.result-content {
background: var(--color-background);
padding: 1rem;
border-radius: 8px;
font-family: var(--font-mono);
font-size: 0.85rem;
margin: 0;
max-height: 40vh;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.screenshot-full {
max-width: 100%;
border-radius: 8px;
border: 1px solid var(--color-border);
cursor: pointer;
}
.kvps-display {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.kvp-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.5rem;
background: var(--color-background);
border-radius: 8px;
border: 1px solid var(--color-border);
}
.kvp-key {
font-weight: 600;
color: var(--color-text);
opacity: 0.7;
font-size: 0.8rem;
text-transform: uppercase;
}
.kvp-value {
color: var(--color-text);
font-size: 0.9rem;
white-space: pre-wrap;
word-break: break-word;
}
/* Scrollbar styling */
.modal-body::-webkit-scrollbar,
.result-content::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.modal-body::-webkit-scrollbar-track,
.result-content::-webkit-scrollbar-track {
background: transparent;
}
.modal-body::-webkit-scrollbar-thumb,
.result-content::-webkit-scrollbar-thumb {
background-color: rgba(155, 155, 155, 0.5);
border-radius: 6px;
}
.modal-body::-webkit-scrollbar-thumb:hover,
.result-content::-webkit-scrollbar-thumb:hover {
background-color: rgba(155, 155, 155, 0.7);
}
/* Responsive */
@media (max-width: 600px) {
.modal-header-compact {
flex-direction: column;
align-items: flex-start;
}
.header-actions {
width: 100%;
justify-content: flex-end;
}
.tool-arg-item {
flex-direction: column;
}
.arg-key {
min-width: unset;
}
}
</style>
</body>
</html>

View file

@ -0,0 +1,197 @@
import { createStore } from "/js/AlpineStore.js";
// Step Detail Store - manages the step detail modal
const model = {
// Selected step for detail modal view
selectedStepForDetail: null,
// ACE editor instance for raw JSON view
_rawEditor: null,
// Show step detail modal
showStepDetail(stepData) {
if (!stepData) return;
this.selectedStepForDetail = stepData;
window.openModal("modals/process-step-detail/process-step-detail.html");
},
// Close step detail modal
closeStepDetail() {
this.selectedStepForDetail = null;
window.closeModal();
},
// Copy text to clipboard
async copyToClipboard(text) {
if (!text) return false;
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error("Failed to copy to clipboard:", err);
return false;
}
},
// Format step data for full copy (all metadata + content)
formatStepForCopy(step) {
if (!step) return "";
const lines = [];
lines.push(`Type: ${step.type || "unknown"}`);
if (step.heading) lines.push(`Heading: ${step.heading}`);
if (step.timestamp) {
const date = new Date(parseFloat(step.timestamp) * 1000);
lines.push(`Timestamp: ${date.toISOString()}`);
}
if (step.durationMs) lines.push(`Duration: ${step.durationMs}ms`);
if (step.kvps) {
lines.push("");
lines.push("--- Data ---");
for (const [key, value] of Object.entries(step.kvps)) {
if (key === "reasoning") continue;
const formattedValue = typeof value === "object" ? JSON.stringify(value, null, 2) : String(value);
lines.push(`${key}: ${formattedValue}`);
}
}
if (step.content) {
lines.push("");
lines.push("--- Content ---");
lines.push(step.content);
}
return lines.join("\n");
},
// Get primary content for a step (type-aware)
getStepPrimaryContent(step) {
if (!step) return "";
if (step.type === "code_exe") {
return step.content || "";
}
if (step.type === "agent" && step.kvps) {
return step.kvps.thoughts || step.kvps.headline || step.content || "";
}
if ((step.type === "tool" || step.type === "mcp") && step.kvps) {
return step.kvps.result || step.content || "";
}
return step.content || "";
},
// Initialize ACE editor for raw JSON view
initRawEditor() {
const container = document.getElementById("step-detail-raw-editor");
if (!container) return;
this.destroyRawEditor();
if (!window.ace?.edit) {
console.warn("ACE editor not available");
return;
}
const stepData = this.selectedStepForDetail;
if (!stepData) return;
const editorInstance = window.ace.edit("step-detail-raw-editor");
if (!editorInstance) return;
this._rawEditor = editorInstance;
const darkMode = window.localStorage?.getItem("darkMode");
const theme = darkMode !== "false" ? "ace/theme/github_dark" : "ace/theme/tomorrow";
this._rawEditor.setTheme(theme);
this._rawEditor.session.setMode("ace/mode/json");
this._rawEditor.setValue(JSON.stringify(stepData, null, 2), -1);
this._rawEditor.setReadOnly(true);
this._rawEditor.clearSelection();
this._rawEditor.setOptions({
showPrintMargin: false,
highlightActiveLine: false,
highlightGutterLine: false
});
},
// Destroy ACE editor instance
destroyRawEditor() {
if (this._rawEditor?.destroy) {
this._rawEditor.destroy();
this._rawEditor = null;
}
},
// Format step type for display
formatStepType(type) {
const typeMap = {
'agent': 'Generation',
'code_exe': 'Code Execution',
'tool': 'Tool Call',
'mcp': 'MCP Tool',
'browser': 'Browser',
'response': 'Response',
'info': 'Info',
'hint': 'Hint',
'warning': 'Warning',
'error': 'Error',
'util': 'Utility',
'progress': 'Progress'
};
return typeMap[type] || (type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Unknown');
},
// Format timestamp for display
formatTimestamp(timestamp) {
if (!timestamp) return '';
const date = new Date(parseFloat(timestamp) * 1000);
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 `${hours}:${minutes}:${seconds}`;
},
// Format duration for display
formatDuration(ms) {
if (!ms) return '';
if (ms < 1000) return `${ms}ms`;
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
},
// Format value for display (handles objects)
formatValue(value) {
if (value === null || value === undefined) return '';
if (typeof value === 'object') return JSON.stringify(value, null, 2);
return String(value);
},
// Format key for display (title case)
formatKey(key) {
return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
},
// Clean text value (handle arrays, remove brackets)
cleanTextValue(value) {
if (Array.isArray(value)) {
return value
.filter(item => item && String(item).trim() && !/^[\[\]]$/.test(String(item).trim()))
.join('\n');
}
if (typeof value === 'object' && value !== null) {
return JSON.stringify(value, null, 2);
}
return String(value).replace(/^\s*[\[\]]\s*$/gm, '').trim();
},
// Clean heading by removing icon:// prefixes
cleanHeading(text) {
if (!text) return "";
return String(text)
.replace(/icon:\/\/[a-zA-Z0-9_]+\s*/g, "")
.trim();
}
};
export const store = createStore("stepDetail", model);

View file

@ -27,20 +27,6 @@
<span class="slider"></span>
</label>
</li>
<li x-data>
<span class="switch-label">Speech</span>
<label class="switch">
<input type="checkbox" x-model="$store.preferences.speech">
<span class="slider"></span>
</label>
</li>
<li x-data>
<span>Show utility messages</span>
<label class="switch">
<input type="checkbox" x-model="$store.preferences.showUtils">
<span class="slider"></span>
</label>
</li>
<li x-data class="chat-width-setting">
<span class="width-label">Width</span>
<div class="width-buttons">
@ -56,6 +42,37 @@
</template>
</div>
</li>
<li x-data class="detail-mode-setting">
<span class="detail-label">Detail</span>
<div class="detail-buttons">
<template x-for="(opt, idx) in $store.preferences.detailModeOptions" :key="opt.value">
<span class="detail-btn-wrapper">
<button type="button"
class="detail-btn"
:class="{ 'active': $store.preferences.detailMode === opt.value }"
@click="$store.preferences.detailMode = opt.value"
:title="opt.title">
<span class="material-symbols-outlined" x-text="opt.icon"></span>
</button>
<span class="detail-sep" x-show="idx < $store.preferences.detailModeOptions.length - 1">·</span>
</span>
</template>
</div>
</li>
<li x-data>
<span class="switch-label">Speech</span>
<label class="switch">
<input type="checkbox" x-model="$store.preferences.speech">
<span class="slider"></span>
</label>
</li>
<li x-data>
<span>Show utility messages</span>
<label class="switch">
<input type="checkbox" x-model="$store.preferences.showUtils">
<span class="slider"></span>
</label>
</li>
</ul>
</span>
</div>
@ -144,6 +161,48 @@
margin: 0 0.1rem;
opacity: 0.5;
}
/* Detail mode button bar */
.detail-mode-setting {
gap: 0.25rem;
}
.detail-mode-setting .detail-label {
flex: 1;
}
.detail-buttons {
display: flex;
align-items: center;
gap: 0;
}
.detail-btn-wrapper {
display: flex;
align-items: center;
}
.detail-btn {
background: none;
border: none;
color: var(--color-primary);
padding: 0.15rem 0.25rem;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.15s ease;
}
.detail-btn .material-symbols-outlined {
font-size: 1rem;
}
.detail-btn:hover {
opacity: 0.85;
}
.detail-btn.active {
opacity: 1;
color: var(--color-text);
}
.detail-sep {
color: var(--color-text-secondary, #666);
font-size: 0.6rem;
margin: 0 0.1rem;
opacity: 0.5;
}
</style>
</body>
</html>

View file

@ -1,6 +1,7 @@
import { createStore } from "/js/AlpineStore.js";
import * as css from "/js/css.js";
import { store as speechStore } from "/components/chat/speech/speech-store.js";
import { store as processGroupStore } from "/components/messages/process-group/process-group-store.js";
// Preferences store centralizes user preference toggles and side-effects
const model = {
@ -68,6 +69,23 @@ const model = {
{ label: "FULL", value: "full" },
],
// Detail mode for process groups/steps expansion
get detailMode() {
return this._detailMode;
},
set detailMode(value) {
this._detailMode = value;
this._applyDetailMode(value);
},
_detailMode: "current", // Default: show current step only
// Detail mode options for UI sidebar
detailModeOptions: [
{ label: "MIN", value: "collapsed", icon: "unfold_less", title: "All collapsed" },
{ label: "CUR", value: "current", icon: "step", title: "Current step only" },
{ label: "ALL", value: "expanded", icon: "unfold_more", title: "All expanded" },
],
// Initialize preferences and apply current state
init() {
try {
@ -104,6 +122,16 @@ const model = {
this._chatWidth = "55"; // Default to standard
}
// Load detail mode preference
try {
const storedDetailMode = localStorage.getItem("detailMode");
if (storedDetailMode && this.detailModeOptions.some(opt => opt.value === storedDetailMode)) {
this._detailMode = storedDetailMode;
}
} catch {
this._detailMode = "current"; // Default
}
// Apply all preferences
this._applyDarkMode(this._darkMode);
this._applyAutoScroll(this._autoScroll);
@ -111,6 +139,7 @@ const model = {
this._applyShowUtils(this._showUtils);
this._applyCollapseProcessGroups(this._collapseProcessGroups);
this._applyChatWidth(this._chatWidth);
this._applyDetailMode(this._detailMode);
} catch (e) {
console.error("Failed to initialize preferences store", e);
}
@ -148,19 +177,14 @@ const model = {
document.querySelectorAll(".process-step.message-util").forEach((el) => {
el.classList.toggle("show-util", value);
});
// Re-apply detail mode to reset current visible step
processGroupStore.applyModeSteps();
},
_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
}
processGroupStore.defaultCollapsed = value;
},
_applyChatWidth(value) {
@ -173,6 +197,14 @@ const model = {
root.style.setProperty("--chat-max-width", `${value}em`);
}
},
_applyDetailMode(value) {
localStorage.setItem("detailMode", value);
// Sync defaultCollapsed based on mode
processGroupStore.defaultCollapsed = value === "collapsed";
// Apply mode to all existing DOM elements
processGroupStore.applyModeSteps();
},
};
export const store = createStore("preferences", model);

View file

@ -70,14 +70,6 @@
margin: 0;
}
.center-container .message {
/* margin-bottom: var(--spacing-sm); */
}
.message .message-body {
/* padding-top: 0.5em;
padding-bottom: 0.5em; */
}
.message-user {
text-align: end;
@ -111,19 +103,12 @@
color: #2e2e2e;
} */
.message-ai {
/* border-bottom-left-radius: var(--spacing-xxs); */
}
.message-center {
align-self: center;
/* border-bottom-left-radius: unset; */
}
.message-followup {
/* margin-left: var(--spacing-lg); */
/* margin-bottom: var(--spacing-lg); */
}
.message-followup .message {
border-radius: 0;
@ -287,13 +272,138 @@
background-color: transparent;
}
.message-error {
background-color: rgba(180, 40, 40, 0.25);
border: 1px solid rgba(220, 60, 60, 0.5);
border-radius: 8px !important;
padding: 12px !important;
/* Collapsible Error Group */
.message-error-group {
background: transparent;
border: none !important;
padding: 0 !important;
}
.error-group {
display: inline-flex;
flex-direction: column;
position: relative;
z-index: 1;
margin: var(--spacing-xs) 0;
padding: var(--spacing-xs) 0;
min-width: 200px;
max-width: 100%;
box-sizing: border-box;
flex-shrink: 0;
}
/* Error Group Header */
.error-group-header {
display: flex;
align-items: center;
padding: 0;
cursor: pointer;
user-select: none;
transition: opacity 0.15s ease;
min-height: 22px;
gap: 6px;
white-space: nowrap;
}
.error-group-header:hover {
opacity: 0.85;
}
/* Expand/collapse triangle */
.error-group-header .expand-icon {
display: inline-block;
width: 0;
height: 0;
border-style: solid;
border-width: 4px 0 4px 6px;
border-color: transparent transparent transparent #ef4444;
opacity: 0.7;
transition: transform 0.2s ease, opacity 0.15s ease;
flex-shrink: 0;
font-size: 0;
margin-right: 2px;
}
.error-group-header:hover .expand-icon {
opacity: 1;
}
.error-group.expanded .error-group-header .expand-icon {
transform: rotate(90deg);
}
/* Error title */
.error-group-header .error-title {
flex: 0 0 auto;
font-size: var(--font-size-medium);
font-weight: 600;
color: var(--color-accent);
opacity: 0.95;
}
/* Error subtitle (short description) */
.error-group-header .error-subtitle {
flex: 1 1 auto;
font-size: 0.75rem;
color: var(--color-text);
opacity: 0.7;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-left: var(--spacing-xs);
font-family: var(--font-family-code);
}
/* Error Group Content - Animated expand/collapse */
.error-group-content {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
margin-top: 0;
padding-top: 0;
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;
overflow: hidden;
}
.error-group-content > .error-content-inner {
min-height: 0;
}
.error-group.expanded .error-group-content {
grid-template-rows: 1fr;
opacity: 1;
margin-top: var(--spacing-xs);
padding-top: var(--spacing-xs);
}
.error-content-inner {
padding: var(--spacing-md);
border-radius: 8px;
border: 1px solid var(--color-accent);
margin-left: var(--spacing-md);
}
/* Callstack styling */
.error-callstack {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-family-code);
font-size: 0.72rem;
line-height: 1.5;
color: var(--color-accent);
opacity: 0.9;
max-height: 30vh;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--color-accent) transparent;
}
/* Terminal styling moved to new terminal block above */
/* Agent and AI Info */
@ -512,10 +622,17 @@
border-bottom: none;
}
.light-mode .message-error {
background-color: rgba(220, 60, 60, 0.15);
border: 1px solid rgba(180, 40, 40, 0.4);
color: #8f1010;
/* Light mode error group */
.light-mode .error-group-header .expand-icon {
border-color: transparent transparent transparent var(--color-accent);
}
.light-mode .error-group-header .error-title {
color: var(--color-accent);
}
.light-mode .error-content-inner {
border: 1px solid var(--color-accent);
}
.light-mode .message-user {
@ -651,10 +768,6 @@
margin-bottom:5em;
}
.message-group-mid {
margin-left: 2em;
}
.message-container {
animation: fadeIn 0.2s;
-webkit-animation: fadeIn 0.2s;

View file

@ -747,6 +747,32 @@ h4 {
}
/* Chat-history attachments overrides (flat styling) */
#chat-history .attachments-container {
padding: 0 !important;
}
#chat-history .attachment-item {
padding: 0 !important;
background-color: transparent !important;
border: none !important;
box-shadow: none !important;
transform: none !important;
}
#chat-history .attachment-item:hover {
background-color: transparent !important;
border: none !important;
box-shadow: none !important;
transform: none !important;
}
#chat-history .attachment-preview,
#chat-history .attachment-item.image-type .attachment-preview {
background: transparent;
}
.attachment-image .attachment-preview {
margin-right: 0;
}

View file

@ -11,6 +11,7 @@ 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 _tooltipsStore } from "/components/tooltips/tooltip-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
@ -414,6 +415,8 @@ function afterMessagesUpdate(logs) {
if (localStorage.getItem("speech") == "true") {
speakMessages(logs);
}
// Apply messages expansion mode after rendering
processGroupStore.applyModeSteps();
}
function speakMessages(logs) {
@ -458,11 +461,7 @@ function updateProgress(progress, active) {
if (!progressBarEl) return;
if (!progress) progress = "";
if (!active) {
removeClassFromElement(progressBarEl, "shiny-text");
} else {
addClassToElement(progressBarEl, "shiny-text");
}
setProgressBarShine(progressBarEl, active);
progress = msgs.convertIcons(progress);
@ -471,6 +470,17 @@ function updateProgress(progress, active) {
}
}
function setProgressBarShine(progressBarEl, active) {
if (!progressBarEl) return;
if (!active) {
removeClassFromElement(progressBarEl, "shiny-text");
// clear any lingering shines in process steps
msgs.clearActiveStepShine();
} else {
addClassToElement(progressBarEl, "shiny-text");
}
}
globalThis.pauseAgent = async function (paused) {
await inputStore.pauseAgent(paused);
};

View file

@ -5,6 +5,7 @@ import { store as _messageResizeStore } from "/components/messages/resize/messag
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 stepDetailStore } from "/components/modals/process-step-detail/step-detail-store.js";
import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
import { formatDuration } from "./time-utils.js";
@ -13,6 +14,42 @@ const chatHistory = document.getElementById("chat-history");
let messageGroup = null;
let currentProcessGroup = null; // Track current process group for collapsible UI
let currentDelegationSteps = {}; // Track delegation steps by agent number for nesting
let activeProcessGroupId = null; // Only one process group should show "running" indicators at a time
let activeProcessGroupEl = null;
let activeStepTitleEl = null;
// Expose activeProcessGroupId for store access
window.activeProcessGroupId = null;
/**
* Mark current process group as active and clear active badges.
*/
function setActiveProcessGroup(group) {
if (!group || !group.id) return;
if (activeProcessGroupId === group.id) return;
// Clear shiny effect from the previous active step title if we moved to a new group
if (activeStepTitleEl && activeProcessGroupEl && activeProcessGroupEl !== group && activeProcessGroupEl.contains(activeStepTitleEl)) {
activeStepTitleEl.classList.remove("shiny-text");
activeStepTitleEl = null;
}
activeProcessGroupId = group.id;
activeProcessGroupEl = group;
window.activeProcessGroupId = group.id; // Keep window copy in sync for store access
}
export function clearActiveStepShine() {
if (activeStepTitleEl) {
activeStepTitleEl.classList.remove("shiny-text");
activeStepTitleEl = null;
}
// clear any lingering shine in process steps
document.querySelectorAll(".process-step .step-title.shiny-text").forEach((el) => {
el.classList.remove("shiny-text");
});
}
/**
* Resolve tool name from kvps, existing attribute, or previous siblings
@ -54,15 +91,35 @@ const PROCESS_TYPES = ['agent', 'tool', 'code_exe', 'browser', 'progress', 'info
// Main types that should always be visible (not collapsed)
const MAIN_TYPES = ['user', 'response', 'error', 'rate_limit'];
/**
* Helper to append a message container to the correct group in chat history
*/
function appendMessageToHistory(messageContainer, groupType, forceNewGroup, id) {
// Check if current messageGroup is still in DOM, if not, reset it (context switch)
if (messageGroup && !document.getElementById(messageGroup.id)) {
messageGroup = null;
}
// Create new group if needed
if (!messageGroup || forceNewGroup || groupType !== messageGroup.getAttribute("data-group-type")) {
messageGroup = document.createElement("div");
messageGroup.id = `message-group-${id}`;
messageGroup.classList.add("message-group", `message-group-${groupType}`);
messageGroup.setAttribute("data-group-type", groupType);
chatHistory.appendChild(messageGroup);
}
// Append message to group
messageGroup.appendChild(messageContainer);
}
export function setMessage(id, type, heading, content, temp, kvps = null, timestamp = null, durationMs = null, agentNumber = 0) {
// 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;
// For user messages, close current process group FIRST (start fresh for next interaction)
if (type === "user") {
@ -71,7 +128,7 @@ export function setMessage(id, type, heading, content, temp, kvps = null, timest
}
// For process types, check if we should add to process group
if (isProcessType) {
if (isProcessType || (type === "response" && agentNumber !== 0)) {
if (processStepElement) {
// Update existing process step
updateProcessStep(processStepElement, id, type, heading, content, kvps, durationMs, agentNumber);
@ -80,59 +137,35 @@ export function setMessage(id, type, heading, content, temp, kvps = null, timest
// Create or get process group for current interaction
if (!currentProcessGroup || !document.getElementById(currentProcessGroup.id)) {
// Create response container for this process group immediately (Option B)
messageContainer = document.createElement("div");
messageContainer.id = `message-${id}`;
messageContainer.classList.add("message-container", "ai-container", "has-process-group");
currentProcessGroup = createProcessGroup(id);
chatHistory.appendChild(currentProcessGroup);
currentProcessGroup.classList.add("embedded");
messageContainer.appendChild(currentProcessGroup);
// Handle DOM insertion immediately
appendMessageToHistory(messageContainer, "left", false, id);
setActiveProcessGroup(currentProcessGroup);
}
// Add step to current process group
processStepElement = addProcessStep(currentProcessGroup, id, type, heading, content, kvps, timestamp, durationMs, agentNumber);
const stepType = (type === "response" && agentNumber !== 0) ? "response" : type;
processStepElement = addProcessStep(currentProcessGroup, id, stepType, heading, content, kvps, timestamp, durationMs, agentNumber);
return processStepElement;
}
// For subordinate agent responses (A1, A2, ...), treat as a process step instead of main response
// agentNumber: 0 = main agent, 1+ = subordinate agents
// Note: subordinate "response" is a completion marker with content
if (type === "response" && agentNumber !== 0) {
if (processStepElement) {
updateProcessStep(processStepElement, id, "response", heading, content, kvps, durationMs, agentNumber);
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 response step (special type to show content)
processStepElement = addProcessStep(currentProcessGroup, id, "response", heading, content, kvps, timestamp, durationMs, agentNumber);
return processStepElement;
}
// For main agent (A0) response, embed the current process group and mark as complete
// For main agent (A0) response, mark the current process group 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);
isNewMessage = true;
} else {
// Check if already embedded
const existingEmbedded = messageContainer.querySelector(".process-group");
if (!existingEmbedded && processGroupToEmbed) {
embedProcessGroup(messageContainer, processGroupToEmbed);
}
}
markProcessGroupComplete(currentProcessGroup, heading);
}
if (!messageContainer) {
// Create a new container if not found
isNewMessage = true;
const sender = type === "user" ? "user" : "ai";
messageContainer = document.createElement("div");
messageContainer.id = `message-${id}`;
@ -142,8 +175,8 @@ export function setMessage(id, type, heading, content, temp, kvps = null, timest
const handler = getHandler(type);
handler(messageContainer, id, type, heading, content, temp, kvps);
// If this is a new message, handle DOM insertion
if (!document.getElementById(`message-${id}`)) {
// If this is a new message (not yet in DOM), handle DOM insertion
if (!messageContainer.parentNode) {
// message type visual grouping
const groupTypeMap = {
user: "right",
@ -163,27 +196,11 @@ export function setMessage(id, type, heading, content, temp, kvps = null, timest
};
const groupType = groupTypeMap[type] || "left";
const forceNewGroup = groupStart[type] || false;
// here check if messageGroup is still in DOM, if not, then set it to null (context switch)
if (messageGroup && !document.getElementById(messageGroup.id))
messageGroup = null;
if (
!messageGroup || // no group yet exists
groupStart[type] || // message type forces new group
groupType != messageGroup.getAttribute("data-group-type") // message type changes group
) {
messageGroup = document.createElement("div");
messageGroup.id = `message-group-${id}`;
messageGroup.classList.add(`message-group`, `message-group-${groupType}`);
messageGroup.setAttribute("data-group-type", groupType);
}
messageGroup.appendChild(messageContainer);
chatHistory.appendChild(messageGroup);
appendMessageToHistory(messageContainer, groupType, forceNewGroup, id);
}
// Simplified implementation - no setup needed
return messageContainer;
}
@ -772,16 +789,126 @@ export function drawMessageError(
temp,
kvps = null
) {
return drawMessageAgentPlain(
"message-error",
messageContainer,
id,
type,
heading,
content,
temp,
kvps
);
// Create or get the message div
let messageDiv = messageContainer.querySelector(".message");
if (!messageDiv) {
messageDiv = document.createElement("div");
messageDiv.classList.add("message", "message-error-group");
messageContainer.appendChild(messageDiv);
}
// Check if error group already exists
let errorGroup = messageDiv.querySelector(".error-group");
if (!errorGroup) {
errorGroup = document.createElement("div");
errorGroup.classList.add("error-group");
errorGroup.setAttribute("data-error-id", id);
// Create header (clickable for expand/collapse)
const header = document.createElement("div");
header.classList.add("error-group-header");
// Expand icon (triangle)
const expandIcon = document.createElement("span");
expandIcon.classList.add("expand-icon");
header.appendChild(expandIcon);
// Status badge (before title)
const badge = document.createElement("span");
badge.classList.add("status-badge", "status-err");
badge.textContent = "ERR";
header.appendChild(badge);
// Title
const title = document.createElement("span");
title.classList.add("error-title");
title.textContent = "Error";
header.appendChild(title);
// Subtitle (short error description)
const subtitle = document.createElement("span");
subtitle.classList.add("error-subtitle");
header.appendChild(subtitle);
// Click handler for expand/collapse
header.addEventListener("click", () => {
errorGroup.classList.toggle("expanded");
});
errorGroup.appendChild(header);
// Create content container (collapsible)
const contentWrapper = document.createElement("div");
contentWrapper.classList.add("error-group-content");
const contentInner = document.createElement("div");
contentInner.classList.add("error-content-inner");
contentWrapper.appendChild(contentInner);
errorGroup.appendChild(contentWrapper);
messageDiv.appendChild(errorGroup);
// Check detail mode and expand if needed
const detailMode = window.Alpine?.store("preferences")?.detailMode || "current";
if (detailMode === "current" || detailMode === "expanded") {
errorGroup.classList.add("expanded");
}
}
// Update subtitle with short error description
const subtitle = errorGroup.querySelector(".error-subtitle");
if (subtitle) {
// Extract short description from heading or content
let shortDesc = "";
// Skip if heading is just "Error" (redundant with title)
if (heading && heading.trim() && heading.trim().toLowerCase() !== "error") {
shortDesc = heading.trim();
}
// If no useful heading, try to extract from content
if (!shortDesc && content && content.trim()) {
const lines = content.trim().split("\n");
// Look for the error line (usually last meaningful line or one matching ErrorType: pattern)
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim();
if (line && /^[\w\.]+Error[:\s]/.test(line)) {
shortDesc = line;
break;
}
}
// Fallback to first non-empty line if no error pattern found
if (!shortDesc) {
for (const line of lines) {
if (line.trim() && !line.startsWith("Traceback")) {
shortDesc = line.trim();
break;
}
}
}
}
// Truncate if too long
if (shortDesc.length > 100) {
shortDesc = shortDesc.substring(0, 97) + "...";
}
subtitle.textContent = shortDesc;
subtitle.title = shortDesc; // Full text on hover
}
// Update content (full callstack)
const contentInner = errorGroup.querySelector(".error-content-inner");
if (contentInner && content) {
contentInner.innerHTML = "";
// Create pre element for callstack/content
const pre = document.createElement("pre");
pre.classList.add("error-callstack");
pre.textContent = content;
contentInner.appendChild(pre);
// Add action buttons for copy functionality
addActionButtonsToElement(contentInner);
}
messageContainer.classList.add("center-container");
}
function drawKvps(container, kvps, latex) {
@ -1128,52 +1255,6 @@ class Scroller {
// 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
// ============================================
@ -1188,8 +1269,8 @@ function createProcessGroup(id) {
group.classList.add("process-group");
group.setAttribute("data-group-id", groupId);
// Check initial expansion state from store (respects user preference)
const initiallyExpanded = processGroupStore.isGroupExpanded(groupId);
// Check initial expansion state from store
const initiallyExpanded = processGroupStore.expandGroup(groupId, true); // true = is active
if (initiallyExpanded) {
group.classList.add('expanded');
}
@ -1200,10 +1281,11 @@ function createProcessGroup(id) {
header.innerHTML = `
<span class="expand-icon"></span>
<span class="group-title">Processing...</span>
<span class="status-badge status-gen status-active group-status">GEN</span>
<span class="status-badge status-gen group-status">GEN</span>
<span class="group-metrics">
<span class="metric-time" title="Start time"><span class="material-symbols-outlined">schedule</span><span class="metric-value">--:--</span></span>
<span class="metric-steps" title="Steps"><span class="material-symbols-outlined">footprint</span><span class="metric-value">0</span></span>
<span class="metric-notifications" title="Warnings/Info/Hint" hidden><span class="material-symbols-outlined">priority_high</span><span class="metric-value">0</span></span>
<span class="metric-duration" title="Duration"><span class="material-symbols-outlined">timer</span><span class="metric-value">0s</span></span>
</span>
`;
@ -1260,6 +1342,9 @@ function getNestedContainer(parentStep) {
* Add a step to a process group
*/
function addProcessStep(group, id, type, heading, content, kvps, timestamp = null, durationMs = null, agentNumber = 0) {
// group with newest step becomes the active one
setActiveProcessGroup(group);
const groupId = group.getAttribute("data-group-id");
let stepsContainer = group.querySelector(".process-steps");
const isGroupCompleted = group.classList.contains("process-group-completed");
@ -1321,9 +1406,17 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul
const title = getStepTitle(heading, kvps, type);
// Check if step should be expanded
// Warning/error steps auto-expand to show content
const isStepExpanded = processGroupStore.isStepExpanded(groupId, id) ||
(type === "warning" || type === "error");
const isActiveStep = !isGroupCompleted && group.id === activeProcessGroupId;
const isStepExpanded = processGroupStore.expandStep(groupId, id, isActiveStep);
// In "current" mode, collapse all other steps
const detailMode = preferencesStore.detailMode;
if (detailMode === "current" && isStepExpanded) {
document.querySelectorAll(".process-step.step-expanded").forEach(s => {
s.classList.remove("step-expanded");
});
}
if (isStepExpanded) {
step.classList.add("step-expanded");
}
@ -1339,10 +1432,9 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul
// Add status color class to step for cascading --step-accent to internal icons
step.classList.add(statusColorClass);
const activeClass = isGroupCompleted ? "" : " status-active";
stepHeader.innerHTML = `
<span class="step-expand-icon"></span>
<span class="status-badge ${statusColorClass}${activeClass}">${statusCode}</span>
<span class="status-badge ${statusColorClass}">${statusCode}</span>
<span class="step-title">${escapeHTML(title)}</span>
`;
@ -1354,11 +1446,6 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul
// Explicitly add or remove the class based on state
if (newState) {
step.classList.add("step-expanded");
// Scroll terminal for newly expanded steps
requestAnimationFrame(() => {
const terminal = step.querySelector(".terminal-output");
if (terminal) terminal.scrollTop = terminal.scrollHeight;
});
} else {
step.classList.remove("step-expanded");
}
@ -1377,15 +1464,24 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul
renderStepDetailContent(detailContent, content, kvps, type);
detail.appendChild(detailContent);
step.appendChild(detail);
// Scroll terminal for already expanded steps
if (isStepExpanded) {
requestAnimationFrame(() => {
const terminal = step.querySelector(".terminal-output");
if (terminal) terminal.scrollTop = terminal.scrollHeight;
});
}
// Store step data on the element for fresh access on modal open
step._stepData = {
type,
heading,
content,
kvps,
timestamp,
durationMs,
agentNumber,
toolName: toolNameToUse
};
// Add "View Details" button for full modal view (reads fresh data from step._stepData)
const viewDetailsBtn = createViewDetailsButton(step);
detail.appendChild(viewDetailsBtn);
step.appendChild(detail);
// Track delegation steps for nesting
if (toolNameToUse === "call_subordinate") {
@ -1400,21 +1496,33 @@ function addProcessStep(group, id, type, heading, content, kvps, timestamp = nul
const parentStep = currentDelegationSteps[agentNumber - 1];
appendTarget = getNestedContainer(parentStep);
step.classList.add("nested-step");
// Auto-expand parent if this nested step is a warning/error
if (type === "warning" || type === "error") {
parentStep.classList.add("step-expanded");
}
}
// 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"));
// Remove shiny effect from the previously active step title (O(1))
if (activeStepTitleEl) {
activeStepTitleEl.classList.remove("shiny-text");
activeStepTitleEl = null;
}
appendTarget.appendChild(step);
// Scroll terminal to bottom on initial render (including page refresh)
const initialTerminal = step.querySelector(".terminal-output");
if (initialTerminal) {
initialTerminal.scrollTop = initialTerminal.scrollHeight;
}
// Update group header
updateProcessGroupHeader(group);
// Apply shiny effect to the active step title
if (!isGroupCompleted && group.id === activeProcessGroupId) {
const titleEl = step.querySelector(".process-step-header .step-title");
if (titleEl) {
titleEl.classList.add("shiny-text");
activeStepTitleEl = titleEl;
}
}
return step;
}
@ -1453,6 +1561,10 @@ function updateProcessStep(stepElement, id, type, heading, content, kvps, durati
let skipFullRender = false;
if (detailContent) {
// Capture scroll state before re-render (uses existing Scroller pattern)
const terminal = detailContent.querySelector(".terminal-output");
const scroller = terminal ? new Scroller(terminal) : null;
// For browser, update image src incrementally to avoid flashing
if (type === "browser" && kvps?.screenshot) {
const existingImg = detailContent.querySelector(".screenshot-img");
@ -1469,9 +1581,28 @@ function updateProcessStep(stepElement, id, type, heading, content, kvps, durati
if (!skipFullRender) {
renderStepDetailContent(detailContent, content, kvps, type);
// Re-apply scroll (stays at bottom if was at bottom)
const newTerminal = detailContent.querySelector(".terminal-output");
if (newTerminal && scroller?.wasAtBottom) {
newTerminal.scrollTop = newTerminal.scrollHeight;
}
}
}
// Update stored step data for fresh access by modal
const timestamp = stepElement._stepData?.timestamp; // preserve original timestamp
stepElement._stepData = {
type,
heading,
content,
kvps,
timestamp,
durationMs,
agentNumber,
toolName: toolNameToUse
};
// Update parent group header
const group = stepElement.closest(".process-group");
if (group) {
@ -1602,8 +1733,6 @@ function renderStepDetailContent(container, content, kvps, type = null) {
processedOutput = convertPathsToLinks(processedOutput);
outputPre.innerHTML = processedOutput;
terminalDiv.appendChild(outputPre);
// Scroll terminal to bottom
outputPre.scrollTop = outputPre.scrollHeight;
}
container.appendChild(terminalDiv);
@ -1800,6 +1929,30 @@ function renderThoughts(container, value) {
}
}
/**
* Create "View Details" button for opening step detail modal
* @param {HTMLElement} stepElement - The step DOM element containing _stepData property
*/
function createViewDetailsButton(stepElement) {
const btnContainer = document.createElement("div");
btnContainer.classList.add("step-detail-actions");
const btn = document.createElement("button");
btn.classList.add("btn", "text-button");
btn.innerHTML = '<span class="material-symbols-outlined">open_in_full</span> View Details';
btn.title = "Open full step details in modal";
btn.addEventListener("click", (e) => {
e.stopPropagation();
// Read fresh data from the step element at click time
const freshData = stepElement._stepData || {};
stepDetailStore.showStepDetail(freshData);
});
btnContainer.appendChild(btn);
return btnContainer;
}
/**
* Update process group header with step count, status, and metrics
*/
@ -1810,10 +1963,40 @@ function updateProcessGroupHeader(group) {
const metricsEl = group.querySelector(".group-metrics");
const isCompleted = group.classList.contains("process-group-completed");
// If completed, only remove active badges and exit early (don't update metrics)
const notificationsEl = metricsEl?.querySelector(".metric-notifications");
if (notificationsEl) {
const counts = { warning: 0, info: 0, hint: 0 };
steps.forEach((step) => {
const stepType = step.getAttribute("data-type");
if (Object.prototype.hasOwnProperty.call(counts, stepType)) {
counts[stepType] += 1;
}
});
const totalNotifications = counts.warning + counts.info + counts.hint;
const countEl = notificationsEl.querySelector(".metric-value");
notificationsEl.classList.remove("status-wrn", "status-inf", "status-hnt");
if (totalNotifications > 0) {
if (countEl) {
countEl.textContent = totalNotifications.toString();
}
if (counts.warning > 0) {
notificationsEl.classList.add("status-wrn");
} else if (counts.info > 0) {
notificationsEl.classList.add("status-inf");
} else {
notificationsEl.classList.add("status-hnt");
}
notificationsEl.hidden = false;
notificationsEl.title = `Warnings: ${counts.warning}, Info: ${counts.info}, Hints: ${counts.hint}`;
} else {
notificationsEl.hidden = true;
}
}
// If completed, don't update metrics
if (isCompleted) {
const activeBadges = group.querySelectorAll(".status-badge.status-active");
activeBadges.forEach(badge => badge.classList.remove("status-active"));
return;
}
@ -1881,14 +2064,14 @@ function updateProcessGroupHeader(group) {
const lastToolName = lastStep.getAttribute("data-tool-name");
const lastTitle = lastStep.querySelector(".step-title")?.textContent || "";
// Update status badge (keep status-active during execution)
// Update status badge
if (statusEl) {
// Status code and color class from store (maps backend types)
const statusCode = processGroupStore.getStepCode(lastType, lastToolName);
const statusColorClass = processGroupStore.getStatusColorClass(lastType, lastToolName);
statusEl.textContent = statusCode;
statusEl.className = `status-badge ${statusColorClass} status-active group-status`;
statusEl.className = `status-badge ${statusColorClass} group-status`;
}
// Update title
@ -1929,17 +2112,13 @@ function truncateText(text, maxLength) {
function markProcessGroupComplete(group, responseTitle) {
if (!group) return;
// Update status badge to END (remove status-active)
// Update status badge to END
const statusEl = group.querySelector(".group-status");
if (statusEl) {
statusEl.innerHTML = '<span class="badge-icon material-symbols-outlined">check</span>END';
statusEl.className = "status-badge status-end group-status"; // No status-active
statusEl.className = "status-badge status-end group-status";
}
// 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) {
@ -1952,6 +2131,7 @@ function markProcessGroupComplete(group, responseTitle) {
// Add completed class to group
group.classList.add("process-group-completed");
// Calculate final duration from backend data (sum of all step durations)
const steps = group.querySelectorAll(".process-step");
let totalDurationMs = 0;
@ -1975,6 +2155,13 @@ export function resetProcessGroups() {
currentProcessGroup = null;
currentDelegationSteps = {};
messageGroup = null;
activeProcessGroupId = null;
activeProcessGroupEl = null;
window.activeProcessGroupId = null; // Keep window copy in sync
if (activeStepTitleEl) {
activeStepTitleEl.classList.remove("shiny-text");
}
activeStepTitleEl = null;
}
/**