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,截图未替换页面或接口内容。
+
+| 富文本:修改前,空输入按 ↑ | 富文本:修改后,空输入按 ↑ |
+| --- | --- |
+|  |  |
+
+| 纯文本:修改前,空输入按 ↑ | 纯文本:修改后,空输入按 ↑ |
+| --- | --- |
+|  |  |
+
+浏览器实际验证了两种编辑器的首次回填、更早/更新浏览和返回空草稿,以及手工输入保留、多行光标与选区保护。富文本回填中的粗体正常渲染,切换到纯文本后仍保留 `**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.changeText("> 引用需要保留")}>引用文字
+ draft.document.update(draft.scope, { attachments: [attachment] }, draft.state.ref)}>加入测试附件
+ draft.document.update(draft.scope, { files: [{ id: "ref-1", path: "README.md", digest: "a".repeat(64), partial: false, redacted: false }] }, draft.state.ref)}>添加引用
+ setThread("thread-b")}>切换聊天
+ {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 event.preventDefault()} onMouseDown={(event) => event.preventDefault()}
+ onClick={() => void select(entry)}>
+ {entry.name}{entry.kind === "directory" ? "/" : ""}
+ ;
+ })}
+
+ {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 ?