diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 21960301393c..62c6a91fbf2f 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -5389,6 +5389,7 @@ export const en: TranslationMap = { }, attachments: { attachedFile: "Attached file", + readFailed: "Could not attach: {names}{more}", showInTextField: "Show in text field", outsideAllowedFolders: "Outside allowed folders", unavailable: "Unavailable", diff --git a/ui/src/pages/chat/components/chat-attachments.test.ts b/ui/src/pages/chat/components/chat-attachments.test.ts new file mode 100644 index 000000000000..e0873022d15e --- /dev/null +++ b/ui/src/pages/chat/components/chat-attachments.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { handleChatAttachmentPaste } from "./chat-attachments.ts"; + +vi.mock("../../../lib/toast.ts", () => ({ showToast: vi.fn() })); + +import { showToast } from "../../../lib/toast.ts"; + +class StubFileReader { + static failNames = new Set(); + result: string | ArrayBuffer | null = null; + private listeners = new Map void>>(); + + addEventListener(type: string, listener: () => void) { + const existing = this.listeners.get(type) ?? []; + existing.push(listener); + this.listeners.set(type, existing); + } + + removeEventListener() {} + abort() {} + + readAsDataURL(file: File) { + queueMicrotask(() => { + if (StubFileReader.failNames.has(file.name)) { + this.emit("error"); + return; + } + this.result = "data:image/png;base64,aGk="; + this.emit("load"); + }); + } + + private emit(type: string) { + for (const listener of this.listeners.get(type) ?? []) { + listener(); + } + } +} + +function pasteEventWithFiles(files: File[]): ClipboardEvent { + return { + preventDefault: () => {}, + clipboardData: { + items: files.map((file) => ({ + type: file.type, + getAsFile: () => file, + })), + getData: () => "", + }, + } as unknown as ClipboardEvent; +} + +describe("chat attachment read failures", () => { + const realFileReader = globalThis.FileReader; + + beforeEach(() => { + vi.stubGlobal("FileReader", StubFileReader as unknown as typeof FileReader); + StubFileReader.failNames = new Set(); + }); + + afterEach(() => { + vi.stubGlobal("FileReader", realFileReader); + vi.clearAllMocks(); + }); + + it("names files whose read failed instead of dropping them silently", async () => { + StubFileReader.failNames = new Set(["bad.png"]); + const onAttachmentsChange = vi.fn(); + handleChatAttachmentPaste( + pasteEventWithFiles([ + new File(["ok"], "good.png", { type: "image/png" }), + new File(["broken"], "bad.png", { type: "image/png" }), + ]), + { attachments: [], onAttachmentsChange }, + ); + await vi.waitFor(() => { + expect(onAttachmentsChange).toHaveBeenCalled(); + }); + expect(vi.mocked(showToast)).toHaveBeenCalledTimes(1); + const message = vi.mocked(showToast).mock.calls[0]?.[0]?.message; + // The read-failure toast is a plain t() string, not a template. + expect(typeof message).toBe("string"); + expect(message).toContain("bad.png"); + // The successful sibling still attaches. + const attached = onAttachmentsChange.mock.calls[0]?.[0] as Array<{ fileName?: string }>; + expect(attached).toHaveLength(1); + expect(attached[0]?.fileName).toBe("good.png"); + }); + + it("does not toast when every read succeeds", async () => { + const onAttachmentsChange = vi.fn(); + handleChatAttachmentPaste( + pasteEventWithFiles([new File(["ok"], "good.png", { type: "image/png" })]), + { attachments: [], onAttachmentsChange }, + ); + await vi.waitFor(() => { + expect(onAttachmentsChange).toHaveBeenCalled(); + }); + expect(vi.mocked(showToast)).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/pages/chat/components/chat-attachments.ts b/ui/src/pages/chat/components/chat-attachments.ts index d3e2725bf3cb..9845454e53ac 100644 --- a/ui/src/pages/chat/components/chat-attachments.ts +++ b/ui/src/pages/chat/components/chat-attachments.ts @@ -6,6 +6,7 @@ import "../../../components/tooltip.ts"; import "../../../components/web-awesome.ts"; import { t } from "../../../i18n/index.ts"; import type { BrowserAnnotationAttachment, ChatAttachment } from "../../../lib/chat/chat-types.ts"; +import { showToast } from "../../../lib/toast.ts"; import { generateAttachmentId, getChatAttachmentDataUrl, @@ -295,15 +296,29 @@ async function appendAttachmentFiles(files: readonly File[], props: ChatAttachme } props.onPendingReadsChange?.(1); try { - const additions = ( - await Promise.all(files.map((file) => readAttachmentFile(file, props))) - ).filter((attachment): attachment is ChatAttachment => attachment !== null); + const results = await Promise.all(files.map((file) => readAttachmentFile(file, props))); + const additions = results.filter( + (attachment): attachment is ChatAttachment => attachment !== null, + ); if (props.readSignal?.aborted) { for (const attachment of additions) { releaseChatAttachmentPayload(attachment.id); } return; } + // Unreadable drops (folders, permission-denied files) must not vanish + // silently: name what was skipped so the user knows it never attached. + const failed = results + .map((attachment, index) => (attachment === null ? files[index]?.name : undefined)) + .filter((name): name is string => Boolean(name)); + if (failed.length > 0) { + showToast({ + message: t("chat.attachments.readFailed", { + names: failed.slice(0, 3).join(", "), + more: failed.length > 3 ? ` +${failed.length - 3}` : "", + }), + }); + } if (additions.length === 0) { return; }