Improve execution input and output presentation

This commit is contained in:
Lucio Lelii 2026-03-11 17:11:37 +01:00
parent 3496344e9d
commit dc45a5581b
5 changed files with 256 additions and 20 deletions

View File

@ -70,7 +70,10 @@
}
@for (executionInput of editableInputs(); track executionInput.key) {
<div class="rounded-md border border-slate-200 bg-slate-50 p-2">
<div class="text-xs font-semibold text-slate-700 mb-1">{{ executionInput.label }}</div>
<div class="mb-3 flex min-h-[52px] flex-col items-center justify-center rounded-[10px] border border-blue-200 bg-gradient-to-b from-blue-50 to-blue-100 px-3 py-2 text-center text-blue-700">
<div class="text-[14px] font-extrabold leading-tight">{{ executionInput.title }}</div>
<div class="mt-1 text-[11px] font-semibold uppercase tracking-[0.04em] text-blue-600">{{ executionInput.subtitle }}</div>
</div>
<div class="text-[10px] text-slate-500 mb-2">Type: {{ executionInput.type }}</div>
@if (isFileInput(executionInput)) {

View File

@ -7,7 +7,8 @@ export type EditableExecutionInput = {
key: string;
nodeId: string;
inputName: string;
label: string;
title: string;
subtitle: string;
type: string;
value: string;
};

View File

@ -54,3 +54,75 @@
white-space: pre-wrap;
word-break: break-word;
}
.execution-aside-tab {
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: #64748b;
font-size: 12px;
font-weight: 700;
padding: 9px 12px;
cursor: pointer;
}
.execution-aside-tab-active {
border-bottom-color: #2563eb;
color: #1d4ed8;
}
.execution-aside-tab:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.execution-output-modal {
margin: 0;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #f8fafc;
color: #0f172a;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
line-height: 1.45;
padding: 14px;
white-space: pre-wrap;
word-break: break-word;
}
.execution-output-badge {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 52px;
border: 1px solid #bfdbfe;
border-radius: 10px;
background: linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%);
color: #1d4ed8;
line-height: 1.2;
padding: 8px 12px;
text-align: center;
}
.execution-output-title {
font-size: 14px;
font-weight: 800;
}
.execution-output-subtitle {
margin-top: 2px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #2563eb;
}
.execution-output-preview {
white-space: pre-wrap;
word-break: break-word;
color: #0f172a;
font-size: 12px;
line-height: 1.45;
}

View File

@ -63,26 +63,88 @@
</button>
@if (contextAsideOpen()) {
<aside class="w-[360px] border border-slate-200 rounded-md bg-white min-h-0 overflow-auto">
<div class="px-3 py-2 text-xs font-semibold text-slate-600 border-b border-slate-200 bg-slate-50">Execution Inputs</div>
<app-task-execution-inputs-panel
[editableInputs]="editableInputs()"
[authorizationRequirements]="missingAuthorizationRequirements()"
[authorizationValues]="pendingAuthorizationValues()"
[savingAuthorizations]="savingAuthorizations()"
[authorizationErrors]="authorizationErrors()"
[savingInputs]="savingInputs()"
[savingErrors]="savingErrors()"
[readOnly]="inputsReadOnly()"
(authorizationValueChange)="onAuthorizationValueChange($event.requirement, $event.value)"
(authorizationSubmit)="submitAuthorization($event)"
(textInputChange)="onTextInputChange($event.input, $event.value)"
(fileInputChange)="onFileInputChange($event.input, $event.file)">
</app-task-execution-inputs-panel>
<aside class="w-[360px] border border-slate-200 rounded-md bg-white min-h-0 overflow-hidden flex flex-col">
<div class="flex border-b border-slate-200 bg-slate-50 px-2 pt-2">
<button
type="button"
class="execution-aside-tab"
[class.execution-aside-tab-active]="activeAsideTab() === 'inputs'"
(click)="selectAsideTab('inputs')">
Execution Inputs
</button>
<button
type="button"
class="execution-aside-tab"
[class.execution-aside-tab-active]="activeAsideTab() === 'output'"
[disabled]="!executionOutputTabEnabled()"
(click)="selectAsideTab('output')">
Execution Output
</button>
</div>
<div class="min-h-0 flex-1 overflow-auto">
@if (activeAsideTab() === 'inputs') {
<app-task-execution-inputs-panel
[editableInputs]="editableInputs()"
[authorizationRequirements]="missingAuthorizationRequirements()"
[authorizationValues]="pendingAuthorizationValues()"
[savingAuthorizations]="savingAuthorizations()"
[authorizationErrors]="authorizationErrors()"
[savingInputs]="savingInputs()"
[savingErrors]="savingErrors()"
[readOnly]="inputsReadOnly()"
(authorizationValueChange)="onAuthorizationValueChange($event.requirement, $event.value)"
(authorizationSubmit)="submitAuthorization($event)"
(textInputChange)="onTextInputChange($event.input, $event.value)"
(fileInputChange)="onFileInputChange($event.input, $event.file)">
</app-task-execution-inputs-panel>
} @else {
<div class="p-3 space-y-3">
@if (!executionOutputs().length) {
<div class="text-xs text-slate-500">No execution output available.</div>
} @else {
@for (output of executionOutputs(); track output.key) {
<div class="rounded-md border border-slate-200 bg-slate-50 p-3">
<div class="execution-output-badge">
<div class="execution-output-title">{{ output.title }}</div>
<div class="execution-output-subtitle">{{ output.subtitle }}</div>
</div>
<div class="execution-output-preview mt-3">{{ output.preview }}</div>
@if (output.isLong) {
<button
type="button"
class="mt-3 text-xs font-semibold text-blue-600 hover:text-blue-700"
(click)="openOutputPreview(output, $event)">
View full output
</button>
}
</div>
}
}
</div>
}
</div>
</aside>
}
</div>
</div>
@if (outputPreviewModal(); as outputModal) {
<div class="fixed inset-0 z-9999 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/45" (click)="closeOutputPreview($event)"></div>
<div class="relative z-10 flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl">
<div class="flex items-start justify-between gap-4 border-b border-slate-200 px-5 py-4">
<div class="min-w-0">
<h3 class="text-lg font-semibold text-slate-900">{{ outputModal.title }}</h3>
<p class="mt-1 break-all text-xs text-slate-500">{{ outputModal.subtitle }}</p>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="closeOutputPreview($event)">Close</button>
</div>
<div class="min-h-0 flex-1 overflow-auto px-5 py-4">
<pre class="execution-output-modal">{{ outputModal.value }}</pre>
</div>
</div>
</div>
}
} @else {
<div class="h-full bg-white border border-slate-200 rounded-md text-slate-400 text-center flex items-center justify-center">
Select an execution from the left panel

View File

@ -14,6 +14,15 @@ import {
import { ReteEditor } from '@shared/rete-editor/rete-editor';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
type ExecutionOutputEntry = {
key: string;
title: string;
subtitle: string;
value: string;
preview: string;
isLong: boolean;
};
@Component({
selector: 'app-task-execution-viewer',
imports: [CommonModule, ReteEditor, TaskExecutionInputsPanelComponent],
@ -27,6 +36,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
private lastExecutionId: string | null = null;
readonly execution = input<TaskExecution | null>(null);
readonly contextAsideOpen = signal(true);
readonly activeAsideTab = signal<'inputs' | 'output'>('inputs');
readonly startInProgress = signal(false);
readonly savingInputs = signal<Record<string, boolean>>({});
readonly savingErrors = signal<Record<string, string>>({});
@ -34,6 +44,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
readonly pendingAuthorizationValues = signal<Record<string, string>>({});
readonly savingAuthorizations = signal<Record<string, boolean>>({});
readonly authorizationErrors = signal<Record<string, string>>({});
readonly outputPreviewModal = signal<ExecutionOutputEntry | null>(null);
constructor() {
effect(() => {
@ -43,6 +54,14 @@ export class TaskExecutionViewerComponent implements OnDestroy {
this.pendingAuthorizationValues.set({});
this.savingAuthorizations.set({});
this.authorizationErrors.set({});
this.activeAsideTab.set('inputs');
this.outputPreviewModal.set(null);
});
effect(() => {
if (!this.executionOutputTabEnabled() && this.activeAsideTab() === 'output') {
this.activeAsideTab.set('inputs');
}
});
}
@ -109,6 +128,35 @@ export class TaskExecutionViewerComponent implements OnDestroy {
return this.formatDuration(context.startTime, context.endTime);
});
readonly executionOutputTabEnabled = computed(() => {
const status = String(this.execution()?.context.status ?? '').toUpperCase();
return status === 'SUCCESS' || status === 'COMPLETED';
});
readonly executionOutputs = computed<ExecutionOutputEntry[]>(() => {
const steps = this.execution()?.context.steps ?? {};
const resultMap = {
...(this.execution()?.context.result ?? {}),
...(this.execution()?.context.executionResult ?? {})
};
return Object.entries(resultMap)
.map(([key, rawValue]) => {
const value = this.stringifyOutputValue(rawValue);
const isLong = value.length > 160;
const label = this.formatExecutionOutputLabel(key, steps);
return {
key,
title: label.title,
subtitle: label.subtitle,
value,
preview: isLong ? `${value.slice(0, 160)}...` : value,
isLong
};
})
.sort((a, b) => a.title.localeCompare(b.title) || a.subtitle.localeCompare(b.subtitle));
});
readonly inputsReadOnly = computed(() => {
const status = this.execution()?.context.status;
return getExecutionStatusGroup(status) !== 'INIT';
@ -168,20 +216,39 @@ export class TaskExecutionViewerComponent implements OnDestroy {
key,
nodeId: step.id,
inputName,
label: `${step.block.name}.${inputName}`,
title: step.block.name,
subtitle: inputName,
type: String(input.descriptor?.type ?? 'TEXT').toUpperCase(),
value: pendingValue ?? (rawValue == null ? '' : String(rawValue))
});
}
}
return entries.sort((a, b) => a.label.localeCompare(b.label));
return entries.sort((a, b) => a.title.localeCompare(b.title) || a.subtitle.localeCompare(b.subtitle));
});
toggleContextAside() {
this.contextAsideOpen.update((open) => !open);
}
selectAsideTab(tab: 'inputs' | 'output') {
if (tab === 'output' && !this.executionOutputTabEnabled()) return;
this.activeAsideTab.set(tab);
}
openOutputPreview(output: ExecutionOutputEntry, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!output.isLong) return;
this.outputPreviewModal.set(output);
}
closeOutputPreview(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.outputPreviewModal.set(null);
}
startExecution() {
const executionId = this.execution()?.id;
if (!executionId || !this.canStartExecution() || this.startInProgress()) return;
@ -326,6 +393,37 @@ export class TaskExecutionViewerComponent implements OnDestroy {
});
}
private stringifyOutputValue(value: unknown): string {
if (value == null) return '';
if (typeof value === 'string') return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
private formatExecutionOutputLabel(
key: string,
steps: Record<string, TaskExecutionStep>
): { title: string; subtitle: string } {
const separatorIndex = key.indexOf(':');
if (separatorIndex < 0) {
return { title: key, subtitle: 'Execution output' };
}
const nodeId = key.slice(0, separatorIndex);
const outputName = key.slice(separatorIndex + 1);
const step = steps[nodeId];
const blockName = step?.block?.name?.trim();
return {
title: blockName || key,
subtitle: outputName || 'Execution output'
};
}
private clearDebounceTimer(timerKey: string) {
const timer = this.textInputDebounceTimers.get(timerKey);
if (!timer) return;