fix(uploads): say how many files a mixed pick, drop or paste skipped

A selection mixing a file the server takes with one it refuses dropped the refused one without a word, and the Compose and paste-dialog pickers never toasted even when every file was refused. Every pick, drop and paste filter now goes through one helper that counts what the classifier refused: a partial refusal toasts how many were skipped while the rest upload, a full one keeps the existing Unsupported file type toast, and a clipboard paste that falls back to its text stays quiet.
This commit is contained in:
JD committed 2026-10-16 10:37:20 -04:00
1 parent ce809eac41
commit 2cfa2783ad
10 files changed
+228 -44

No files matched your search

+77 -1
View File
@@ -13,6 +13,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const accessorySource = readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8');
const appSource = readFileSync(resolve('src/web/public/app.js'), 'utf8');
const imageInputSource = readFileSync(resolve('src/web/public/image-input.js'), 'utf8');
// The real filter-and-toast helper both dialogs call (image-input.js), so these
// tests pin the shipped wording rather than a copy of it.
const { _admitPromptAttachments } = new Function(
`class CodemanApp {}\n${imageInputSource}\nreturn CodemanApp.prototype;`
)() as { _admitPromptAttachments: (files: unknown[], options?: { textPasted?: boolean }) => unknown[] };
type Timer = { callback: () => void; delay: number };
@@ -54,6 +60,7 @@ function loadComposer(sessionId = 'session-1') {
? 'document'
: null,
showToast: vi.fn(),
_admitPromptAttachments,
};
const schedule = (callback: () => void, delay = 0) => {
timers.push({ callback, delay });
@@ -566,7 +573,7 @@ describe('mobile prompt composer', () => {
const pasteImage = () => {
const item = { type: 'image/png', getAsFile: () => image };
const event = new document.defaultView!.Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: { items: [item] } });
Object.defineProperty(event, 'clipboardData', { value: { items: [item], getData: () => '' } });
input.dispatchEvent(event);
};
@@ -597,9 +604,37 @@ describe('mobile prompt composer', () => {
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([video, pdf], { insert: false });
expect(app.showToast).toHaveBeenCalledWith('1 file skipped: unsupported type', 'info');
await vi.waitFor(() => expect(textarea(document).value).toContain('/tmp/image-one.png'));
});
it('says so when the Compose picker gets nothing it can upload', () => {
const { app, bar, document } = loadComposer();
bar.composePrompt();
const fileInput = document.querySelector('.paste-file-input') as HTMLInputElement;
const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' });
Object.defineProperty(fileInput, 'files', { configurable: true, value: [script] });
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
expect(app.showToast).toHaveBeenCalledWith('Unsupported file type', 'error');
});
it('leaves a text paste that also carries a refused file to the editor, without a toast', () => {
const { app, bar, document } = loadComposer();
bar.composePrompt();
const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' });
const event = new document.defaultView!.Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: { items: [{ type: 'text/x-shellscript', getAsFile: () => script }], getData: () => 'echo hi' },
});
textarea(document).dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
expect(app.showToast).not.toHaveBeenCalled();
});
it('puts an uploaded document path into the draft like a photo', async () => {
const { app, bar, document } = loadComposer();
app._uploadAndInsertImages.mockResolvedValueOnce(['/case/.codeman-uploads/paste-1-ab-notes.pdf']);
@@ -628,6 +663,7 @@ describe('mobile prompt composer', () => {
{ type: 'text/plain', getAsFile: () => null },
{ type: 'video/mp4', getAsFile: () => video },
],
getData: () => '',
},
});
input.dispatchEvent(event);
@@ -724,6 +760,46 @@ describe('mobile shell Paste', () => {
expect(document.querySelector('.paste-overlay')).toBeNull();
});
it('uploads the admitted files of a mixed pick and says how many it skipped', () => {
const { app, document } = openShellPaste();
const fileInput = document.querySelector('.paste-overlay .paste-file-input') as HTMLInputElement;
const image = new document.defaultView!.File(['image'], 'shot.png', { type: 'image/png' });
const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' });
Object.defineProperty(fileInput, 'files', { configurable: true, value: [image, script] });
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([image], { sessionId: 'session-1' });
expect(app.showToast).toHaveBeenCalledWith('1 file skipped: unsupported type', 'info');
});
it('keeps the dialog open and says so when the pick holds nothing it can upload', () => {
const { app, document } = openShellPaste();
const fileInput = document.querySelector('.paste-overlay .paste-file-input') as HTMLInputElement;
const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' });
Object.defineProperty(fileInput, 'files', { configurable: true, value: [script] });
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
expect(app.showToast).toHaveBeenCalledWith('Unsupported file type', 'error');
expect(document.querySelector('.paste-overlay')).not.toBeNull();
});
it('leaves a text paste that also carries a refused file to the textarea, without a toast', () => {
const { app, document, input } = openShellPaste();
const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' });
const event = new document.defaultView!.Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: { items: [{ type: 'text/x-shellscript', getAsFile: () => script }], getData: () => 'echo hi' },
});
input.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
expect(app.showToast).not.toHaveBeenCalled();
expect(document.querySelector('.paste-overlay')).not.toBeNull();
});
it('sends nothing for an empty paste', () => {
const { app, document, send, runTimers } = openShellPaste();