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 }); }