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) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-09 16:49:40 +02:00
parent 0aec372d73
commit 86e61690bb
10 changed files with 276 additions and 8 deletions

View File

@ -1,6 +1,7 @@
<router-outlet />
<app-global-notification />
<app-confirm-dialog-host></app-confirm-dialog-host>
<app-execution-error-dialog-host></app-execution-error-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>

View File

@ -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

View File

@ -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<ExecutionErrorDialogState | null>(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);
}
}

View File

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

View File

@ -0,0 +1,28 @@
@if (state(); as dialogState) {
<app-modal-shell
[title]="title()"
[subtitle]="subtitle()"
ariaLabel="Execution error details"
maxWidth="760px"
(backdropClick)="close()"
(closeClick)="close()">
<div class="execution-error__body">
<div class="execution-error__actions">
<button
type="button"
mat-stroked-button
class="execution-error__copy"
aria-label="Copy the error text"
(click)="copyErrors($event)">
<mat-icon [fontIcon]="copied() ? 'check' : 'content_copy'"></mat-icon>
<span>{{ copied() ? 'Copied' : 'Copy' }}</span>
</button>
</div>
@for (error of dialogState.errors; track $index) {
<pre class="execution-error__text">{{ error }}</pre>
}
</div>
</app-modal-shell>
}

View File

@ -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<typeof setTimeout> | 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;
}
}

View File

@ -51,15 +51,18 @@
</div>
}
@if (hasExecutionErrors()) {
<div class="llm-error-alert-wrap">
<div class="llm-error-alert">
<div class="llm-error-alert-wrap" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<button
type="button"
class="llm-error-alert"
aria-label="Open the execution error"
(click)="openExecutionErrors($event)">
<i class="bi bi-exclamation-triangle-fill"></i>
</div>
</button>
<div class="llm-error-tooltip">
<div class="llm-error-title">Execution errors</div>
@for (error of executionErrors(); track $index) {
<div class="llm-error-item">{{ error }}</div>
}
<div class="llm-error-title">{{ executionErrorHeadline() }}</div>
<div class="llm-error-item">{{ executionErrorSummary() }}</div>
<div class="llm-error-hint">Click to read and copy the full error</div>
</div>
</div>
}

View File

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

View File

@ -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<string, Map<string, Record<string, any> | null>>();
private static readonly globalFieldUiMetaCache = new Map<string, Map<string, FieldUiMeta>>();
private static readonly globalFieldLabelCache = new Map<string, Map<string, string>>();
@ -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');
}

View File

@ -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;