diff --git a/src/app/app.html b/src/app/app.html
index 487f984..7d89607 100644
--- a/src/app/app.html
+++ b/src/app/app.html
@@ -8,6 +8,7 @@
+
diff --git a/src/app/app.ts b/src/app/app.ts
index c23b90d..dd24a1e 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -6,6 +6,7 @@ import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ConfirmDialogHostComponent } from '@shared/confirm-dialog/confirm-dialog';
import { ExecutionErrorDialogHostComponent } from '@shared/execution-error-dialog/execution-error-dialog';
+import { ExecutionEventDialogHostComponent } from '@shared/execution-event-dialog/execution-event-dialog';
import { GlobalNotificationComponent } from '@shared/global-notification/global-notification';
import { HumanInteractionDialogHostComponent } from '@shared/human-interaction-dialog/human-interaction-dialog';
import { NodeSettingsDialogHostComponent } from '@shared/node-settings-dialog/node-settings-dialog';
@@ -20,7 +21,7 @@ import { ProjectDialogComponent } from '@shared/project-dialog/project-dialog';
@Component({
selector: 'app-root',
- imports: [RouterOutlet, ConfirmDialogHostComponent, ExecutionErrorDialogHostComponent, GlobalNotificationComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent, BiasImpactExperimentDialogHostComponent, BiasRerunDialogHostComponent, BiasCompareDialogHostComponent, BiasReportDialogHostComponent, ProjectDialogComponent, ProjectDeleteDialogComponent, ProjectContextDialogComponent],
+ imports: [RouterOutlet, ConfirmDialogHostComponent, ExecutionErrorDialogHostComponent, ExecutionEventDialogHostComponent, GlobalNotificationComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent, BiasImpactExperimentDialogHostComponent, BiasRerunDialogHostComponent, BiasCompareDialogHostComponent, BiasReportDialogHostComponent, ProjectDialogComponent, ProjectDeleteDialogComponent, ProjectContextDialogComponent],
templateUrl: './app.html',
styleUrl: './app.css',
changeDetection: ChangeDetectionStrategy.OnPush
diff --git a/src/app/services/dialogs/execution-event-dialog.ts b/src/app/services/dialogs/execution-event-dialog.ts
new file mode 100644
index 0000000..6a9d38a
--- /dev/null
+++ b/src/app/services/dialogs/execution-event-dialog.ts
@@ -0,0 +1,38 @@
+// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
+
+import { Injectable, signal } from '@angular/core';
+
+export type ExecutionEventDialogState = {
+ /** A short name for the kind of event, used as the dialog title. */
+ title: string;
+ /** Where and when it happened: node name and time, when the event says. */
+ subtitle: string;
+ /** The event's own sentence, which is often a paragraph and belongs in the body, not the title. */
+ message: string;
+ /** Whatever the event recorded beyond that sentence, rendered as a tree. */
+ details: unknown;
+};
+
+/**
+ * Holds the execution event currently opened for reading.
+ *
+ *
Separate from the settings dialog, which can only render fields of text: what an event carries
+ * is a small object - counts, names, nested values - and reading it as a wall of JSON means counting
+ * braces to find one number.
+ */
+@Injectable({ providedIn: 'root' })
+export class ExecutionEventDialogService {
+ private readonly _state = signal(null);
+
+ readonly state = this._state.asReadonly();
+
+ open(state: ExecutionEventDialogState) {
+ this._state.set(state);
+ }
+
+ close() {
+ this._state.set(null);
+ }
+}
diff --git a/src/app/shared/execution-event-dialog/execution-event-dialog.css b/src/app/shared/execution-event-dialog/execution-event-dialog.css
new file mode 100644
index 0000000..f8e66d7
--- /dev/null
+++ b/src/app/shared/execution-event-dialog/execution-event-dialog.css
@@ -0,0 +1,37 @@
+/*
+ * SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ * Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
+ */
+
+.execution-event__body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+/* The event's sentence: prose, so it reads as prose rather than as a heading. */
+.execution-event__message {
+ margin: 0;
+ color: #334155;
+ font-size: 0.875rem;
+ line-height: 1.5;
+}
+
+.execution-event__actions {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.execution-event__copy {
+ --mdc-outlined-button-label-text-color: #475569;
+}
+
+.execution-event__details {
+ max-height: 55vh;
+ overflow: auto;
+ padding: 0.75rem;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ background: #f8fafc;
+}
diff --git a/src/app/shared/execution-event-dialog/execution-event-dialog.html b/src/app/shared/execution-event-dialog/execution-event-dialog.html
new file mode 100644
index 0000000..0cf93c2
--- /dev/null
+++ b/src/app/shared/execution-event-dialog/execution-event-dialog.html
@@ -0,0 +1,38 @@
+
+
+@if (state(); as dialogState) {
+
+
+
+ @if (dialogState.message) {
+
{{ dialogState.message }}
+ }
+
+
+
+
+
+
+
+
+
+
+}
diff --git a/src/app/shared/execution-event-dialog/execution-event-dialog.ts b/src/app/shared/execution-event-dialog/execution-event-dialog.ts
new file mode 100644
index 0000000..aeccc1c
--- /dev/null
+++ b/src/app/shared/execution-event-dialog/execution-event-dialog.ts
@@ -0,0 +1,55 @@
+// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
+
+import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { ExecutionEventDialogService } from '@services/dialogs/execution-event-dialog';
+import { JsonViewerComponent } from '@shared/json-viewer/json-viewer';
+import { ModalShellComponent } from '@shared/modal-shell/modal-shell';
+
+/**
+ * Reads what one execution event recorded.
+ *
+ *
The numbers behind an event - characters over budget, tool names, durations - are a small
+ * object, so they are shown as one: a tree that can be scanned, not a block of JSON to be parsed by
+ * eye. The event's own sentence sits in the body rather than the title, because several of them run
+ * to a full paragraph and a title that wraps five lines reads as an error page.
+ */
+@Component({
+ selector: 'app-execution-event-dialog-host',
+ imports: [MatButtonModule, MatIconModule, ModalShellComponent, JsonViewerComponent],
+ templateUrl: './execution-event-dialog.html',
+ styleUrl: './execution-event-dialog.css',
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class ExecutionEventDialogHostComponent {
+ private readonly dialog = inject(ExecutionEventDialogService);
+
+ readonly state = this.dialog.state;
+ readonly copied = signal(false);
+
+ private copiedReset: ReturnType | null = null;
+
+ close() {
+ this.dialog.close();
+ }
+
+ /** Copies the details as JSON: what gets pasted into an issue is the data, not the tree drawing. */
+ async copyDetails(event?: Event) {
+ event?.preventDefault();
+ event?.stopPropagation();
+ const details = this.state()?.details;
+ if (details === undefined) return;
+
+ try {
+ await navigator.clipboard.writeText(JSON.stringify(details, null, 2));
+ this.copied.set(true);
+ if (this.copiedReset) clearTimeout(this.copiedReset);
+ this.copiedReset = setTimeout(() => this.copied.set(false), 2000);
+ } catch {
+ // Clipboard access can be refused; the tree is still there to read and select by hand.
+ }
+ }
+}
diff --git a/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts b/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts
index 2ac5055..d2ff3f3 100644
--- a/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts
+++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts
@@ -6,6 +6,7 @@ import { TaskExecution, TaskExecutionStep } from '@models/task-execution';
import {
buildAuthorizationGate,
buildVisibleExecutionLogs,
+ executionEventTitle,
getExecutionInputValues,
getExecutionOutputValues,
hasStoredValue,
@@ -59,6 +60,20 @@ describe('execution viewer runtime values', () => {
expect(event.detailsText).toContain('"budgetChars": 60000');
});
+ it('names an event in two words, so a paragraph does not end up as a title', () => {
+ // "Still 6332 character(s) over budget after pruning everything available..." set as a heading
+ // wraps five lines and reads as an error page; the type says the same thing short.
+ expect(executionEventTitle('LLM_CONTEXT_PRUNED')).toBe('LLM context pruned');
+ expect(executionEventTitle('MCP_TOOL_CALL')).toBe('MCP tool call');
+ expect(executionEventTitle('HUMAN_EVALUATION_RECORDED')).toBe('Human evaluation recorded');
+ });
+
+ it('falls back to something sayable for an event type it has never seen', () => {
+ expect(executionEventTitle('SOME_FUTURE_EVENT')).toBe('Some future event');
+ expect(executionEventTitle(null)).toBe('Execution event');
+ expect(executionEventTitle(' ')).toBe('Execution event');
+ });
+
it('offers nothing to open when an event carries no details', () => {
// A button that opens an empty box is worse than no button.
const [noDetails] = buildVisibleExecutionLogs([{ id: 'a', timestamp: 1, type: 'STEP_STARTED' }]);
diff --git a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts
index 45fe4a0..558be8c 100644
--- a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts
+++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts
@@ -258,6 +258,32 @@ export function buildVisibleExecutionLogs(logs: ExecutionEventLogEntry[]): Execu
});
}
+/** Initialisms that read as shouting when title-cased, and as typos when lower-cased. */
+const EVENT_TITLE_INITIALISMS = new Set(['llm', 'mcp', 'http', 'id']);
+
+/**
+ * A short name for a kind of event, for the head of a dialog.
+ *
+ *
The event's own message is regularly a full paragraph - "Still 6332 character(s) over budget
+ * after pruning everything available..." - and a paragraph set as a title wraps five lines and reads
+ * as an error page. The type says the same thing in two words, and the message keeps its place in
+ * the body.
+ */
+export function executionEventTitle(type: string | null | undefined): string {
+ const raw = String(type ?? '').trim();
+ if (!raw) return 'Execution event';
+
+ const words = raw.toLowerCase().split(/[_\s]+/).filter(Boolean);
+ if (!words.length) return 'Execution event';
+
+ return words
+ .map((word, index) => {
+ if (EVENT_TITLE_INITIALISMS.has(word)) return word.toUpperCase();
+ return index === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word;
+ })
+ .join(' ');
+}
+
/**
* What an event carries beyond its sentence - iteration numbers, character counts, tool names.
*
diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts
index 3244633..02adb95 100644
--- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts
+++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts
@@ -47,6 +47,7 @@ import {
HumanInteractionDialogService
} from '@services/dialogs/human-interaction-dialog';
import { NodeSettingField, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
+import { ExecutionEventDialogService } from '@services/dialogs/execution-event-dialog';
import {
LLMDescriptorWithCredential,
openLLMDescriptorSettingsWithCredential
@@ -81,6 +82,7 @@ import {
stringifyOutputValue,
formatDuration,
fallbackExecutionLogMessage,
+ executionEventTitle,
logLevelClass as _logLevelClass,
logTypeIcon as _logTypeIcon,
inheritedSimulator as _inheritedSimulator,
@@ -144,6 +146,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
private flowsService = inject(FlowsService);
private humanInteractionDialog = inject(HumanInteractionDialogService);
private settingsDialog = inject(NodeSettingsDialogService);
+ private eventDialog = inject(ExecutionEventDialogService);
private fieldRetriever = inject(FieldRetriever);
private containersService = inject(ContainersService);
private blocksService = inject(BlocksService);
@@ -1566,25 +1569,17 @@ export class TaskExecutionViewerComponent implements OnDestroy {
* called, how long a step waited - were already reaching the browser and being dropped on the
* floor, leaving the API as the only way to read them.
*/
- async openEventDetails(event: ExecutionLogEntryView, domEvent?: Event) {
+ openEventDetails(event: ExecutionLogEntryView, domEvent?: Event) {
domEvent?.preventDefault();
domEvent?.stopPropagation();
if (!event.detailsText) return;
- await this.settingsDialog.open({
- title: event.messageText,
- previewOnly: true,
- fields: [
- {
- key: 'details',
- label: event.type ?? 'Event details',
- type: 'textarea',
- readonly: true,
- rows: 18,
- copyable: true
- }
- ],
- initial: { details: event.detailsText }
+ const when = new Date(event.timestamp).toLocaleString();
+ this.eventDialog.open({
+ title: executionEventTitle(event.type),
+ subtitle: event.nodeName ? `${event.nodeName} ยท ${when}` : when,
+ message: event.messageText,
+ details: event.details
});
}