diff --git a/docs/acceptance/browser-screenshot-viewer-20261022/README.md b/docs/acceptance/browser-screenshot-viewer-20261022/README.md new file mode 100644 index 00000000..bbef6695 --- /dev/null +++ b/docs/acceptance/browser-screenshot-viewer-20261022/README.md @@ -0,0 +1,40 @@ +# Agent Browser screenshot viewer + +Clicking an Agent Browser screenshot now opens the existing original-image viewer: fit or 100% size, zoom, copy PNG, download the original, and Escape to return focus. An updated screenshot, browser session, or Run closes the previous preview. Verification or image-decoding failures do not enable a preview. + +## Real UI comparison + +Captured in Microsoft Edge on Windows. Both versions use the same production Go API (`cmd/workbenchjourney`) and the same screenshot produced by the native Chrome browser tool against its owned local test page. The offline provider supplies deterministic tool calls. The baseline is `fc0935d6fc03ca7abccbd148ce52d26c2bc154f8`; the updated view is the production Vite build from this change. + +The API keeps its startup bundle in memory, so the updated production bundle was served with Vite preview proxying the same API. The card comparisons are direct Playwright element screenshots at a 1280 × 900 viewport. The viewer screenshots use 1280 × 900 and 480 × 600 viewports. + +| Before: thumbnail only | After: open original | +| --- | --- | +| ![Before](before-card.png) | ![After](after-card.png) | + +![Original viewer](after-viewer.png) + +![Original viewer in the minimum desktop window](after-viewer-narrow.png) + +## Verification + +- Agent Browser component: 21 tests passed; existing image-viewer suite: 8 tests passed. Covers verified bytes, original dimensions, copy/download, focus restoration, screenshot/session/Run changes, rejected/undecodable images, and recovery after a failed screenshot download. +- TypeScript and production build passed. +- Actual browser clicks opened the screenshot, switched to 100%, downloaded the original, copied PNG to the clipboard, fitted the view, and closed with Escape. Clipboard image dimensions remained 1258 × 802; focus returned to the screenshot button. The 480 × 600 dialog stayed inside the viewport. +- Downloaded file SHA-256 matched the server screenshot: `1caabbcbf77b317544d9040675eb72da092e7163b769ec64f6e218622f2562b8` (36,058 bytes). + +The approach keeps UC's existing verified-screenshot model and reuses its image viewer. Browser inspection is an established workflow in [Codex](https://learn.chatgpt.com/docs/browser?surface=app) and [Claude Code Desktop](https://code.claude.com/docs/en/desktop); this change improves inspection of UC's captured pages. + +## Recovering a failed screenshot request + +A follow-up review reproduced a missing recovery action: if the first screenshot download failed, the card asked the user to refresh the status but provided no reload button. An unchanged screenshot identity also prevented subsequent status reads from retrying the image. The card now offers **Reload screenshot** after a failed read; it repeats the verified image GET for the same captured page. + +These additional screenshots use the production Go API, an actual native browser capture, and Edge at 1280 × 900. In each frontend, Playwright interrupted only the first screenshot request with `connectionreset`; no response bodies or DOM content were replaced. Before is commit `2358e46d4f80535858f10f0dba7a0976421992ad`; after is the production build containing the retry action. Both use the same browser session and image. + +| Before: no recovery action | After: explicit retry | +| --- | --- | +| ![Failed screenshot without retry](retry-before.png) | ![Failed screenshot with retry](retry-after.png) | + +![Same screenshot recovered after retry](retry-recovered.png) + +The retry returned a verified 1258 × 802 image, cleared the error and allowed the original viewer to open. No API mutation was issued during retry or preview. The browser was then stopped through the existing control. The new regression test failed on the previous implementation and passed with the fix. diff --git a/docs/acceptance/browser-screenshot-viewer-20261022/after-card.png b/docs/acceptance/browser-screenshot-viewer-20261022/after-card.png new file mode 100644 index 00000000..6aff2da4 Binary files /dev/null and b/docs/acceptance/browser-screenshot-viewer-20261022/after-card.png differ diff --git a/docs/acceptance/browser-screenshot-viewer-20261022/after-viewer-narrow.png b/docs/acceptance/browser-screenshot-viewer-20261022/after-viewer-narrow.png new file mode 100644 index 00000000..cd327236 Binary files /dev/null and b/docs/acceptance/browser-screenshot-viewer-20261022/after-viewer-narrow.png differ diff --git a/docs/acceptance/browser-screenshot-viewer-20261022/after-viewer.png b/docs/acceptance/browser-screenshot-viewer-20261022/after-viewer.png new file mode 100644 index 00000000..3121ff9d Binary files /dev/null and b/docs/acceptance/browser-screenshot-viewer-20261022/after-viewer.png differ diff --git a/docs/acceptance/browser-screenshot-viewer-20261022/before-card.png b/docs/acceptance/browser-screenshot-viewer-20261022/before-card.png new file mode 100644 index 00000000..05908fc4 Binary files /dev/null and b/docs/acceptance/browser-screenshot-viewer-20261022/before-card.png differ diff --git a/docs/acceptance/browser-screenshot-viewer-20261022/retry-after.png b/docs/acceptance/browser-screenshot-viewer-20261022/retry-after.png new file mode 100644 index 00000000..a7a9c692 Binary files /dev/null and b/docs/acceptance/browser-screenshot-viewer-20261022/retry-after.png differ diff --git a/docs/acceptance/browser-screenshot-viewer-20261022/retry-before.png b/docs/acceptance/browser-screenshot-viewer-20261022/retry-before.png new file mode 100644 index 00000000..c8976510 Binary files /dev/null and b/docs/acceptance/browser-screenshot-viewer-20261022/retry-before.png differ diff --git a/docs/acceptance/browser-screenshot-viewer-20261022/retry-recovered.png b/docs/acceptance/browser-screenshot-viewer-20261022/retry-recovered.png new file mode 100644 index 00000000..cfc46c20 Binary files /dev/null and b/docs/acceptance/browser-screenshot-viewer-20261022/retry-recovered.png differ diff --git a/docs/acceptance/plan-confirmed-choice-20261022/README.md b/docs/acceptance/plan-confirmed-choice-20261022/README.md new file mode 100644 index 00000000..4327a3ff --- /dev/null +++ b/docs/acceptance/plan-confirmed-choice-20261022/README.md @@ -0,0 +1,10 @@ +# Confirmed plan selection — 2026-10-22 + +Real Chrome screenshots of the production frontend against an isolated Go API and deterministic local provider. The browser proposed two directions, selected direction two, confirmed it, refreshed, reconnected and reopened **View plan**. + +The before build is commit `fc0935d6fc03ca7abccbd148ce52d26c2bc154f8`. The after build contains the implementation in `f26802e9c88492a23676359e6eb9feb18e932ab1`. Both read the same persisted server selection: proposal `plan-proposal-20261021181611-640a34e64617`, direction `2`. The API remained running while the frontend changed. + +| Viewport | Before | After | +| --- | --- | --- | +| 1280 × 900 | [Neither direction selected](before-desktop.png) | [Direction two selected and marked 已确认](after-desktop.png) | +| 390 × 844 | [Neither direction selected](before-mobile.png) | [Read-only confirmation visible](after-mobile.png) | diff --git a/docs/acceptance/plan-confirmed-choice-20261022/after-desktop.png b/docs/acceptance/plan-confirmed-choice-20261022/after-desktop.png new file mode 100644 index 00000000..f9c6f083 Binary files /dev/null and b/docs/acceptance/plan-confirmed-choice-20261022/after-desktop.png differ diff --git a/docs/acceptance/plan-confirmed-choice-20261022/after-mobile.png b/docs/acceptance/plan-confirmed-choice-20261022/after-mobile.png new file mode 100644 index 00000000..4e9fcb08 Binary files /dev/null and b/docs/acceptance/plan-confirmed-choice-20261022/after-mobile.png differ diff --git a/docs/acceptance/plan-confirmed-choice-20261022/before-desktop.png b/docs/acceptance/plan-confirmed-choice-20261022/before-desktop.png new file mode 100644 index 00000000..dcf16a57 Binary files /dev/null and b/docs/acceptance/plan-confirmed-choice-20261022/before-desktop.png differ diff --git a/docs/acceptance/plan-confirmed-choice-20261022/before-mobile.png b/docs/acceptance/plan-confirmed-choice-20261022/before-mobile.png new file mode 100644 index 00000000..47aa62ba Binary files /dev/null and b/docs/acceptance/plan-confirmed-choice-20261022/before-mobile.png differ diff --git a/web/package-lock.json b/web/package-lock.json index 9d8c1bd7..03e7da3c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -20,6 +20,7 @@ "@tiptap/starter-kit": "3.32.0", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", + "dompurify": "3.4.17", "highlight.js": "11.11.1", "lucide-react": "1.31.0", "monaco-editor": "0.56.0", @@ -42,7 +43,7 @@ "openapi-typescript": "7.13.0", "typescript": "5.9.3", "vite": "8.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" } }, "node_modules/@adobe/css-tools": { @@ -1388,16 +1389,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1406,13 +1407,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@vitest/spy": "4.1.12", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1433,9 +1434,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", "dev": true, "license": "MIT", "dependencies": { @@ -1446,13 +1447,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.11", + "@vitest/utils": "4.1.12", "pathe": "^2.0.3" }, "funding": { @@ -1460,14 +1461,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1476,9 +1477,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", "dev": true, "license": "MIT", "funding": { @@ -1486,13 +1487,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", + "@vitest/pretty-format": "4.1.12", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1840,9 +1841,9 @@ "peer": true }, "node_modules/dompurify": { - "version": "3.4.16", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", - "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -4281,9 +4282,9 @@ } }, "node_modules/undici": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", - "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "dev": true, "license": "MIT", "engines": { @@ -4498,19 +4499,19 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -4538,12 +4539,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/web/package.json b/web/package.json index 89d9de27..2f3eed72 100644 --- a/web/package.json +++ b/web/package.json @@ -27,6 +27,7 @@ "@tiptap/starter-kit": "3.32.0", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", + "dompurify": "3.4.17", "highlight.js": "11.11.1", "lucide-react": "1.31.0", "monaco-editor": "0.56.0", @@ -49,16 +50,16 @@ "openapi-typescript": "7.13.0", "typescript": "5.9.3", "vite": "8.3.4", - "vitest": "4.1.11" + "vitest": "4.1.12" }, "overrides": { "@redocly/openapi-core": { "js-yaml": "4.3.2" }, "brace-expansion": "5.0.12", - "dompurify": "3.4.16", + "dompurify": "3.4.17", "nanoid": "3.3.18", "postcss": "8.5.25", - "undici": "7.29.1" + "undici": "7.30.1" } } diff --git a/web/src/lib/monaco-sanitizer.test.ts b/web/src/lib/monaco-sanitizer.test.ts new file mode 100644 index 00000000..a6fb29d0 --- /dev/null +++ b/web/src/lib/monaco-sanitizer.test.ts @@ -0,0 +1,21 @@ +import DOMPurify from "dompurify"; +// @ts-expect-error Monaco ships this runtime sanitizer without declarations. +import { safeSetInnerHtml, sanitizeHtml } from "monaco-editor/base/browser/domSanitize.js"; + +describe("Monaco sanitizer", () => { + it.each(["trusted HTML", "DOM fragment"])("uses the installed DOMPurify for %s", (output) => { + const sanitize = vi.spyOn(DOMPurify, "sanitize"); + const dirty = '

Kept formatting

' + + 'unsafe' + + 'safe'; + const target = document.createElement("div"); + + if (output === "trusted HTML") target.innerHTML = String(sanitizeHtml(dirty)); + else safeSetInnerHtml(target, dirty); + + expect(sanitize).toHaveBeenCalled(); + expect(target.querySelector("strong")).toHaveTextContent("formatting"); + expect(target.querySelector('script, [onerror], a[href^="javascript:"]')).toBeNull(); + expect(target.querySelector('a[href="https://example.com"]')).toHaveTextContent("safe"); + }); +}); diff --git a/web/src/v2/components/agent-browser.css b/web/src/v2/components/agent-browser.css index a5763fb3..307c779c 100644 --- a/web/src/v2/components/agent-browser.css +++ b/web/src/v2/components/agent-browser.css @@ -15,6 +15,9 @@ .v2-agent-browser-page a { display:block; max-width:100%; color:var(--v2-accent); text-decoration:underline; text-underline-offset:3px; } .v2-agent-browser-page a svg { display:inline; margin-left:5px; vertical-align:middle; } .v2-agent-browser img { display:block; width:100%; height:auto; max-height:320px; object-fit:contain; border:1px solid var(--v2-border); border-radius:8px; background:var(--prayu-surface-solid); } +.v2-agent-browser .v2-agent-browser-screenshot { position:relative; display:block; width:100%; min-width:0; padding:0; border:0; cursor:zoom-in; } +.v2-agent-browser-screenshot span { position:absolute; right:8px; top:8px; display:inline-flex; align-items:center; gap:5px; padding:4px 8px; border:1px solid var(--v2-border); border-radius:6px; background:var(--prayu-surface-solid); } +.v2-agent-browser .v2-agent-browser-screenshot:disabled { cursor:default; } .v2-agent-browser details { min-width:0; } .v2-agent-browser summary { cursor:pointer; color:var(--v2-muted); } .v2-agent-browser details p { margin-top:7px; } diff --git a/web/src/v2/components/agent-browser.test.tsx b/web/src/v2/components/agent-browser.test.tsx index 6929caf9..26bf82a1 100644 --- a/web/src/v2/components/agent-browser.test.tsx +++ b/web/src/v2/components/agent-browser.test.tsx @@ -1,7 +1,7 @@ import type { ReactElement } from "react"; import type { RenderOptions } from "@testing-library/react"; import { LocaleProvider } from "../../lib/locale"; -import { act, cleanup, fireEvent, render as renderComponent, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render as renderComponent, screen, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { APIRequestError, type APIClient } from "../../api/client"; import { V2AgentBrowser } from "./agent-browser"; @@ -43,6 +43,100 @@ it("uses a read-only GET and renders only a verified screenshot", async () => { expect(screen.queryByRole("button", { name: /打开/ })).not.toBeInTheDocument(); }); +async function decodeScreenshot() { + const image = await screen.findByRole("img", { name: "Agent 浏览器页面:Report run-browser" }); + Object.defineProperties(image, { naturalWidth: { value: 1600 }, naturalHeight: { value: 900 } }); + fireEvent.load(image); + return screen.getByRole("button", { name: "查看浏览器截图原图" }); +} + +it("opens the decoded screenshot at its original dimensions and reuses verified bytes for copy and download", async () => { + const png = new Blob(["verified PNG"], { type: "image/png" }); + const items: Record>[] = []; + vi.stubGlobal("ClipboardItem", class { constructor(data: Record>) { items.push(data); } }); + const write = vi.fn(async () => {}); + vi.stubGlobal("navigator", Object.create(navigator, { clipboard: { value: { write } } })); + const client = { baseURL: "/api/v1", get: vi.fn(async () => status()), postControl: vi.fn(), + downloadVerifiedImage: vi.fn(async () => png) } as unknown as APIClient; + draw(client); + expect(await screen.findByRole("button", { name: "查看浏览器截图原图" })).toBeDisabled(); + const trigger = await decodeScreenshot(); + trigger.focus(); + fireEvent.click(trigger); + const dialog = screen.getByRole("dialog", { name: "图片预览 agent-browser-aaaaaaaaaaaa.png" }); + fireEvent.click(within(dialog).getByRole("button", { name: "100%" })); + expect(within(dialog).getByRole("img")).toHaveStyle({ width: "1600px", height: "900px" }); + const download = within(dialog).getByRole("link", { name: "下载原图 agent-browser-aaaaaaaaaaaa.png" }); + expect(download).toHaveAttribute("href", "blob:agent-browser"); + expect(download).toHaveAttribute("download", "agent-browser-aaaaaaaaaaaa.png"); + expect(write).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByRole("button", { name: "复制图片" })); + expect(await screen.findByText("已复制图片")).toBeInTheDocument(); + expect(await items[0]["image/png"]).toBe(png); + expect(client.downloadVerifiedImage).toHaveBeenCalledTimes(1); + expect(client.postControl).not.toHaveBeenCalled(); + fireEvent.keyDown(dialog, { key: "Escape" }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); +}); + +it.each(["screenshot", "session", "run"])("closes an original preview when the %s changes", async (change) => { + const client = { baseURL: "/api/v1", get: vi.fn(async () => status()), postControl: vi.fn(), + downloadVerifiedImage: vi.fn(async () => new Blob(["png"], { type: "image/png" })) } as unknown as APIClient; + const view = draw(client); + fireEvent.click(await decodeScreenshot()); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + vi.mocked(client.downloadVerifiedImage).mockImplementationOnce(() => new Promise(() => {})); + if (change === "run") { + vi.mocked(client.get).mockResolvedValue(status("run-new", "agent-browser-new")); + view.rerender(); + } else { + const next = change === "session" ? status("run-browser", "agent-browser-new") + : { ...status(), screenshot: { ...status().screenshot, sha256: "b".repeat(64) } }; + await act(async () => { view.queries.setQueryData(agentBrowserQueryKey(client.baseURL, "run-browser"), next); }); + } + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:agent-browser"); +}); + +it("does not offer an original preview when screenshot verification fails", async () => { + const client = { baseURL: "/api/v1", get: vi.fn(async () => status()), postControl: vi.fn(), + downloadVerifiedImage: vi.fn(async () => { throw new Error("hash mismatch"); }) } as unknown as APIClient; + draw(client); + expect(await screen.findByRole("alert")).toHaveTextContent("无法验证当前页面截图"); + expect(screen.queryByRole("button", { name: "查看浏览器截图原图" })).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); +}); + +it("can reload the same screenshot after a failed download without issuing browser actions", async () => { + const download = vi.fn() + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValueOnce(new Blob(["verified PNG"], { type: "image/png" })); + const client = { baseURL: "/api/v1", get: vi.fn(async () => status()), postControl: vi.fn(), + downloadVerifiedImage: download } as unknown as APIClient; + draw(client); + expect(await screen.findByRole("alert")).toHaveTextContent("无法验证当前页面截图"); + fireEvent.click(screen.getByRole("button", { name: "重新读取截图" })); + const trigger = await decodeScreenshot(); + expect(trigger).toBeEnabled(); + expect(download).toHaveBeenCalledTimes(2); + expect(download.mock.calls[1].slice(0, 2)).toEqual(download.mock.calls[0].slice(0, 2)); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + fireEvent.click(trigger); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(client.postControl).not.toHaveBeenCalled(); +}); + +it("reports an undecodable screenshot without enabling its original preview", async () => { + const client = { baseURL: "/api/v1", get: vi.fn(async () => status()), postControl: vi.fn(), + downloadVerifiedImage: vi.fn(async () => new Blob(["broken PNG"], { type: "image/png" })) } as unknown as APIClient; + draw(client); + fireEvent.error(await screen.findByRole("img", { name: "Agent 浏览器页面:Report run-browser" })); + expect(screen.getByRole("alert")).toHaveTextContent("无法显示当前页面截图"); + expect(screen.getByRole("button", { name: "查看浏览器截图原图" })).toBeDisabled(); +}); + it("closes the exact observed session once and distinguishes the Run", async () => { const get = vi.fn(async () => status()); const closed = { ...status(), state: "closed", screenshot: undefined, tree_reaped: true, profile_removed: true }; diff --git a/web/src/v2/components/agent-browser.tsx b/web/src/v2/components/agent-browser.tsx index e9d9b796..9d13dc81 100644 --- a/web/src/v2/components/agent-browser.tsx +++ b/web/src/v2/components/agent-browser.tsx @@ -1,9 +1,11 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ExternalLink, RefreshCw, X } from "lucide-react"; +import { ExternalLink, Maximize2, RefreshCw, X } from "lucide-react"; import { useLocale, type LocaleText } from "../../lib/locale"; import type { APIClient } from "../../api/client"; import { agentBrowserAPIAbsent, agentBrowserQueryKey, closeAgentBrowser, readAgentBrowser, type AgentBrowserStatus } from "../../api/agent-browser"; +import { V2ImageViewer } from "./image-viewer"; +import "./image-input.css"; import "./agent-browser.css"; export function V2AgentBrowser(props: { client: APIClient; runID: string; running: boolean; onDismiss?: () => void }) { @@ -55,7 +57,9 @@ function AgentBrowserCard({ client, runID, running, onDismiss }: { client: APICl const queries = useQueryClient(); const key = agentBrowserQueryKey(client.baseURL, runID); const [message, setMessage] = useState(""); - const [image, setImage] = useState<{ binding: string; url: string }>(); + const [image, setImage] = useState<{ binding: string; url: string; blob: Blob }>(); + const [failedImageBinding, setFailedImageBinding] = useState(""); + const [imageRetry, setImageRetry] = useState(0); const status = useQuery({ queryKey: key, queryFn: ({ signal }) => readAgentBrowser(client, runID, signal), retry: false, refetchOnMount: "always", refetchInterval: (query) => running && !query.state.error ? 3000 : false }); const current = status.data; @@ -75,14 +79,15 @@ function AgentBrowserCard({ client, runID, running, onDismiss }: { client: APICl ? `${runID}\0${current.session_id}\0${current.screenshot.locator}\0${current.screenshot.sha256}` : ""; useEffect(() => { setImage(undefined); + setFailedImageBinding(""); if (!current?.screenshot || !current.session_id || !imageBinding) return; const abort = new AbortController(); let objectURL = ""; void client.downloadVerifiedImage(screenshotPath(runID, current), current.screenshot, abort.signal).then((blob) => { if (abort.signal.aborted) return; - objectURL = URL.createObjectURL(blob); setImage({ binding: imageBinding, url: objectURL }); - }).catch(() => { if (!abort.signal.aborted) setMessage(t("无法验证当前页面截图,请重新读取状态。", "The current screenshot could not be verified. Refresh the browser status.")); }); + objectURL = URL.createObjectURL(blob); setImage({ binding: imageBinding, url: objectURL, blob }); + }).catch(() => { if (!abort.signal.aborted) setFailedImageBinding(imageBinding); }); return () => { abort.abort(); if (objectURL) URL.revokeObjectURL(objectURL); }; - }, [client, imageBinding, runID]); + }, [client, imageBinding, runID, imageRetry]); if (status.isError && agentBrowserAPIAbsent(status.error)) return null; if (status.isPending) return null; @@ -107,7 +112,13 @@ function AgentBrowserCard({ client, runID, running, onDismiss }: { client: APICl {current.title && {current.title}} {current.url && {current.url}} } - {image?.binding === imageBinding && {t(`Agent} + {image?.binding === imageBinding && } + {failedImageBinding && failedImageBinding === imageBinding &&

+ {t("无法验证当前页面截图。", "The current screenshot could not be verified.")}{" "} + +

} {(current.last_action || current.updated_at || current.failure_code) &&
{t("最近状态", "Latest status")} {current.last_action &&

{t("最近动作:", "Last action: ")}{actionText(current.last_action, t)}

}

{t("更新时间:", "Updated: ")}{new Date(current.updated_at).toLocaleTimeString(locale)}

@@ -118,3 +129,25 @@ function AgentBrowserCard({ client, runID, running, onDismiss }: { client: APICl ; } + +function BrowserScreenshot({ url, blob, name, alt }: { url: string; blob: Blob; name: string; alt: string }) { + const { t } = useLocale(); + const trigger = useRef(null); + const [dimensions, setDimensions] = useState<{ width: number; height: number }>(); + const [failed, setFailed] = useState(false); + const [viewing, setViewing] = useState(false); + return <> + + {failed &&

{t("无法显示当前页面截图,请让 Agent 重新截图。", "The current screenshot could not be displayed. Ask the agent to capture it again.")}

} + {viewing && dimensions && setViewing(false)} returnFocusRef={trigger} />} + ; +} diff --git a/web/src/v2/components/thread-plan.css b/web/src/v2/components/thread-plan.css index f12653ad..e28f3b93 100644 --- a/web/src/v2/components/thread-plan.css +++ b/web/src/v2/components/thread-plan.css @@ -31,8 +31,10 @@ .v2-thread-plan button:hover:not(:disabled) { background:var(--v2-control-hover); color:var(--v2-ink); } .v2-thread-plan button:focus-visible, .v2-thread-plan summary:focus-visible, .v2-plan-trigger:focus-visible { outline:2px solid var(--v2-accent); outline-offset:3px; } .v2-plan-directions > section { border:1px solid var(--v2-border); border-radius:10px; padding:14px 16px; margin:14px 0; } +.v2-plan-directions > section.is-confirmed { border-color:var(--v2-accent); } .v2-plan-choice { display:flex; gap:9px; align-items:baseline; } .v2-plan-choice input { accent-color:var(--v2-accent); } +.v2-plan-confirmed { flex-shrink:0; margin-left:auto; color:var(--v2-accent); font-size:12px; } .v2-thread-plan strong { font-weight:600; } .v2-thread-plan summary { cursor:pointer; width:fit-content; } .v2-thread-plan li { margin:6px 0; } diff --git a/web/src/v2/components/thread-plan.test.tsx b/web/src/v2/components/thread-plan.test.tsx index adb28278..673a8795 100644 --- a/web/src/v2/components/thread-plan.test.tsx +++ b/web/src/v2/components/thread-plan.test.tsx @@ -8,7 +8,10 @@ import { V2RecoveryProvider, useV2RecoveryStore, type V2RecoveryStore } from ".. import { V2ThreadPlanControl } from "./thread-plan"; function fixture() { - const state: { phase: string; proposal: string | null } = { phase: "plan", proposal: "proposal-1" }; + const directions = [{ ordinal: 1, title: "整理日志", summary: "只生成汇总文件,保留接口。", + tradeoffs: ["优先复用现有解析器"], modules: [{ ordinal: 1, title: "汇总", objective: "输出中文汇总", acceptance_criteria: ["不修改原始日志"], dependencies: [] }] }]; + const state: { phase: string; proposal: string | null; directions: typeof directions; + selection?: { proposal_id: string; direction_ordinal: number } } = { phase: "plan", proposal: "proposal-1", directions }; const result = (threadID: string, body: ThreadPlanRequest, status = "completed") => ({ version: "plan_delivery_control.v1", thread_id: threadID, run_id: body.run_id, action: body.action, state: status, proposal_id: body.proposal_id, direction: body.direction, manual_acceptance: body.manual_acceptance, @@ -21,8 +24,7 @@ function fixture() { const requests = new Map(); const hooks: { execute?: (body: ThreadPlanRequest) => Promise; observe?: () => unknown } = {}; const get = vi.fn(async (path: string) => ({ run: { id: path.split("/")[2], status: "running" }, mode: { phase: state.phase }, - plan_delivery: { proposal: state.proposal ? { id: state.proposal, directions: [{ ordinal: 1, title: "整理日志", summary: "只生成汇总文件,保留接口。", - tradeoffs: ["优先复用现有解析器"], modules: [{ ordinal: 1, title: "汇总", objective: "输出中文汇总", acceptance_criteria: ["不修改原始日志"], dependencies: [] }] }] } : undefined } })); + plan_delivery: { proposal: state.proposal ? { id: state.proposal, directions: state.directions } : undefined, selection: state.selection } })); const postControl = vi.fn(async (path: string, body: ThreadPlanRequest, key: string) => { const threadID = path.split("/")[2]; requests.set(key, { threadID, body }); if (hooks.execute) return hooks.execute(body); @@ -182,3 +184,84 @@ test("settled confirmation needs the exact selected plan and accepted message id expect(() => parseThreadPlanObservation({ ...valid, proposal_id: undefined }, attempt)).toThrow(/来源或格式/u); expect(() => parseThreadPlanObservation({ ...valid, turn_request: { ...valid.turn_request, thread_id: "task-b" } }, attempt)).toThrow(/来源或格式/u); }); + +test.each(["plan", "deliver"])("reopening a %s plan shows the server-confirmed direction as read-only", async (phase) => { + const f = fixture(); f.state.phase = phase; + f.state.directions.push({ ...f.state.directions[0], ordinal: 2, title: "建立日志索引" }); + f.state.selection = { proposal_id: "proposal-1", direction_ordinal: 2 }; + const user = userEvent.setup(); + const view = mount(f); + await user.click(await screen.findByRole("button", { name: "查看计划" })); + expect(screen.getByRole("radio", { name: "建立日志索引 已确认" })).toBeChecked(); + await user.click(screen.getByRole("radio", { name: "整理日志" })); + expect(screen.getByRole("radio", { name: "整理日志" })).not.toBeChecked(); + expect(screen.getByRole("radio", { name: "建立日志索引 已确认" })).toBeDisabled(); + expect(screen.queryByRole("button", { name: "确认计划并执行" })).not.toBeInTheDocument(); + view.unmount(); + mount(f); + await user.click(await screen.findByRole("button", { name: "查看计划" })); + expect(screen.getByRole("radio", { name: "建立日志索引 已确认" })).toBeChecked(); + expect(f.postControl).not.toHaveBeenCalled(); +}); + +test.each([ + { name: "another proposal", selection: { proposal_id: "old-proposal", direction_ordinal: 1 } }, + { name: "a missing direction", selection: { proposal_id: "proposal-1", direction_ordinal: 2 } }, + { name: "no saved selection", selection: undefined }, +])("a delivered plan does not invent a confirmed choice from $name", async ({ selection }) => { + const f = fixture(); f.state.phase = "deliver"; f.state.selection = selection; + mount(f); const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: "查看计划" })); + expect(screen.getByRole("radio", { name: "整理日志" })).not.toBeChecked(); + expect(screen.getByRole("radio", { name: "整理日志" })).toBeDisabled(); + expect(screen.queryByText("已确认")).not.toBeInTheDocument(); + expect(f.postControl).not.toHaveBeenCalled(); +}); + +test("a replacement proposal requires a fresh choice instead of inheriting the saved confirmation", async () => { + const f = fixture(); f.state.phase = "deliver"; + f.state.directions.push({ ...f.state.directions[0], ordinal: 2, title: "建立日志索引" }); + f.state.selection = { proposal_id: "proposal-1", direction_ordinal: 2 }; + const view = mount(f); const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: "查看计划" })); + f.state.phase = "plan"; f.state.proposal = "proposal-2"; + await act(async () => { await view.queries.invalidateQueries({ queryKey: ["run", "task-a-run"] }); }); + await screen.findByRole("dialog", { name: "确认计划" }); + expect(screen.queryByText("已确认")).not.toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "建立日志索引" })).not.toBeChecked(); + expect(screen.getByRole("button", { name: "确认计划并执行" })).toBeDisabled(); + await user.click(screen.getByRole("radio", { name: "整理日志" })); + await user.click(screen.getByRole("button", { name: "确认计划并执行" })); + await waitFor(() => expect(f.postControl).toHaveBeenCalledTimes(1)); + expect(f.postControl.mock.calls[0][1]).toMatchObject({ proposal_id: "proposal-2", direction: 1 }); +}); + +test("a refreshed server confirmation replaces an unsaved local choice", async () => { + const f = fixture(); f.state.directions.push({ ...f.state.directions[0], ordinal: 2, title: "建立日志索引" }); + const view = mount(f); const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: "查看计划" })); + await user.click(screen.getByRole("radio", { name: "整理日志" })); + f.state.phase = "deliver"; f.state.selection = { proposal_id: "proposal-1", direction_ordinal: 2 }; + await act(async () => { await view.queries.invalidateQueries({ queryKey: ["run", "task-a-run"] }); }); + await screen.findByRole("radio", { name: "建立日志索引 已确认" }); + expect(screen.getByRole("radio", { name: "整理日志" })).not.toBeChecked(); + expect(screen.getByRole("radio", { name: "建立日志索引 已确认" })).toBeChecked(); + expect(f.postControl).not.toHaveBeenCalled(); +}); + +test("a saved selection still allows continuing its prepared original request", async () => { + const f = fixture(); f.state.selection = { proposal_id: "proposal-1", direction_ordinal: 1 }; + const attempt: ThreadPlanAttempt = { threadID: "task-a", key: "thread-plan-prepared", body: { + version: "plan_delivery_control.v1", action: "confirm", run_id: "task-a-run", proposal_id: "proposal-1", + direction: 1, manual_acceptance: "on_demand", content: "继续原计划" } }; + f.hooks.observe = () => f.result("task-a", attempt.body, "prepared"); + const view = mount(f); const user = userEvent.setup(); + await screen.findByRole("button", { name: "查看计划" }); + act(() => view.getStore().write("thread:task-a:plan-attempt", attempt)); + await user.click(await screen.findByRole("button", { name: "计划操作待核对" })); + expect(screen.getByRole("radio", { name: "整理日志 已确认" })).toBeChecked(); + expect(screen.queryByRole("button", { name: "确认计划并执行" })).not.toBeInTheDocument(); + await user.click(await screen.findByRole("button", { name: "继续原操作" })); + await waitFor(() => expect(f.postControl).toHaveBeenCalledTimes(1)); + expect(f.postControl.mock.calls[0]).toEqual(["/threads/task-a/plan", attempt.body, attempt.key]); +}); diff --git a/web/src/v2/components/thread-plan.tsx b/web/src/v2/components/thread-plan.tsx index b3945442..8ac94110 100644 --- a/web/src/v2/components/thread-plan.tsx +++ b/web/src/v2/components/thread-plan.tsx @@ -122,11 +122,14 @@ function ThreadPlanContent({ client, threadID, runID, active, working, hasUnsent } }); const phase = detail.data?.mode.phase; const proposal = detail.data?.plan_delivery?.proposal; + const selection = detail.data?.plan_delivery?.selection; + const confirmed = selection?.proposal_id === proposal?.id + ? proposal?.directions.find((direction) => direction.ordinal === selection?.direction_ordinal) : undefined; const latest = choice?.proposalID === proposal?.id ? proposal?.directions.find((item) => item.ordinal === choice?.direction) : undefined; const ready = available && active && !working && !mutation.isPending && !attempt && !damaged && !detail.isError && !detail.isPending; const show = () => { setOpen(true); - if (proposal?.directions.length === 1) setChoice({ proposalID: proposal.id, direction: proposal.directions[0].ordinal }); + if (phase === "plan" && !confirmed && proposal?.directions.length === 1) setChoice({ proposalID: proposal.id, direction: proposal.directions[0].ordinal }); else if (choice?.proposalID !== proposal?.id) setChoice(null); void detail.refetch(); }; @@ -150,7 +153,7 @@ function ThreadPlanContent({ client, threadID, runID, active, working, hasUnsent {open && createPortal(
{ if (event.target === event.currentTarget) setOpen(false); }}>
-

{phase === "plan" ? t("确认计划", "Confirm plan") : t("任务计划", "Task plan")}

+

{phase === "plan" && !confirmed ? t("确认计划", "Confirm plan") : t("任务计划", "Task plan")}

{t("先规划时先分析和形成方案。确认后在同一对话执行;后续修改要求仍可直接发消息。", "Planning analyzes the task and proposes an approach. Confirm to execute in this conversation; send further messages to change requirements.")}

{detail.isPending &&

{t("正在读取计划…", "Loading plan…")}

} @@ -169,10 +172,12 @@ function ThreadPlanContent({ client, threadID, runID, active, working, hasUnsent
} {!attempt && shownObservation &&

{observationText(shownObservation, t)}

} {!detail.isError && proposal &&
- {proposal.directions.map((direction) =>
- + {proposal.directions.map((direction) =>
+

{direction.summary}

{direction.tradeoffs.length > 0 &&
    {direction.tradeoffs.map((text, index) =>
  • {text}
  • )}
}
{t(`查看步骤与验收标准(${direction.modules.length} 项)`, `View steps and acceptance criteria (${direction.modules.length})`)} @@ -184,7 +189,7 @@ function ThreadPlanContent({ client, threadID, runID, active, working, hasUnsent
} {!detail.isPending && !detail.isError && !proposal &&

{phase === "plan" ? t("尚无可确认的计划。请在对话中提出需求或补充要求,等待计划形成。", "No plan is ready to confirm. Send your requirements in the conversation and wait for a plan.") : t("当前采用默认处理方式。需要先讨论方案时,可开启输入框旁的“计划模式”。", "The default work mode is active. Enable Plan mode beside the composer to discuss an approach first.")}

} {working &&

{t("Agent 仍在处理消息。可以直接在对话中补充要求,待本轮处理结束后确认最新计划。", "The agent is still processing messages. Add requirements in the conversation and confirm the latest plan after this turn ends.")}

} - {phase === "plan" && proposal && <> + {phase === "plan" && proposal && !confirmed && <> {hasUnsentDraft &&

{t("输入框中还有未发送内容。请先发送修改要求并等待计划更新,再确认执行。", "The composer contains an unsent draft. Send your changes and wait for the plan to update before confirming.")}

} {!latest &&

{t("请选择当前方案;计划更新后需要重新核对。", "Select an approach. Review it again if the plan changes.")}

} {latest &&
{t("确认时发送的消息", "Message sent on confirmation")}

{confirmContent}

} diff --git a/web/vite.config.ts b/web/vite.config.ts index 7faf222b..fd8574df 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,7 +1,23 @@ +import { fileURLToPath } from "node:url"; import react from "@vitejs/plugin-react"; -import { loadEnv } from "vite"; +import { loadEnv, normalizePath, type Plugin } from "vite"; import { defineConfig } from "vitest/config"; +// npm overrides cannot replace Monaco's embedded copy. Redirect its sanitizer import +// in both Vite and the separate dependency prebundler to the installed DOMPurify. +const monacoSanitizer = normalizePath(fileURLToPath(import.meta.resolve("monaco-editor/base/browser/domSanitize.js"))); +const monacoDOMPurify: Plugin = { + name: "monaco-dompurify", + enforce: "pre", + async resolveId(source, importer) { + if (source === "./dompurify/dompurify.js" && importer && normalizePath(importer) === monacoSanitizer) { + const resolved = await this.resolve("dompurify", importer, { skipSelf: true }); + if (!resolved) throw new Error("Cannot resolve installed DOMPurify for the Monaco sanitizer"); + return resolved; + } + }, +}; + export default defineConfig(({ mode }) => { const env = loadEnv(mode, ".", ""); const apiTarget = env.CYBERAGENT_API_TARGET || "http://127.0.0.1:8765"; @@ -19,7 +35,10 @@ export default defineConfig(({ mode }) => { }; return { - plugins: [react()], + plugins: [react(), monacoDOMPurify], + optimizeDeps: { + rolldownOptions: { plugins: [monacoDOMPurify] }, + }, server: { host: "127.0.0.1", port: 5173, @@ -39,6 +58,7 @@ export default defineConfig(({ mode }) => { }, test: { include: ["src/**/*.{test,spec}.{ts,tsx}"], + server: { deps: { inline: ["monaco-editor"] } }, environment: "jsdom", globals: true, setupFiles: "./src/test/setup.ts",