Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/acceptance/image-clipboard-feedback-20261022/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion web/src/v2/components/conversation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down
2 changes: 2 additions & 0 deletions web/src/v2/components/image-input.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions web/src/v2/components/image-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ function ImagePreview({ client, image, onRemove, locked }: {
const [retry, setRetry] = useState(0);
const [open, setOpen] = useState(false);
const [menu, setMenu] = useState<MenuPosition | null>(null);
const [notice, setNotice] = useState("");
const [notice, setNotice] = useState({ message: "", error: false });
const trigger = useRef<HTMLButtonElement>(null);
const close = () => setOpen(false);
useEffect(() => {
Expand Down Expand Up @@ -70,8 +70,8 @@ function ImagePreview({ client, image, onRemove, locked }: {
{onRemove && <button className="v2-image-remove" type="button" aria-label={t(`移除图片 ${name}`, `Remove image ${name}`)} disabled={locked}
onClick={onRemove}><X size={14} aria-hidden="true" /></button>}
{locked && <small>{t("随原消息提交", "Submitting with original message")}</small>}
{notice && <small role="status">{notice}</small>}
{menu && <ResourceMenu position={menu} items={menuItems} onClose={() => setMenu(null)} onResult={(message, failed) => { if (failed) setError(message); else setNotice(message); }} />}
{notice.message && <small className="v2-image-notice" role={notice.error ? "alert" : "status"}>{notice.message}</small>}
{menu && <ResourceMenu position={menu} items={menuItems} onClose={() => setMenu(null)} onResult={(message, error = false) => setNotice({ message, error })} />}
{error && <button className="v2-image-retry" title={error} type="button" onClick={() => setRetry((value) => value + 1)}>{t("重新读取图片", "Reload image")}</button>}
{open && original && <V2ImageViewer image={image} url={original.url} blob={original.blob} onClose={close} returnFocusRef={trigger} />}
</div>;
Expand Down
22 changes: 22 additions & 0 deletions web/src/v2/components/image-viewer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<V2ImagePreview client={client} images={[original]} />);
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" };
Expand Down
13 changes: 9 additions & 4 deletions web/src/v2/components/image-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Blob> {
// Reuse the authenticated, hash-verified bytes. Fetching the display's blob:
Expand Down Expand Up @@ -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); }
};

Expand Down
Loading