}
@if (inputSavingError(executionInput.key); as errorMessage) {
{{ errorMessage }}
}
+ }
diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts
index a139204..22339f1 100644
--- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts
+++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.spec.ts
@@ -101,7 +101,38 @@ describe('TaskExecutionInputsPanelComponent', () => {
const picker = fixture.nativeElement.querySelector('input[type="file"]') as HTMLInputElement;
expect(picker.accept).toBe('application/pdf');
- expect(fixture.nativeElement.textContent).toContain('Allowed: PDF; up to 4 files; total up to 32,000,000 bytes.');
+ // Said before a file is picked, in the units someone reads a file size in.
+ expect(fixture.nativeElement.textContent).toContain('PDF · up to 4 files · max 32 MB');
+ expect(fixture.nativeElement.querySelector('.inputs-panel-dropzone')).toBeTruthy();
+ });
+
+ it('names the file it is uploading, and what it uploaded, instead of the native picker', async () => {
+ const fixture = await build([makeInput({
+ key: 'global:document',
+ inputName: 'document',
+ type: 'FILE',
+ multiple: false,
+ fileConstraints: { acceptedMediaTypes: ['application/pdf'], maxFiles: 1, maxTotalBytes: 32_000_000 }
+ })]);
+
+ const picker = fixture.nativeElement.querySelector('input[type="file"]') as HTMLInputElement;
+ const file = new File(['%PDF-1.7'], 'plan-01.pdf', { type: 'application/pdf' });
+ Object.defineProperty(picker, 'files', { value: [file], configurable: true });
+ picker.dispatchEvent(new Event('change'));
+ fixture.detectChanges();
+
+ fixture.componentRef.setInput('savingInputs', { 'global:document': true });
+ fixture.detectChanges();
+ expect(fixture.nativeElement.textContent).toContain('plan-01.pdf');
+ expect(fixture.nativeElement.textContent).toContain('Uploading');
+
+ fixture.componentRef.setInput('savingInputs', {});
+ fixture.componentRef.setInput('savingErrors', { 'global:document': 'Only PDF files are accepted.' });
+ fixture.detectChanges();
+ // The file that failed is named next to why, and the zone invites another try.
+ expect(fixture.nativeElement.textContent).toContain('plan-01.pdf');
+ expect(fixture.nativeElement.textContent).toContain('Only PDF files are accepted.');
+ expect(fixture.nativeElement.querySelector('.inputs-panel-upload-error')).toBeTruthy();
});
it('counts every input, node ones included, since all of them are required', async () => {
diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts
index 329f9f9..c66918d 100644
--- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts
+++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts
@@ -93,6 +93,34 @@ function formatBytes(bytes: number): string {
return `${new Intl.NumberFormat('en-US').format(bytes)} bytes`;
}
+/**
+ * Sizes for the hint above the picker, where the point is to set expectations before a file is
+ * chosen. A rejection still reports exact bytes: there, the difference between 32,000,000 and
+ * 32,000,001 is the whole reason the file was refused.
+ */
+function formatBytesShort(bytes: number): string {
+ const units: [number, string][] = [[1_000_000_000, 'GB'], [1_000_000, 'MB'], [1_000, 'KB']];
+ for (const [scale, unit] of units) {
+ if (bytes >= scale) {
+ const value = bytes / scale;
+ return `${Number.isInteger(value) ? value : value.toFixed(1)} ${unit}`;
+ }
+ }
+ return `${bytes} bytes`;
+}
+
+/**
+ * The name to show for a file the run already holds. The stored value is the server's temp path,
+ * whose name is the input's name, then digits, then the original filename - stripping that lead-in
+ * is what gets back to the name the person recognises.
+ */
+function storedFileName(storedPath: string, inputName: string): string {
+ const base = storedPath.split(/[\\/]/).pop() ?? storedPath;
+ const escapedInputName = inputName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const stripped = base.replace(new RegExp(`^${escapedInputName}_*\\d+`), '');
+ return stripped.trim().length ? stripped : base;
+}
+
function mediaTypeLabel(mediaTypes: string[]): string {
const labels = mediaTypes.map((mediaType) => {
if (mediaType === 'image/png') return 'PNG';
@@ -187,6 +215,15 @@ export class TaskExecutionInputsPanelComponent {
private readonly authorizationVisibility = new Map();
+ /**
+ * What was picked, per input key. The native control reports a file once and the panel has to keep
+ * naming it afterwards - while it uploads, and after the value stored for it is a server path.
+ */
+ private readonly selectedFileNames = signal>({});
+
+ /** Which drop zone the pointer is currently over, so only that one lights up. */
+ private readonly draggingKey = signal(null);
+
private readonly pendingKeySet = computed(() => new Set(this.pendingKeys()));
readonly pendingCount = computed(() => this.editableInputs()
@@ -378,15 +415,57 @@ export class TaskExecutionInputsPanelComponent {
return mediaTypes.length ? mediaTypes.join(',') : null;
}
+ /**
+ * What this input will accept, said before a file is chosen rather than after one is refused.
+ * A file count is only worth saying on an input that takes more than one.
+ */
fileInputHint(input: EditableExecutionInput): string | null {
const constraints = input.fileConstraints;
if (!constraints) return null;
const parts: string[] = [];
const mediaTypes = constraints.acceptedMediaTypes ?? [];
- if (mediaTypes.length) parts.push(`Allowed: ${mediaTypeLabel(mediaTypes)}`);
- if (constraints.maxFiles != null) parts.push(`up to ${constraints.maxFiles} files`);
- if (constraints.maxTotalBytes != null) parts.push(`total up to ${formatBytes(constraints.maxTotalBytes)}`);
- return parts.length ? `${parts.join('; ')}.` : null;
+ if (mediaTypes.length) parts.push(mediaTypeLabel(mediaTypes));
+ if (input.multiple && constraints.maxFiles != null) parts.push(`up to ${constraints.maxFiles} files`);
+ if (constraints.maxTotalBytes != null) parts.push(`max ${formatBytesShort(constraints.maxTotalBytes)}`);
+ return parts.length ? parts.join(' · ') : null;
+ }
+
+ isDragging(input: EditableExecutionInput): boolean {
+ return this.draggingKey() === input.key;
+ }
+
+ onDragOver(input: EditableExecutionInput, event: DragEvent) {
+ if (this.readOnly()) return;
+ event.preventDefault();
+ this.draggingKey.set(input.key);
+ }
+
+ onDragLeave(input: EditableExecutionInput, event: DragEvent) {
+ event.preventDefault();
+ if (this.draggingKey() === input.key) {
+ this.draggingKey.set(null);
+ }
+ }
+
+ onFileDrop(input: EditableExecutionInput, event: DragEvent) {
+ event.preventDefault();
+ this.draggingKey.set(null);
+ if (this.readOnly()) return;
+ const files = event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : [];
+ if (files.length) {
+ this.emitFiles(input, files);
+ }
+ }
+
+ /** The name to show for a file input: what was picked here, else what the run already holds. */
+ uploadFileLabel(input: EditableExecutionInput): string {
+ const picked = this.selectedFileNames()[input.key] ?? [];
+ if (picked.length) return picked.join(', ');
+ const values = Array.isArray(input.value) ? input.value : [input.value];
+ const names = values
+ .filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
+ .map((value) => storedFileName(value, input.inputName));
+ return names.length ? names.join(', ') : 'Uploaded file';
}
textValues(input: EditableExecutionInput): string[] {
@@ -409,10 +488,19 @@ export class TaskExecutionInputsPanelComponent {
}
onFileInputChange(input: EditableExecutionInput, event: Event) {
- if (this.readOnly()) return;
const target = event.target as HTMLInputElement | null;
const files = target?.files ? Array.from(target.files) : [];
- if (!files.length) return;
+ // Clearing the control is what makes a retry possible: picking the same file twice fires no
+ // change event, and picking the same file again is exactly what follows a failed upload.
+ if (target) {
+ target.value = '';
+ }
+ if (this.readOnly() || !files.length) return;
+ this.emitFiles(input, files);
+ }
+
+ private emitFiles(input: EditableExecutionInput, files: File[]) {
+ this.selectedFileNames.update((current) => ({ ...current, [input.key]: files.map((file) => file.name) }));
this.fileInputChange.emit({ input, files });
}
diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts
index 028d992..12b2a2a 100644
--- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts
+++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts
@@ -105,6 +105,25 @@ import {
resolveExecutionDependencies
} from './execution-graph';
+/**
+ * What to tell someone whose upload was refused. The API answers a rejection with a `detail` saying
+ * why - the type it will not take, the size, a name it cannot build a file from - and that sentence
+ * is the only part they can act on, so it is what gets shown. The generic line is the fallback for
+ * a failure that arrived without one, a dropped connection being the usual case.
+ */
+function uploadFailureMessage(failure: unknown): string {
+ const body = (failure as { error?: unknown } | null)?.error;
+ const detail = typeof body === 'string'
+ ? body
+ : (body as { detail?: unknown; message?: unknown } | null)?.detail
+ ?? (body as { detail?: unknown; message?: unknown } | null)?.message;
+ const text = typeof detail === 'string' ? detail.trim() : '';
+ if (!text || text.startsWith('<')) {
+ return 'The upload did not reach the server. Check the connection and try again.';
+ }
+ return text.endsWith('.') ? text : `${text}.`;
+}
+
@Component({
selector: 'app-task-execution-viewer',
imports: [CommonModule, FormsModule, ReteEditor, TaskExecutionInputsPanelComponent, MatButtonModule, MatIconModule, MatTooltipModule, MatFormFieldModule, MatSelectModule, BiasImpactReportListComponent, JsonViewerComponent],
@@ -1289,7 +1308,9 @@ export class TaskExecutionViewerComponent implements OnDestroy {
request$.subscribe({
next: () => this.clearInputSaving(input.key),
- error: () => this.setInputError(input.key, 'Failed to upload file')
+ // The server explains a refused upload - the wrong type, a size, a name it cannot use. Saying
+ // only "Failed to upload file" threw all of that away and left nothing to act on.
+ error: (failure) => this.setInputError(input.key, uploadFailureMessage(failure))
});
}