From 86e61690bb4c5f7da6aee3e8bdd8e574bcb0d9f6 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Wed, 9 Sep 2026 16:49:40 +0200 Subject: [PATCH] Open a step's failure instead of squeezing it into a tooltip A step's error is regularly a provider payload or a stack, and a hover tooltip could only ever clip it: there was no way to read past the first few lines, let alone paste it into a bug report. The badge now teases the failure - "Error executing" plus its first line, clamped - and opens the whole text in a dialog where it keeps its own line breaks, scrolls, stays selectable, and copies in one click. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/app.html | 1 + src/app/app.ts | 3 +- .../dialogs/execution-error-dialog.ts | 33 +++++++++ .../execution-error-dialog.css | 36 ++++++++++ .../execution-error-dialog.html | 28 ++++++++ .../execution-error-dialog.ts | 67 +++++++++++++++++++ .../nodes/task-step-node/task-step-node.html | 17 +++-- .../task-step-node/task-step-node.spec.ts | 38 +++++++++++ .../nodes/task-step-node/task-step-node.ts | 38 +++++++++++ src/styles.css | 23 +++++++ 10 files changed, 276 insertions(+), 8 deletions(-) create mode 100644 src/app/services/dialogs/execution-error-dialog.ts create mode 100644 src/app/shared/execution-error-dialog/execution-error-dialog.css create mode 100644 src/app/shared/execution-error-dialog/execution-error-dialog.html create mode 100644 src/app/shared/execution-error-dialog/execution-error-dialog.ts diff --git a/src/app/app.html b/src/app/app.html index 69ca37e..c6644f3 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,6 +1,7 @@ + diff --git a/src/app/app.ts b/src/app/app.ts index 71434be..a53df2c 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,6 +1,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 { 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'; @@ -15,7 +16,7 @@ import { ProjectDialogComponent } from '@shared/project-dialog/project-dialog'; @Component({ selector: 'app-root', - imports: [RouterOutlet, ConfirmDialogHostComponent, GlobalNotificationComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent, BiasImpactExperimentDialogHostComponent, BiasRerunDialogHostComponent, BiasCompareDialogHostComponent, BiasReportDialogHostComponent, ProjectDialogComponent, ProjectDeleteDialogComponent, ProjectContextDialogComponent], + imports: [RouterOutlet, ConfirmDialogHostComponent, ExecutionErrorDialogHostComponent, 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-error-dialog.ts b/src/app/services/dialogs/execution-error-dialog.ts new file mode 100644 index 0000000..f00c04f --- /dev/null +++ b/src/app/services/dialogs/execution-error-dialog.ts @@ -0,0 +1,33 @@ +import { Injectable, signal } from '@angular/core'; + +export type ExecutionErrorDialogState = { + /** The step the failure belongs to, used as the dialog subtitle. */ + nodeName: string; + nodeId: string | null; + /** The failure text, exactly as the run recorded it: never trimmed for display. */ + errors: string[]; +}; + +/** + * Holds the execution failure currently opened for reading. + * + * A step's error can be a provider payload or a stack hundreds of characters long, which a hover + * tooltip can only truncate. The badge on the node therefore teases the error and opens it here, + * where it can be scrolled, selected and copied. + */ +@Injectable({ providedIn: 'root' }) +export class ExecutionErrorDialogService { + private readonly _state = signal(null); + + readonly state = this._state.asReadonly(); + + open(state: ExecutionErrorDialogState) { + const errors = state.errors.filter((error) => error.trim().length > 0); + if (!errors.length) return; + this._state.set({ ...state, errors }); + } + + close() { + this._state.set(null); + } +} diff --git a/src/app/shared/execution-error-dialog/execution-error-dialog.css b/src/app/shared/execution-error-dialog/execution-error-dialog.css new file mode 100644 index 0000000..7993cb8 --- /dev/null +++ b/src/app/shared/execution-error-dialog/execution-error-dialog.css @@ -0,0 +1,36 @@ +.execution-error__body { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.execution-error__actions { + display: flex; + justify-content: flex-end; +} + +.execution-error__copy { + --mdc-outlined-button-label-text-color: #475569; +} + +/* + * The run records the failure as one string, newlines and all: a stack read line by line is the + * whole point of opening this, so the text keeps its own wrapping and scrolls rather than being + * reflowed or clipped. + */ +.execution-error__text { + margin: 0; + max-height: 55vh; + overflow: auto; + padding: 0.75rem; + border: 1px solid #fecaca; + border-radius: 8px; + background: #fff1f2; + color: #7f1d1d; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.8125rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + user-select: text; +} diff --git a/src/app/shared/execution-error-dialog/execution-error-dialog.html b/src/app/shared/execution-error-dialog/execution-error-dialog.html new file mode 100644 index 0000000..cb56b01 --- /dev/null +++ b/src/app/shared/execution-error-dialog/execution-error-dialog.html @@ -0,0 +1,28 @@ +@if (state(); as dialogState) { + + +
+
+ +
+ + @for (error of dialogState.errors; track $index) { +
{{ error }}
+ } +
+
+} diff --git a/src/app/shared/execution-error-dialog/execution-error-dialog.ts b/src/app/shared/execution-error-dialog/execution-error-dialog.ts new file mode 100644 index 0000000..7451297 --- /dev/null +++ b/src/app/shared/execution-error-dialog/execution-error-dialog.ts @@ -0,0 +1,67 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { ExecutionErrorDialogService } from '@services/dialogs/execution-error-dialog'; +import { ModalShellComponent } from '@shared/modal-shell/modal-shell'; + +/** + * Reads a failed step's error in full. + * + * The node badge can only tease a failure; the text behind it is regularly a provider payload or a + * stack that has to be read line by line and pasted into a bug report. Here it keeps its own + * line breaks, scrolls, stays selectable, and can be copied in one click. + */ +@Component({ + selector: 'app-execution-error-dialog-host', + imports: [MatButtonModule, MatIconModule, ModalShellComponent], + templateUrl: './execution-error-dialog.html', + styleUrl: './execution-error-dialog.css', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ExecutionErrorDialogHostComponent { + private readonly dialog = inject(ExecutionErrorDialogService); + + readonly state = this.dialog.state; + readonly copied = signal(false); + + private copiedReset: ReturnType | null = null; + + readonly title = computed(() => { + const errors = this.state()?.errors ?? []; + return errors.length > 1 ? `${errors.length} execution errors` : 'Execution error'; + }); + + readonly subtitle = computed(() => { + const state = this.state(); + if (!state) return null; + return state.nodeId ? `${state.nodeName} · ${state.nodeId}` : state.nodeName; + }); + + close() { + this.clearCopiedReset(); + this.copied.set(false); + this.dialog.close(); + } + + async copyErrors(event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + const errors = this.state()?.errors ?? []; + if (!errors.length) return; + + try { + await navigator.clipboard.writeText(errors.join('\n\n')); + this.clearCopiedReset(); + this.copied.set(true); + this.copiedReset = setTimeout(() => this.copied.set(false), 2000); + } catch { + // Clipboard access can be denied or missing; the text stays selectable either way. + } + } + + private clearCopiedReset() { + if (this.copiedReset === null) return; + clearTimeout(this.copiedReset); + this.copiedReset = null; + } +} diff --git a/src/app/shared/nodes/task-step-node/task-step-node.html b/src/app/shared/nodes/task-step-node/task-step-node.html index 9eb4e7e..e633ad5 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.html +++ b/src/app/shared/nodes/task-step-node/task-step-node.html @@ -51,15 +51,18 @@ } @if (hasExecutionErrors()) { -
-
+
+
+
-
Execution errors
- @for (error of executionErrors(); track $index) { -
{{ error }}
- } +
{{ executionErrorHeadline() }}
+
{{ executionErrorSummary() }}
+
Click to read and copy the full error
} diff --git a/src/app/shared/nodes/task-step-node/task-step-node.spec.ts b/src/app/shared/nodes/task-step-node/task-step-node.spec.ts index 06326e6..534001c 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.spec.ts +++ b/src/app/shared/nodes/task-step-node/task-step-node.spec.ts @@ -9,6 +9,7 @@ import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-d import { HumanInteractionDialogService } from '@services/dialogs/human-interaction-dialog'; import { TaskExecutionsService } from '@services/task-executions/task-executions'; import { BiasImpactExperimentDialogService } from '@services/dialogs/bias-impact-experiment-dialog'; +import { ExecutionErrorDialogService } from '@services/dialogs/execution-error-dialog'; import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state'; import { BiasImpactReport } from '@models/bias-impact'; @@ -228,6 +229,43 @@ describe('TaskStepNodeComponent bias canvas highlighting', () => { expect(fixture.nativeElement.querySelector('.llm-subflow-badge')).toBeNull(); }); + describe('execution error badge', () => { + const LONG_ERROR = [ + 'LLM provider call failed with status 500 while generating the answer for step-changed, and the body came back empty', + ' at Provider.call(provider.java:120)', + ' at Step.run(step.java:48)' + ].join('\n'); + + it('teases the failure in the tooltip instead of spelling it out', () => { + setStepConfig({ __executionErrors: [LONG_ERROR] }); + + const tooltip = fixture.nativeElement.querySelector('.llm-error-tooltip'); + expect(tooltip.querySelector('.llm-error-title').textContent).toContain('Error executing'); + // One line, clamped: the stack below it is what the dialog is for. + expect(tooltip.querySelector('.llm-error-item').textContent).not.toContain('provider.java'); + expect(component.executionErrorSummary().endsWith('…')).toBe(true); + }); + + it('opens the whole error, unshortened, when the badge is clicked', () => { + const dialog = TestBed.inject(ExecutionErrorDialogService); + const open = vi.spyOn(dialog, 'open'); + setStepConfig({ __executionErrors: [LONG_ERROR] }); + + fixture.nativeElement.querySelector('button.llm-error-alert').click(); + + expect(open).toHaveBeenCalledWith(expect.objectContaining({ + nodeId: 'step-changed', + errors: [LONG_ERROR] + })); + }); + + it('counts the failures in the headline when a step reports more than one', () => { + setStepConfig({ __executionErrors: ['first failure', 'second failure'] }); + + expect(component.executionErrorHeadline()).toBe('Error executing (2)'); + }); + }); + it('flags a node as bias-active when it has active annotation ids in its execution config', () => { expect(component.isBiasActive()).toBe(true); }); diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts index d331ee9..8bfd121 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.ts +++ b/src/app/shared/nodes/task-step-node/task-step-node.ts @@ -7,6 +7,7 @@ import { BiasCapabilities } from '@models/bias-impact'; import { BlocksService } from '@services/blocks/blocks'; import { ContainersService } from '@services/containers/containers'; import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog'; +import { ExecutionErrorDialogService } from '@services/dialogs/execution-error-dialog'; import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-dialog'; import { HumanDecisionOption, @@ -90,6 +91,9 @@ type FieldUiMeta = { changeDetection: ChangeDetectionStrategy.OnPush }) export class TaskStepNodeComponent { + /** How much of a failure the hover tooltip shows before the dialog has to be opened. */ + private static readonly ERROR_SUMMARY_MAX_LENGTH = 90; + private static readonly globalFieldSchemaCache = new Map | null>>(); private static readonly globalFieldUiMetaCache = new Map>(); private static readonly globalFieldLabelCache = new Map>(); @@ -98,6 +102,7 @@ export class TaskStepNodeComponent { private containersService = inject(ContainersService); private settingsDialog = inject(NodeSettingsDialogService); private subflowPreview = inject(SubflowPreviewDialogService); + private executionErrorDialog = inject(ExecutionErrorDialogService); private cdr = inject(ChangeDetectorRef); private humanInteractionDialog = inject(HumanInteractionDialogService); private taskExecutionsService = inject(TaskExecutionsService); @@ -402,6 +407,39 @@ export class TaskStepNodeComponent { return this.executionErrors().length > 0; } + executionErrorHeadline(): string { + const count = this.executionErrors().length; + return count > 1 ? `Error executing (${count})` : 'Error executing'; + } + + /** + * The opening of the failure, at tooltip length. + * + * A step's error is regularly a provider payload or a stack: on hover it can only ever be a + * tease, so it is cut at the first line break and clamped, and the whole of it is a click away. + */ + executionErrorSummary(): string { + const first = this.executionErrors()[0]; + if (!first) return ''; + const firstLine = first.split(/\r?\n/, 1)[0].trim() || first.trim(); + return firstLine.length > TaskStepNodeComponent.ERROR_SUMMARY_MAX_LENGTH + ? `${firstLine.slice(0, TaskStepNodeComponent.ERROR_SUMMARY_MAX_LENGTH).trimEnd()}…` + : firstLine; + } + + openExecutionErrors(event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + const errors = this.executionErrors(); + if (!errors.length) return; + + this.executionErrorDialog.open({ + nodeName: this.nodeTitle(), + nodeId: this.executionNodeId(), + errors + }); + } + executionWarnings(): string[] { return this.getExecutionMessages('__executionWarnings'); } diff --git a/src/styles.css b/src/styles.css index a5e2102..c8101e5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -362,6 +362,14 @@ body.node-focus-modal-open { background: #dc2626; color: #fff7ed; box-shadow: 0 0 0 3px rgba(127, 29, 29, 0.2); + padding: 0; + cursor: pointer; +} + +.llm-error-alert:hover, +.llm-error-alert:focus-visible { + background: #b91c1c; + box-shadow: 0 0 0 4px rgba(127, 29, 29, 0.28); } .llm-warning-alert { @@ -421,6 +429,21 @@ body.node-focus-modal-open { line-height: 1.3; } +/* + * A failure is teased here, never reproduced: the summary is already cut to one line, and this + * keeps a long unbroken token (a URL, a payload) from stretching the tooltip off the canvas. + */ +.llm-error-item { + overflow-wrap: anywhere; +} + +.llm-error-hint { + margin-top: 4px; + font-size: 10px; + font-style: italic; + opacity: 0.8; +} + .llm-param-block { border: 1px solid #dbe2ea; border-radius: 8px;