diff --git a/docs/acceptance/image-clipboard-feedback-20261022/README.md b/docs/acceptance/image-clipboard-feedback-20261022/README.md new file mode 100644 index 00000000..c2dccfd3 --- /dev/null +++ b/docs/acceptance/image-clipboard-feedback-20261022/README.md @@ -0,0 +1,25 @@ +# Image clipboard failure feedback + +The image thumbnail's context menu previously stored action failures as image-loading errors. A denied clipboard write therefore offered **Reload image**, concealed the actual reason in the button title, and could leave that error behind after a later successful copy. It now displays an accessible action error while retaining the valid thumbnail. A successful retry replaces the error with **Image copied**. Both the thumbnail menu and original viewer use the same localized clipboard-denial message. + +## Real browser comparison + +Captured in Microsoft Edge on Windows, 1280 × 900, using the production frontend and production `workbenchjourney` Go API with a temporary project. Before uses `fc0935d6fc03ca7abccbd148ce52d26c2bc154f8`; after uses this change's production build. Each view uploads the same real 1258 × 802 PNG through the file picker. + +Playwright configured the test browser's clipboard permission to denied; `navigator.permissions.query` confirmed that state. The API, clipboard methods, responses and DOM were not replaced. Right-click **Copy image** then invoked the browser's real clipboard API. + +| Before: misleading reload action | After: reason and next action | +| --- | --- | +| ![Before](before.png) | ![After](after.png) | + +After permitting clipboard access in that test browser, the same thumbnail copied successfully and the previous error disappeared: + +![Copy succeeds after granting permission](recovered.png) + +## Verification + +- New regression failed on the previous implementation and passed after the fix. It verifies readable failure feedback, no image-reload action, successful retry clearing the error, one original download and reuse of verified bytes. +- Image input and viewer suites: 25 tests passed. TypeScript and production build passed. +- The real browser's **Save image as…** action remained available while clipboard access was denied. The downloaded file retained SHA-256 `1caabbcbf77b317544d9040675eb72da092e7163b769ec64f6e218622f2562b8`. +- Reading the actual clipboard after recovery returned PNG at the original 1258 × 802 dimensions. +- The test provider does not declare image model support, so the screenshot shows the existing send restriction. No image was sent to a model during this copy/download check. diff --git a/docs/acceptance/image-clipboard-feedback-20261022/after.png b/docs/acceptance/image-clipboard-feedback-20261022/after.png new file mode 100644 index 00000000..a4354672 Binary files /dev/null and b/docs/acceptance/image-clipboard-feedback-20261022/after.png differ diff --git a/docs/acceptance/image-clipboard-feedback-20261022/before.png b/docs/acceptance/image-clipboard-feedback-20261022/before.png new file mode 100644 index 00000000..6dc5ecb0 Binary files /dev/null and b/docs/acceptance/image-clipboard-feedback-20261022/before.png differ diff --git a/docs/acceptance/image-clipboard-feedback-20261022/recovered.png b/docs/acceptance/image-clipboard-feedback-20261022/recovered.png new file mode 100644 index 00000000..893805a3 Binary files /dev/null and b/docs/acceptance/image-clipboard-feedback-20261022/recovered.png differ diff --git a/web/src/v2/components/conversation.test.tsx b/web/src/v2/components/conversation.test.tsx index 007c5ebc..48053bf3 100644 --- a/web/src/v2/components/conversation.test.tsx +++ b/web/src/v2/components/conversation.test.tsx @@ -988,7 +988,7 @@ describe("V2Conversation", () => { view.queryClient.setQueryData(v2QueryKeys.execution("thread-a"), { ...execution, state: "idle", execution_id: undefined }); }); - expect(screen.getByText("发送消息即可继续,也可以仅解除暂停。")).toBeInTheDocument(); + expect(await screen.findByText("发送消息即可继续,也可以仅解除暂停。")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "发送 thread-a" })); await waitFor(() => expect(submitThreadTurn).toHaveBeenCalledWith("thread-a", expect.objectContaining({ content: "pending-thread-a" }), expect.any(String))); diff --git a/web/src/v2/components/image-input.css b/web/src/v2/components/image-input.css index d43932db..5ae566a0 100644 --- a/web/src/v2/components/image-input.css +++ b/web/src/v2/components/image-input.css @@ -7,6 +7,8 @@ .v2-image-remove { position:absolute; right:-5px; top:-5px; display:grid; place-items:center; width:24px; height:24px; border:1px solid var(--v2-border-strong); border-radius:50%; background:var(--prayu-surface-solid); color:inherit; } .v2-image-remove:disabled { opacity:.45; } .v2-image-retry { font-size:12px; background:transparent; color:inherit; border:0; text-decoration:underline; } +.v2-image-notice { display:block; margin-top:5px; line-height:1.5; overflow-wrap:anywhere; } +.v2-image-notice[role="alert"] { color:var(--v2-danger); } .v2-image-overlay { position:fixed; inset:0; z-index:2000; display:grid; place-items:center; padding:24px; background:color-mix(in srgb, var(--prayu-primary-fg) 74%, transparent); } .v2-image-dialog { display:grid; grid-template-rows:auto minmax(0, 1fr) auto; width:min(1440px, 100%); height:min(960px, calc(100dvh - 48px)); min-width:0; border:1px solid var(--v2-border); border-radius:12px; background:var(--prayu-surface-solid); overflow:hidden; color:var(--v2-ink); box-shadow:0 20px 80px var(--prayu-glass-shadow); } .v2-image-viewer-header { display:flex; gap:10px; align-items:center; padding:12px 16px; border-bottom:1px solid var(--v2-border); } diff --git a/web/src/v2/components/image-input.tsx b/web/src/v2/components/image-input.tsx index 3c8fd9d9..2e50f271 100644 --- a/web/src/v2/components/image-input.tsx +++ b/web/src/v2/components/image-input.tsx @@ -33,7 +33,7 @@ function ImagePreview({ client, image, onRemove, locked }: { const [retry, setRetry] = useState(0); const [open, setOpen] = useState(false); const [menu, setMenu] = useState(null); - const [notice, setNotice] = useState(""); + const [notice, setNotice] = useState({ message: "", error: false }); const trigger = useRef(null); const close = () => setOpen(false); useEffect(() => { @@ -70,8 +70,8 @@ function ImagePreview({ client, image, onRemove, locked }: { {onRemove && } {locked && {t("随原消息提交", "Submitting with original message")}} - {notice && {notice}} - {menu && setMenu(null)} onResult={(message, failed) => { if (failed) setError(message); else setNotice(message); }} />} + {notice.message && {notice.message}} + {menu && setMenu(null)} onResult={(message, error = false) => setNotice({ message, error })} />} {error && } {open && original && } ; diff --git a/web/src/v2/components/image-viewer.test.tsx b/web/src/v2/components/image-viewer.test.tsx index 9d5c1eed..a0a8fcdb 100644 --- a/web/src/v2/components/image-viewer.test.tsx +++ b/web/src/v2/components/image-viewer.test.tsx @@ -83,6 +83,28 @@ it("copies the verified original PNG only after a click and reports success only expect(client.downloadWorkspaceImage).toHaveBeenCalledTimes(1); }); +it("reports a thumbnail copy rejection without asking to reload a valid image, and clears it after success", async () => { + const write = vi.fn().mockRejectedValueOnce(new DOMException("Clipboard write denied", "NotAllowedError")) + .mockResolvedValueOnce(undefined); + const contents = clipboard(write); + const client = fixture(); + render(); + const trigger = screen.getByRole("button", { name: `预览图片 ${original.name}` }); + await waitFor(() => expect(trigger).toBeEnabled()); + fireEvent.contextMenu(trigger); + fireEvent.click(screen.getByRole("menuitem", { name: "复制图片" })); + expect(await screen.findByRole("alert")).toHaveTextContent("浏览器未允许写入剪贴板"); + expect(screen.queryByRole("button", { name: "重新读取图片" })).not.toBeInTheDocument(); + expect(screen.queryByText("已复制图片")).not.toBeInTheDocument(); + expect(trigger).toBeEnabled(); + fireEvent.contextMenu(trigger); + fireEvent.click(screen.getByRole("menuitem", { name: "复制图片" })); + expect(await screen.findByText("已复制图片")).toHaveAttribute("role", "status"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(client.downloadWorkspaceImage).toHaveBeenCalledTimes(1); + expect(await contents[1]["image/png"]).toBe(png); +}); + it("converts JPEG only for the clipboard and keeps the original download and dimensions", async () => { const jpeg = new Blob(["original JPEG bytes"], { type: "image/jpeg" }); const image: WorkspaceImageAttachment = { ...original, mime_type: "image/jpeg", name: "原图.jpg" }; diff --git a/web/src/v2/components/image-viewer.tsx b/web/src/v2/components/image-viewer.tsx index 08e704a6..652dd0b5 100644 --- a/web/src/v2/components/image-viewer.tsx +++ b/web/src/v2/components/image-viewer.tsx @@ -10,7 +10,14 @@ export async function copyImageToClipboard(blob: Blob, image: PreviewImage, t: L if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") throw new Error(t("当前浏览器不支持复制图片,请下载原图。", "This browser cannot copy images. Download the original.")); const png = clipboardPNG(blob, image, t); void png.catch(() => {}); - await navigator.clipboard.write([new ClipboardItem({ "image/png": png })]); + try { + await navigator.clipboard.write([new ClipboardItem({ "image/png": png })]); + } catch (failure) { + if (failure instanceof DOMException && failure.name === "NotAllowedError") { + throw new Error(t("浏览器未允许写入剪贴板,请允许权限后重试,或下载原图。", "Clipboard access was denied. Allow it and retry, or download the original image.")); + } + throw failure; + } } async function clipboardPNG(blob: Blob, image: PreviewImage, t: LocaleText): Promise { // Reuse the authenticated, hash-verified bytes. Fetching the display's blob: @@ -81,9 +88,7 @@ export function V2ImageViewer({ image, url, blob, onClose, returnFocusRef }: { await copyImageToClipboard(blob, image, t); setCopied(true); } catch (failure) { - setCopyError(failure instanceof DOMException && failure.name === "NotAllowedError" - ? t("浏览器未允许写入剪贴板,请允许权限后重试,或下载原图。", "Clipboard access was denied. Allow it and retry, or download the original image.") - : t("复制图片失败:", "Could not copy image: ") + (failure instanceof Error ? failure.message : t("请重试,或下载原图。", "Try again or download the original image."))); + setCopyError(t("复制图片失败:", "Could not copy image: ") + (failure instanceof Error ? failure.message : t("请重试,或下载原图。", "Try again or download the original image."))); } finally { setCopying(false); } };