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) <noreply@anthropic.com>
This commit is contained in:
parent
8c3d9c6ed3
commit
469099dffc
|
|
@ -8,6 +8,7 @@
|
|||
<app-global-notification />
|
||||
<app-confirm-dialog-host></app-confirm-dialog-host>
|
||||
<app-execution-error-dialog-host></app-execution-error-dialog-host>
|
||||
<app-execution-event-dialog-host></app-execution-event-dialog-host>
|
||||
<app-human-interaction-dialog-host></app-human-interaction-dialog-host>
|
||||
<app-node-settings-dialog-host></app-node-settings-dialog-host>
|
||||
<app-subflow-preview-dialog-host></app-subflow-preview-dialog-host>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - 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.
|
||||
*
|
||||
* <p>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<ExecutionEventDialogState | null>(null);
|
||||
|
||||
readonly state = this._state.asReadonly();
|
||||
|
||||
open(state: ExecutionEventDialogState) {
|
||||
this._state.set(state);
|
||||
}
|
||||
|
||||
close() {
|
||||
this._state.set(null);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
|
||||
-->
|
||||
|
||||
@if (state(); as dialogState) {
|
||||
<app-modal-shell
|
||||
[title]="dialogState.title"
|
||||
[subtitle]="dialogState.subtitle"
|
||||
ariaLabel="Execution event details"
|
||||
maxWidth="680px"
|
||||
(backdropClick)="close()"
|
||||
(closeClick)="close()">
|
||||
|
||||
<div class="execution-event__body">
|
||||
@if (dialogState.message) {
|
||||
<p class="execution-event__message">{{ dialogState.message }}</p>
|
||||
}
|
||||
|
||||
<div class="execution-event__actions">
|
||||
<button
|
||||
type="button"
|
||||
mat-stroked-button
|
||||
class="execution-event__copy"
|
||||
aria-label="Copy the event details"
|
||||
(click)="copyDetails($event)">
|
||||
<mat-icon [fontIcon]="copied() ? 'check' : 'content_copy'"></mat-icon>
|
||||
<span>{{ copied() ? 'Copied' : 'Copy' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="execution-event__details">
|
||||
<app-json-viewer [value]="dialogState.details"></app-json-viewer>
|
||||
</div>
|
||||
</div>
|
||||
</app-modal-shell>
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - 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.
|
||||
*
|
||||
* <p>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<typeof setTimeout> | 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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' }]);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue