From 469099dffc29fb2fee74e80e14d772ff1003cb9f Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Mon, 21 Sep 2026 11:24:17 +0200 Subject: [PATCH] Read an event's details as an object, under a title that fits The first cut put the event's own sentence in the heading and its details in a textarea of raw JSON. A pruning warning runs to a full paragraph, so the title wrapped five lines and read as an error page, and finding one number in the body meant counting braces. The event now opens in its own dialog: a two-word name from the event type, the node and time beneath it, the sentence as prose, and the details as the JSON tree the app already had. The name is derived, not tabulated - a type nobody has seen yet still reads as words - with the initialisms that would otherwise shout or look like typos spelled out. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/app.html | 1 + src/app/app.ts | 3 +- .../dialogs/execution-event-dialog.ts | 38 +++++++++++++ .../execution-event-dialog.css | 37 +++++++++++++ .../execution-event-dialog.html | 38 +++++++++++++ .../execution-event-dialog.ts | 55 +++++++++++++++++++ .../execution-viewer.utils.spec.ts | 15 +++++ .../execution-viewer.utils.ts | 26 +++++++++ .../task-execution-viewer.ts | 25 ++++----- 9 files changed, 222 insertions(+), 16 deletions(-) create mode 100644 src/app/services/dialogs/execution-event-dialog.ts create mode 100644 src/app/shared/execution-event-dialog/execution-event-dialog.css create mode 100644 src/app/shared/execution-event-dialog/execution-event-dialog.html create mode 100644 src/app/shared/execution-event-dialog/execution-event-dialog.ts 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 }); }