Give a file input a picker that says what it takes and what went wrong

The native file control said "Choose file / No file chosen": nothing about
what the input accepts, nothing about the file once chosen, and on failure
a bare "Failed to upload file" that threw away the server's explanation -
the wrong type, a size, a name it could not build a file from.

Replace it with a drop zone that states the accepted types and size up
front, then gives way to the file itself while it uploads and once it
lands, with Replace to change it. A failed upload now names the file that
failed next to what the server actually said, and the zone invites another
try - which also needed the native control cleared, since picking the same
file twice fires no change event and picking the same file again is
exactly what a retry is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-14 12:27:52 +02:00
parent f41962993c
commit 84f528203c
5 changed files with 416 additions and 11 deletions

View File

@ -200,6 +200,204 @@
color: #64748b;
}
/*
* The upload block. The native file control stays in the DOM - only it can open the picker and
* carry `accept` - but off-screen rather than display:none, so it keeps working for assistive
* technology and for tests that drive it directly.
*/
.inputs-panel-upload-native {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.inputs-panel-dropzone {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
width: 100%;
padding: 14px 10px;
border: 1px dashed #cbd5e1;
border-radius: 6px;
background: #f8fafc;
color: #475569;
font: inherit;
text-align: center;
cursor: pointer;
transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
}
.inputs-panel-dropzone:hover:not(:disabled),
.inputs-panel-dropzone:focus-visible {
border-color: #2563eb;
background: #eff6ff;
color: #1d4ed8;
outline: none;
}
/* Dragging over it has to read as a target, not just as hover. */
.inputs-panel-dropzone-active {
border-color: #2563eb;
border-style: solid;
background: #dbeafe;
color: #1d4ed8;
}
.inputs-panel-dropzone-failed {
border-color: #fca5a5;
background: #fef2f2;
color: #b91c1c;
}
.inputs-panel-dropzone:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.inputs-panel-dropzone .mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
line-height: 20px;
}
.inputs-panel-dropzone-title {
font-size: 12px;
font-weight: 500;
}
.inputs-panel-dropzone-hint {
color: #94a3b8;
font-size: 10.5px;
}
/* Once a file is chosen the zone gives way to the file itself: name first, state second. */
.inputs-panel-upload-card {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #ffffff;
font-size: 12px;
}
.inputs-panel-upload-card .mat-icon {
flex: 0 0 auto;
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
}
.inputs-panel-upload-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #0f172a;
font-weight: 500;
}
.inputs-panel-upload-state {
flex: 0 0 auto;
color: #2563eb;
font-size: 11px;
}
.inputs-panel-upload-busy {
border-color: #bfdbfe;
background: #eff6ff;
}
.inputs-panel-upload-busy .mat-icon {
color: #2563eb;
}
.inputs-panel-upload-done {
border-color: #bbf7d0;
background: #f0fdf4;
}
.inputs-panel-upload-done .mat-icon {
color: #16a34a;
}
.inputs-panel-upload-spinner {
animation: inputs-panel-upload-spin 900ms linear infinite;
}
@keyframes inputs-panel-upload-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.inputs-panel-upload-spinner {
animation: none;
}
}
.inputs-panel-upload-replace {
flex: 0 0 auto;
padding: 2px 8px;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #ffffff;
color: #334155;
font: inherit;
font-size: 11px;
cursor: pointer;
}
.inputs-panel-upload-replace:hover:not(:disabled) {
border-color: #94a3b8;
color: #0f172a;
}
.inputs-panel-upload-replace:disabled {
cursor: not-allowed;
opacity: 0.55;
}
/* Names the file that failed, not just that something did, and says what the server said. */
.inputs-panel-upload-error {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 6px;
padding: 8px 10px;
border: 1px solid #fecaca;
border-radius: 6px;
background: #fef2f2;
color: #b91c1c;
font-size: 11.5px;
line-height: 1.45;
}
.inputs-panel-upload-error .mat-icon {
flex: 0 0 auto;
font-size: 16px;
width: 16px;
height: 16px;
line-height: 16px;
}
.inputs-panel-upload-error strong {
font-weight: 600;
word-break: break-all;
}
.inputs-panel-items {
display: flex;
flex-direction: column;

View File

@ -204,15 +204,79 @@
</div>
@if (isFileInput(executionInput)) {
<!--
The native control is kept for what only it can do - open the picker, carry `accept` - and
hidden, because its own "Choose file / No file chosen" says nothing about what this input
takes, what was chosen, or whether the upload got there.
-->
<input
#filePicker
type="file"
class="block w-full text-sm text-slate-700"
class="inputs-panel-upload-native"
[attr.accept]="fileAccept(executionInput)"
[attr.multiple]="isMultipleInput(executionInput) ? '' : null"
[disabled]="readOnly()"
(change)="onFileInputChange(executionInput, $event)" />
@if (fileInputHint(executionInput); as hint) {
<div class="mt-1 text-[11px] text-slate-500">{{ hint }}</div>
@if (isInputSaving(executionInput.key)) {
<div class="inputs-panel-upload-card inputs-panel-upload-busy">
<!-- autorenew, not progress_activity: the latter is a Material Symbols name and this app
ships the classic Material Icons font, where it renders as nothing at all. -->
<mat-icon class="inputs-panel-upload-spinner" fontIcon="autorenew"></mat-icon>
<span class="inputs-panel-upload-name">{{ uploadFileLabel(executionInput) }}</span>
<span class="inputs-panel-upload-state">Uploading…</span>
</div>
} @else if (executionInput.provided && !inputSavingError(executionInput.key)) {
<div class="inputs-panel-upload-card inputs-panel-upload-done">
<mat-icon fontIcon="task_alt"></mat-icon>
<span class="inputs-panel-upload-name" [matTooltip]="uploadFileLabel(executionInput)">
{{ uploadFileLabel(executionInput) }}
</span>
<button
type="button"
class="inputs-panel-upload-replace"
[disabled]="readOnly()"
[attr.aria-label]="'Replace the file uploaded for ' + executionInput.subtitle"
(click)="filePicker.click()">
Replace
</button>
</div>
} @else {
<button
type="button"
class="inputs-panel-dropzone"
[class.inputs-panel-dropzone-active]="isDragging(executionInput)"
[class.inputs-panel-dropzone-failed]="!!inputSavingError(executionInput.key)"
[disabled]="readOnly()"
[attr.aria-label]="'Choose a file for ' + executionInput.subtitle"
(click)="filePicker.click()"
(dragover)="onDragOver(executionInput, $event)"
(dragleave)="onDragLeave(executionInput, $event)"
(drop)="onFileDrop(executionInput, $event)">
<mat-icon [fontIcon]="inputSavingError(executionInput.key) ? 'refresh' : 'cloud_upload'"></mat-icon>
<span class="inputs-panel-dropzone-title">
@if (inputSavingError(executionInput.key)) {
Choose another file, or drop it here
} @else if (isMultipleInput(executionInput)) {
Drop files here, or click to choose
} @else {
Drop a file here, or click to choose
}
</span>
@if (fileInputHint(executionInput); as hint) {
<span class="inputs-panel-dropzone-hint">{{ hint }}</span>
}
</button>
}
@if (inputSavingError(executionInput.key); as uploadError) {
<div class="inputs-panel-upload-error">
<mat-icon fontIcon="error_outline"></mat-icon>
<span>
<strong>{{ uploadFileLabel(executionInput) }}</strong> was not uploaded.
{{ uploadError }}
</span>
</div>
}
} @else if (isMultipleInput(executionInput)) {
@if (itemsOpen(executionInput)) {
@ -263,12 +327,15 @@
(ngModelChange)="onTextInputChange(executionInput, $event)"></textarea>
}
<!-- A file input reports both of these inside its own card, next to the file they are about. -->
@if (!isFileInput(executionInput)) {
@if (isInputSaving(executionInput.key)) {
<div class="mt-1 text-[11px] text-blue-600">Saving…</div>
}
@if (inputSavingError(executionInput.key); as errorMessage) {
<div class="mt-1 text-[11px] text-red-600">{{ errorMessage }}</div>
}
}
</div>
</ng-template>

View File

@ -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 () => {

View File

@ -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<string, boolean>();
/**
* 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<Record<string, string[]>>({});
/** Which drop zone the pointer is currently over, so only that one lights up. */
private readonly draggingKey = signal<string | null>(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 });
}

View File

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