diff --git a/docs/evidence/composer-file-path/README.md b/docs/evidence/composer-file-path/README.md new file mode 100644 index 00000000..aa5e2911 --- /dev/null +++ b/docs/evidence/composer-file-path/README.md @@ -0,0 +1,91 @@ +# Composer project path completion + +Captured on Windows with Chrome 155 on 2026-10-22, using the production Go API, +`web/dist`, and `cmd/workbenchjourney` with its offline provider. The provider ran +against a disposable real workspace and saved conversations. File listings, +file snapshots, message submission and replies used the existing endpoints. + +## Before and after + +The baseline is `bf34da021df886b6cc29f7ff212ddd24c4019def` (#458). Each pair shows +the same project, empty conversation, `检查 @` draft and viewport. The baseline +bundle was copied before editing and served by the same Go API used for the +changed renderer. + +| Viewport | Before | After | +| --- | --- | --- | +| 1440 × 960 | [Before](before-wide.png) | [Root directory candidates](after-wide.png) | +| 390 × 844 | [Before](before-narrow.png) | [Root directory candidates](after-narrow.png) | + +## Real selection and submission + +The real file `资料 目录/说明 文.md` contains the offline provider's `JOURNEY_READ` +trigger and `JOURNEY_FILE_PROOF_2026`. In rich mode, typing `@`, choosing the +`资料 目录/` directory and selecting the file by keyboard produced a quoted path +and the normal file-reference chip. Sending a normal Chinese prompt started the +provider and produced the saved response `Read README.md: JOURNEY_FILE_PROOF_2026`. + +- [Rich mode: selected file](after-attached-rich.png) +- [Rich mode: saved message and reply](after-sent-rich.png) +- [Plain mode: selected file with Tab](after-attached-plain.png) +- [Plain mode: second saved message and reply](after-sent-plain.png) + +The second submission returned HTTP 202. Its recorded request included: + +```json +{ + "version": "thread_message_submission.v1", + "content": "再次检查 @\"资料 目录/说明 文.md\"", + "files": [{ + "source_kind": "workspace_file", + "path": "资料 目录/说明 文.md", + "expected_sha256": "821620b4cfddfb8a756b23183c9ddb2712ca79228d7c74ba7849d7485fe42485" + }] +} +``` + +The earlier sidebar chat with a `JOURNEY_READ` title is a fixture probe: Markdown +escaped the trigger in its body. The successful rich-mode run above instead +supplied the trigger in the actual referenced file. + +For the failure check, `资料 目录/失败 示例.md` was created on disk, listed by the +real directory endpoint, then deleted before selection. Reading it returned HTTP +404. The [failure screenshot](after-read-failure.png) shows the inline error; +the draft remained exactly `保留这段草稿 @"资料 目录/失` and no file reference was added. + +## Keyboard, language and boundaries + +- [English, 1440 × 960](after-english.png) and + [English, 390 × 844](after-english-narrow.png) show the real Chinese path under + English controls. After Escape, the listbox count was zero and the draft was + unchanged, including after the browser's following selection event. +- The document client/scroll widths were 1440/1440 and 390/390. The candidate + bounds were x=501.5…1238.2 and x=12.7…377.3. The existing conversation scroll + area in the narrow sent-chat screenshot still has a horizontal scrollbar + (client=380, scroll=521); this base does not include the separate #441 tooltip + fix (`03dc2611`). +- Candidates come from `workspaceExplore` for the current parent directory only. + The existing 200-entry limit and truncation notice remain. A filename outside + that returned directory page is not discoverable through this completion. + `workspaceSearch` and file-body scans are not used. +- Completion acts at the end of an active path token, including immediately + before its closing quote. Moving into the middle of an existing path leaves + normal editing available. File selection reuses the existing snapshot, + digest, partial/redacted flags, four-file limit and submission permission checks. + +Focused checks passed: 60 tests across the five files below, followed by +`npm run build` (TypeScript and production bundle; existing bundle-size warning). + +```sh +cd web +npx vitest run src/v2/components/file-path-mention.test.ts src/v2/components/file-path-suggestions.test.tsx src/v2/components/markdown-input.test.tsx src/v2/components/file-context.test.tsx src/v2/components/composer-stop-shortcut.test.tsx +npm run build +``` + +The tests cover both editor modes, local replacement and rich formatting, +directory-only requests, spaces and Chinese paths, email/code/link protection, +IME, Enter/Tab/Escape, read failure, closing while reading, late responses after +scope/mode/manual edits, restored drafts and the existing stop shortcut. +Browser coverage is Windows Chrome. Native Tauri and macOS were not exercised. +#440 prompt history is not in this base; its `historyKey` call must remain after +candidate navigation when the branches are combined. diff --git a/docs/evidence/composer-file-path/after-attached-plain.png b/docs/evidence/composer-file-path/after-attached-plain.png new file mode 100644 index 00000000..686de7f3 Binary files /dev/null and b/docs/evidence/composer-file-path/after-attached-plain.png differ diff --git a/docs/evidence/composer-file-path/after-attached-rich.png b/docs/evidence/composer-file-path/after-attached-rich.png new file mode 100644 index 00000000..ad3ccda5 Binary files /dev/null and b/docs/evidence/composer-file-path/after-attached-rich.png differ diff --git a/docs/evidence/composer-file-path/after-english-narrow.png b/docs/evidence/composer-file-path/after-english-narrow.png new file mode 100644 index 00000000..2cd72aa3 Binary files /dev/null and b/docs/evidence/composer-file-path/after-english-narrow.png differ diff --git a/docs/evidence/composer-file-path/after-english.png b/docs/evidence/composer-file-path/after-english.png new file mode 100644 index 00000000..2adeed20 Binary files /dev/null and b/docs/evidence/composer-file-path/after-english.png differ diff --git a/docs/evidence/composer-file-path/after-narrow.png b/docs/evidence/composer-file-path/after-narrow.png new file mode 100644 index 00000000..f8be7679 Binary files /dev/null and b/docs/evidence/composer-file-path/after-narrow.png differ diff --git a/docs/evidence/composer-file-path/after-read-failure.png b/docs/evidence/composer-file-path/after-read-failure.png new file mode 100644 index 00000000..77a63be1 Binary files /dev/null and b/docs/evidence/composer-file-path/after-read-failure.png differ diff --git a/docs/evidence/composer-file-path/after-sent-plain.png b/docs/evidence/composer-file-path/after-sent-plain.png new file mode 100644 index 00000000..727705de Binary files /dev/null and b/docs/evidence/composer-file-path/after-sent-plain.png differ diff --git a/docs/evidence/composer-file-path/after-sent-rich.png b/docs/evidence/composer-file-path/after-sent-rich.png new file mode 100644 index 00000000..e6f73e2e Binary files /dev/null and b/docs/evidence/composer-file-path/after-sent-rich.png differ diff --git a/docs/evidence/composer-file-path/after-wide.png b/docs/evidence/composer-file-path/after-wide.png new file mode 100644 index 00000000..533af12e Binary files /dev/null and b/docs/evidence/composer-file-path/after-wide.png differ diff --git a/docs/evidence/composer-file-path/before-narrow.png b/docs/evidence/composer-file-path/before-narrow.png new file mode 100644 index 00000000..45583c61 Binary files /dev/null and b/docs/evidence/composer-file-path/before-narrow.png differ diff --git a/docs/evidence/composer-file-path/before-wide.png b/docs/evidence/composer-file-path/before-wide.png new file mode 100644 index 00000000..345edcab Binary files /dev/null and b/docs/evidence/composer-file-path/before-wide.png differ diff --git a/docs/images/prompt-history/combined-escape-closes-list.png b/docs/images/prompt-history/combined-escape-closes-list.png new file mode 100644 index 00000000..a8aa447e Binary files /dev/null and b/docs/images/prompt-history/combined-escape-closes-list.png differ diff --git a/docs/images/prompt-history/combined-escape-stops-run.png b/docs/images/prompt-history/combined-escape-stops-run.png new file mode 100644 index 00000000..e3f32e96 Binary files /dev/null and b/docs/images/prompt-history/combined-escape-stops-run.png differ diff --git a/docs/images/prompt-history/combined-plain-history.png b/docs/images/prompt-history/combined-plain-history.png new file mode 100644 index 00000000..ef36ee1b Binary files /dev/null and b/docs/images/prompt-history/combined-plain-history.png differ diff --git a/docs/images/prompt-history/combined-plain-selection.png b/docs/images/prompt-history/combined-plain-selection.png new file mode 100644 index 00000000..d4ceb8a3 Binary files /dev/null and b/docs/images/prompt-history/combined-plain-selection.png differ diff --git a/docs/images/prompt-history/combined-rich-history.png b/docs/images/prompt-history/combined-rich-history.png new file mode 100644 index 00000000..d6af8a52 Binary files /dev/null and b/docs/images/prompt-history/combined-rich-history.png differ diff --git a/docs/images/prompt-history/combined-rich-selection.png b/docs/images/prompt-history/combined-rich-selection.png new file mode 100644 index 00000000..dc4dc34c Binary files /dev/null and b/docs/images/prompt-history/combined-rich-selection.png differ diff --git a/docs/images/prompt-history/plain-after.png b/docs/images/prompt-history/plain-after.png new file mode 100644 index 00000000..9756dc3c Binary files /dev/null and b/docs/images/prompt-history/plain-after.png differ diff --git a/docs/images/prompt-history/plain-before.png b/docs/images/prompt-history/plain-before.png new file mode 100644 index 00000000..dff45f9a Binary files /dev/null and b/docs/images/prompt-history/plain-before.png differ diff --git a/docs/images/prompt-history/rich-after.png b/docs/images/prompt-history/rich-after.png new file mode 100644 index 00000000..c2fc1e31 Binary files /dev/null and b/docs/images/prompt-history/rich-after.png differ diff --git a/docs/images/prompt-history/rich-before.png b/docs/images/prompt-history/rich-before.png new file mode 100644 index 00000000..88880ebf Binary files /dev/null and b/docs/images/prompt-history/rich-before.png differ diff --git a/docs/prompt-history.md b/docs/prompt-history.md new file mode 100644 index 00000000..65268e53 --- /dev/null +++ b/docs/prompt-history.md @@ -0,0 +1,58 @@ +# 当前聊天的输入历史 + +在空输入框按 ↑,可回填当前聊天最近一条已发送的文字。浏览历史时,把光标移到全文开头再按 ↑ 可取更早输入;在全文末尾按 ↓ 可取较新输入,越过最新一条后返回原来的空草稿。富文本与纯文本编辑器使用相同规则,回填保留原始 Markdown。 + +手工编辑后退出历史浏览。中文输入法组合、带修饰键的方向键、选区和正文中间的光标移动仍由编辑器处理。输入已有文字、附件或文件/MCP 引用时,不进入历史浏览;回填只复制文字,不重新加入旧附件。正在读取时若编辑、加入附件、切换聊天、移动选区或失去输入焦点,旧请求不会覆盖草稿。 + +历史索引逐页读取公开 transcript,并沿用公开消息投影排序与过滤;只包含已保存的用户输入,排除待发送、撤回、内部与临时消息。选中某条历史后再读取其完整已保存正文,因此不受当前聊天窗口页数及预览截断影响。索引或正文读取失败会显示错误并保留草稿,可以再次按 ↑ 重试。 + +## 真实页面验证 + +2026-10-22,Windows Edge,1280 × 900,生产前端、生产 API 和 SQLite。基线为 `96ba29bf`。通过 `cmd/workbenchjourney` 的离线模型提供者,从界面发送两条真实请求并读取 README;前后页面使用同一份已保存的对话。历史请求使用真实公开 API,截图未替换页面或接口内容。 + +| 富文本:修改前,空输入按 ↑ | 富文本:修改后,空输入按 ↑ | +| --- | --- | +| ![富文本修改前](images/prompt-history/rich-before.png) | ![富文本修改后](images/prompt-history/rich-after.png) | + +| 纯文本:修改前,空输入按 ↑ | 纯文本:修改后,空输入按 ↑ | +| --- | --- | +| ![纯文本修改前](images/prompt-history/plain-before.png) | ![纯文本修改后](images/prompt-history/plain-after.png) | + +浏览器实际验证了两种编辑器的首次回填、更早/更新浏览和返回空草稿,以及手工输入保留、多行光标与选区保护。富文本回填中的粗体正常渲染,切换到纯文本后仍保留 `**the proof**` 原文。以上为生产网页验证;未作为原生打包窗口验证。 + +## 自动检查 + +- `npm test -- src/v2/use-prompt-history.test.tsx src/v2/components/composer-history.test.tsx src/v2/components/markdown-input.test.tsx src/v2/components/composer.test.tsx src/v2/components/composer-attachment-settlement.test.tsx`:46 个测试通过。覆盖跨页顺序、长正文、公开消息过滤、分页/正文失败、延迟请求保护、富文本与纯文本、IME、选区、正文光标、手工编辑、附件/引用及现有发送行为。 +- `npm run build`:通过。 +- `go build -o output/playwright/prompt-history/cyberagent-after.exe ./cmd/cyberagent`:通过,用于真实 API 页面验证。 + +## 与项目路径补全和 Esc 停止的组合验证 + +在原输入历史提交 `c2b6200e` 中合入路径补全提交 `8e5de069` 后,两个编辑器都先让路径候选处理按键,再处理输入历史,最后处理发送。合并只涉及 `composer.tsx` 和 `markdown-input.tsx` 的真实冲突。 + +组合回归先复现了富文本问题:回填含 `@R` 的历史正文时,`setContent` 的选区回调早于正文身份更新,生成的候选持有旧正文,随后 Enter 被草稿校验拒绝。恢复外部草稿期间现在复用 `replacing` 标记抑制该回调;用户后续移动光标或编辑时按当前正文生成候选。 + +2026-10-22,Windows Chrome 155,1280 × 900。使用本树生产构建、生产 API、SQLite 和 `cmd/workbenchjourney` 离线提供者。实际发送 `JOURNEY_READ: 请读取 README @R`,服务返回 HTTP 202,工具读取真实 README 并保存回复;随后在两种编辑模式中从空输入按 ↑ 回填这条消息。原四张前后截图保持原样,本次追加以下组合截图: + +| 行为 | 真实截图 | +| --- | --- | +| 纯文本从空输入回填历史 | [回填](images/prompt-history/combined-plain-history.png) | +| 纯文本候选 ↑↓ / Enter 加入真实 README 引用 | [选择文件](images/prompt-history/combined-plain-selection.png) | +| 富文本从空输入回填历史 | [回填](images/prompt-history/combined-rich-history.png) | +| 富文本候选 ↑↓ / Enter 加入真实 README 引用 | [选择文件](images/prompt-history/combined-rich-selection.png) | +| Esc 关闭候选、保留原草稿 | [关闭列表](images/prompt-history/combined-escape-closes-list.png) | +| 运行中 Esc 停止并保留草稿 | [执行已停止](images/prompt-history/combined-escape-stops-run.png) | + +富文本候选操作前后正文分别为 `JOURNEY_READ: 请读取 README @R` 与 `JOURNEY_READ: 请读取 README @README.md `;↑↓ 没有改变正文,候选操作期间新增历史正文请求为 0。选择文件后出现真实引用芯片,没有提交新消息。 + +关闭候选时记录到 0 个 POST 请求,草稿仍为 `关闭候选后保留草稿 @R`。另一次真实运行中按 Esc 后,`POST /threads/{id}/interrupt` 返回 HTTP 202、状态 `stopping`,后续真实执行查询返回 HTTP 200、状态 `idle`;草稿仍为 `停止后保留的草稿`。 + +生产页面沿用原有限制:模型正在执行时禁止新增项目文件引用,因此运行中的停止按钮与路径候选不会同时可用。浏览器分别验证上述两个真实状态;两种编辑模式的 Composer 集成测试验证“第一次 Esc 关闭列表、第二次 Esc 调用停止”,且引用和草稿不变。未修改执行期文件引用限制。 + +组合检查通过:以下 8 个测试文件共 87 项、`npm run build` 和 `git diff --check`。生产 bundle 摘要为 `b63d9a403439ff619ec436d02e96ff555a12d93af61c76f7481a5fe95971c710`。浏览器覆盖 Windows Chrome,未运行原生打包窗口。 + +```sh +cd web +npx vitest run src/v2/use-prompt-history.test.tsx src/v2/components/composer-history.test.tsx src/v2/components/markdown-input.test.tsx src/v2/components/file-path-mention.test.ts src/v2/components/file-path-suggestions.test.tsx src/v2/components/composer-stop-shortcut.test.tsx src/v2/components/file-context.test.tsx src/v2/draft-context.test.tsx +npm run build +``` 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/composer-history.test.tsx b/web/src/v2/components/composer-history.test.tsx new file mode 100644 index 00000000..d8528f2d --- /dev/null +++ b/web/src/v2/components/composer-history.test.tsx @@ -0,0 +1,163 @@ +import { useState } from "react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { TextSelection } from "@tiptap/pm/state"; +import type { Editor } from "@tiptap/core"; +import type { APIClient } from "../../api/client"; +import type { WorkspaceExplorerView } from "../../api/types"; +import { LocaleProvider } from "../../lib/locale"; +import { V2RecoveryProvider } from "../recovery-storage"; +import { useV2DraftDocument } from "../draft-context"; +import { V2Composer } from "./composer"; + +vi.mock("./permission-control", () => ({ V2PermissionControl: () => null })); +const newest = "**完整输入**\n\n第二段:不要截断"; +const attachment = { id: "file-1", workspace_id: "ws", name: "kept.txt", sha256: "a".repeat(64), byte_size: 12, + mime_type: "text/plain", readability: "stored_only" as const, text_bytes: 0, redacted: false }; +function setup(plainText: boolean, newestText = newest) { + localStorage.setItem("universal-code.composer-plain-text", String(plainText)); + const getPage = vi.fn(async () => ({ items: ["old", "new"].map((id, index) => ({ + id, run_id: "run-1", run_ordinal: 1, sequence: index + 1, source: "operator", kind: "operator_input", + activity_type: "message", detail: "preview", title: "User", durable: true, provisional: false, + created_at: `2026-10-22T00:00:0${index + 1}Z`, + })), page: { limit: 100 }, requestID: "1" })); + const get = vi.fn(async (path: string) => ({ message_id: path.split("/").at(-1), content: path.endsWith("new") ? newestText : "Older input" })); + const workspaceExplore = vi.fn(async (_workspace: string, path: string): Promise => ({ + protocol_version: "workspace_explorer.v1", workspace_id: "ws", path, kind: path === "." ? "directory" : "file", + content: path === "." ? "" : "Readme content", entries: path === "." ? [{ name: "README.md", path: "README.md", + kind: "file", readable: true, size_bytes: 14 }] : [], redaction_count: 0, truncated: false, + root_path_exposed: false, total_bytes: 14, returned_bytes: 14, + provenance: { version: "context_provenance.v1", source_kind: path === "." ? "workspace_listing" : "workspace_file", + source_ref: path, content_sha256: "b".repeat(64), instruction_authorized: false }, + })); + const client = { get, getPage, workspaceExplore, baseURL: "/api/v1", hasThreadControl: true, hasEvidenceAttachment: true } as unknown as APIClient; + const submit = vi.fn(async () => undefined); + const stop = vi.fn(() => true); + function Fixture() { + const [thread, setThread] = useState("thread-a"); + const draft = useV2DraftDocument("ws", thread)!; + return <> {}} onSubmit={submit} onStop={stop} /> + + + + + {draft.state.snapshot.text} + {JSON.stringify([draft.state.snapshot.files, draft.state.snapshot.attachments])} + ; + } + render( + + ); + return { get, getPage, submit, stop, workspaceExplore }; +} +const input = () => screen.getByRole("textbox", { name: "继续对话" }); +function selection(from: "start" | "end" | number, to?: number) { + const box = input(); + act(() => { + box.focus(); + if (box instanceof HTMLTextAreaElement) { + const position = from === "start" ? 0 : from === "end" ? box.value.length : from; + box.setSelectionRange(position, to ?? position); + } else { + // Set the real editor selection; jsdom cannot perform native caret moves. + const view = (box as HTMLElement & { editor: Editor }).editor.view; + const doc = view.state.doc; + const position = from === "start" ? TextSelection.atStart(doc).from : from === "end" ? TextSelection.atEnd(doc).from : from; + view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, position, to ?? position))); + } + }); +} +function key(name: string, extra = {}) { return fireEvent.keyDown(input(), { key: name, ...extra }); } + +beforeAll(() => Object.defineProperties(Range.prototype, { + getClientRects: { configurable: true, value: () => [] }, + getBoundingClientRect: { configurable: true, value: () => new DOMRect() }, +})); +beforeEach(() => { localStorage.clear(); localStorage.setItem("prayu.locale.v1", "zh-CN"); }); +afterEach(cleanup); + +it.each([true, false])("recalls complete saved prompts and restores the managed draft in plain mode %s", async (plain) => { + const test = setup(plain); + selection("start"); key("ArrowUp"); + await waitFor(() => expect(screen.getByTestId("draft").textContent).toBe(newest)); + expect(screen.getByText(/历史输入 1 \/ 2/)).toBeInTheDocument(); + if (!plain) expect(input().querySelector("strong")).toHaveTextContent("完整输入"); + selection("start"); key("ArrowUp"); + await waitFor(() => expect(screen.getByTestId("draft")).toHaveTextContent("Older input")); + selection("end"); key("ArrowDown"); + await waitFor(() => expect(screen.getByTestId("draft").textContent).toBe(newest)); + selection("end"); key("ArrowDown"); + expect(screen.getByTestId("draft")).toBeEmptyDOMElement(); + expect(test.getPage).toHaveBeenCalledOnce(); expect(test.get).toHaveBeenCalledTimes(2); + expect(test.submit).not.toHaveBeenCalled(); +}); + +it.each([true, false])("leaves IME, modifiers, selections and multiline caret movement alone in plain mode %s", async (plain) => { + const test = setup(plain); + selection("start"); + key("ArrowUp", { isComposing: true }); key("ArrowUp", { keyCode: 229 }); + for (const modifier of ["shiftKey", "ctrlKey", "metaKey", "altKey"]) key("ArrowUp", { [modifier]: true }); + fireEvent.compositionStart(input()); key("ArrowUp"); + expect(test.getPage).not.toHaveBeenCalled(); + fireEvent.compositionEnd(input()); selection("start"); + // ProseMirror's Safari IME guard (jsdom's vendor) consumes the first key + // immediately after compositionend. The next ordinary key may recall. + key("ArrowUp"); + key("ArrowUp"); + await waitFor(() => expect(screen.getByTestId("draft").textContent).toBe(newest)); + selection(3); key("ArrowUp"); key("ArrowDown"); + selection(1, 3); key("ArrowUp"); key("ArrowDown"); + expect(screen.getByTestId("draft").textContent).toBe(newest); expect(test.get).toHaveBeenCalledOnce(); + // A manual edit leaves history. Down must never discard the changed text. + if (plain) fireEvent.change(input(), { target: { value: "手工修改" } }); + else fireEvent.paste(input(), { clipboardData: { getData: (type: string) => type === "text/plain" ? "手工修改" : "" } }); + const edited = screen.getByTestId("draft").textContent; + selection("end"); key("ArrowDown"); + expect(screen.getByTestId("draft").textContent).toBe(edited); + expect(screen.queryByText(/历史输入 1 \/ 2/)).not.toBeInTheDocument(); +}); + +it.each(["加入测试附件", "添加引用", "引用文字"])("preserves %s and does not enter history", (action) => { + const test = setup(true); + fireEvent.click(screen.getByRole("button", { name: action })); + const files = screen.getByTestId("draft-files").textContent; + const text = screen.getByTestId("draft").textContent; + selection("start"); key("ArrowUp"); + expect(test.getPage).not.toHaveBeenCalled(); + expect(screen.getByTestId("draft-files").textContent).toBe(files); + expect(screen.getByTestId("draft").textContent).toBe(text); +}); + +it.each([true, false])("gives file candidates priority over active history and then Escape stop in plain mode %s", async (plain) => { + const test = setup(plain, "@R"); + selection("start"); key("ArrowUp"); + await waitFor(() => expect(screen.getByTestId("draft")).toHaveTextContent("@R")); + if (!plain) expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + selection("start"); + selection("end"); + if (plain) fireEvent.select(input()); + await screen.findByRole("option", { name: "README.md" }); + // Down at the end would restore the empty pre-history draft if history won. + key("ArrowDown"); key("ArrowUp"); + expect(screen.getByTestId("draft")).toHaveTextContent("@R"); + key("Enter"); + await screen.findByRole("button", { name: "移除引用 README.md" }); + expect(screen.getByTestId("draft")).toHaveTextContent("@README.md"); + expect(test.get).toHaveBeenCalledOnce(); expect(test.getPage).toHaveBeenCalledOnce(); + expect(test.submit).not.toHaveBeenCalled(); + + selection("end"); + if (plain) fireEvent.change(input(), { target: { value: "保留引用 @R" } }); + else fireEvent.paste(input(), { clipboardData: { getData: (type: string) => type === "text/plain" ? " @R" : "" } }); + await screen.findByRole("option", { name: "README.md" }); + const draft = screen.getByTestId("draft").textContent; + const files = screen.getByTestId("draft-files").textContent; + key("Escape"); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + expect(test.stop).not.toHaveBeenCalled(); + key("Escape"); + expect(test.stop).toHaveBeenCalledOnce(); + expect(screen.getByTestId("draft").textContent).toBe(draft); + expect(screen.getByTestId("draft-files").textContent).toBe(files); + expect(test.submit).not.toHaveBeenCalled(); +}); diff --git a/web/src/v2/components/composer.tsx b/web/src/v2/components/composer.tsx index 360b18ed..bbdd83f9 100644 --- a/web/src/v2/components/composer.tsx +++ b/web/src/v2/components/composer.tsx @@ -8,7 +8,7 @@ import type { WorkspaceView } from "../../api/types"; import { V2ModelRouteControl, type V2PendingModelRoute } from "./model-route-control"; import { V2PermissionControl } from "./permission-control"; import { V2ComposerAddMenu, type ComposerAddAction } from "./composer-add-menu"; -import { V2FileContext, useV2FileReferences, type V2FileReference } from "./file-context"; +import { V2FileContext, useV2FileReferences, withV2FileReference, type V2FileReference } from "./file-context"; import { useV2ThreadSubmissions, V2SubmissionError, v2TurnOutcomeKnown, type V2TurnInput } from "../use-thread-turn"; import { useV2PersistenceWarning, useV2RecoveryStore } from "../recovery-storage"; import { acceptedImageTypes, imageIdentities, maximumImages, type WorkspaceImageAttachment } from "../../api/image-attachments"; @@ -25,6 +25,7 @@ import { maximumMCPReferences, mcpReferenceIdentities, type MCPDraftReference } import { V2MCPReferenceChips, V2MCPReferencePicker, useV2MCPReferences } from "./mcp-reference-picker"; import { MarkdownInput, composerPlainTextKey, readComposerPlainText, type MarkdownInputHandle } from "./markdown-input"; import { v2AttachmentImportKey } from "../attachment-keys"; +import { usePromptHistory } from "../use-prompt-history"; const maximumContentBytes = 16 * 1024; @@ -174,6 +175,11 @@ export function V2Composer({ client, threadID, workspaceID, runID, workspaces, d }); }, [confirmedSubmission, threadID, workspaceID, submissions, error?.reason, error?.inputFingerprint]); const inputRef = useRef(null); + const promptHistory = usePromptHistory({ client, threadID, scope, value: content, + blocked: Boolean(disabled || submitting || references.files.length || mcp.references.length || images.images.length || + images.uploading || attachments.attachments.length || attachments.uploading || attachments.pending || nativeClipboard.pending || + nativeClipboard.busy || importStatus.busy || managedDraft?.state.conflict || managedDraft?.state.error), + apply: setContent, atBoundary: (direction) => inputRef.current?.atHistoryBoundary(direction) ?? false }); const normalized = content.trim(); const [editorExpanded, setEditorExpanded] = useState(false); const hasUnsentDraft = Boolean(normalized || references.files.length || mcp.references.length || images.images.length || images.uploading || attachments.attachments.length || attachments.uploading || attachments.pending || nativeClipboard.pending || nativeClipboard.busy || importStatus.busy || @@ -232,6 +238,7 @@ export function V2Composer({ client, threadID, workspaceID, runID, workspaces, d // A descendant portal can contain its own form (settings or file search). if (event && event.target !== event.currentTarget) return; if (!ready) return; + promptHistory.cancel(); const submittedFiles = references.files; const submittedImages = images.images; const submittedAttachments = attachments.attachments; @@ -401,7 +408,24 @@ export function V2Composer({ client, threadID, workspaceID, runID, workspaces, d onOpenChange={setReferencePickerOpen} returnFocusRef={addTriggerRef} onChange={references.update} workspaceID={workspaceID} />} { setContent(next); setError(null); }} onSubmit={() => void submit()} + onChange={(next) => { promptHistory.cancel(); setContent(next); setError(null); }} onSubmit={() => void submit()} + onHistoryNavigate={promptHistory.navigate} onHistoryCancel={promptHistory.cancel} + filePaths={workspaceID && client.hasEvidenceAttachment && !attachmentsDisabled && !fileReferenceUnavailableReason && + !managedDraft?.state.conflict && !managedDraft?.state.error ? { client, workspaceID, onAttach: (file, next, expected) => { + const limitMessage = t("最多引用 4 个文件,请先移除一个引用。", "You can reference up to 4 files. Remove a reference first."); + const changed = () => new Error(t("草稿已变化,请重新选择文件。", "The draft changed. Choose the file again.")); + if (currentContentRef.current !== expected) throw changed(); + if (managedDraft) { + const current = managedDraft.document.read(managedDraft.scope); + if (current.snapshot.text !== expected || current.conflict || current.error) throw changed(); + managedDraft.document.update(managedDraft.scope, (snapshot) => ({ ...snapshot, text: next, + files: withV2FileReference(snapshot.files, file, limitMessage) }), current.ref); + } else { + const files = withV2FileReference(references.files, file, limitMessage); + references.update(() => files); setContent(next, expected); + } + setError(null); + } } : undefined} placeholder={inputPlaceholder} inputRef={inputRef} value={content} scope={scope} plainText={plainText} /> {byteLength >= 4096 &&
maximumContentBytes ? "is-over-limit" : undefined}> @@ -447,6 +471,8 @@ export function V2Composer({ client, threadID, workspaceID, runID, workspaces, d
+ {promptHistory.notice &&

{promptHistory.notice.text}

} {workspaceID && references.files.length > 0 && fileReferenceUnavailableReason &&

{fileReferenceUnavailableReason} {t(" 已选引用与草稿会保留;先移除项目文件引用,即可发送补充文字和上传附件。", " Your references and draft are kept. Remove project file references to send text and uploaded attachments.")}

} diff --git a/web/src/v2/components/file-context.test.tsx b/web/src/v2/components/file-context.test.tsx index 9c00159a..90271835 100644 --- a/web/src/v2/components/file-context.test.tsx +++ b/web/src/v2/components/file-context.test.tsx @@ -8,9 +8,22 @@ import type { PropsWithChildren } from "react"; import { APIRequestError, type APIClient } from "../../api/client"; import { V2Composer } from "./composer"; import { useV2ThreadTurn, useV2ThreadSubmissions } from "../use-thread-turn"; -import { v2FileReferenceKey } from "./file-context"; +import { v2FileReferenceKey, withV2FileReference } from "./file-context"; +import type { WorkspaceExplorerView } from "../../api/types"; const digest = "a".repeat(64); +it("shares the four-file limit and same-path replacement across reference entry points", () => { + const files = Array.from({ length: 4 }, (_, index) => ({ id: `ref-${index}`, path: `${index}.md`, digest, + partial: false, redacted: false })); + const incoming = { path: "new.md", provenance: { content_sha256: "b".repeat(64) }, truncated: true, redaction_count: 1 } as WorkspaceExplorerView; + expect(() => withV2FileReference(files, incoming, "最多四个")).toThrow("最多四个"); + expect(files).toHaveLength(4); + const replaced = withV2FileReference(files, { ...incoming, path: "2.md" }, "最多四个"); + expect(replaced).toHaveLength(4); + expect(replaced.at(-1)).toMatchObject({ path: "2.md", digest: "b".repeat(64), partial: true, redacted: true }); + expect(replaced.at(-1)?.id).not.toBe("ref-2"); +}); + function setup() { const workspaceExplore = vi.fn(async (_workspace: string, path: string) => ({ protocol_version: "workspace_explorer.v1", workspace_id: "workspace-1", path, diff --git a/web/src/v2/components/file-context.tsx b/web/src/v2/components/file-context.tsx index 7e42378d..283cd96f 100644 --- a/web/src/v2/components/file-context.tsx +++ b/web/src/v2/components/file-context.tsx @@ -3,6 +3,7 @@ import { createPortal } from "react-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { X } from "lucide-react"; import type { APIClient } from "../../api/client"; +import type { WorkspaceExplorerView } from "../../api/types"; import { WorkspaceExplorer } from "../../components/workspace-explorer"; import { useModalFocusTrap } from "../../hooks/use-modal-focus-trap"; import { useV2DraftDocument } from "../draft-context"; @@ -16,6 +17,14 @@ export interface V2FileReference { redacted: boolean; } +export function withV2FileReference(files: V2FileReference[], file: WorkspaceExplorerView, limitMessage: string): V2FileReference[] { + if (files.length >= 4 && !files.some(({ path }) => path === file.path)) throw new Error(limitMessage); + return [...files.filter(({ path }) => path !== file.path), { + id: globalThis.crypto.randomUUID(), path: file.path, digest: file.provenance.content_sha256, + partial: file.truncated, redacted: file.redaction_count > 0, + }]; +} + export const v2FileReferenceKey = (workspaceID: string, threadID: string) => ["v2", "draft-files", workspaceID, threadID] as const; @@ -75,14 +84,11 @@ export function V2FileContext({ client, workspaceID, files, onChange, disabled, {error &&

{error}

} { if (disabled || unavailableReason) return; - if (files.length >= 4 && !files.some(({ path }) => path === file.path)) { - setError(t("最多引用 4 个文件,请先移除一个引用。", "You can reference up to 4 files. Remove a reference first.")); return; - } - const reference = { id: globalThis.crypto.randomUUID(), path: file.path, - digest: file.provenance.content_sha256, partial: file.truncated, - redacted: file.redaction_count > 0 }; - onChange((current) => [...current.filter(({ path }) => path !== file.path), reference]); - onClose(); + try { + onChange((current) => withV2FileReference(current, file, + t("最多引用 4 个文件,请先移除一个引用。", "You can reference up to 4 files. Remove a reference first."))); + onClose(); + } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); } }} /> , document.body)} diff --git a/web/src/v2/components/file-path-mention.test.ts b/web/src/v2/components/file-path-mention.test.ts new file mode 100644 index 00000000..aeaa396b --- /dev/null +++ b/web/src/v2/components/file-path-mention.test.ts @@ -0,0 +1,38 @@ +import { completedFilePath, filePathMention, plainFilePathMention } from "./file-path-mention"; + +it("splits only the current path into a parent directory and a filename prefix", () => { + expect(filePathMention("检查 @", 4)).toMatchObject({ from: 3, parent: ".", prefix: "" }); + expect(filePathMention("检查 @src/v2/co 后文", 13)).toMatchObject({ from: 3, to: 13, parent: "src/v2", prefix: "co" }); + expect(filePathMention("@src/", 5)).toMatchObject({ parent: "src", prefix: "" }); + expect(filePathMention("@.", 2)).toMatchObject({ parent: ".", prefix: "." }); +}); + +it("keeps spaces, Unicode and escaped quotes in paths without changing the server path", () => { + const directory = completedFilePath("资料 目录", true); + expect(directory).toEqual({ text: '@"资料 目录/"', caret: 8 }); + expect(filePathMention(directory.text, directory.caret)).toMatchObject({ parent: "资料 目录", prefix: "", to: 9 }); + expect(filePathMention('@"资料 目录/说明 文', 12)).toMatchObject({ parent: "资料 目录", prefix: "说明 文" }); + const quote = completedFilePath('has"quote', true); + expect(filePathMention(quote.text, quote.caret)).toMatchObject({ parent: 'has"quote' }); + expect(completedFilePath("资料 目录/说明 文.md", false).text).toBe('@"资料 目录/说明 文.md" '); +}); + +it.each(["@src/component.ts", '@"资料 目录/说明 文.md"']) + ("does not complete inside the existing path %s", (text) => { + expect(filePathMention(text, text.indexOf("/") + 2)).toBeNull(); + }); + +it.each(["name@example.com", "@../private", "@/root/file", "@C:/file", "@src\\file", "@src//file", "@./file", "@read me", '@"closed"']) + ("does not interpret %s as an active project path", (text) => expect(filePathMention(text, text.length)).toBeNull()); + +it.each(["```ts\n@src", "~~~text\n@src", " @src", "- `@src", "> ```\n> @src", "`unfinished @src", "[label @src](target)"]) + ("protects Markdown code/link text: %s", (text) => { + const caret = text.indexOf("@src") + 4; + expect(plainFilePathMention(text, caret)).toBeNull(); + }); + +it("allows normal text after a completed code span or a closed fence", () => { + for (const text of ["`code` @src", "```\ncode\n```\n@src", "- Review @src"]) { + expect(plainFilePathMention(text, text.length)).toMatchObject({ parent: ".", prefix: "src" }); + } +}); diff --git a/web/src/v2/components/file-path-mention.ts b/web/src/v2/components/file-path-mention.ts new file mode 100644 index 00000000..d8b74ad2 --- /dev/null +++ b/web/src/v2/components/file-path-mention.ts @@ -0,0 +1,62 @@ +import { MarkdownManager } from "@tiptap/markdown"; +import { validWorkspaceRelativePath } from "../../api/client/validation"; + +export interface FilePathMention { + from: number; + to: number; + parent: string; + prefix: string; +} + +// This is a path token, not a Markdown extension or an attachment parser. +// Only an explicit selection can add a file reference to the draft. +export function filePathMention(text: string, caret: number): FilePathMention | null { + const before = text.slice(0, caret); + const matches = [...before.matchAll(/(?:^|\s)@/gu)]; + const match = matches.at(-1); + if (!match) return null; + const from = match.index! + match[0].length - 1; + const source = before.slice(from + 1); + let path = source; + let to = caret; + if (source.startsWith('"')) { + // Completing a prefix inside an existing path would duplicate its suffix. + if (text[caret] && text[caret] !== '"') return null; + // JSON string escaping also preserves quotes in Unix file names. + try { path = JSON.parse(`${source}"`) as string; } catch { return null; } + if (text[caret] === '"') to++; + } else if (/[\s"\\]/u.test(source) || (text[caret] && !/\s/u.test(text[caret]))) return null; + if (Array.from(path).length > 512 || /[\\:\u0000-\u001f\u007f]/u.test(path)) return null; + const slash = path.lastIndexOf("/"); + const parent = slash < 0 ? "." : path.slice(0, slash); + const prefix = path.slice(slash + 1); + if (!validWorkspaceRelativePath(parent) || (slash >= 0 && parent === ".")) return null; + return { from, to, parent, prefix }; +} + +let markdown: MarkdownManager | undefined; +export function plainFilePathMention(text: string, caret: number): FilePathMention | null { + const mention = filePathMention(text, caret); + if (!mention) return null; + // Reuse the editor's Markdown lexer for fenced/indented code, inline code, + // links and HTML, including nested lists. The marker locates this caret only. + let marker = "UCFILEPATHCARET"; + while (text.includes(marker)) marker += "X"; + const markedText = text.slice(0, mention.from) + marker + text.slice(mention.from); + markdown ??= new MarkdownManager(); + let protectedText = false; + markdown.instance.walkTokens(markdown.instance.lexer(markedText), (token) => { + if (!token.raw?.includes(marker)) return; + if (["code", "codespan", "link", "image", "html"].includes(token.type) || + // An unfinished inline-code delimiter is still literal in the lexer. + (token.type === "text" && token.raw.slice(0, token.raw.indexOf(marker)).includes("`"))) protectedText = true; + }); + return protectedText ? null : mention; +} + +export function completedFilePath(path: string, directory: boolean): { text: string; caret: number } { + const value = path + (directory ? "/" : ""); + const quoted = /[\s"]/u.test(value); + const text = `@${quoted ? JSON.stringify(value) : value}${directory ? "" : " "}`; + return { text, caret: text.length - (directory && quoted ? 1 : 0) }; +} diff --git a/web/src/v2/components/file-path-suggestions.css b/web/src/v2/components/file-path-suggestions.css new file mode 100644 index 00000000..b202a5d6 --- /dev/null +++ b/web/src/v2/components/file-path-suggestions.css @@ -0,0 +1,12 @@ +.v2-file-path-input { position: relative; min-width: 0; } +.v2-file-path-suggestions { position: absolute; z-index: 30; bottom: calc(100% + 8px); left: 0; right: 0; max-height: min(320px, 45vh); overflow: auto; border: 1px solid var(--v2-border); border-radius: 12px; background: var(--v2-floating-surface); box-shadow: 0 12px 32px rgb(0 0 0 / .14); padding: 6px; font-size: 12px; } +.v2-file-path-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 6px 8px; color: var(--v2-muted); } +.v2-file-path-heading > span { min-width: 0; overflow-wrap: anywhere; } +.v2-file-path-heading button { display: grid; place-items: center; flex-shrink: 0; border: 0; background: transparent; color: inherit; padding: 4px; border-radius: 5px; cursor: pointer; } +.v2-file-path-suggestions [role="option"] { display: flex; align-items: center; gap: 8px; width: 100%; border: 0; border-radius: 6px; padding: 8px; background: transparent; color: var(--v2-ink); text-align: left; font: inherit; cursor: pointer; } +.v2-file-path-suggestions [role="option"] > svg { flex-shrink: 0; } +.v2-file-path-suggestions [role="option"] > span { min-width: 0; overflow-wrap: anywhere; } +.v2-file-path-suggestions [role="option"][aria-selected="true"], .v2-file-path-suggestions [role="option"]:hover { background: var(--v2-control-hover); } +.v2-file-path-suggestions p { margin: 6px; color: var(--v2-muted); overflow-wrap: anywhere; } +.v2-file-path-suggestions [role="alert"] { color: var(--v2-danger); } +.v2-file-path-suggestions .v2-file-path-help { font-size: 11px; } diff --git a/web/src/v2/components/file-path-suggestions.test.tsx b/web/src/v2/components/file-path-suggestions.test.tsx new file mode 100644 index 00000000..6a6edada --- /dev/null +++ b/web/src/v2/components/file-path-suggestions.test.tsx @@ -0,0 +1,192 @@ +import { useState } from "react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { APIClient } from "../../api/client"; +import type { WorkspaceExplorerView } from "../../api/types"; +import { LocaleProvider } from "../../lib/locale"; +import { MarkdownInput } from "./markdown-input"; +import { V2Composer } from "./composer"; +import { V2RecoveryProvider } from "../recovery-storage"; + +const digest = "a".repeat(64); +const snapshot = (path: string, kind: "directory" | "file", names: string[] = []): WorkspaceExplorerView => ({ + protocol_version: "workspace_explorer.v1", workspace_id: "workspace", path, kind, content: kind === "file" ? "proof" : "", + entries: names.map((name) => ({ name, path: path === "." ? name : `${path}/${name}`, + kind: name.includes(".") ? "file" : "directory", readable: true, size_bytes: 5 })), + redaction_count: 0, truncated: false, root_path_exposed: false, total_bytes: 0, returned_bytes: 0, + provenance: { version: "context_provenance.v1", source_kind: kind === "file" ? "workspace_file" : "workspace_listing", + source_ref: path, content_sha256: digest, instruction_authorized: false }, +}); +const directory = (path: string) => path === "." ? snapshot(path, "directory", ["src", "资料 目录"]) + : path === "src" ? snapshot(path, "directory", ["component.ts", "context.ts"]) + : path === "资料 目录" ? snapshot(path, "directory", ["说明 文.md"]) : snapshot(path, "file"); +const clientWith = (explore = vi.fn(async (_id: string, path: string, _signal?: AbortSignal) => directory(path))) => + ({ hasEvidenceAttachment: true, workspaceExplore: explore } as unknown as APIClient); + +function Harness({ plain, client, scope = "one", attach = vi.fn(), submit = vi.fn() }: { + plain: boolean; client: APIClient; scope?: string; attach?: (file: WorkspaceExplorerView) => void; submit?: () => void; +}) { + const [value, setValue] = useState(""); + return <> { attach(file); setValue(next); } }} /> + {value}; +} +function wrap() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return ({ children }: { children: React.ReactNode }) => + {children}; +} +function enter(text: string, plain: boolean, caret = text.length) { + const input = screen.getByRole("textbox"); + act(() => input.focus()); + if (plain) fireEvent.change(input, { target: { value: text, selectionStart: caret, selectionEnd: caret } }); + else fireEvent.paste(input, { clipboardData: { getData: (type: string) => type === "text/plain" ? text : "" } }); + return input; +} +beforeAll(() => Object.defineProperties(Range.prototype, { + getClientRects: { configurable: true, value: () => [] }, + getBoundingClientRect: { configurable: true, value: () => new DOMRect() }, +})); +beforeEach(() => { localStorage.clear(); localStorage.setItem("prayu.locale.v1", "zh-CN"); }); +afterEach(cleanup); + +it.each([true, false])("browses directories and attaches the real snapshot with Enter/Tab in plain mode %s", async (plain) => { + const client = clientWith(); const attach = vi.fn(); const submit = vi.fn(); + render(, { wrapper: wrap() }); + const input = enter("检查 @", plain); + await screen.findByRole("option", { name: "资料 目录/" }); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "Tab" }); + await screen.findByRole("option", { name: "说明 文.md" }); + expect(screen.getByTestId("draft")).toHaveTextContent('@"资料 目录/"'); + expect(client.workspaceExplore).toHaveBeenCalledTimes(2); + fireEvent.keyDown(input, { key: "Enter" }); + await waitFor(() => expect(attach).toHaveBeenCalledWith(expect.objectContaining({ path: "资料 目录/说明 文.md", + provenance: expect.objectContaining({ content_sha256: digest }) }))); + expect(screen.getByTestId("draft").textContent).toBe('检查 @"资料 目录/说明 文.md" '); + expect(submit).not.toHaveBeenCalled(); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); +}); + +it("filters only the requested directory, preserves surrounding text, and exposes truncation", async () => { + const explore = vi.fn(async (_id: string, path: string) => ({ ...directory(path), truncated: true })); + render(, { wrapper: wrap() }); + const input = enter("前文 @src/com 后文", true, "前文 @src/com".length); + await screen.findByRole("option", { name: "component.ts" }); + expect(screen.queryByRole("option", { name: "context.ts" })).not.toBeInTheDocument(); + expect(explore).toHaveBeenCalledWith("workspace", "src", expect.any(AbortSignal)); + expect(screen.getByText(/仅列出部分目录条目/)).toBeVisible(); + fireEvent.keyDown(input, { key: "Enter" }); + await waitFor(() => expect(screen.getByTestId("draft")).toHaveTextContent("前文 @src/component.ts 后文")); + expect((input as HTMLTextAreaElement).selectionStart).toBe("前文 @src/component.ts ".length); +}); + +it("keeps rich formatting and an ordinary local undo entry", async () => { + render(, { wrapper: wrap() }); + const input = enter("**重点** @src/com", false); + await screen.findByRole("option", { name: "component.ts" }); + fireEvent.keyDown(input, { key: "Enter" }); + await waitFor(() => expect(screen.getByTestId("draft")).toHaveTextContent("**重点** @src/component.ts")); + expect(input.querySelector("strong")).toHaveTextContent("重点"); + fireEvent.keyDown(input, { key: "z", ctrlKey: true }); + await waitFor(() => expect(screen.getByTestId("draft").textContent).not.toContain("component.ts")); +}); + +it.each([true, false])("keeps file-read failures and ignores closed pending selections in plain mode %s", async (plain) => { + let finish!: (file: WorkspaceExplorerView) => void; + const explore = vi.fn((_id: string, path: string) => path.endsWith(".ts") + ? new Promise((resolve) => { finish = resolve; }) : Promise.resolve(directory(path))); + const attach = vi.fn(); + render(, { wrapper: wrap() }); + const input = enter("检查 @src/com", plain); + await screen.findByRole("option", { name: "component.ts" }); + fireEvent.keyDown(input, { key: "Enter" }); + await screen.findByText("正在读取文件…"); + fireEvent.keyDown(input, { key: "Escape" }); + fireEvent.select(input); + fireEvent.keyUp(input, { key: "Escape" }); + await act(async () => finish(snapshot("src/component.ts", "file"))); + expect(attach).not.toHaveBeenCalled(); + expect(screen.getByTestId("draft")).toHaveTextContent("检查 @src/com"); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); +}); + +it.each(["switch", "edit", "mode"])("rejects a late file response after %s", async (change) => { + let finish!: (file: WorkspaceExplorerView) => void; + const explore = vi.fn((_id: string, path: string) => path.endsWith(".ts") + ? new Promise((resolve) => { finish = resolve; }) : Promise.resolve(directory(path))); + const client = clientWith(explore); const attach = vi.fn(); + const component = (scope: string, plain = true) => ; + const view = render(component("one"), { wrapper: wrap() }); + const input = enter("@src/com", true); + await screen.findByRole("option", { name: "component.ts" }); + fireEvent.keyDown(input, { key: "Enter" }); + await screen.findByText("正在读取文件…"); + if (change === "switch") { view.rerender(component("two")); view.rerender(component("one")); } + else if (change === "mode") view.rerender(component("one", false)); + else enter("新的草稿", true); + await act(async () => finish(snapshot("src/component.ts", "file"))); + expect(attach).not.toHaveBeenCalled(); +}); + +it.each([true, false])("preserves drafts on read error and does not act during IME in plain mode %s", async (plain) => { + const client = clientWith(vi.fn(async (_id, path) => { + if (path.endsWith(".ts")) throw new Error("文件无法读取"); + return directory(path); + })); + const submit = vi.fn(); + render(, { wrapper: wrap() }); + const input = enter("@src/com", plain); + await screen.findByRole("option", { name: "component.ts" }); + fireEvent.keyDown(input, { key: "Enter", isComposing: true }); + expect(client.workspaceExplore).toHaveBeenCalledTimes(1); + fireEvent.compositionStart(input); + fireEvent.keyDown(input, { key: "Enter", keyCode: 229 }); + expect(submit).not.toHaveBeenCalled(); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + fireEvent.compositionEnd(input); + if (!plain) { fireEvent.keyDown(input, { key: "a", ctrlKey: true }); enter("@src/com", false); } + await screen.findByRole("option", { name: "component.ts" }); + fireEvent.keyDown(input, { key: "Enter" }); + await screen.findByRole("alert"); + expect(screen.getByTestId("draft")).toHaveTextContent("@src/com"); + expect(submit).not.toHaveBeenCalled(); +}); + +it("uses the real composer's draft/reference chain and gives an open listbox Escape priority", async () => { + localStorage.setItem("universal-code.composer-plain-text", "true"); + const submit = vi.fn(async () => undefined); const stop = vi.fn(() => true); + render( + undefined} /> + , { wrapper: wrap() }); + const input = enter("检查 @src/com", true); + await screen.findByRole("option", { name: "component.ts" }); + fireEvent.keyDown(input, { key: "Escape" }); + expect(stop).not.toHaveBeenCalled(); + fireEvent.keyDown(input, { key: "Escape" }); + expect(stop).toHaveBeenCalledOnce(); + enter("检查 @src/cont", true); + await screen.findByRole("option", { name: "context.ts" }); + fireEvent.keyDown(input, { key: "Enter" }); + await screen.findByRole("button", { name: "移除引用 src/context.ts" }); + fireEvent.keyDown(input, { key: "Enter", ctrlKey: true }); + await waitFor(() => expect(submit).toHaveBeenCalledWith("检查 @src/context.ts", + [expect.objectContaining({ path: "src/context.ts", digest })], [], expect.any(Object), [])); +}); + +it("closes an old plain-text candidate when a restored draft replaces the value", async () => { + const client = clientWith(); + const component = (value: string) => ; + const view = render(component("@src/com"), { wrapper: wrap() }); + const input = screen.getByRole("textbox") as HTMLTextAreaElement; + act(() => { input.focus(); input.setSelectionRange(input.value.length, input.value.length); }); + fireEvent.select(input); + await screen.findByRole("option", { name: "component.ts" }); + view.rerender(component("恢复的草稿")); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + expect(input).toHaveValue("恢复的草稿"); +}); diff --git a/web/src/v2/components/file-path-suggestions.tsx b/web/src/v2/components/file-path-suggestions.tsx new file mode 100644 index 00000000..06ae807c --- /dev/null +++ b/web/src/v2/components/file-path-suggestions.tsx @@ -0,0 +1,93 @@ +import { useEffect, useImperativeHandle, useRef, useState, type Ref } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { File, Folder, X } from "lucide-react"; +import type { APIClient } from "../../api/client"; +import type { WorkspaceExplorerView } from "../../api/types"; +import { useLocale } from "../../lib/locale"; +import type { FilePathMention } from "./file-path-mention"; +import "./file-path-suggestions.css"; + +export interface FilePathTarget extends FilePathMention { original: string } +export interface FilePathSuggestionsHandle { keyDown(event: KeyboardEvent): boolean } + +export function FilePathSuggestions({ client, workspaceID, target, listID, inputRef, onActive, onClose, onSelect }: { + client: APIClient; workspaceID: string; target: FilePathTarget; listID: string; + inputRef: Ref; onActive: (id: string | undefined) => void; onClose: () => void; + onSelect: (target: FilePathTarget, path: string, file?: WorkspaceExplorerView) => void; +}) { + const { t } = useLocale(); + const [selected, setSelected] = useState(0); + const [error, setError] = useState(""); + const [reading, setReading] = useState(false); + const request = useRef(null); + const active = useRef(null); + const current = useRef(target); + current.current = target; + const directory = useQuery({ queryKey: ["workspace", workspaceID, "explore", target.parent], + queryFn: ({ signal }) => client.workspaceExplore(workspaceID, target.parent, signal), retry: false }); + const entries = directory.data?.kind === "directory" ? directory.data.entries.filter((entry) => + entry.readable && entry.name.toLocaleLowerCase().startsWith(target.prefix.toLocaleLowerCase())) : []; + const index = Math.min(selected, Math.max(0, entries.length - 1)); + useEffect(() => { + setSelected(0); setError(""); setReading(false); + return () => { request.current?.abort(); request.current = null; }; + }, [target, client, workspaceID]); + useEffect(() => { onActive(entries.length ? `${listID}-${index}` : undefined); }, [listID, index, entries.length, onActive]); + useEffect(() => { active.current?.scrollIntoView?.({ block: "nearest" }); }, [index, target, entries.length]); + const select = async (entry: typeof entries[number]) => { + if (reading) return; + if (entry.kind === "directory") { onSelect(target, entry.path); return; } + const controller = new AbortController(); + request.current = controller; + setReading(true); setError(""); + let attaching = false; + try { + const file = await client.workspaceExplore(workspaceID, entry.path, controller.signal); + if (controller.signal.aborted || current.current !== target) return; + if (file.kind !== "file") throw new Error(t("文件已变化,请重新选择。", "The file changed. Choose it again.")); + attaching = true; + onSelect(target, entry.path, file); + } catch (reason) { + if (!controller.signal.aborted && current.current === target) setError(attaching && reason instanceof Error ? reason.message : + t("文件读取失败,输入已保留。请重新选择文件。", "Unable to read the file. Your input is kept. Choose the file again.")); + } finally { + if (!controller.signal.aborted && current.current === target) setReading(false); + } + }; + useImperativeHandle(inputRef, () => ({ keyDown: (event) => { + if (event.isComposing || event.keyCode === 229 || event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return false; + if (!["ArrowUp", "ArrowDown", "Enter", "Tab", "Escape"].includes(event.key)) return false; + if (event.key === "Tab" && !entries.length) { onClose(); return false; } + event.preventDefault(); event.stopPropagation(); + if (event.key === "Escape") onClose(); + else if (event.key === "ArrowUp") setSelected(Math.max(0, index - 1)); + else if (event.key === "ArrowDown") setSelected(Math.max(0, Math.min(entries.length - 1, index + 1))); + else if (entries[index]) void select(entries[index]); + return true; + } })); + + return
+
{t("项目路径", "Project paths")} · {target.parent} +
+
+ {entries.map((entry, item) => { + const Icon = entry.kind === "directory" ? Folder : File; + return ; + })} +
+ {error ?

{error}

: directory.isError ? +

{t("无法列出此目录,输入已保留。", "Unable to list this directory. Your input is kept.")}

: + reading ?

{t("正在读取文件…", "Reading file…")}

: + directory.isPending ?

{t("正在列出目录…", "Listing directory…")}

: + !entries.length ?

{t("当前目录没有匹配项。", "No matches in this directory.")}

: null} + {directory.data?.truncated &&

{t("仅列出部分目录条目;可输入完整父目录路径。", "Only part of this directory is listed. You can type the full parent path.")}

} +

{t("↑↓ 选择 · Enter / Tab 补全 · Esc 关闭", "↑↓ select · Enter / Tab complete · Esc close")}

+
; +} diff --git a/web/src/v2/components/markdown-input.tsx b/web/src/v2/components/markdown-input.tsx index bee462c7..54d109f0 100644 --- a/web/src/v2/components/markdown-input.tsx +++ b/web/src/v2/components/markdown-input.tsx @@ -1,4 +1,4 @@ -import { useImperativeHandle, useLayoutEffect, useRef, type Ref } from "react"; +import { useId, useImperativeHandle, useLayoutEffect, useRef, useState, type Ref } from "react"; import { Editor } from "@tiptap/core"; import { StarterKit } from "@tiptap/starter-kit"; import { Markdown } from "@tiptap/markdown"; @@ -9,36 +9,135 @@ import { Slice } from "@tiptap/pm/model"; import { EditorState, TextSelection } from "@tiptap/pm/state"; import "./markdown-input.css"; import { sendsOnEnter, useChatPreferences } from "../chat-preferences"; +import type { HistoryDirection } from "../use-prompt-history"; +import type { APIClient } from "../../api/client"; +import type { WorkspaceExplorerView } from "../../api/types"; +import { completedFilePath, filePathMention, plainFilePathMention } from "./file-path-mention"; +import { FilePathSuggestions, type FilePathSuggestionsHandle, type FilePathTarget } from "./file-path-suggestions"; export const composerPlainTextKey = "universal-code.composer-plain-text"; export function readComposerPlainText(): boolean { try { return localStorage.getItem(composerPlainTextKey) === "true"; } catch { return false; } } -export interface MarkdownInputHandle { focus(): void } +export interface MarkdownInputHandle { focus(): void; atHistoryBoundary(direction: HistoryDirection): boolean } // The durable draft remains Markdown. The editor document and undo history are // local to this draft scope; merely mounting or switching views never rewrites it. export function MarkdownInput({ value, onChange, onSubmit, plainText, scope, label, placeholder, - disabled, inputRef }: { + disabled, inputRef, filePaths, onHistoryNavigate, onHistoryCancel }: { value: string; onChange: (value: string) => void; onSubmit: () => void; plainText: boolean; scope: string; label: string; placeholder: string; disabled: boolean; inputRef: Ref; + onHistoryNavigate?: (direction: HistoryDirection) => boolean; + onHistoryCancel?: () => void; + filePaths?: { client: APIClient; workspaceID: string; + onAttach: (file: WorkspaceExplorerView, next: string, expected: string) => void }; }) { const { send } = useChatPreferences(); const mount = useRef(null); const textarea = useRef(null); const editor = useRef(null); - const latest = useRef({ value, onChange, onSubmit, disabled, label, placeholder, send }); - latest.current = { value, onChange, onSubmit, disabled, label, placeholder, send }; + const latest = useRef({ value, onChange, onSubmit, disabled, label, placeholder, send, filePaths, onHistoryNavigate, onHistoryCancel }); + latest.current = { value, onChange, onSubmit, disabled, label, placeholder, send, filePaths, onHistoryNavigate, onHistoryCancel }; const representedValue = useRef(value); const restoreFocus = useRef(false); + const [target, setTarget] = useState(null); + const targetRef = useRef(null); + const dismissedPath = useRef(null); + const suggestions = useRef(null); + const listID = useId(); + const [activeOption, setActiveOption] = useState(); + const composing = useRef(false); + const atHistoryBoundary = (direction: HistoryDirection) => { + if (latest.current.disabled || composing.current) return false; + const input = textarea.current; + if (input) return document.activeElement === input && input.selectionStart === input.selectionEnd && + input.selectionStart === (direction === "older" ? 0 : input.value.length); + const view = editor.current?.view; + if (!view?.hasFocus() || view.composing) return false; + const { selection, doc } = view.state; + return selection instanceof TextSelection && selection.empty && selection.from === + (direction === "older" ? TextSelection.atStart(doc).from : TextSelection.atEnd(doc).from); + }; + const historyKey = (event: globalThis.KeyboardEvent) => { + if (event.isComposing || event.keyCode === 229 || composing.current || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false; + const direction = event.key === "ArrowUp" ? "older" : event.key === "ArrowDown" ? "newer" : null; + if (!direction || !atHistoryBoundary(direction) || !latest.current.onHistoryNavigate?.(direction)) return false; + event.preventDefault(); return true; + }; + const replacing = useRef(false); + const pendingCaret = useRef<{ value: string; at: number; directory: boolean } | null>(null); + const closePaths = () => { targetRef.current = null; setTarget(null); setActiveOption(undefined); }; + const dismissPaths = () => { dismissedPath.current = targetRef.current; closePaths(); }; + const showPaths = (next: FilePathTarget | null) => { + if (!latest.current.filePaths || latest.current.disabled || composing.current) { closePaths(); return; } + // Browsers may emit select after Escape or blur without moving the caret. + if (next && JSON.stringify(next) === JSON.stringify(dismissedPath.current)) { closePaths(); return; } + if (next) dismissedPath.current = null; + if (JSON.stringify(next) === JSON.stringify(targetRef.current)) return; + targetRef.current = next; setTarget(next); + }; + const readPlainPaths = (input: HTMLTextAreaElement) => { + const found = input.selectionStart === input.selectionEnd && document.activeElement === input + ? plainFilePathMention(input.value, input.selectionStart) : null; + showPaths(found ? { ...found, original: input.value } : null); + }; + const readRichPaths = (instance: Editor) => { + const { selection } = instance.state; + const { $from } = selection; + if (!instance.view.hasFocus() || instance.view.composing || !selection.empty || !$from.parent.isTextblock || + $from.parent.type.spec.code || $from.marks().some((mark) => mark.type.spec.code || mark.type.name === "link")) { + closePaths(); return; + } + const text = $from.parent.textBetween(0, $from.parent.content.size, undefined, "\ufffc"); + const found = filePathMention(text, $from.parentOffset); + let protectedText = false; + if (found) $from.parent.nodesBetween(found.from, found.to, (node) => { + if (node.marks.some((mark) => mark.type.spec.code || mark.type.name === "link")) protectedText = true; + }); + if (protectedText) { closePaths(); return; } + showPaths(found ? { ...found, from: found.from + $from.start(), to: found.to + $from.start(), + original: representedValue.current } : null); + }; + const choosePath = (selected: FilePathTarget, path: string, file?: WorkspaceExplorerView) => { + if (targetRef.current !== selected || !latest.current.filePaths || composing.current || latest.current.disabled || + latest.current.value !== selected.original) return; + const completion = completedFilePath(path, !file); + const commit = (next: string) => file ? latest.current.filePaths!.onAttach(file, next, selected.original) : latest.current.onChange(next); + const input = textarea.current; + if (input) { + if (input.value !== selected.original) return; + const next = input.value.slice(0, selected.from) + completion.text + input.value.slice(selected.to); + commit(next); + pendingCaret.current = { value: next, at: selected.from + completion.caret, directory: !file }; + closePaths(); + } else { + const instance = editor.current; + if (!instance || instance.view.composing) return; + const transaction = instance.state.tr.insertText(completion.text, selected.from, selected.to); + transaction.setSelection(TextSelection.create(transaction.doc, selected.from + completion.caret)); + const next = instance.markdown!.serialize(transaction.doc.toJSON()); + commit(next); + closePaths(); + replacing.current = true; + instance.view.dispatch(transaction.scrollIntoView()); + replacing.current = false; + instance.view.focus(); + if (!file) readRichPaths(instance); + } + }; + + useLayoutEffect(() => { closePaths(); dismissedPath.current = null; pendingCaret.current = null; + return () => { targetRef.current = null; }; + }, [scope, plainText, disabled, filePaths?.client, filePaths?.workspaceID, Boolean(filePaths)]); useImperativeHandle(inputRef, () => ({ focus: () => { if (textarea.current) textarea.current.focus(); else editor.current?.view.focus(); - } }), []); + }, atHistoryBoundary }), []); useLayoutEffect(() => { + composing.current = false; if (plainText || !mount.current) return; representedValue.current = latest.current.value; let plainPaste = false; @@ -53,9 +152,17 @@ export function MarkdownInput({ value, onChange, onSubmit, plainText, scope, lab editorProps: { attributes: { role: "textbox", "aria-multiline": "true", "aria-label": latest.current.label, "data-placeholder": latest.current.placeholder, class: "v2-markdown-input", dir: "auto" }, + handleDOMEvents: { + compositionstart() { composing.current = true; latest.current.onHistoryCancel?.(); closePaths(); return false; }, + compositionend() { composing.current = false; return false; }, + blur() { dismissPaths(); return false; }, + }, handleKeyDown(view, event) { plainPaste = event.shiftKey && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v"; if (event.isComposing || view.composing || event.keyCode === 229) return false; + // Candidate navigation precedes both sending and prompt-history keys. + if (suggestions.current?.keyDown(event)) return true; + if (historyKey(event)) return true; if (event.key !== "Enter" || event.shiftKey || event.altKey) return false; const { $from } = view.state.selection; const fence = $from.parent.type.name === "paragraph" && /^```([\w+-]*)$/.exec($from.parent.textContent); @@ -93,8 +200,9 @@ export function MarkdownInput({ value, onChange, onSubmit, plainText, scope, lab onUpdate({ editor: current }) { const next = current.isEmpty ? "" : current.getMarkdown(); representedValue.current = next; - latest.current.onChange(next); + if (!replacing.current) { latest.current.onChange(next); readRichPaths(current); } }, + onSelectionUpdate({ editor: current }) { if (!replacing.current) readRichPaths(current); }, }); editor.current = instance; if (restoreFocus.current) instance.commands.focus("end"); @@ -105,6 +213,7 @@ export function MarkdownInput({ value, onChange, onSubmit, plainText, scope, lab }, [plainText, scope]); useLayoutEffect(() => { + if (targetRef.current && targetRef.current.original !== value) closePaths(); const instance = editor.current; if (instance) { instance.setEditable(!disabled, false); @@ -112,8 +221,10 @@ export function MarkdownInput({ value, onChange, onSubmit, plainText, scope, lab instance.view.dom.setAttribute("aria-disabled", String(disabled)); instance.view.dom.setAttribute("data-placeholder", placeholder); if (value !== representedValue.current) { + closePaths(); // An external change is a restored/cleared draft, not a user edit. // Reset history so Undo cannot resurrect a sent message or another task. + replacing.current = true; instance.commands.setContent(value, { contentType: "markdown", emitUpdate: false }); const doc = instance.state.doc; instance.view.updateState(EditorState.create({ @@ -121,21 +232,47 @@ export function MarkdownInput({ value, onChange, onSubmit, plainText, scope, lab selection: TextSelection.atEnd(doc), })); representedValue.current = value; + replacing.current = false; } } if (textarea.current) { textarea.current.style.height = "0px"; textarea.current.style.height = `${Math.min(240, Math.max(38, textarea.current.scrollHeight))}px`; if (restoreFocus.current) { textarea.current.focus(); restoreFocus.current = false; } + const pending = pendingCaret.current; + if (pending?.value === value) { + pendingCaret.current = null; + textarea.current.focus(); textarea.current.setSelectionRange(pending.at, pending.at); + if (pending.directory) readPlainPaths(textarea.current); + } } }, [value, disabled, label, placeholder, plainText, scope]); - return plainText ?