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:
parent
0aec372d73
commit
86e61690bb
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue