From eba101019180362e14ea7d27b29c955d254be144 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Thu, 21 May 2026 05:40:06 +0800 Subject: [PATCH] fix local office resource editing - add local-folder OnlyOffice sign/callback writeback and edit-tab handling - align main resource tabs, attachment edit menu, slash isolation, and filetree context behavior - record Sidex/Hermes gap reviews and Reasonix task checklists --- ...21-browser-test-office-view-edit-plugin.md | 60 ++ ...26-05-21-browser-test-tab-office-upload.md | 66 ++ ...-05-21-office-upload-open-clean-browser.md | 57 ++ ...er-a-local-folder-trash-empty-workspace.md | 60 ++ ...-21-worker-a-office-local-sign-contract.md | 62 ++ ...2026-05-21-worker-b-main-page-tab-title.md | 48 ++ ...6-05-21-worker-b-office-edit-menu-guard.md | 68 ++ ...21-worker-c-onlyoffice-docx-upload-open.md | 53 ++ ...onlyoffice-local-save-callback-contract.md | 65 ++ ...21-worker-d-filetree-context-menu-sidex.md | 53 ++ ...er-d-onlyoffice-annotation-plugin-noise.md | 58 ++ ...5-21-worker-e-slash-side-pane-isolation.md | 48 ++ .../hermes-acp-permission-decision-loop.md | 57 ++ .../hermes-acp-session-info-plan-ui.md | 54 ++ .../hermes-acp-session-load-resume.md | 56 ++ .../hermes-acp-tool-location-open-action.md | 55 ++ .gitignore | 13 +- ...older-page-trash-and-empty-workspace-v1.md | 44 ++ ...ab-office-upload-browser-bugs-review-v1.md | 52 ++ ...ch-filetree-office-slash-bugs-review-v1.md | 99 +++ ...5-28-main-page-tab-title-active-sync-v1.md | 38 + ...9-onlyoffice-docx-upload-open-freeze-v1.md | 42 ++ ...iletree-context-menu-sidex-alignment-v1.md | 46 ++ ...31-side-pane-slash-overlay-isolation-v1.md | 38 + ...e-tab-page-switch-and-filetree-focus-v1.md | 42 ++ ...fice-chinese-docx-upload-open-freeze-v1.md | 49 ++ ...1-office-edit-sign-plugin-gap-review-v1.md | 107 +++ ...older-media-sign-office-url-contract-v1.md | 46 ++ ...5-35-office-edit-mode-menu-and-guard-v1.md | 62 ++ ...ce-local-edit-save-callback-contract-v1.md | 66 ++ ...ffice-annotation-plugin-noise-policy-v1.md | 51 ++ ...30-acp-session-load-resume-checklist-v1.md | 53 ++ ...p-permission-decision-loop-checklist-v1.md | 52 ++ ...l-location-and-open-action-checklist-v1.md | 54 ++ ...3-acp-session-info-plan-ui-checklist-v1.md | 49 ++ .../13-hermes-vscode-plugin-gap-review-v1.md | 99 +++ rust/Cargo.lock | 2 + rust/crates/mnote-web/Cargo.toml | 2 + rust/crates/mnote-web/src/acp_bridge.rs | 110 ++- rust/crates/mnote-web/src/acp_client.rs | 192 ++++- .../mnote-web/src/acp_session_manager.rs | 689 ++++++++++++++++-- rust/crates/mnote-web/src/acp_types.rs | 70 +- rust/crates/mnote-web/src/routes/gateway.rs | 22 +- .../mnote-web/src/routes/hermes_client.rs | 99 ++- .../src/routes/local_folder_source.rs | 92 +-- rust/crates/mnote-web/src/routes/mod.rs | 4 + .../crates/mnote-web/src/routes/onlyoffice.rs | 653 ++++++++++++++--- rust/crates/mnote-web/src/routes/web_shell.rs | 107 ++- rust/crates/mnote-web/src/ssr/pages/layout.rs | 447 +++++++++++- scripts/task463-onlyoffice-resolver-smoke.js | 81 +- 50 files changed, 4309 insertions(+), 283 deletions(-) create mode 100644 .codex/reasonix-tasks/2026-05-21-browser-test-office-view-edit-plugin.md create mode 100644 .codex/reasonix-tasks/2026-05-21-browser-test-tab-office-upload.md create mode 100644 .codex/reasonix-tasks/2026-05-21-office-upload-open-clean-browser.md create mode 100644 .codex/reasonix-tasks/2026-05-21-worker-a-local-folder-trash-empty-workspace.md create mode 100644 .codex/reasonix-tasks/2026-05-21-worker-a-office-local-sign-contract.md create mode 100644 .codex/reasonix-tasks/2026-05-21-worker-b-main-page-tab-title.md create mode 100644 .codex/reasonix-tasks/2026-05-21-worker-b-office-edit-menu-guard.md create mode 100644 .codex/reasonix-tasks/2026-05-21-worker-c-onlyoffice-docx-upload-open.md create mode 100644 .codex/reasonix-tasks/2026-05-21-worker-c-onlyoffice-local-save-callback-contract.md create mode 100644 .codex/reasonix-tasks/2026-05-21-worker-d-filetree-context-menu-sidex.md create mode 100644 .codex/reasonix-tasks/2026-05-21-worker-d-onlyoffice-annotation-plugin-noise.md create mode 100644 .codex/reasonix-tasks/2026-05-21-worker-e-slash-side-pane-isolation.md create mode 100644 .codex/reasonix-tasks/hermes-acp-permission-decision-loop.md create mode 100644 .codex/reasonix-tasks/hermes-acp-session-info-plan-ui.md create mode 100644 .codex/reasonix-tasks/hermes-acp-session-load-resume.md create mode 100644 .codex/reasonix-tasks/hermes-acp-tool-location-open-action.md create mode 100644 bugs/04-tree-domain/done/4-45-local-folder-page-trash-and-empty-workspace-v1.md create mode 100644 bugs/05-editor-mainline/done/2026-05-21-tab-office-upload-browser-bugs-review-v1.md create mode 100644 bugs/05-editor-mainline/done/2026-05-21-workbench-filetree-office-slash-bugs-review-v1.md create mode 100644 bugs/05-editor-mainline/done/5-28-main-page-tab-title-active-sync-v1.md create mode 100644 bugs/05-editor-mainline/done/5-29-onlyoffice-docx-upload-open-freeze-v1.md create mode 100644 bugs/05-editor-mainline/done/5-30-filetree-context-menu-sidex-alignment-v1.md create mode 100644 bugs/05-editor-mainline/done/5-31-side-pane-slash-overlay-isolation-v1.md create mode 100644 bugs/05-editor-mainline/done/5-32-main-resource-tab-page-switch-and-filetree-focus-v1.md create mode 100644 bugs/05-editor-mainline/done/5-33-office-chinese-docx-upload-open-freeze-v1.md create mode 100644 bugs/05-editor-mainline/process/2026-05-21-office-edit-sign-plugin-gap-review-v1.md create mode 100644 bugs/05-editor-mainline/process/5-34-local-folder-media-sign-office-url-contract-v1.md create mode 100644 bugs/05-editor-mainline/process/5-35-office-edit-mode-menu-and-guard-v1.md create mode 100644 bugs/05-editor-mainline/process/5-36-onlyoffice-local-edit-save-callback-contract-v1.md create mode 100644 bugs/05-editor-mainline/process/5-37-onlyoffice-annotation-plugin-noise-policy-v1.md create mode 100644 design/07-ai/process/7-30-acp-session-load-resume-checklist-v1.md create mode 100644 design/07-ai/process/7-31-acp-permission-decision-loop-checklist-v1.md create mode 100644 design/07-ai/process/7-32-acp-tool-location-and-open-action-checklist-v1.md create mode 100644 design/07-ai/process/7-33-acp-session-info-plan-ui-checklist-v1.md create mode 100644 design/10-review/process/13-hermes-vscode-plugin-gap-review-v1.md diff --git a/.codex/reasonix-tasks/2026-05-21-browser-test-office-view-edit-plugin.md b/.codex/reasonix-tasks/2026-05-21-browser-test-office-view-edit-plugin.md new file mode 100644 index 00000000..47e63492 --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-browser-test-office-view-edit-plugin.md @@ -0,0 +1,60 @@ +# Reasonix Browser Tester:Office 只读打开、编辑入口与插件噪音验证 + +你是浏览器测试员,不是代码修改员。 + +禁止修改源码、禁止 git commit、禁止回滚文件。使用真实浏览器或 Playwright 验证。所有截图和 `result.json` 写入指定输出目录。失败时保留 console/network 摘要。最终只汇报测试证据,不提出未经验证的代码结论。 + +## Project Root + +`/mnt/Data1T/mnote` + +## 输出目录 + +`/mnt/Data1T/mnote/tmp/reasonix-office-view-edit-plugin-2026-05-21` + +## 前置 + +- 被测入口:`http://127.0.0.1:3000` +- 使用全新的 isolated browser context / userDataDir。 +- 测试开始前清空 cookies、localStorage、sessionStorage、IndexedDB、Cache Storage。 +- 默认登录:访问 `/auth`,点击“测试账号快速登录”;如失败,使用邮箱 `mnote.e2e@example.com`、密码 `MnoteE2E123!`。 +- 测试文件:`/home/lix/Downloads/重庆发展特殊化妆品可行性报告_政府汇报版.docx` + +## 测试目标 + +1. 上传该 docx 后,默认打开到主编辑器右侧 resource tab,而不是浏览器 popup。 +2. 默认 OnlyOffice 是只读/view 模式。 +3. 三点菜单或右键菜单存在“使用编辑模式打开”入口。 +4. 点击编辑入口后,记录 guard 或 `mode=edit` 的可观察证据。 +5. 记录 `/api/media/sign` 对 local asset 是否还出现 404。 +6. 记录 annotation/plugin 404/pageerror 是否存在;如果存在,判断是否影响主文档可见渲染。 + +## PASS 标准 + +- 截图必须显示 OnlyOffice 文档内容区或明确的 OnlyOffice 编辑器界面。 +- `errorCode=-18`、WebSocket handshake failed、polling failed、连接失败、主 iframe 空白必须标记 FAIL/BLOCKED。 +- 插件 404 只有在主文档已渲染且 WebSocket 正常时,才可标记为 non-critical。 + +## 输出格式 + +写入 `result.json`,至少包含: + +```json +{ + "status": "PASS|FAIL|BLOCKED", + "steps": [], + "assertions": [], + "screenshots": [], + "consoleErrors": [], + "networkFailures": [], + "onlyoffice": { + "iframeSrc": "", + "modeEvidence": "", + "documentKeyEvidence": "", + "mediaSignLocal404": false, + "plugin404NonCritical": null + } +} +``` + +同时输出简短 `final.md`,列出复现步骤、观察结果和截图路径。 diff --git a/.codex/reasonix-tasks/2026-05-21-browser-test-tab-office-upload.md b/.codex/reasonix-tasks/2026-05-21-browser-test-tab-office-upload.md new file mode 100644 index 00000000..bb5488e5 --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-browser-test-tab-office-upload.md @@ -0,0 +1,66 @@ +# Reasonix Browser Test: Tab / Office / Upload Bugs + +项目根目录:`/mnt/Data1T/mnote` + +请先阅读并遵守:`/home/lix/.codex/skills/reasonix-browser-tester/SKILL.md` + +## 角色 + +你是独立浏览器测试员,不是代码修改员。 + +禁止修改源码、禁止 git commit、禁止回滚文件。只允许创建测试输出目录、临时测试数据和截图。 + +## 测试目标 + +验证以下 MNote UI 行为是否真实存在,并输出可复核证据: + +1. 在主 resource tab 打开时,点击左侧文件树中的主 Markdown 页面,是否能切回 page tab。 +2. 点击左侧“新建页面”后,是否能切到新页面的主 Markdown page tab;文件树焦点/选中是否落在新建页面。 +3. 上传并打开真实文件: + `/home/lix/Downloads/重庆发展特殊化妆品可行性报告_政府汇报版.docx` + 验证是否进入主 resource tab、是否出现 `/onlyoffice` iframe、是否出现错误提示或卡死。 +4. 观察上传中文文件名时浏览器是否卡死;记录 console、network、页面是否还能响应。 + +## 建议测试方式 + +- 默认入口:`http://127.0.0.1:3000` +- 使用 Playwright/Chrome。 +- 优先使用 local-first 临时 workspace: + - 创建临时 root。 + - 写入 `.mnote/workspace.json`。 + - 创建 `Alpha.md`、`Beta.md` 等测试页面。 +- 可通过 `/auth` 的“测试账号快速登录”登录。 + +## 断言证据 + +请记录: + +- 当前 URL。 +- `.mnote-main-tab.is-active` 的文本、`data-mnote-main-tab`、`data-mnote-tab-kind`。 +- `[data-mnote-main-tab="page"]` 是否 active。 +- 左侧 filetree 行的 `data-selected` / `data-focused` / `data-active`。 +- Office iframe `src`。 +- 上传 API 响应状态和 body。 +- console error/warning。 +- network failed 请求。 + +## 输出目录 + +写入: + +`/mnt/Data1T/mnote/tmp/reasonix-browser-test-2026-05-21-tab-office-upload/` + +必须输出: + +- `result.json` +- `final.md` +- 至少 3 张截图:切换前 resource tab、点击 md 后状态、docx 打开状态。失败时额外截图。 + +## 最终回复格式 + +只汇报: + +- 输出目录。 +- 每个测试目标 pass/fail。 +- 关键失败证据。 +- 未能验证的原因。 diff --git a/.codex/reasonix-tasks/2026-05-21-office-upload-open-clean-browser.md b/.codex/reasonix-tasks/2026-05-21-office-upload-open-clean-browser.md new file mode 100644 index 00000000..e12fe40e --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-office-upload-open-clean-browser.md @@ -0,0 +1,57 @@ +你是浏览器测试员,不是代码修改员。 +禁止修改源码、禁止 git commit、禁止回滚文件。 +使用真实浏览器或 Playwright 验证。 +所有截图和 result.json 写入指定输出目录。 +失败时保留 console/network 摘要。 +最终只汇报测试证据,不提出未经验证的代码结论。 + +# Reasonix 浏览器测试任务:Office 中文 docx 上传并打开 + +## 输出目录 + +`/mnt/Data1T/mnote/tmp/reasonix-office-upload-open-clean-2026-05-21/` + +## 测试入口 + +- URL: `http://127.0.0.1:3000` +- 登录:访问 `/auth`,点击“测试账号快速登录” +- 必须使用全新 isolated browser context / userDataDir。 +- 测试开始前清空 cookies、localStorage、sessionStorage、IndexedDB、Cache Storage。 + +## 测试文件 + +上传真实文件: + +`/home/lix/Downloads/重庆发展特殊化妆品可行性报告_政府汇报版.docx` + +## 测试步骤 + +1. 创建临时 local-first workspace,至少包含 `Alpha.md` 和 `.mnote/workspace.json`。 +2. 打开 `Alpha.md` 页面。 +3. 上传上述 docx 到当前页面,记录上传 API 状态、耗时、返回 asset。 +4. 点击或派发真实 UI 等价操作打开该 docx 附件到主编辑器资源 tab。 +5. 等待 OnlyOffice iframe 内部进入稳定状态,最长 90 秒。 +6. 截图最终打开效果,截图必须显示以下之一: + - Office 文档内容区 / 编辑器 UI 已可见。 + - 明确的 OnlyOffice 错误面板或连接失败信息。 +7. 记录 console errors、failed requests、OnlyOffice WebSocket / polling URL。 +8. 检查 WebSocket / polling URL 的 doc key 是否为安全 key,不包含 `/`、`:`、中文字符。 + +## 判定规则 + +- `PASS` 不能只因为 upload 200、resource tab active 或 iframe 存在。 +- 如果出现 `errorCode=-18`、`连接失败`、WebSocket handshake failed、polling failed,标记为 `FAIL` 或 `BLOCKED`,并截图。 +- 如果页面上传后卡死、刷新后无法恢复、需要重新登录,标记为 `FAIL`。 +- 如果 DocumentServer 未运行或代理不可达,标记为 `BLOCKED`,并记录服务状态和网络错误。 + +## 输出要求 + +- `result.json`: + - `overallStatus`: `pass` / `fail` / `blocked` + - `freshContext`: 是否使用全新上下文并清空缓存 + - `upload`: status、elapsedMs、asset + - `open`: tabKind、iframeSrc、officeReady、visibleTextOrError + - `onlyofficeNetwork`: websocketUrls、pollingUrls、failedRequests + - `docKeySafety`: safe、observedKeys、unsafeReasons + - `screenshots`: 最终截图路径 +- `final.md`:简要步骤、观察、结论、截图路径。 diff --git a/.codex/reasonix-tasks/2026-05-21-worker-a-local-folder-trash-empty-workspace.md b/.codex/reasonix-tasks/2026-05-21-worker-a-local-folder-trash-empty-workspace.md new file mode 100644 index 00000000..76c835ab --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-worker-a-local-folder-trash-empty-workspace.md @@ -0,0 +1,60 @@ +# Reasonix Worker A: Local Folder Trash + Empty Workspace + +Project root: `/mnt/Data1T/mnote` + +## 背景 + +用户报告: + +- 文件树删除页面后全部跑到资源垃圾箱,页面垃圾箱没有使用。 +- 会突然出现“初始化的新页面”,删除后还会出现;空白页面应保持空白,不显示任何占位。 + +事实源: + +- Review: `bugs/05-editor-mainline/process/2026-05-21-workbench-filetree-office-slash-bugs-review-v1.md` +- Checklist: `bugs/04-tree-domain/process/4-45-local-folder-page-trash-and-empty-workspace-v1.md` + +## Ownership + +你负责: + +- `rust/crates/mnote-web/src/routes/local_folder_source.rs` +- 必要时 `rust/crates/mnote-web/src/routes/gateway.rs` 中 trash 分类/渲染的小范围补丁 +- 相关 Rust tests + +你不是独自在代码库中工作。不要回滚、覆盖、格式化或清理其他人/其他 worker 的改动。不要提交 git。 + +## 任务 + +1. 移除默认工作区自动创建 `初始化的新页面/初始化的新页面.md` 的行为。 +2. 更新相关测试,不再断言默认页面存在。 +3. 修复本地文件树删除页面 bundle 目录时被归类为 `local_directory` 资源的问题: + - 若目录内存在同名 `.md`,应按 Markdown 页面生命周期进入 trash。 + - trash index 应能让页面垃圾箱识别为页面/document,而不是资源 local_directory。 +4. 空 root / 空 page tree 不应投影出“初始化的新页面”。 + +## 关键线索 + +- `ensure_default_workspace_home_page` 当前会创建初始化页面。 +- `trash_local_entry` 现在先解析 `local-dir:*`,导致页面 bundle 目录先走 `trash_local_directory`。 +- `trash_local_markdown_page` 已能删除 bundle,但需要让入口优先识别 bundle 页面。 + +## 验证 + +至少运行: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web create_default_local_workspace_for_actor_at_base_creates_manifest_and_root_only -- --test-threads=1 +cargo test -p mnote-web local_tree_command_manages_loose_markdown_like_regular_file -- --test-threads=1 +cargo test -p mnote-web local_tree_command_delete_folder_moves_directory_to_trash -- --test-threads=1 +``` + +如新增测试,请一并运行。 + +## 最终回复格式 + +- 修改文件列表 +- 完成的 checklist 项 +- 运行过的命令和结果 +- 未完成项/风险 diff --git a/.codex/reasonix-tasks/2026-05-21-worker-a-office-local-sign-contract.md b/.codex/reasonix-tasks/2026-05-21-worker-a-office-local-sign-contract.md new file mode 100644 index 00000000..e1ba0d48 --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-worker-a-office-local-sign-contract.md @@ -0,0 +1,62 @@ +# Reasonix Worker A:OnlyOffice local-folder 不再误请求 media/sign + +你是编码 worker,不是审查员。你不独占代码库,可能有其他 worker 同时修改不同文件;不要回滚、覆盖或清理他人的改动。 + +## Project Root + +`/mnt/Data1T/mnote` + +## 背景 + +详见: + +- `bugs/05-editor-mainline/process/2026-05-21-office-edit-sign-plugin-gap-review-v1.md` +- `bugs/05-editor-mainline/process/5-34-local-folder-media-sign-office-url-contract-v1.md` + +当前 `/onlyoffice` 页面只要有 `assetId` 就请求 `/api/media/sign`。local-folder asset 形如 `local:asset:`,本来应直接使用传入的 local open `fileUrl`,因此这个请求会产生误导性的 404。 + +## Ownership + +你负责: + +- `rust/crates/mnote-web/src/routes/onlyoffice.rs` +- 与 above 契约直接相关的 `mnote-web` 单测 +- 更新 `bugs/05-editor-mainline/process/5-34-local-folder-media-sign-office-url-contract-v1.md` 的本轮执行记录 + +不要修改: + +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- ACP / Hermes 相关文件 +- git 状态无关文件 + +## 任务 + +1. 让 `/onlyoffice` 页面识别 local-folder asset / local fileUrl,并在这种情况下跳过 `/api/media/sign`。 +2. 保持非 local asset 仍可请求 `/api/media/sign`。 +3. 保持 doc key 安全化,不引入 `/`、`:`、中文等危险字符。 +4. 补或调整单测,确保页面脚本包含该判断。 + +## 设计约束 + +- local 判断应保守:优先基于 `assetId` 前缀 `local:` / `local-file:`,以及 `fileUrl` path 为 `/api/local-folder/files/open`。 +- 不要把 `/api/media/sign` 改成本地文件签名端点。 +- 不要改变 callback 或 edit/save 逻辑。 + +## 验收命令 + +请至少运行: + +```bash +cargo test -p mnote-web onlyoffice -- --test-threads=1 +``` + +如果失败,记录失败原因和相关输出。 + +## 最终输出 + +在 final 中列出: + +- 修改文件 +- 关键实现点 +- 运行过的命令和结果 +- 未完成项或风险 diff --git a/.codex/reasonix-tasks/2026-05-21-worker-b-main-page-tab-title.md b/.codex/reasonix-tasks/2026-05-21-worker-b-main-page-tab-title.md new file mode 100644 index 00000000..8bfc6681 --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-worker-b-main-page-tab-title.md @@ -0,0 +1,48 @@ +# Reasonix Worker B: Main Page Tab Title Sync + +Project root: `/mnt/Data1T/mnote` + +## 背景 + +用户报告:主 tab 的标题与实际打开页面标题不一致,不会随实际打开的 tab / 页面变化。 + +事实源: + +- Review: `bugs/05-editor-mainline/process/2026-05-21-workbench-filetree-office-slash-bugs-review-v1.md` +- Checklist: `bugs/05-editor-mainline/process/5-28-main-page-tab-title-active-sync-v1.md` + +## Ownership + +你负责小范围修改: + +- `rust/crates/mnote-web/src/routes/web_shell.rs` 中 page tab / `updatePaneChrome` / 标题保存同步相关逻辑 +- `rust/crates/mnote-web/src/ssr/pages/document.rs` 如需要补 data 属性 +- 相关 Rust tests + +不要修改 OnlyOffice、文件树菜单、slash 定位、ACP/Hermes 文件。你不是独自在代码库中工作;不要回滚、覆盖、格式化或清理其他 worker 的改动。不要提交 git。 + +## 任务 + +1. `replacePaneDocument` / `updatePaneChrome(primary)` 更新 primary document 后,同步: + - `[data-mnote-main-tab="page"] .mnote-main-tab-title` + - page tab 上的 document/workspace data 属性(如需要) +2. 标题保存控制器 `updateVisibleTitle` 保存标题时,同步当前 page tab 标题。 +3. 资源 tab 与 page tab 切换时,page tab 标题始终代表当前 primary document。 +4. 补一个 Rust 字符串测试或现有测试断言。 + +## 验证 + +至少运行: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web page_layout_renders_main_editor_tab_strip -- --test-threads=1 +cargo test -p mnote-web document_shell_runtime_renders_resource_tab_runtime -- --test-threads=1 +``` + +## 最终回复格式 + +- 修改文件列表 +- 完成的 checklist 项 +- 运行过的命令和结果 +- 未完成项/风险 diff --git a/.codex/reasonix-tasks/2026-05-21-worker-b-office-edit-menu-guard.md b/.codex/reasonix-tasks/2026-05-21-worker-b-office-edit-menu-guard.md new file mode 100644 index 00000000..988a75eb --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-worker-b-office-edit-menu-guard.md @@ -0,0 +1,68 @@ +# Reasonix Worker B:Office 编辑模式菜单与 guard + +你是编码 worker,不是审查员。你不独占代码库,可能有其他 worker 同时修改不同文件;不要回滚、覆盖或清理他人的改动。 + +## Project Root + +`/mnt/Data1T/mnote` + +## 背景 + +详见: + +- `bugs/05-editor-mainline/process/2026-05-21-office-edit-sign-plugin-gap-review-v1.md` +- `bugs/05-editor-mainline/process/5-35-office-edit-mode-menu-and-guard-v1.md` + +产品口径:Office 默认只读打开;三点菜单增加“使用编辑模式打开”。当前 local-folder edit/save callback 没有完整闭环,因此编辑入口不能表现成默认可保存功能。 + +## Ownership + +你负责: + +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- 与 layout JS 字符串相关的 `mnote-web` 单测 +- 更新 `bugs/05-editor-mainline/process/5-35-office-edit-mode-menu-and-guard-v1.md` 的本轮执行记录 + +不要修改: + +- `rust/crates/mnote-web/src/routes/onlyoffice.rs` +- `rust/crates/mnote-web/src/routes/media.rs` +- ACP / Hermes 相关文件 + +## 任务 + +1. 确认 Office 默认打开路径使用 `mode=view`,尤其是 local uploaded asset 和编辑器附件点击。 +2. 在正文附件三点菜单中增加“使用编辑模式打开”。 +3. 在文件树 Office asset 右键菜单中增加“使用编辑模式打开”。 +4. 点击编辑入口时打开 `mode=edit` 的 OnlyOffice URL。 +5. 如果保存闭环未完成,给编辑入口增加 guard: + - 可以用 `window.confirm` 或明确状态标记提示“编辑保存仍在实验中,建议先备份文件”; + - 必须便于浏览器测试识别,例如设置 `data-mnote-last-office-edit-mode-requested="true"` 和 `data-mnote-last-office-edit-mode-guard="shown"`。 + +## 设计约束 + +- 不默认 edit。 +- 不承诺保存已闭环。 +- 不影响 md/text/code 附件继续走 tiptap 渲染 / 编辑路径。 +- 尽量复用现有 `buildLocalOnlyOfficeOpenUrl`、`buildOnlyOfficeOpenUrl`、`openConvexAssetFromFileTree` 等函数。 + +## 验收命令 + +请至少运行: + +```bash +cargo test -p mnote-web sidebar_tree_js -- --test-threads=1 +cargo test -p mnote-web onlyoffice -- --test-threads=1 +``` + +如果失败,记录失败原因和相关输出。 + +## 最终输出 + +在 final 中列出: + +- 修改文件 +- 菜单项位置与 action 名称 +- 默认 view 与显式 edit 的实现点 +- 运行过的命令和结果 +- 未完成项或风险 diff --git a/.codex/reasonix-tasks/2026-05-21-worker-c-onlyoffice-docx-upload-open.md b/.codex/reasonix-tasks/2026-05-21-worker-c-onlyoffice-docx-upload-open.md new file mode 100644 index 00000000..19010bd4 --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-worker-c-onlyoffice-docx-upload-open.md @@ -0,0 +1,53 @@ +# Reasonix Worker C: OnlyOffice Docx Upload Open Freeze + +Project root: `/mnt/Data1T/mnote` + +## 背景 + +用户报告:上传 `DHA美黑化妆品监管综述_2026-05-16.docx` 后卡死,docx 打不开,并出现 OnlyOffice 连接失败信息。 + +事实源: + +- Review: `bugs/05-editor-mainline/process/2026-05-21-workbench-filetree-office-slash-bugs-review-v1.md` +- Checklist: `bugs/05-editor-mainline/process/5-29-onlyoffice-docx-upload-open-freeze-v1.md` + +## Ownership + +你负责: + +- `rust/crates/mnote-web/src/routes/onlyoffice.rs` +- `rust/crates/mnote-web/src/routes/local_folder_source.rs` 中 local file open / upload 的小范围修复 +- `rust/crates/mnote-web/src/routes/web_shell.rs` 中 office resource tab 打开/错误 UI 的小范围修复 +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` 中附件 open URL 的小范围修复 +- OnlyOffice/local upload 相关 tests + +不要修改文件树 trash、默认 workspace、右键菜单结构、slash 定位、ACP/Hermes 文件。你不是独自在代码库中工作;不要回滚、覆盖、格式化或清理其他 worker 的改动。不要提交 git。 + +## 任务 + +1. 定位 docx 卡死发生层级:上传、附件插入、主 tab iframe、OnlyOffice DocumentServer 回源或错误处理。 +2. 确保本地 docx 打开时: + - `fileUrl` 指向 `/api/local-folder/files/open?rootUri=...&path=...` + - `fileType=docx` + - `assetId=local-file:` + - 主 tab 打开优先,不默认弹出浏览器窗口。 +3. OnlyOffice iframe 加载失败时显示非阻塞错误,不让主页面卡死。 +4. 补 targeted test。 + +## 验证 + +至少运行: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web local_file_open_sets_pdf_and_office_content_type -- --test-threads=1 +cargo test -p mnote-web onlyoffice_page_exposes_documentserver_fetch_base_for_local_files -- --test-threads=1 +cargo test -p mnote-web sidebar_tree_runtime_opens_office_assets_through_resource_shell -- --test-threads=1 +``` + +## 最终回复格式 + +- 修改文件列表 +- 完成的 checklist 项 +- 运行过的命令和结果 +- 未完成项/风险 diff --git a/.codex/reasonix-tasks/2026-05-21-worker-c-onlyoffice-local-save-callback-contract.md b/.codex/reasonix-tasks/2026-05-21-worker-c-onlyoffice-local-save-callback-contract.md new file mode 100644 index 00000000..4d952131 --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-worker-c-onlyoffice-local-save-callback-contract.md @@ -0,0 +1,65 @@ +# Reasonix Worker C:OnlyOffice local-folder callback 保存契约 + +你是编码 worker,但本任务优先做设计与最小安全实现评估。你不独占代码库,可能有其他 worker 同时修改不同文件;不要回滚、覆盖或清理他人的改动。 + +## Project Root + +`/mnt/Data1T/mnote` + +## 背景 + +详见: + +- `bugs/05-editor-mainline/process/2026-05-21-office-edit-sign-plugin-gap-review-v1.md` +- `bugs/05-editor-mainline/process/5-36-onlyoffice-local-edit-save-callback-contract-v1.md` + +ONLYOFFICE 官方口径:callback `status === 2` 或 `status === 6` 时,后端需要下载 `body.url` 并写回文件,成功返回 `{ "error": 0 }`。当前 `mnote-web` callback 代理 legacy Next,local-folder 写回没有闭环。 + +## Ownership + +你负责: + +- 优先更新 `bugs/05-editor-mainline/process/5-36-onlyoffice-local-edit-save-callback-contract-v1.md` +- 如判断安全且改动小,可修改: + - `rust/crates/mnote-web/src/routes/onlyoffice.rs` + - `rust/crates/adapter-onlyoffice/src/lib.rs` + - 相关单测 + +不要修改: + +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- ACP / Hermes 相关文件 + +## 任务 + +1. 梳理当前 callback 能否定位 local-folder 原文件。 +2. 明确实现 local writeback 还缺哪些输入:rootUri、relative path、workspace/source kind、session。 +3. 如果现有 URL 参数已经足够安全定位文件,则实现最小 status 2/6 下载写回。 +4. 如果现有输入不足,先不要硬写实现;更新 checklist,明确缺口,并建议 Worker B 的编辑入口保持 guard。 + +## 必须满足的安全条件 + +- 写回路径必须落在当前 local root allowed roots 内。 +- status 非 2/6 不写文件并返回 `{ "error": 0 }`。 +- 下载 URL 必须经过现有 OnlyOffice internal URL rewrite 逻辑或等价安全处理。 +- 不允许从 assetId 字符串直接拼任意绝对路径写入。 + +## 验收命令 + +如改代码,请至少运行: + +```bash +cargo test -p mnote-web onlyoffice_callback -- --test-threads=1 +``` + +如果只更新设计,也请说明为什么本轮不应实现。 + +## 最终输出 + +在 final 中列出: + +- 修改文件 +- 是否实现代码,若未实现说明阻塞输入 +- 安全边界判断 +- 运行过的命令和结果 +- 未完成项或风险 diff --git a/.codex/reasonix-tasks/2026-05-21-worker-d-filetree-context-menu-sidex.md b/.codex/reasonix-tasks/2026-05-21-worker-d-filetree-context-menu-sidex.md new file mode 100644 index 00000000..db43ac65 --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-worker-d-filetree-context-menu-sidex.md @@ -0,0 +1,53 @@ +# Reasonix Worker D: Filetree Context Menu Sidex Alignment + +Project root: `/mnt/Data1T/mnote` + +## 背景 + +用户报告:文件树右键菜单大多没有实际作用;主页面使用右键菜单“侧边栏显示”提示暂不支持在侧栏打开资源。用户要求核对 Sidex 右键菜单,调整当前可用菜单。 + +事实源: + +- Review: `bugs/05-editor-mainline/process/2026-05-21-workbench-filetree-office-slash-bugs-review-v1.md` +- Checklist: `bugs/05-editor-mainline/process/5-30-filetree-context-menu-sidex-alignment-v1.md` +- Sidex 参考:`reference-code/sidex-main/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts` + +## Ownership + +你负责: + +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` 中文件树右键菜单 items 和 handler 的小范围修改 +- 必要时 `rust/crates/mnote-web/src/tree_shell/filetree_runtime.rs` +- 必要时 `rust/crates/mnote-web/src/routes/local_folder_source.rs` 的 `create_folder` 已有入口接线,不要大改 tree command +- 相关 tests + +不要修改 Office、slash 定位、默认 workspace、ACP/Hermes 文件。你不是独自在代码库中工作;不要回滚、覆盖、格式化或清理其他 worker 的改动。不要提交 git。 + +## 任务 + +1. 对照 Sidex/VSCode ExplorerContext 的原则:只显示/启用可用动作。 +2. local_folder 下: + - `New File` 继续创建页面。 + - `New Folder` 接入 `create_folder` 或隐藏/禁用但不能误导。 + - `Copy Path` 复制真实 relative path / asset path,不是标题。 + - `Reveal` 至少滚动聚焦并记录状态。 + - `Duplicate` 若没有真实 copy,隐藏或禁用。 + - `Open Right` 只对页面显示;资源未支持侧栏时隐藏,避免 unsupported placeholder。 +3. 更新字符串测试。 + +## 验证 + +至少运行: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web sidebar_tree_runtime_renders_context_menu_and_scoped_title_updates -- --test-threads=1 +cargo test -p mnote-web sidebar_tree_runtime_handles_navigation_drag_and_filetree_actions -- --test-threads=1 +``` + +## 最终回复格式 + +- 修改文件列表 +- 完成的 checklist 项 +- 运行过的命令和结果 +- 未完成项/风险 diff --git a/.codex/reasonix-tasks/2026-05-21-worker-d-onlyoffice-annotation-plugin-noise.md b/.codex/reasonix-tasks/2026-05-21-worker-d-onlyoffice-annotation-plugin-noise.md new file mode 100644 index 00000000..9104b4ee --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-worker-d-onlyoffice-annotation-plugin-noise.md @@ -0,0 +1,58 @@ +# Reasonix Worker D:OnlyOffice annotation 插件 404/pageerror 噪音 + +你是编码 worker。你不独占代码库,可能有其他 worker 同时修改不同文件;不要回滚、覆盖或清理他人的改动。 + +## Project Root + +`/mnt/Data1T/mnote` + +## 背景 + +详见: + +- `bugs/05-editor-mainline/process/2026-05-21-office-edit-sign-plugin-gap-review-v1.md` +- `bugs/05-editor-mainline/process/5-37-onlyoffice-annotation-plugin-noise-policy-v1.md` + +OnlyOffice 自定义 annotation/custom assistant 插件可能产生 404 或 pageerror。需要确认来源,并让测试能区分“插件噪音”和“主文档打开失败”。 + +## Ownership + +你负责: + +- `bugs/05-editor-mainline/process/5-37-onlyoffice-annotation-plugin-noise-policy-v1.md` +- 如确认是 MNote 显式注入插件,可修改: + - `rust/crates/mnote-web/src/routes/onlyoffice.rs` + - `src/components/onlyoffice/` 下直接的自定义插件配置文件 + - 相关测试 + +不要修改: + +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- ACP / Hermes 相关文件 +- OnlyOffice 主静态资源目录的大规模文件 + +## 任务 + +1. 搜索 annotation / custom assistant / pluginsData / autostart 来源。 +2. 判断 404 是否由 MNote 显式注入插件引起。 +3. 如果 MNote 显式注入且当前不依赖该插件,禁用无效注入或加开关。 +4. 如果是 OnlyOffice 内部可选资源,更新 checklist 说明为 non-critical,但不能吞掉 `errorCode=-18`、WebSocket failed、主 iframe 空白等真正失败。 +5. 如有必要,补测试或 smoke 文档口径。 + +## 验收命令 + +请至少运行与你修改相关的测试;优先: + +```bash +cargo test -p mnote-web onlyoffice -- --test-threads=1 +``` + +## 最终输出 + +在 final 中列出: + +- 修改文件 +- 404/pageerror 来源 +- 处理策略 +- 运行过的命令和结果 +- 未完成项或风险 diff --git a/.codex/reasonix-tasks/2026-05-21-worker-e-slash-side-pane-isolation.md b/.codex/reasonix-tasks/2026-05-21-worker-e-slash-side-pane-isolation.md new file mode 100644 index 00000000..988598d4 --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-worker-e-slash-side-pane-isolation.md @@ -0,0 +1,48 @@ +# Reasonix Worker E: Slash Menu Side Pane Isolation + +Project root: `/mnt/Data1T/mnote` + +## 背景 + +用户报告:虽然侧栏打开不支持,但在主栏打开 slash 菜单时,侧栏也出现一个 slash 菜单。 + +事实源: + +- Review: `bugs/05-editor-mainline/process/2026-05-21-workbench-filetree-office-slash-bugs-review-v1.md` +- Checklist: `bugs/05-editor-mainline/process/5-31-side-pane-slash-overlay-isolation-v1.md` + +## Ownership + +你负责: + +- `rust/crates/mnote-web/src/routes/web_shell.rs` 中 slash menu root 选择、定位、secondary unsupported cleanup +- 必要时 `rust/spikes/leptos-tiptap-spike/src/lib.rs` 中 overlay data 属性/host 绑定 +- 相关 tests + +不要修改 Office、右键菜单、文件树 trash、默认 workspace、ACP/Hermes 文件。你不是独自在代码库中工作;不要回滚、覆盖、格式化或清理其他 worker 的改动。不要提交 git。 + +## 任务 + +1. `positionSlashMenuForRoot(root)` 只能定位当前 root 内的 slash menu;不能 fallback 到全局第一个 slash menu。 +2. `activeEditorRootForSlashMenu()` 只选择可见且含 selection/focus 的 editor root;hidden / unsupported secondary root 不应参与。 +3. 打开 unsupported side target 时清理 secondary pane 遗留的 slash overlay。 +4. 补字符串测试或 Rust 单测,明确覆盖“不使用全局 slash menu fallback”。 + +## 验证 + +至少运行: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web document_shell_runtime_renders_resource_tab_runtime -- --test-threads=1 +cargo test -p mnote-web document_shell_runtime_positions_slash_menu_per_editor_host -- --test-threads=1 +``` + +如果第二个测试不存在,请新增相近测试并运行。 + +## 最终回复格式 + +- 修改文件列表 +- 完成的 checklist 项 +- 运行过的命令和结果 +- 未完成项/风险 diff --git a/.codex/reasonix-tasks/hermes-acp-permission-decision-loop.md b/.codex/reasonix-tasks/hermes-acp-permission-decision-loop.md new file mode 100644 index 00000000..1d7cec23 --- /dev/null +++ b/.codex/reasonix-tasks/hermes-acp-permission-decision-loop.md @@ -0,0 +1,57 @@ +# Reasonix 任务:ACP permission 决策闭环 + +Project root: `/mnt/Data1T/mnote` + +你不是唯一在代码库中工作的 agent。不要回滚、覆盖或整理与你任务无关的改动;不要提交 git;不要修改 `.gitignore`、`.codegraph/`、`auth-console.txt`、`auth-network.txt`。 + +## 背景 + +Codex 已写 review:`design/10-review/process/13-hermes-vscode-plugin-gap-review-v1.md` + +本任务对应 checklist:`design/07-ai/process/7-31-acp-permission-decision-loop-checklist-v1.md` + +当前问题:`AcpClient` 对 incoming JSON-RPC `session/request_permission` 立即返回 unsupported/deny;Page AI UI 虽有按钮,但点击不能真正影响 ACP 请求。 + +## Ownership + +允许直接修改: + +- `rust/crates/mnote-web/src/acp_client.rs` +- `rust/crates/mnote-web/src/acp_session_manager.rs` +- `rust/crates/mnote-web/src/acp_bridge.rs` +- `rust/crates/mnote-web/src/routes/hermes_client.rs` +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` 中 permission 决策接线相关的小范围代码 +- 相关 Rust 测试 +- `design/07-ai/process/7-31-acp-permission-decision-loop-checklist-v1.md` 的执行记录 + +注意:`layout.rs` 可能被其他 worker 改动。只做 permission endpoint 调用与事件处理,不重排 Page AI drawer。 + +## 目标 + +实现 `permission.requested -> UI allow/deny -> ACP JSON-RPC response -> permission.allowed/denied` 的闭环。超时、缺失 pending request、缺失 run 时默认 deny 或返回清晰错误。 + +## 具体要求 + +1. `AcpClient` 支持注册 incoming request handler,handler 可异步返回 result/error。 +2. `session/request_permission` 不应立即 unsupported;应进入 pending request,向前端发 `permission.requested`。 +3. 增加后端 endpoint 或扩展现有 endpoint,让前端按 run id / permission id 提交 allow/deny。 +4. 前端 `pageAiResolvePermission()` 必须调用后端,而不只是本地改 UI 状态。 +5. 决策完成后向 SSE/UI 发送 `permission.allowed` 或 `permission.denied`。 +6. 补 mock ACP 测试覆盖 allow、deny、timeout/default deny。 + +## 验收命令 + +请至少运行: + +```bash +cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web acp_client permission -- --test-threads=1 +cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web acp_bridge permission -- --test-threads=1 +cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --test-threads=1 +``` + +## 最终回复格式 + +- 修改文件列表 +- 关键实现说明 +- 实际运行的验证命令和结果 +- 未完成项 / 风险 diff --git a/.codex/reasonix-tasks/hermes-acp-session-info-plan-ui.md b/.codex/reasonix-tasks/hermes-acp-session-info-plan-ui.md new file mode 100644 index 00000000..e4f31f78 --- /dev/null +++ b/.codex/reasonix-tasks/hermes-acp-session-info-plan-ui.md @@ -0,0 +1,54 @@ +# Reasonix 任务:ACP session info / plan UI + +Project root: `/mnt/Data1T/mnote` + +你不是唯一在代码库中工作的 agent。不要回滚、覆盖或整理与你任务无关的改动;不要提交 git;不要修改 `.gitignore`、`.codegraph/`、`auth-console.txt`、`auth-network.txt`。 + +## 背景 + +Codex 已写 review:`design/10-review/process/13-hermes-vscode-plugin-gap-review-v1.md` + +本任务对应 checklist:`design/07-ai/process/7-33-acp-session-info-plan-ui-checklist-v1.md` + +当前 `AcpSessionManager` 已解析 `SessionInfoUpdate` 与 `PlanUpdate`,但 `acp_bridge.rs` 丢弃它们。 + +## Ownership + +允许直接修改: + +- `rust/crates/mnote-web/src/acp_bridge.rs` +- `rust/crates/mnote-web/src/routes/hermes_client.rs` +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` 中 session info / plan event 处理的小范围代码 +- 相关 Rust 测试 +- `design/07-ai/process/7-33-acp-session-info-plan-ui-checklist-v1.md` 的执行记录 + +注意:`layout.rs` 可能被其他 worker 改动。请只做 event handling 和轻量 UI,不重排 Page AI drawer。 + +## 目标 + +把 `session_info_update` 和 `plan` 通过 SSE 转到 Page AI UI。session info 用于更新当前会话标题;plan 用轻量系统/状态消息展示,不写入页面正文。 + +## 具体要求 + +1. `AcpSessionEvent::SessionInfoUpdate` 转成 `session.info.updated` SSE。 +2. `AcpSessionEvent::PlanUpdate` 转成 `plan.updated` SSE。 +3. 前端收到 session info 后更新当前 session title,并持久化当前 session list。 +4. 前端收到 plan 后渲染轻量 plan 状态,不能作为普通 assistant 正文。 +5. 如果可低风险实现,解析 tool output 中 todo JSON 并复用 plan 区域展示;若风险高,可只写 TODO 到 checklist 执行记录。 +6. 补 bridge 单测和 `SIDEBAR_TREE_JS` 字符串断言。 + +## 验收命令 + +请至少运行: + +```bash +cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web acp_bridge -- --test-threads=1 +cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --test-threads=1 +``` + +## 最终回复格式 + +- 修改文件列表 +- 关键实现说明 +- 实际运行的验证命令和结果 +- 未完成项 / 风险 diff --git a/.codex/reasonix-tasks/hermes-acp-session-load-resume.md b/.codex/reasonix-tasks/hermes-acp-session-load-resume.md new file mode 100644 index 00000000..790c63ad --- /dev/null +++ b/.codex/reasonix-tasks/hermes-acp-session-load-resume.md @@ -0,0 +1,56 @@ +# Reasonix 任务:ACP session/load 恢复 + +Project root: `/mnt/Data1T/mnote` + +你不是唯一在代码库中工作的 agent。不要回滚、覆盖或整理与你任务无关的改动;不要提交 git;不要修改 `.gitignore`、`.codegraph/`、`auth-console.txt`、`auth-network.txt`。 + +## 背景 + +Codex 已写 review:`design/10-review/process/13-hermes-vscode-plugin-gap-review-v1.md` + +本任务对应 checklist:`design/07-ai/process/7-30-acp-session-load-resume-checklist-v1.md` + +参考实现:`reference-code/hermes-vscode-main/src/sessionManager.ts`,重点是 `ensureSession()` 中调用 `session/load { sessionId, cwd, mcpServers: [] }`,失败再 fallback `session/new`。 + +## Ownership + +允许直接修改: + +- `rust/crates/mnote-web/src/acp_session_manager.rs` +- `rust/crates/mnote-web/src/routes/hermes_client.rs` +- 与上述变更直接相关的 Rust 测试 +- `design/07-ai/process/7-30-acp-session-load-resume-checklist-v1.md` 的执行记录 + +禁止修改: + +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- Convex schema / migrations +- 无关设计稿和 smoke 脚本 + +## 目标 + +实现或补齐 ACP `session/load` 恢复能力。恢复已有 ACP adapter session 时必须实际调用 adapter;load 返回 null 或错误时 fallback 到 `session/new`。 + +## 具体要求 + +1. 给 `AcpSessionManager` 增加清晰 API,例如 `load_session(session_id, cwd)` / `ensure_session(cwd, stored_session_id)`。 +2. load 成功后当前 `session_id` 必须是 stored id。 +3. load 失败、返回 null 或不支持时,不应让后续 prompt 继续使用 phantom id,应创建新 ACP session。 +4. 在 `routes/hermes_client.rs` ACP run 启动处接入恢复逻辑;注意区分 MNote session id、ACP session id、run id。 +5. 补 mock ACP 测试:`session/load` 成功、null fallback、新建 session 后 prompt 仍可工作。 + +## 验收命令 + +请至少运行: + +```bash +cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web acp_session_manager -- --test-threads=1 +cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web hermes_client_acp_resume -- --test-threads=1 +``` + +## 最终回复格式 + +- 修改文件列表 +- 关键实现说明 +- 实际运行的验证命令和结果 +- 未完成项 / 风险 diff --git a/.codex/reasonix-tasks/hermes-acp-tool-location-open-action.md b/.codex/reasonix-tasks/hermes-acp-tool-location-open-action.md new file mode 100644 index 00000000..ad59e602 --- /dev/null +++ b/.codex/reasonix-tasks/hermes-acp-tool-location-open-action.md @@ -0,0 +1,55 @@ +# Reasonix 任务:ACP tool locations 与打开动作 + +Project root: `/mnt/Data1T/mnote` + +你不是唯一在代码库中工作的 agent。不要回滚、覆盖或整理与你任务无关的改动;不要提交 git;不要修改 `.gitignore`、`.codegraph/`、`auth-console.txt`、`auth-network.txt`。 + +## 背景 + +Codex 已写 review:`design/10-review/process/13-hermes-vscode-plugin-gap-review-v1.md` + +本任务对应 checklist:`design/07-ai/process/7-32-acp-tool-location-and-open-action-checklist-v1.md` + +参考实现:`reference-code/hermes-vscode-main/src/protocol.ts` 会解析 `tool_call.locations[].path`;`src/chatPanel.ts` 在 read/edit tool 完成后打开对应 editor。 + +## Ownership + +允许直接修改: + +- `rust/crates/mnote-web/src/acp_types.rs` +- `rust/crates/mnote-web/src/acp_session_manager.rs` +- `rust/crates/mnote-web/src/acp_bridge.rs` +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` 中 tool card 展示 / 打开动作小范围代码 +- 相关 Rust 测试或必要 smoke +- `design/07-ai/process/7-32-acp-tool-location-and-open-action-checklist-v1.md` 的执行记录 + +注意:`layout.rs` 可能被其他 worker 改动。请避免大范围重排,只接线 tool locations 展示和 open action。 + +## 目标 + +保留 ACP tool `locations[]`,并在 Page AI tool card 中显示和打开。默认打开到 MNote 主编辑器资源 tab / 现有 file open resolver,不默认新浏览器窗口。 + +## 具体要求 + +1. `SessionUpdate::ToolCall` 解析 `locations: [{ path }]`。 +2. `AcpSessionEvent::ToolCall` 携带 locations。 +3. `acp_bridge` SSE `tool.started` 包含 locations。 +4. 前端 tool card 保存并显示 locations。 +5. read/edit tool completed 时提供打开动作;优先复用现有主编辑器资源 tab 或已有 file open 函数。 +6. 补测试覆盖 parse + SSE + 前端字符串断言。 + +## 验收命令 + +请至少运行: + +```bash +cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web acp_tool -- --test-threads=1 +cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --test-threads=1 +``` + +## 最终回复格式 + +- 修改文件列表 +- 关键实现说明 +- 实际运行的验证命令和结果 +- 未完成项 / 风险 diff --git a/.gitignore b/.gitignore index 9635f084..5a1faa7c 100644 --- a/.gitignore +++ b/.gitignore @@ -62,11 +62,12 @@ tmp # pnpm 本地缓存 /.pnpm-store/ .playwright-mcp -rust/spikes/leptos-tiptap-spike/trunk-8123.err -rust/spikes/leptos-tiptap-spike/trunk-8123.out -design/05-editor-mainline/reference-code - -# 本地 Wolai 对标取证与截图产物 +rust/spikes/leptos-tiptap-spike/trunk-8123.err +rust/spikes/leptos-tiptap-spike/trunk-8123.out +design/05-editor-mainline/reference-code +/reference-code/ + +# 本地 Wolai 对标取证与截图产物 /test-results/ /wolai-*.png /wolai-*.md @@ -77,4 +78,4 @@ design/05-editor-mainline/reference-code # 下载的 Wolai 静态页面参考包,不作为可维护设计稿入库 design/design/html/ -recycle \ No newline at end of file +recycle diff --git a/bugs/04-tree-domain/done/4-45-local-folder-page-trash-and-empty-workspace-v1.md b/bugs/04-tree-domain/done/4-45-local-folder-page-trash-and-empty-workspace-v1.md new file mode 100644 index 00000000..06f827a0 --- /dev/null +++ b/bugs/04-tree-domain/done/4-45-local-folder-page-trash-and-empty-workspace-v1.md @@ -0,0 +1,44 @@ +# Checklist: Local Folder Page Trash And Empty Workspace + +## 目标 + +- 修复删除页面后进入资源垃圾箱的问题。 +- 停止自动生成“初始化的新页面”。 +- 空白本地工作区保持空白,不创建占位页面。 + +## 允许修改 + +- `rust/crates/mnote-web/src/routes/local_folder_source.rs` +- 必要时 `rust/crates/mnote-web/src/routes/gateway.rs` 中 trash 渲染分类逻辑 +- 相关 Rust 单测与 smoke + +## 禁止事项 + +- 不删除用户真实文件。 +- 不回滚 ACP/Hermes 相关改动。 +- 不改旧 React 回收区。 + +## 待办 + +- [x] 移除默认工作区创建“初始化的新页面”的运行时行为。 +- [x] 更新旧断言:默认 workspace 只创建 `.mnote` 元数据,不创建默认页面目录。 +- [x] 对页面 bundle 目录删除进行页面生命周期识别:若目录内存在同名 `.md`,删除应走 `trash_local_markdown_page`。 +- [x] trash index 中页面记录应可被页面垃圾箱识别,不再作为 `local_directory` 资源统计。 +- [x] 空 page tree / file tree 不渲染“初始化的新页面”。 +- [x] 补充或更新 targeted tests。 + +## 本轮执行记录 + +- 代码:`local_folder_source.rs` 移除默认首页创建,并让 `local-dir:*` 页面 bundle 删除转入 `markdown_bundle` 页面垃圾箱。 +- 新增测试:`local_tree_command_delete_page_bundle_directory_uses_page_trash`。 +- 浏览器验证:`tmp/bug-20260521-workbench-browser-verify/result-trash.json`,截图 `06-page-bundle-trash-classification.png`。 +- 说明:`task432-filetree-trash-page-dual-browser-no-refresh-smoke` 当前失败在 convex-source 双浏览器实时 delta 未插入新建子页,不是本地页面 bundle 垃圾箱分类失败;后续应按实时同步历史 smoke 单独处理或退役。 + +## 验收命令 + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web local_tree_command_manages_loose_markdown_like_regular_file -- --test-threads=1 +cargo test -p mnote-web local_tree_command_delete_folder_moves_directory_to_trash -- --test-threads=1 +cargo test -p mnote-web create_default_local_workspace_for_actor_at_base_creates_manifest_and_root_only -- --test-threads=1 +``` diff --git a/bugs/05-editor-mainline/done/2026-05-21-tab-office-upload-browser-bugs-review-v1.md b/bugs/05-editor-mainline/done/2026-05-21-tab-office-upload-browser-bugs-review-v1.md new file mode 100644 index 00000000..953b3e5e --- /dev/null +++ b/bugs/05-editor-mainline/done/2026-05-21-tab-office-upload-browser-bugs-review-v1.md @@ -0,0 +1,52 @@ +# Tab / Office / Upload Browser Bugs Review V1 + +日期:2026-05-21 + +## 范围 + +本轮覆盖用户继续反馈的 4 类缺陷: + +1. 主 resource tab 激活后,点击左侧主 Markdown 文件或新建页面,不能切回主 Markdown page tab。 +2. 左侧新建页面后,文件树焦点/选中没有落到新建页面。 +3. 真实中文名 docx 上传后仍打不开:`/home/lix/Downloads/重庆发展特殊化妆品可行性报告_政府汇报版.docx`。 +4. 上传文件偶发浏览器卡死,刷新无效但文件实际已上传;怀疑中文文件名、中文符号、Office 打开链路或上传后插入编辑器引发卡住。 + +## 当前初步判断 + +- `navigateToDocument` 在当前文档 ID 相同的路径上只更新树行状态,不会调用 resource tab runtime 切回 page tab。因此当前 page 已经是同一 Markdown 时,点击左侧文件树会停留在 resource tab。 +- `openPrimaryDocument` 替换 primary pane 后未显式调用 `activateMainEditorTab('')`,从 resource tab 导航到其他 Markdown 页面时也可能仍显示 resource host。 +- `createPage` 创建成功后直接 `navigateToDocument`,没有等待 local folder projection 刷新,也没有把 filetree selection state 的 `selectedRowIds/focusedRowId` 指向新页面。 +- 上传 Office 附件后 `insertUploadedAssetIntoEditor` 会同步构造 OnlyOffice 链接并拉取 `whoami`,若 DocumentServer 或 iframe 链路异常,当前需要确认是否只是打开失败,还是上传后插入/刷新/资源行追加链路导致页面失去响应。 +- 之前浏览器测试只验证到 `/onlyoffice` iframe URL 和 resource tab 状态,未完整验证 DocumentServer 内部是否报 `errorCode=-18`;本轮需要抓 iframe/console/network 证据。 + +## 修改策略 + +- Tab 切回: + - 在 document pane runtime 暴露 `activatePageTab()`。 + - `openPrimaryDocument` 成功后强制切回 page tab。 + - `navigateToDocument` 的 same-document fast path 也调用 `activatePageTab()`,解决“点击当前主 md 不能从 resource tab 切回”。 +- 新建页焦点: + - 增加 filetree 选择 helper,按 documentId 选中并聚焦 `doc:{documentId}` 行。 + - `createPage` 在 local_folder 下先刷新 sidebar projection,再选中新页面,再导航。 + - document pane primary 切换后派发 `mnote:primary-document-activated`,由 sidebar runtime 同步 selection state。 +- Office / 上传: + - 用真实中文 docx 做浏览器测试,记录上传 API、resource tab、iframe src、OnlyOffice 页面错误。 + - 若出现卡死,优先让上传链路避免等待非关键插入/打开操作;上传完成后 UI 不应被 OnlyOffice whoami/iframe 初始化阻塞。 + - 需要区分“DocumentServer 内部连接失败”和“MNote 浏览器卡死”两类问题,不混为同一个修复。 +- Reasonix 测试流程: + - 新增 `/home/lix/.codex/skills/reasonix-browser-tester/SKILL.md`。 + - 本轮派发 Reasonix 独立浏览器测试任务,Codex 只复核其 `result.json` 和截图。 + +## 不做事项 + +- 不回滚现有 ACP/Hermes 改动。 +- 不把 Office 改成下载兜底作为唯一行为。 +- 不把 Reasonix 测试结论当成事实源;必须复核截图和结构化日志。 +- 不清理用户已有上传文件或真实工作区文件。 + +## 验收 + +- 点击 page tab / 左侧当前 Markdown / 左侧其他 Markdown,都能从 resource tab 回到主 Markdown page tab。 +- 左侧新建页面后,URL、page tab 标题、filetree `data-selected`、`data-focused` 指向新页面。 +- 上传指定中文 docx 后浏览器不应卡死;resource tab 有明确打开状态或错误状态;刷新后不应要求重新登录才能恢复。 +- Reasonix 输出独立浏览器测试证据,Codex 与本地验证对照。 diff --git a/bugs/05-editor-mainline/done/2026-05-21-workbench-filetree-office-slash-bugs-review-v1.md b/bugs/05-editor-mainline/done/2026-05-21-workbench-filetree-office-slash-bugs-review-v1.md new file mode 100644 index 00000000..1f8e4ca1 --- /dev/null +++ b/bugs/05-editor-mainline/done/2026-05-21-workbench-filetree-office-slash-bugs-review-v1.md @@ -0,0 +1,99 @@ +# Workbench Filetree / Office / Slash Bug Review V1 + +日期:2026-05-21 + +## 范围 + +本轮覆盖用户截图 23-26 中的 6 个 P0/P1 缺陷: + +1. 文件树删除页面后,页面进入资源垃圾箱,页面垃圾箱没有使用。 +2. 主编辑区 tab 标题与实际打开页面标题不一致。 +3. 上传 docx 后页面卡死,docx 打不开。 +4. 不应自动出现“初始化的新页面”;空白工作区应保持空白。 +5. 文件树右键菜单大量动作不可用,需对照 Sidex / VSCode 文件资源管理器收敛到可用动作。 +6. 主栏打开 slash 菜单时,侧栏也出现 slash 菜单。 + +## 当前证据 + +- `rust/crates/mnote-web/src/routes/local_folder_source.rs` + - `ensure_default_workspace_home_page` 会创建 `初始化的新页面/初始化的新页面.md`。 + - `create_default_local_workspace_for_actor_at_base_creates_manifest_and_root_only` 仍断言该默认页面存在。 + - `trash_local_entry` 先解析 `local-dir:*`,再解析 `local-file:*`,最后才走 `trash_local_markdown_page`。 + - 文件树中页面 bundle 目录在 `scan_directory` 中是 `rowKind=folder`,因此删除会走 `trash_local_directory`,`resourceKind=local_directory`。 +- `rust/crates/mnote-web/src/routes/web_shell.rs` + - `replacePaneDocument` / `updatePaneChrome` 会更新正文、面包屑、`document.title`,但没有同步 `[data-mnote-main-tab="page"] .mnote-main-tab-title`。 + - `openUnsupportedSideTarget` 会替换 secondary root 为不支持占位;`positionSlashMenuForRoot` 仍允许 fallback 到 `document.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]')`,存在跨 pane 定位/展示风险。 + - `openResourceAsSideTarget` 对 markdown/text/code/office 全部进入 unsupported,占位会污染侧栏;这与用户期望“不可用动作不要误打开侧栏”冲突。 +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` + - 文件树右键菜单直接展示 `open-right / new-file / new-folder / paste / refresh / collapse / reveal / duplicate / rename / delete`,其中部分 disabled 或仅 dispatch event,无真实实现。 + - `openConvexAssetFromFileTree` 对 local office 可走主 tab 的 iframe,但 Convex office 仍直接 `window.open(buildOnlyOfficeOpenUrl(...))`。 + - 附件上传 `insertUploadedAssetIntoEditor` 和 `enhanceEditorAttachmentLink` 已尝试统一附件 UI;docx 卡死需要先确认是上传阶段阻塞、OnlyOffice iframe 回源失败,还是 active-tab iframe 加载导致主页面阻塞。 +- Sidex / VSCode 参考: + - Sidex 保留 VSCode 的 `MenuId.ExplorerContext` 菜单体系,文件树动作来源在 `reference-code/sidex-main/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts`。 + - 可对照的基础动作是 Open to the Side、New File、New Folder、Reveal、Copy Path、Rename、Delete;动作是否显示/启用由 context key 决定,不是把所有潜在动作硬塞进菜单。 + - Sidex workspace 层有 `FileClipboard`、`InlineInputKind::{Rename, NewFile, NewFolder}` 等状态,说明新建/重命名应走内联输入或明确命令,而不是菜单项只发事件。 + +## 修改策略 + +- Bug1 / Bug4 属于本地文件夹 tree/source 口径问题,优先修 Rust kernel-facing 路径: + - 默认工作区不再创建“初始化的新页面”。 + - bundle 页面删除必须以 Markdown 页面生命周期入 trash,页面垃圾箱可识别,不应按 `local_directory` 资源归类。 + - 空 root page tree 可返回空列表;UI 不渲染占位页面。 +- Bug2 属于主编辑区 tab chrome 同步: + - `updatePaneChrome(primary)` 同步 page tab 标题、`data-document-id` 和 active 状态。 + - 页面标题保存事件也应更新 page tab。 +- Bug3 属于 Office 打开链路: + - 上传本地 docx 后,主 tab 打开应使用 `/onlyoffice?...fileUrl=/api/local-folder/files/open...`,并避免在主线程同步读取/解析大文件。 + - 如果 OnlyOffice DocumentServer 不可连,应显示非阻塞错误,不应卡死整个页面。 +- Bug5 属于菜单可用性: + - 菜单只显示/启用已有真实实现。 + - “在右侧边栏打开”只对已支持侧栏打开的页面显示;资源侧栏未支持时不要打开 unsupported placeholder。 + - `Copy Path` 对 local folder 应复制真实 relative path,不是复制标题。 + - New Folder 在 local_folder 下应接入 `create_folder`,否则隐藏。 +- Bug6 属于 editor host 隔离: + - Slash 菜单定位只能操作当前 root 内的菜单,不能 fallback 到别的 pane 的菜单。 + - 不支持侧栏资源时应清理 secondary editor/slash overlay,或完全不打开侧栏。 + +## 不做事项 + +- 不回滚当前工作区已有 ACP/Hermes 改动。 +- 不把资源 tab、side pane、OnlyOffice 做成全新架构;只修当前 P0 可见缺陷。 +- 不复活旧 React / BlockNote 运行时。 +- 不把 Sidex 菜单实现照搬成通用命令注册系统;本轮只借鉴“按可用能力显示动作”的原则。 + +## 验收 + +- Rust targeted tests 至少覆盖: + - local workspace 默认初始化不创建“初始化的新页面”。 + - 删除页面 bundle 进入 page/document trash,而不是 `local_directory` resource trash。 + - 主 page tab 随 active primary document 标题更新。 + - local docx onlyoffice URL / content-type / proxy 回源可达。 + - 文件树菜单不可用动作隐藏或禁用,已显示动作有真实 handler。 + - slash menu 不跨 primary/secondary/resource pane 显示。 +- UI 类最终必须做真实浏览器或 smoke 验证,并保留截图/输出。 + +## 本轮执行结果 + +- 已完成并移动到 `done/`: + - `bugs/04-tree-domain/done/4-45-local-folder-page-trash-and-empty-workspace-v1.md` + - `bugs/05-editor-mainline/done/5-28-main-page-tab-title-active-sync-v1.md` + - `bugs/05-editor-mainline/done/5-29-onlyoffice-docx-upload-open-freeze-v1.md` + - `bugs/05-editor-mainline/done/5-30-filetree-context-menu-sidex-alignment-v1.md` + - `bugs/05-editor-mainline/done/5-31-side-pane-slash-overlay-isolation-v1.md` +- Rust 验证: + - `cargo test -p mnote-web -- --test-threads=1`:481 passed。 + - 新增 `local_tree_command_delete_page_bundle_directory_uses_page_trash`,锁定 `local-dir:*` 页面 bundle 删除进入 `markdown_bundle` 页面垃圾箱。 +- Smoke / 浏览器验证: + - `node scripts/task463-onlyoffice-resolver-smoke.js`:通过。 + - `node scripts/task462-resource-tab-error-placeholder-smoke.js`:通过。 + - `node scripts/task455-local-folder-mindmap-clean-smoke.js`:通过。 + - `node scripts/task458-local-create-page-no-conflict-smoke.js`:通过。 + - `node scripts/task464-local-folder-resource-trash-ui-smoke.js`:通过。 + - `node scripts/task472-side-target-secondary-pane-smoke.js`:通过。 +- 真实浏览器证据: + - `tmp/bug-20260521-workbench-browser-verify/result-title-docx-menu.json` + - `tmp/bug-20260521-workbench-browser-verify/result-slash.json` + - `tmp/bug-20260521-workbench-browser-verify/result-trash.json` + - 截图覆盖:主 tab 标题同步、真实 docx resource tab、资源右键菜单、primary/secondary slash 隔离、页面 bundle 垃圾箱分类。 +- 历史 smoke 注意: + - `node scripts/task432-filetree-trash-page-dual-browser-no-refresh-smoke.js` 当前失败在 convex-source 双浏览器 live delta 未插入新建子页;失败点早于本轮页面删除/垃圾箱断言,不代表本地页面 bundle 垃圾箱修复失败。后续应按实时同步历史 smoke 单独修复或退役。 diff --git a/bugs/05-editor-mainline/done/5-28-main-page-tab-title-active-sync-v1.md b/bugs/05-editor-mainline/done/5-28-main-page-tab-title-active-sync-v1.md new file mode 100644 index 00000000..96515f9e --- /dev/null +++ b/bugs/05-editor-mainline/done/5-28-main-page-tab-title-active-sync-v1.md @@ -0,0 +1,38 @@ +# Checklist: Main Page Tab Title Active Sync + +## 目标 + +主编辑区 page tab 标题必须随当前 primary document 切换和标题保存同步更新。 + +## 允许修改 + +- `rust/crates/mnote-web/src/routes/web_shell.rs` +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- `rust/crates/mnote-web/src/ssr/pages/document.rs` +- 相关测试 / smoke + +## 禁止事项 + +- 不改资源 tab 整体架构。 +- 不移除资源 tab close guard。 +- 不回滚现有 `layout.rs` 用户改动。 + +## 待办 + +- [x] 在 `updatePaneChrome(primary)` 中同步 `[data-mnote-main-tab="page"]` 的 title、document id、workspace id。 +- [x] 标题保存控制器 `updateVisibleTitle` 同步当前 page tab 标题。 +- [x] 切换回 page tab 时确保 tab 标题与当前 primary document 一致。 +- [x] 增加 Rust 字符串测试或 browser smoke 覆盖。 + +## 本轮执行记录 + +- 代码:`web_shell.rs` 在标题更新与 primary pane 切换时同步主 page tab。 +- 浏览器验证:`tmp/bug-20260521-workbench-browser-verify/result-title-docx-menu.json` 中 `tabState.tabTitle=Beta`,截图 `01-beta-page-tab-title.png`。 + +## 验收命令 + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web page_layout_renders_main_editor_tab_strip -- --test-threads=1 +cargo test -p mnote-web document_shell_runtime_renders_resource_tab_runtime -- --test-threads=1 +``` diff --git a/bugs/05-editor-mainline/done/5-29-onlyoffice-docx-upload-open-freeze-v1.md b/bugs/05-editor-mainline/done/5-29-onlyoffice-docx-upload-open-freeze-v1.md new file mode 100644 index 00000000..ef601abe --- /dev/null +++ b/bugs/05-editor-mainline/done/5-29-onlyoffice-docx-upload-open-freeze-v1.md @@ -0,0 +1,42 @@ +# Checklist: OnlyOffice Docx Upload Open Freeze + +## 目标 + +上传 docx 后点击打开不应卡死;本地 docx 应能在主编辑器 resource tab 中打开 OnlyOffice,失败时显示非阻塞错误。 + +## 允许修改 + +- `rust/crates/mnote-web/src/routes/onlyoffice.rs` +- `rust/crates/mnote-web/src/routes/local_folder_source.rs` +- `rust/crates/mnote-web/src/routes/web_shell.rs` +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- OnlyOffice / local upload 相关测试 + +## 禁止事项 + +- 不绕开 local-first rootUri 授权。 +- 不把 docx 改成浏览器下载兜底作为唯一行为。 +- 不引入新外部服务。 + +## 待办 + +- [x] 复现并定位上传后卡死发生在上传、插入附件 UI、resource tab iframe、OnlyOffice DocumentServer 回源哪一层。 +- [x] 确保 local docx 打开 URL 走 `/api/local-folder/files/open` 且 content-type 正确。 +- [x] 主 tab iframe 打开 OnlyOffice 时设置清晰 loading/error 状态,超时不阻塞主页面。 +- [x] Convex / local office 行为尽量统一到 resource tab;新窗口只作为“在新窗口打开”动作。 +- [x] 补充 docx 上传/打开 targeted smoke 或 Rust 字符串测试。 + +## 本轮执行记录 + +- 代码:`onlyoffice.rs` 为 API 加载和初始化增加超时错误;`layout.rs` 让 office 资源优先进入主 resource tab。 +- 浏览器验证:使用真实文件 `/mnt/Data1T/research/DHA_cosmetics/progress/DHA美黑化妆品监管综述_2026-05-16.docx` 上传并打开,结果在 `tmp/bug-20260521-workbench-browser-verify/result-title-docx-menu.json`,截图 `02-real-docx-resource-tab.png`。 +- Smoke:`node scripts/task463-onlyoffice-resolver-smoke.js` 已通过。 + +## 验收命令 + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web local_file_open_sets_pdf_and_office_content_type -- --test-threads=1 +cargo test -p mnote-web onlyoffice_page_exposes_documentserver_fetch_base_for_local_files -- --test-threads=1 +cargo test -p mnote-web sidebar_tree_runtime_opens_office_assets_through_resource_shell -- --test-threads=1 +``` diff --git a/bugs/05-editor-mainline/done/5-30-filetree-context-menu-sidex-alignment-v1.md b/bugs/05-editor-mainline/done/5-30-filetree-context-menu-sidex-alignment-v1.md new file mode 100644 index 00000000..b20d8489 --- /dev/null +++ b/bugs/05-editor-mainline/done/5-30-filetree-context-menu-sidex-alignment-v1.md @@ -0,0 +1,46 @@ +# Checklist: Filetree Context Menu Sidex Alignment + +## 目标 + +对照 Sidex / VSCode Explorer Context,收敛 MNote 文件树右键菜单:只展示可用动作,已展示动作必须有真实效果。 + +## 允许修改 + +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- `rust/crates/mnote-web/src/tree_shell/filetree_runtime.rs` +- `rust/crates/mnote-web/src/routes/local_folder_source.rs` 中已有 tree command 入口的小范围扩展 +- 相关测试 + +## 禁止事项 + +- 不照搬 Sidex 菜单注册框架。 +- 不保留点击后只 dispatch 但用户不可见无效果的菜单项。 +- 不让资源“在右侧边栏打开”生成 unsupported placeholder。 + +## Sidex 对照结论 + +- Sidex / VSCode 的 Explorer 菜单来自 `MenuId.ExplorerContext`,动作在 `fileActions.contribution.ts` 注册。 +- 基础动作包括 Open to the Side、New File、New Folder、Reveal、Copy Path、Rename、Delete。 +- 动作显示/启用依赖 context key;不可用动作不应常驻为“假按钮”。 + +## 待办 + +- [x] local_folder 下 `New File` 继续创建页面;`New Folder` 接入 `create_folder` 或在非 local_folder 隐藏。 +- [x] `Copy Path` 对 local folder 复制 `relativePath` / asset path,不复制标题。 +- [x] `Reveal` 只做滚动/聚焦即可,但需记录状态。 +- [x] `Duplicate` 若未实现 copy,则隐藏或禁用并说明;不要只 dispatch。 +- [x] `Open Right` 对页面可打开 secondary document;对资源若不支持侧栏则隐藏,避免 unsupported placeholder。 +- [x] 更新菜单字符串测试。 + +## 本轮执行记录 + +- 代码:`layout.rs` 收口文件树右键菜单,移除资源侧栏打开和未实现 duplicate,接入 local folder `create_folder` 与真实 `Copy Path`。 +- 浏览器验证:`tmp/bug-20260521-workbench-browser-verify/result-title-docx-menu.json` 中资源菜单为“在新窗口打开 / 重命名 / 复制资源 ID / 删除 / Copy Path / Refresh / Reveal”,截图 `03-asset-context-menu.png`。 + +## 验收命令 + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web sidebar_tree_runtime_renders_context_menu_and_scoped_title_updates -- --test-threads=1 +cargo test -p mnote-web sidebar_tree_runtime_handles_navigation_drag_and_filetree_actions -- --test-threads=1 +``` diff --git a/bugs/05-editor-mainline/done/5-31-side-pane-slash-overlay-isolation-v1.md b/bugs/05-editor-mainline/done/5-31-side-pane-slash-overlay-isolation-v1.md new file mode 100644 index 00000000..f7e9ddbb --- /dev/null +++ b/bugs/05-editor-mainline/done/5-31-side-pane-slash-overlay-isolation-v1.md @@ -0,0 +1,38 @@ +# Checklist: Side Pane Slash Overlay Isolation + +## 目标 + +主编辑器 slash 菜单不得在侧栏/secondary pane 中出现;不支持侧栏打开资源时不得留下会响应 slash 的侧栏 editor host。 + +## 允许修改 + +- `rust/crates/mnote-web/src/routes/web_shell.rs` +- 必要时 `rust/spikes/leptos-tiptap-spike/src/lib.rs` +- 相关测试 / smoke + +## 禁止事项 + +- 不禁用主编辑器 slash 菜单。 +- 不删除侧栏页面编辑能力。 +- 不用全局 CSS 隐藏全部 slash menu 作为表面修复。 + +## 待办 + +- [x] `positionSlashMenuForRoot` 只查当前 root 内菜单;不能 fallback 到全局第一个 slash menu。 +- [x] `activeEditorRootForSlashMenu` 优先选择含有 selection/focus 的可见 root;secondary hidden/unsupported root 不参与。 +- [x] 打开 unsupported side target 时彻底卸载 secondary view 并清理遗留 slash overlay。 +- [x] 为 primary + secondary 同时存在时的 slash 隔离补测试。 +- [x] 若 leptos runtime 自身生成全局 overlay,需要给 overlay 绑定 `data-pane-role` 或 editor host id。 + +## 本轮执行记录 + +- 代码:`web_shell.rs` 增加 `setSlashMenuInactive` / `hideSlashMenusOutsideRoot`,非当前 selection/focus root 的 slash menu 会被标记 `data-mnote-slash-inactive` 并隐藏。 +- 浏览器验证:`tmp/bug-20260521-workbench-browser-verify/result-slash.json`,`primaryMenus=1`、`secondaryMenus=0`、`inactiveSecondary=1`,截图 `05-slash-primary-secondary-isolation.png`。 + +## 验收命令 + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web document_shell_runtime_renders_resource_tab_runtime -- --test-threads=1 +cargo test -p mnote-web document_shell_runtime_positions_slash_menu_per_editor_host -- --test-threads=1 +``` diff --git a/bugs/05-editor-mainline/done/5-32-main-resource-tab-page-switch-and-filetree-focus-v1.md b/bugs/05-editor-mainline/done/5-32-main-resource-tab-page-switch-and-filetree-focus-v1.md new file mode 100644 index 00000000..88441883 --- /dev/null +++ b/bugs/05-editor-mainline/done/5-32-main-resource-tab-page-switch-and-filetree-focus-v1.md @@ -0,0 +1,42 @@ +# Checklist: Main Resource Tab Page Switch And Filetree Focus + +## 目标 + +- resource tab 激活时,点击主 Markdown 或其他 Markdown 页面能切回主 page tab。 +- 新建页面后,主编辑区显示新页面,filetree 选中和焦点落在新页面。 + +## 允许修改 + +- `rust/crates/mnote-web/src/routes/web_shell.rs` +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- 相关 Rust 字符串测试与浏览器 smoke + +## 禁止事项 + +- 不改资源 tab 整体架构。 +- 不删除 resource tab MRU / close guard。 +- 不回滚 ACP/Hermes 相关改动。 + +## 待办 + +- [x] document pane runtime 暴露 `activatePageTab()`。 +- [x] `openPrimaryDocument` 成功后切回 page tab。 +- [x] `navigateToDocument` same-document fast path 也能从 resource tab 切回 page tab。 +- [x] 页面树整行点击也能触发打开,避免只点内部按钮才生效。 +- [x] filetree 增加按 documentId 选中/聚焦 helper。 +- [x] `createPage` 在 local folder projection 刷新后选中新页面。 +- [x] primary document 激活后同步 filetree selection state。 +- [x] 补浏览器验证覆盖 resource tab -> md page 切换、新建页面焦点。 + +## 验收命令 + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web -- --test-threads=1 +``` + +## 验证记录 + +- `cargo test -p mnote-web -- --test-threads=1`:482 passed。 +- Playwright 复测:resource tab 激活后点击左侧 `Beta` 页面,URL 切到 `/documents/local-md:Beta.md`,active tab 为 `page`,resource host 隐藏,页面树 active 行为 `Beta`。 +- 截图:`/mnt/Data1T/mnote/tmp/bug-20260521-tab-office-upload-after-row-click.png`。 diff --git a/bugs/05-editor-mainline/done/5-33-office-chinese-docx-upload-open-freeze-v1.md b/bugs/05-editor-mainline/done/5-33-office-chinese-docx-upload-open-freeze-v1.md new file mode 100644 index 00000000..25f9beb8 --- /dev/null +++ b/bugs/05-editor-mainline/done/5-33-office-chinese-docx-upload-open-freeze-v1.md @@ -0,0 +1,49 @@ +# Checklist: Office Chinese Docx Upload Open Freeze + +## 目标 + +上传并打开中文文件名 docx 时,浏览器不应卡死;Office 打开失败时必须是可见、非阻塞错误。 + +指定测试文件: + +`/home/lix/Downloads/重庆发展特殊化妆品可行性报告_政府汇报版.docx` + +## 允许修改 + +- `rust/crates/mnote-web/src/routes/local_folder_source.rs` +- `rust/crates/mnote-web/src/routes/onlyoffice.rs` +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- 相关 browser smoke + +## 禁止事项 + +- 不引入新外部服务。 +- 不把 docx 只做下载兜底。 +- 不清理用户真实下载文件。 + +## 待办 + +- [x] 用真实中文 docx 复现上传、插入附件、点击打开、OnlyOffice iframe 内错误。 +- [x] 记录 console/network/iframe src/页面响应性。 +- [x] 区分 DocumentServer `errorCode=-18` 与浏览器主页面卡死。 +- [x] 上传完成后不得等待非关键 Office 初始化;上传链路必须释放 UI。 +- [x] 对中文文件名/URL 编码和 OnlyOffice document key 安全化补测试。 +- [x] 增加 Reasonix clean browser 测试流程,并用截图交叉验证。 + +## 验收命令 + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web -- --test-threads=1 +node scripts/task463-onlyoffice-resolver-smoke.js +``` + +## 验证记录 + +- `cargo test -p mnote-web onlyoffice -- --test-threads=1`:12 passed。 +- `cargo test -p mnote-web -- --test-threads=1`:482 passed。 +- Reasonix clean browser 测试输出:`/mnt/Data1T/mnote/tmp/reasonix-office-upload-open-clean-2026-05-21/result.json`,结论 `pass`。 +- Reasonix 最终截图:`/mnt/Data1T/mnote/tmp/reasonix-office-upload-open-clean-2026-05-21/04-final-state.png`,可见 OnlyOffice 文档正文。 +- Codex 自测输出:`/mnt/Data1T/mnote/tmp/codex-office-upload-open-clean-2026-05-21/result.json`。 +- Codex 最终截图:`/mnt/Data1T/mnote/tmp/codex-office-upload-open-clean-2026-05-21/codex-office-final.png`,可见 OnlyOffice 文档正文。 +- 已确认 WebSocket URL 使用安全 doc key,例如 `mnote_1258170954_1240121036`,不含 `/`、`:` 或中文字符;未出现 `errorCode=-18`。 diff --git a/bugs/05-editor-mainline/process/2026-05-21-office-edit-sign-plugin-gap-review-v1.md b/bugs/05-editor-mainline/process/2026-05-21-office-edit-sign-plugin-gap-review-v1.md new file mode 100644 index 00000000..8ffad923 --- /dev/null +++ b/bugs/05-editor-mainline/process/2026-05-21-office-edit-sign-plugin-gap-review-v1.md @@ -0,0 +1,107 @@ +# Office local-first 预览、编辑与插件噪音缺口审查 v1 + +## 背景 + +本轮只审查 OnlyOffice 与 MNote local-first 工作区之间的三个接口缺口: + +1. `/api/media/sign` 对 local-folder asset 返回 404。 +2. Office 附件默认只读,但需要三点菜单提供“使用编辑模式打开”。 +3. OnlyOffice 自定义 annotation 插件存在 404 / pageerror 噪音。 + +上一轮浏览器验证已经确认:中文 docx 上传后可在 resource tab 中只读打开,`errorCode=-18` 的 WebSocket 连接问题已通过 doc key 安全化和 `/onlyoffice-server` 反代修复。因此本轮不把“只读预览可见”重新定义为失败。 + +## 官方依据 + +Context7 查询 `/onlyoffice/api.onlyoffice.com` 得到的关键口径: + +- `editorConfig.mode` 支持 `"view"` / `"edit"`,只是初始化编辑器模式。 +- 保存必须配置 `editorConfig.callbackUrl`,后端在 callback `status === 2` 或 `status === 6` 时下载 `body.url` 并写回文件,成功返回 `{ "error": 0 }`。 +- `onRequestEditRights` 是 view 模式切 edit 的官方事件;触发后集成方必须以 edit mode 重新初始化编辑器。 +- 插件通过 `editorConfig.plugins.autostart` 和 `editorConfig.plugins.pluginsData` 连接;annotation / custom assistant 类插件 404 属于可选插件加载链路,不等于主文档渲染失败。 + +## 代码现状 + +- `rust/crates/mnote-web/src/routes/media.rs` 的 `/api/media/sign` 当前只服务 Convex media asset,内部查询 `mediaAssets:getById` / `mediaAssets:refreshUrl`。local-folder asset 形如 `local:asset:`,不在 Convex media 表中,所以返回 404 是现状契约不匹配。 +- `rust/crates/mnote-web/src/routes/onlyoffice.rs` 的 `/onlyoffice` 页面在 `resolveAssetUrlAndKey()` 中只要有 `assetId` 就请求 `/api/media/sign`;失败后静默保留传入的 `fileUrl`。这就是“当前 view 模式不阻断打开”的原因。 +- local-folder Office 预览已经可以直接使用 `/api/local-folder/files/open?rootUri=...&path=...` 作为 `fileUrl`,不需要经过 `/api/media/sign`。 +- `/api/onlyoffice/callback` 当前仍代理 legacy Next writeback。对 local-folder 的 `status 2/6 -> 下载 body.url -> 原文件覆盖写回 -> watcher 同步` 没有完整闭环。 +- `layout.rs` 中正文附件菜单已有 `new-window`,但没有“使用编辑模式打开”。部分路径当前会默认生成 `mode=edit`,这与“默认只读、显式编辑”的产品口径不一致。 +- annotation 插件 404 / pageerror 暂未证明会影响文档渲染;当前更像是 OnlyOffice 静态插件包或自定义插件配置缺失导致的 console 噪音。 + +## 根因判断 + +### 1. `/api/media/sign` local-folder 404 + +根因不是权限失效,而是端点 ownership 错位:`/api/media/sign` 是历史 Convex media asset 签名端点,local-folder 文件已经有 local open route,二者不应混用。 + +P0 目标不是让 `/api/media/sign` 接管所有本地文件,而是让 OnlyOffice local 路径不再无意义请求该端点,避免误导日志和后续测试。 + +### 2. 编辑模式入口 + +OnlyOffice `mode=edit` 并不等于 MNote 已支持保存。没有 callback 写回闭环时,直接默认编辑会制造“看似能改、实际不能保存”的假功能。 + +P0 目标: + +- 默认打开仍为 `view`。 +- 菜单提供“使用编辑模式打开”入口。 +- 编辑入口必须带 guard:清楚标记为实验能力,或在 local-folder writeback 未闭环时阻止/提示。 + +P1 目标才是实现 local-folder callback 写回。 + +### 3. annotation 插件 404 / pageerror + +插件链路属于可选增强。若配置了 autostart / pluginsData 但静态资源不存在,会产生 404 或 pageerror。只要正文渲染和 OnlyOffice 主 WebSocket 正常,这不是打开失败。 + +P0 目标: + +- 不把可选插件 404 当成 Office 打开失败。 +- 不让缺失插件反复污染测试结论。 +- 若当前 MNote 并未真正依赖 annotation 插件,则应禁用 autostart 或明确过滤为 non-critical。 + +## Checklist 拆分 + +- `5-34-local-folder-media-sign-office-url-contract-v1.md` + - P0:OnlyOffice local-folder 打开不再请求 `/api/media/sign`。 + - P1:补回归测试,确认 local asset 仍可只读打开。 + +- `5-35-office-edit-mode-menu-and-guard-v1.md` + - P0:正文附件与文件树资源菜单增加“使用编辑模式打开”。 + - P0:默认打开保持只读。 + - P0:若保存闭环未完成,编辑入口必须有 guard / 实验标记。 + +- `5-36-onlyoffice-local-edit-save-callback-contract-v1.md` + - P1:设计或实现 local-folder callback 写回。 + - P1:覆盖 status 2/6、下载 URL rewrite、路径权限、冲突保护。 + +- `5-37-onlyoffice-annotation-plugin-noise-policy-v1.md` + - P0:定位 annotation 404 来源。 + - P0:禁用无效 autostart / pluginsData 或把该类错误降级为 non-critical 测试噪音。 + +## 不做事项 + +- 不默认以 edit 模式打开 Office。 +- 不在没有保存闭环时承诺“Office 可编辑保存”。 +- 不把 local-folder 文件任意暴露给 `/api/media/sign`,除非经过 rootUri / workspace / allowed roots 校验。 +- 不把 annotation 插件 404 等同于主文档渲染失败。 + +## 验收要求 + +- `cargo test -p mnote-web -- --test-threads=1` +- `codegraph sync .` 后确认索引健康。 +- Reasonix clean browser 测试:全新 browser context,登录测试账号,上传 `/home/lix/Downloads/重庆发展特殊化妆品可行性报告_政府汇报版.docx`,打开默认只读,截图确认内容可见;打开三点菜单确认编辑入口;点击编辑入口后截图和日志证明行为符合 guard / edit URL 预期。 +- Codex 必须复核 Reasonix 的 `result.json` 和截图,再自行做一次浏览器截图核查。 + +## 当前执行结论 + +- P0 已处理: + - local-folder Office 打开跳过 `/api/media/sign`,继续使用 `/api/local-folder/files/open`。 + - Office 默认打开为 `mode=view`。 + - 菜单提供“使用编辑模式打开”,进入前有实验 guard。 + - 已存在 Office resource tab 从 view 切 edit 会刷新同一 iframe URL,不再只激活旧 tab。 + - annotation/custom assistant 插件 404 已归类为 non-critical noise,不作为主文档打开失败。 +- P1 仍保留: + - local-folder Office edit/save callback 写回闭环未实现,编辑模式仍不能承诺保存到原文件。 + - `onRequestEditRights` 事件重新初始化 edit URL 未实现。 +- 浏览器证据: + - Reasonix:`tmp/reasonix-office-view-edit-plugin-2026-05-21/result.json` 与截图。 + - Codex:`tmp/codex-office-view-edit-plugin-2026-05-21/result.json`、`01-office-view-mode.png`、`02-office-edit-mode.png`。 diff --git a/bugs/05-editor-mainline/process/5-34-local-folder-media-sign-office-url-contract-v1.md b/bugs/05-editor-mainline/process/5-34-local-folder-media-sign-office-url-contract-v1.md new file mode 100644 index 00000000..10766a14 --- /dev/null +++ b/bugs/05-editor-mainline/process/5-34-local-folder-media-sign-office-url-contract-v1.md @@ -0,0 +1,46 @@ +# 5-34 local-folder media/sign 与 Office URL 契约 + +## 目标 + +OnlyOffice 打开 local-folder asset 时不再向 `/api/media/sign` 发起无意义请求;local 文件继续通过 local-folder open route 进入 OnlyOffice 只读预览。 + +## 原因 + +`/api/media/sign` 当前只签 Convex media asset。local-folder asset 已有 `rootUri + path` 的本地文件打开链路,不应混入 Convex 签名端点。 + +## 允许修改 + +- `rust/crates/mnote-web/src/routes/onlyoffice.rs` +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- 相关 `mnote-web` 单测 + +## 禁止事项 + +- 不开放任意本地路径签名。 +- 不把 `/api/media/sign` 扩成绕过 allowed roots 的本地文件下载端点。 +- 不改变非 local Convex media asset 的签名行为。 + +## Checklist + +- [x] `/onlyoffice` 页面识别 local asset / local fileUrl,跳过 `/api/media/sign`。 +- [x] local asset 的 doc key 仍稳定、安全,不包含 `/`、`:`、中文等危险字符。 +- [x] 非 local asset 仍可走 `/api/media/sign` 解析 signedUrl。 +- [x] 补单测覆盖 local asset 不依赖 media sign 的页面脚本契约。 + +## 验收 + +- `cargo test -p mnote-web onlyoffice -- --test-threads=1` +- `cargo test -p mnote-web -- --test-threads=1` + +## 本轮执行记录 + +- 2026-05-21:Reasonix worker A 执行完成。 + - 在 `page()` 模板 JS 中添加 `isLocalFolderAsset()` 守卫函数。 + - `resolveAssetUrlAndKey()` 中 local-folder asset 跳过 `/api/media/sign`。 + - 守卫条件:`assetId` 前缀为 `local:` 或 `local-file:`,或 `fileUrl` 路径含 `/api/local-folder/files/open`。 + - 新增 `onlyoffice_page_skips_media_sign_for_local_folder_asset` 单测。 + - `cargo test -p mnote-web onlyoffice -- --test-threads=1` 全部 13 项通过。 +- 2026-05-21:Codex 复核补充。 + - 干净浏览器上下文上传并打开 `/home/lix/Downloads/重庆发展特殊化妆品可行性报告_政府汇报版.docx`。 + - local-folder iframe `fileUrl` 指向 `/api/local-folder/files/open?...`,未出现 `/api/media/sign` local 404。 + - 证据:`tmp/codex-office-view-edit-plugin-2026-05-21/result.json`,截图 `01-office-view-mode.png` / `02-office-edit-mode.png`。 diff --git a/bugs/05-editor-mainline/process/5-35-office-edit-mode-menu-and-guard-v1.md b/bugs/05-editor-mainline/process/5-35-office-edit-mode-menu-and-guard-v1.md new file mode 100644 index 00000000..ead5d57e --- /dev/null +++ b/bugs/05-editor-mainline/process/5-35-office-edit-mode-menu-and-guard-v1.md @@ -0,0 +1,62 @@ +# 5-35 Office 编辑模式菜单与保护 + +## 目标 + +Office 文件默认只读打开;在正文附件三点菜单和文件树资源右键菜单中增加“使用编辑模式打开”入口。保存闭环未完成前,编辑入口必须有 guard 或明确实验标记。 + +## 原因 + +ONLYOFFICE `mode=edit` 只是编辑器初始化模式。若 MNote 没有完成 callback 写回,默认 edit 会让用户误以为修改已经保存到本地文件。 + +## 允许修改 + +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- `rust/crates/mnote-web/src/routes/onlyoffice.rs` 中与 view/edit 初始化事件相关的最小补充 +- 相关 `mnote-web` 单测 + +## 禁止事项 + +- 不默认 edit。 +- 不声称 edit/save 已完整支持。 +- 不改动 ACP / Hermes 无关代码。 + +## Checklist + +- [x] `buildOnlyOfficeAssetOpenUrl` / local Office 默认输出 `mode=view`。 +- [x] 正文附件菜单增加“使用编辑模式打开”。 +- [x] 文件树 Office asset 菜单增加“使用编辑模式打开”。 +- [x] 编辑模式入口明确使用 `mode=edit` 打开到主 resource tab 或新窗口,行为与现有打开目标一致。 +- [x] 在 local-folder save callback 未闭环时,编辑入口有 guard:可提示“编辑保存仍在实验中”,或通过 data/status 标记便于测试识别。 +- [x] 已存在 Office resource tab 从 `mode=view` 切到显式 `mode=edit` 时,刷新同一 tab 的 iframe URL,而不是只激活旧 tab。 +- [ ] 若启用 `onRequestEditRights`,必须重新初始化为 edit URL,不只 reload 当前 view。(P1,未实现) + +## 验收 + +- `cargo test -p mnote-web sidebar_tree_js -- --test-threads=1` +- `cargo test -p mnote-web onlyoffice -- --test-threads=1` +- 浏览器截图:默认打开是只读;菜单中存在编辑入口;点击编辑入口后的页面 URL / debug state 包含 `mode=edit` 或 guard 提示。 + +## 本轮执行记录 + +- 2026-05-21:Reasonix worker B 卡在计划阶段后由 Codex 终止;本条实现来自其它 Reasonix 结果与 Codex 复核修正。 + - `buildOnlyOfficeOpenUrl` 默认 mode 从 `'edit'` 改为 `'view'`。 + - `buildOnlyOfficeOpenPath` 默认 mode 从 `'edit'` 改为 `'view'`。 + - `buildLocalOnlyOfficeOpenUrl` 默认 mode 从 `'edit'` 改为 `'view'`。 + - `normalizeOnlyOfficeAttachmentHref` 默认 mode 从 `'edit'` 改为 `'view'`。 + - `detailFromEditorAttachmentLink` 默认 mode 从 `'edit'` 改为 `'view'`。 + - `enhanceEditorAttachmentLink` 默认 mode 从 `'edit'` 改为 `'view'`。 + - 正文附件菜单(`openTreeContextMenu` `isAttachment` 分支)增加菜单项 `{ action: 'open-edit-mode', icon: 'edit_note', label: '使用编辑模式打开' }`,放置于"在新窗口打开"之前。 + - 文件树 asset 菜单(`isAsset` 分支)增加相同的 `open-edit-mode` 菜单项。 + - 新增 `withOfficeEditModeGuard()` 守卫函数: + - 设置 `data-mnote-last-office-edit-mode-requested="true"` 和 `data-mnote-last-office-edit-mode-guard="shown"` 以支持浏览器测试识别。 + - 弹出 `window.confirm('编辑保存仍在实验中,建议先备份文件。是否继续?')` 对话框,用户确认后才继续。 + - `handleTreeContextMenuAction` 中 attachment 和 isAsset 分支分别处理 `open-edit-mode` 动作,调用 guard 后再以 `mode=edit` 打开。 + - Codex 修正:普通“在新窗口打开”保持 `mode=view`;只有“使用编辑模式打开”才传入 `mode=edit`。 + - Codex 补充 `sidebar_tree_runtime_opens_office_assets_through_resource_shell` 契约测试,覆盖默认 view、显式 edit、guard 标记和 edit URL 重写。 + - `cargo test -p mnote-web sidebar_tree_runtime_opens_office_assets_through_resource_shell -- --test-threads=1` 通过。 + - `cargo test -p mnote-web onlyoffice -- --test-threads=1` 通过。 +- 2026-05-21:Codex 浏览器复核发现并修复“已有 Office tab 切 edit 不刷新”。 + - 根因:`openResourceInActiveTab()` 遇到已存在 `objectIdentity` 时只调用 `activateMainEditorTab(objectIdentity)`,没有更新旧 iframe 的 `officeUrl`。 + - 修复:`web_shell.rs` 新增 `refreshExistingOfficeResourceTab()`,仅对 `entry.kind === 'office'` 且新旧 `href/officeUrl` 不一致时重建 passive iframe。 + - 回归:`scripts/task463-onlyoffice-resolver-smoke.js` 新增 Test 1b,断言同一 docx tab 从 `mode=view` 刷新到 `mode=edit`,且不会创建第二个 Office tab。 + - Codex clean browser 自测通过:中文 docx 默认 view;同 tab edit 后 iframe URL `mode=edit`;证据见 `tmp/codex-office-view-edit-plugin-2026-05-21/result.json`。 diff --git a/bugs/05-editor-mainline/process/5-36-onlyoffice-local-edit-save-callback-contract-v1.md b/bugs/05-editor-mainline/process/5-36-onlyoffice-local-edit-save-callback-contract-v1.md new file mode 100644 index 00000000..f22b7263 --- /dev/null +++ b/bugs/05-editor-mainline/process/5-36-onlyoffice-local-edit-save-callback-contract-v1.md @@ -0,0 +1,66 @@ +# 5-36 OnlyOffice local-folder 编辑保存 callback 契约 + +## 目标 + +为 local-folder Office 编辑保存建立 callback 写回契约。P1 可以先落设计和测试骨架;若实现,必须覆盖 status 2/6 的下载写回和路径安全。 + +## 原因 + +ONLYOFFICE 官方保存链路要求 callback `status === 2` 或 `status === 6` 时,集成后端下载 `body.url` 并写回原文件。当前 `mnote-web` callback 仍代理 legacy Next,local-folder 写回未闭环。 + +## 允许修改 + +- `rust/crates/mnote-web/src/routes/onlyoffice.rs` +- `rust/crates/adapter-onlyoffice/src/lib.rs` +- 相关单测 +- 如需记录设计,可补充本 checklist + +## 禁止事项 + +- 不绕过 allowed roots 写任意路径。 +- 不在没有冲突保护时覆盖用户无关文件。 +- 不强行删除 legacy callback 代理。 + +## Checklist + +- [ ] 明确 local asset callback 定位:从 `assetId` / `fileUrl` / session 推导 rootUri 与 path。 +- [ ] status 非 2/6 时返回 `{ "error": 0 }`,不写文件。 +- [ ] status 2/6 时下载 rewritten `body.url`。 +- [ ] 写回前校验目标路径属于当前 local root allowed roots。 +- [ ] 写回后触发 watcher / projection 刷新或说明现有 watcher 如何感知。 +- [ ] 若本轮不实现完整写回,必须在编辑入口保留 guard,不让用户以为保存已支持。 + +## 验收 + +- `cargo test -p mnote-web onlyoffice_callback -- --test-threads=1` +- `cargo test -p mnote-web -- --test-threads=1` +- 后续浏览器编辑保存验收需单独设计,不纳入 P0。 + +## 本轮执行记录 + +- 2026-05-21:Reasonix worker C 分析完成。 + **结论:本轮不应实现完整写回。** + + **阻塞输入缺口:** + + 1. callback URL 当前只携带 `assetId` 和 `userId`,缺少 `rootUri` 和 `path`。 + - `buildCallbackUrl()` 在 `onlyoffice.rs` 页脚 JS 中硬编码只传两个参数。 + - 对于 local-folder asset(assetId `local:asset:path/to/file.docx`),无法从 assetId 推导 rootUri。 + - 没有 rootUri 就无法 `resolve_onlyoffice_local_file_path` → 无法安全写回。 + 2. `adapter-onlyoffice` 的 `prepare_callback` 不感知 local-folder 路径。 + - `OnlyOfficeCallbackPreparationInput` 没有 rootUri / path 字段。 + - `prepare_callback` 只做下载 URL rewrite 和 session 定位,不含路径校验。 + 3. callback Rust handler 的 `proxy_legacy_onlyoffice_json` 目前代理 legacy Next;local-folder 无 legacy 时返回 NOT_IMPLEMENTED。 + + **实现写回所需的最小增量(下轮实现):** + + - `page()` 页脚的 `buildCallbackUrl()`:检测 local asset,追加 `rootUri` 和 `path` query 参数。 + - `OnlyOfficeCallbackQuery` struct:增加 `root_uri: Option` 和 `path: Option`。 + - `callback()` handler:检测到 local asset 时(assetId 含 `local:` / `local-file:` 前缀),从 query 提取 rootUri 和 path,调用 `resolve_onlyoffice_local_file_path` 校验路径在 allowed roots 内。 + - `adapter-onlyoffice`:在 `OnlyOfficeCallbackPreparationInput` 和 `prepare_callback` 增加 local path 字段。 + - status 2/6:下载 rewritten body.url,写回校验过的本地路径。 + - status 非 2/6:返回 `{"error": 0}`,不写文件。 + + **当前 guard 状态:** + - Worker B 已在 `layout.rs` 中实现 `withOfficeEditModeGuard`:confirm 对话框 + `data-mnote-last-office-edit-mode-requested` / `data-mnote-last-office-edit-mode-guard` 数据属性。 + - 编辑入口在 local-folder writeback 未闭环时带 guard 提示,符合安全策略。 diff --git a/bugs/05-editor-mainline/process/5-37-onlyoffice-annotation-plugin-noise-policy-v1.md b/bugs/05-editor-mainline/process/5-37-onlyoffice-annotation-plugin-noise-policy-v1.md new file mode 100644 index 00000000..2913bfd9 --- /dev/null +++ b/bugs/05-editor-mainline/process/5-37-onlyoffice-annotation-plugin-noise-policy-v1.md @@ -0,0 +1,51 @@ +# 5-37 OnlyOffice annotation 插件噪音策略 + +## 目标 + +定位 OnlyOffice annotation / custom assistant 插件 404 或 pageerror 来源,并把它从主打开失败中剥离:能禁用则禁用,不能禁用则在测试和日志中明确为 non-critical。 + +## 原因 + +ONLYOFFICE 插件通过 `editorConfig.plugins.autostart` 和 `pluginsData` 注入。缺失插件资源会产生 404/pageerror,但不应影响主文档渲染、WebSocket 或只读预览。 + +## 允许修改 + +- `rust/crates/mnote-web/src/routes/onlyoffice.rs` +- `src/components/onlyoffice/` 下与自定义插件配置直接相关的文件 +- 相关测试和 smoke 断言 + +## 禁止事项 + +- 不删除 OnlyOffice 主静态资源。 +- 不把所有 console error 都静默吞掉。 +- 不把主文档渲染失败降级为插件噪音。 + +## Checklist + +- [x] 找到 annotation/custom assistant 插件配置或请求来源。 +- [x] 若 MNote 当前不依赖该插件,禁用无效 autostart / pluginsData。(确认当前代码已无 autostart 注入,无需额外禁用) +- [x] 若属于 OnlyOffice 内置可选插件缺资源,记录为 non-critical,并更新浏览器测试过滤口径。 +- [ ] 补测试或文档,确保 `errorCode=-18` / WebSocket 失败仍被视为失败,不被插件噪音掩盖。(P1,后续补充) + +## 验收 + +- `cargo test -p mnote-web onlyoffice -- --test-threads=1` +- Reasonix clean browser 测试的 console/network 摘要能区分插件 404 与主打开失败。 + +## 本轮执行记录 + +- 2026-05-21:Reasonix worker D 分析完成。 + - **来源确认**:MNote 后端代码(`onlyoffice.rs`)**没有**在 `editorConfig` 中设置 `plugins.autostart` 或 `plugins.pluginsData`。`MNOTE_AGENT_PLUGIN_GUID` 常量虽定义但未被任何配置引用。 + - 旧的 `recycle/wolai-frontend/` 中存在设置 `autostart: [MNOTE_AGENT_PLUGIN_GUID]` 和 `pluginsData: [pluginConfigUrl]` 的代码,但那是已废弃的前端实现。 + - 当前 `onlyoffice.rs` 的 `page()` 函数生成的 JS 中 `editorConfig` 无 `plugins` 字段。 + - `src/components/onlyoffice/onlyoffice-plugins/` 下有 OnlyOffice 内置的 annotation / custom-assistant 插件静态文件(GUID `{9DC93CDB-B576-4F0C-B55E-FCC9C48DD007}`),但这些是 OnlyOffice 服务端自带的插件发现机制,非 MNote 注入。 + - **处理策略**: + 1. 404/pageerror 属于 OnlyOffice DocumentServer 内置可选插件自动发现的非关键链路,不影响主文档渲染。 + 2. 不需要禁用代码层面的 `autostart`——因为当前代码已不注入任何插件。 + 3. 在浏览器测试和日志分析中,将 annotation/custom-assistant 相关 404 标记为 non-critical noise,**不**等同于文档打开失败。 + 4. 保持 `errorCode=-18`(WebSocket 连接失败)和主 iframe 空白仍视为真正失败。 + - 未修改代码文件。 +- 2026-05-21:Reasonix 与 Codex 浏览器复核一致。 + - Reasonix 结果:`tmp/reasonix-office-view-edit-plugin-2026-05-21/result.json` 中插件 404 / `CustomAssistantManager is not defined` 存在,但 Office iframe 默认 view 可打开。 + - Codex 结果:`tmp/codex-office-view-edit-plugin-2026-05-21/result.json` 中同样捕获 `/sdkjs-plugins/{9DC93CDB-B576-4F0C-B55E-FCC9C48DD007}/...` 404;默认 view 与显式 edit 的主 iframe 均可建立。 + - 当前结论保持:该类插件错误作为 non-critical noise 记录;不应掩盖 `errorCode=-18`、主 iframe 空白、文档加载失败等真正失败。 diff --git a/design/07-ai/process/7-30-acp-session-load-resume-checklist-v1.md b/design/07-ai/process/7-30-acp-session-load-resume-checklist-v1.md new file mode 100644 index 00000000..a27265c8 --- /dev/null +++ b/design/07-ai/process/7-30-acp-session-load-resume-checklist-v1.md @@ -0,0 +1,53 @@ +# 7-30 ACP session/load 恢复 checklist v1 + +> 状态:process +> +> Owner:ACP session lifecycle +> +> 参考:`reference-code/hermes-vscode-main/src/sessionManager.ts` + +## 目标 + +让 MNote ACP runtime 在恢复已有 ACP session 时真正调用 adapter 的 `session/load`,避免只恢复 MNote 会话历史而产生 phantom ACP session。 + +## 允许修改范围 + +- `rust/crates/mnote-web/src/acp_session_manager.rs` +- `rust/crates/mnote-web/src/routes/hermes_client.rs` +- 与 session load/resume 直接相关的 Rust 测试 + +## 禁止事项 + +- 不改 Page AI 前端 UI。 +- 不改 Convex schema。 +- 不删除或覆盖现有 local AI session JSONL 逻辑。 + +## Checklist + +- [x] 在 `AcpSessionManager` 增加 `load_session(session_id, cwd)` 或等价能力,参数与 Hermes VSCode 一致:`session/load { sessionId, cwd, mcpServers: [] }`。 +- [x] load 成功时把当前 ACP session id 设置为 loaded id;load 返回 null / error 时允许 fallback 到 `session/new`。 +- [ ] 明确区分 MNote session id 与 ACP adapter session id;需要持久化/返回时字段名不能混淆。 +- [x] ACP run 启动处优先尝试恢复已知 ACP session id,再创建新 session。 +- [x] 补 mock ACP 测试覆盖 `session/load` 成功、返回 null fallback、新 session 仍可 prompt。 +- [x] 更新本文件“执行记录”。 + +## 验收命令 + +```bash +cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web acp_session_manager -- --test-threads=1 +cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web hermes_client_acp_resume -- --test-threads=1 +``` + +## 执行记录 + +- 2026-05-20 22:06 — Reasonix Code 执行,Codex 复核并通过聚焦测试。 + - `acp_types.rs`: 新增 `SessionLoadParams` / `SessionLoadResult` 类型。 + - `acp_session_manager.rs`: + - 新增 `load_session(session_id, cwd)` → 调用 ACP `session/load`,成功时设置 internal session_id,null/error 返回 false。 + - 新增 `ensure_session(cwd, stored_session_id)` → 1) 复用已有 session,2) 有 stored id 时尝试 load,3) fallback 到 `session/new`。 + - 新增 7 个单元测试覆盖所有路径:load 成功、null fallback、复用 active、无 stored 创建新 session、fallback 后 prompt 仍可工作。 + - `hermes_client.rs`: + - `acp_stream_events()` 中从 payload 提取 `acpSessionId`,传给 `ensure_session()` 替代原来的 `create_session`。 + - 错误码从 `acp_session_create_failed` 改为 `acp_session_ensure_failed`。 + - Codex 复核:当前 `acpSessionId` 只从 run payload 读取,前端/session store 仍未稳定持久化并传回该字段,因此“session id 字段全链路区分与持久化”保留未完成。 +- 验收:`cargo test -p mnote-web acp_session_manager -- --test-threads=1`、`cargo test -p mnote-web hermes_client_acp_resume -- --test-threads=1`、`cargo test -p mnote-web acp -- --test-threads=1` 通过。 diff --git a/design/07-ai/process/7-31-acp-permission-decision-loop-checklist-v1.md b/design/07-ai/process/7-31-acp-permission-decision-loop-checklist-v1.md new file mode 100644 index 00000000..2c223807 --- /dev/null +++ b/design/07-ai/process/7-31-acp-permission-decision-loop-checklist-v1.md @@ -0,0 +1,52 @@ +# 7-31 ACP permission 决策闭环 checklist v1 + +> 状态:done +> +> Owner:ACP incoming request / permission loop +> +> 参考:`reference-code/hermes-vscode-main/src/acpClient.ts`、`src/sessionManager.ts` + +## 目标 + +把 `session/request_permission` 从"后端立即 unsupported/deny"改成"后端发出 pending request,前端 allow/deny,后端回写 JSON-RPC response"的真实闭环;超时仍默认 deny。 + +## 允许修改范围 + +- `rust/crates/mnote-web/src/acp_client.rs` +- `rust/crates/mnote-web/src/acp_session_manager.rs` +- `rust/crates/mnote-web/src/acp_bridge.rs` +- `rust/crates/mnote-web/src/routes/hermes_client.rs` +- 只允许为接线 permission 按需小改 `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- 相关 Rust 测试 + +## 禁止事项 + +- 不把权限结果存为新的长期正文/附件真相。 +- 不默认 allow;超时、缺失 run、缺失 pending request 都必须 deny 或返回清晰错误。 +- 不大改 Page AI drawer 视觉结构。 + +## Checklist + +- [x] `AcpClient` 支持注册 incoming request handler,并允许 handler 返回 JSON-RPC result/error。 +- [x] `session/request_permission` 进入 pending map,生成稳定 permission id,向 SSE 发 `permission.requested`。 +- [x] 增加 HTTP endpoint 或现有 endpoint 扩展:前端可按 run id / permission id 提交 allow/deny。 +- [x] 提交 allow/deny 后,ACP JSON-RPC request 得到对应 response;前端收到 `permission.allowed` / `permission.denied`。 +- [x] 超时默认 deny,并向 UI 和 runtime event 写明 timeout。 +- [ ] 补 mock ACP 测试:incoming request 等待决策、allow 返回 result、deny 返回 result/error、timeout deny。 +- [x] 更新本文件"执行记录"。 + +## 验收命令 + +```bash +cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web acp_client -- --test-threads=1 +cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web acp_bridge -- --test-threads=1 +cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --test-threads=1 +``` + +## 执行记录 + +- 2026-05-20:Reasonix 实施,Codex 复核并修正 JSON-RPC id 保真与 permission response 语义。 +- 改动点:`AcpClient` 增加 `on_incoming_request()` / `respond_to_incoming()` / `respond_to_incoming_error()`;`dispatch_message` 将 incoming request 转发给 handler 而非立即 error;`AcpSessionManager.new()` 注册 incoming request handler,将 `session/request_permission` 存入 pending map + 发射 `permission.requested` + 启动 60s 超时自动 deny;`AcpSessionManager` 增加 `resolve_permission()` 方法;`acp_bridge` SSE 转换支持 `permission.requested`;`hermes_client.rs` 增加 `resolve_permission` HTTP endpoint;`routes/mod.rs` 注册路由;`layout.rs` 中 `pageAiResolvePermission` 调用后端。 +- Codex 修正:incoming JSON-RPC request id 使用原始 `serde_json::Value` 保存和回写,避免字符串 id 被改成 0;allow/deny 正常路径改为参考 Hermes VSCode 插件的 `{ outcome: "selected", optionId }`,并补 `permission_response_selects_allow_and_deny_options` 测试。 +- 未完成:已有 `acp_client` incoming request 基础测试和 option 选择 helper 测试,但还缺“mock ACP 发 permission -> HTTP resolve endpoint -> agent 收到 selected option”的端到端测试,故该 checklist 的 mock 闭环项保留未完成。 +- 验收:`cargo test -p mnote-web acp_client -- --test-threads=1`、`cargo test -p mnote-web acp_bridge -- --test-threads=1`、`cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --test-threads=1`、`cargo test -p mnote-web acp -- --test-threads=1` 通过。 diff --git a/design/07-ai/process/7-32-acp-tool-location-and-open-action-checklist-v1.md b/design/07-ai/process/7-32-acp-tool-location-and-open-action-checklist-v1.md new file mode 100644 index 00000000..5a9eee48 --- /dev/null +++ b/design/07-ai/process/7-32-acp-tool-location-and-open-action-checklist-v1.md @@ -0,0 +1,54 @@ +# 7-32 ACP tool locations 与打开动作 checklist v1 + +> 状态:process +> +> Owner:ACP tool event data + Page AI open action +> +> 参考:`reference-code/hermes-vscode-main/src/protocol.ts`、`src/chatPanel.ts` + +## 目标 + +保留 ACP `tool_call.locations[]`,在 Page AI tool card 中展示位置,并对 read/edit 完成事件提供打开动作。默认打开到 MNote 主编辑器资源 tab / 当前工作区 tab,不默认新浏览器窗口。 + +## 允许修改范围 + +- `rust/crates/mnote-web/src/acp_types.rs` +- `rust/crates/mnote-web/src/acp_session_manager.rs` +- `rust/crates/mnote-web/src/acp_bridge.rs` +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- 相关 Rust 测试和必要 smoke 脚本 + +## 禁止事项 + +- 不重写资源 tab registry。 +- 不新增与 Sidex workbench checklist 冲突的 editor group 模型。 +- 不默认 `window.open`。 +- 不绕过 existing allowed root / local workspace access。 + +## Checklist + +- [x] `SessionUpdate::ToolCall` 解析 `locations: [{ path }]`。 +- [x] `AcpSessionEvent::ToolCall` 携带 locations,并通过 SSE `tool.started` 发送。 +- [x] `tool.completed` 能保留或关联此前相同 `toolCallId` 的 locations/kind/title。 +- [x] Page AI tool card 展示 location 摘要。 +- [x] 对 read/edit completed tool card 增加“打开”动作,优先复用已有 resource tab / file open resolver。 +- [ ] 补测试覆盖 locations parse、SSE 数据、前端字符串断言或 smoke。 +- [x] 更新本文件“执行记录”。 + +## 验收命令 + +```bash +cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web acp_tool -- --test-threads=1 +cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --test-threads=1 +``` + +## 执行记录 + +- 2026-05-20 Reasonix Code 实现: + - `acp_types.rs`: 新增 `ToolLocation` 结构体(含 `path` 字段),`SessionUpdate::ToolCall` 新增 `locations: Vec`(含反序列化支持)。 + - `acp_session_manager.rs`: `AcpSessionEvent::ToolCall` 新增 `locations: Vec`,`session_update_to_event` 中映射 locations。 + - `acp_bridge.rs`: SSE `tool.started` 事件数据包含 `locations` 数组,桥测试中验证。 + - `layout.rs` 前端:SSE 消息解析提取 `locations`;`pageAiApplyToolEvent` 存储 `locations`;tool card 渲染显示位置路径与"打开"按钮;点击按钮调用 `openLocalResourceInActiveTab` 优先复用主编辑器资源 tab,失败则 fallback 为 `window.open`。 + - 测试覆盖:`test_session_update_tool_call_with_locations`(parse)、`acp_tool_events_keep_detail_for_collapsible_ui` 验证 SSE locations 字段、`page_ai_uses_backend_acp_session_runtime_store` 验证布局完整性。 + - Codex 复核:代码层已通过,但尚未做真实浏览器 smoke 点击 tool location 打开动作;因此“前端 smoke”保留未完成。 + - 验收通过:`cargo test -p mnote-web acp_bridge -- --test-threads=1`、`cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --test-threads=1`、`cargo test -p mnote-web acp -- --test-threads=1`。 diff --git a/design/07-ai/process/7-33-acp-session-info-plan-ui-checklist-v1.md b/design/07-ai/process/7-33-acp-session-info-plan-ui-checklist-v1.md new file mode 100644 index 00000000..8178c0bc --- /dev/null +++ b/design/07-ai/process/7-33-acp-session-info-plan-ui-checklist-v1.md @@ -0,0 +1,49 @@ +# 7-33 ACP session info / plan UI checklist v1 + +> 状态:process +> +> Owner:ACP metadata SSE + Page AI lightweight UI +> +> 参考:`reference-code/hermes-vscode-main/src/sessionManager.ts`、`src/webview/renderers.ts` + +## 目标 + +把已解析但被丢弃的 `session_info_update` 和 `plan` 事件送到 Page AI UI:自动标题更新当前会话;plan/todo 以轻量状态面板或系统消息展示,不混入正文真相。 + +## 允许修改范围 + +- `rust/crates/mnote-web/src/acp_bridge.rs` +- `rust/crates/mnote-web/src/routes/hermes_client.rs` +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- 相关 Rust 测试 + +## 禁止事项 + +- 不改 ACP parser 类型,除非发现现有 PlanEntry 无法表达必要字段。 +- 不实现完整 Phase C review session。 +- 不把 plan/todo 写入页面正文。 + +## Checklist + +- [x] `AcpSessionEvent::SessionInfoUpdate` 转成 `session.info.updated` SSE。 +- [x] `AcpSessionEvent::PlanUpdate` 转成 `plan.updated` SSE,data 包含 entries。 +- [x] Page AI 收到 session info 后更新当前 session title,并持久化 localStorage/local session view。 +- [x] Page AI 收到 plan 后渲染轻量 plan 状态(collapsible details/summary + 编号列表),不当作普通 assistant 正文。 +- [ ] tool raw output 中包含 todo JSON 时,可以复用 plan 区域或 tool card 状态显示。(风险可控,todo JSON 解析需额外确认格式约定;建议后续补) +- [x] 补 bridge 单测和 `SIDEBAR_TREE_JS` 字符串断言。 +- [x] 更新本文件“执行记录”。 + +## 验收命令 + +```bash +cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web acp_bridge -- --test-threads=1 +cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --test-threads=1 +``` + +## 执行记录 + +- 2026-05-20 Reasonix:桥接 SessionInfoUpdate → `session.info.updated` SSE、PlanUpdate → `plan.updated` SSE。 +- 2026-05-20 Reasonix:前端 SSE handler 解析 session.info.updated 更新当前 session title 并持久化;plan.updated 在 pageAiMessages 末尾创建/更新 kind=plan 消息。 +- 2026-05-20 Reasonix:renderPageAiConversation 添加 kind===plan 渲染分支:collapsible details/summary 面板显示编号步骤列表。 +- 2026-05-20 Reasonix:acp_bridge 添加两个单测(session_info_update / plan_update);SIDEBAR_TREE_JS 添加 4 个断言。 +- 2026-05-20 Codex 复核:todo JSON 格式尚未确认,保留为未完成项;其余已通过 `cargo test -p mnote-web acp_bridge -- --test-threads=1`、`cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --test-threads=1`、`cargo test -p mnote-web acp -- --test-threads=1`。 diff --git a/design/10-review/process/13-hermes-vscode-plugin-gap-review-v1.md b/design/10-review/process/13-hermes-vscode-plugin-gap-review-v1.md new file mode 100644 index 00000000..ba06bee2 --- /dev/null +++ b/design/10-review/process/13-hermes-vscode-plugin-gap-review-v1.md @@ -0,0 +1,99 @@ +# 13 Hermes VSCode 插件对照审查 v1 + +> 状态:process +> +> 日期:2026-05-20 +> +> 范围:`reference-code/hermes-vscode-main` 对照 MNote 当前 `mnote-web` Hermes / ACP / Page AI 实现。 + +## 1. 审查方法 + +本轮先以 Hermes VSCode 插件为参考实现,核查它对 ACP runtime、会话恢复、权限请求、tool call、上下文注入和 Webview 控制面的处理方式,再对照 MNote 当前 Rust Web 主链: + +- 参考实现:`src/acpClient.ts`、`src/sessionManager.ts`、`src/protocol.ts`、`src/chatPanel.ts`、`src/sessionStore.ts`、`src/webview/main.ts`、`src/webview/renderers.ts`、`src/modelCatalog.ts`、`src/skillCatalog.ts`。 +- MNote 实现:`rust/crates/mnote-web/src/acp_client.rs`、`acp_session_manager.rs`、`acp_bridge.rs`、`routes/hermes_client.rs`、`ssr/pages/layout.rs`。 +- 审查口径:不把 VSCode UI 状态照搬为 MNote 真相源;MNote 继续遵守 local-first、Rust Web 承载、Page AI 文件引用 + agent 原生 patch/diff + watcher 同步主线。 + +## 2. Hermes VSCode 可借鉴模型 + +- `SessionManager.ensureSession()` 在已有 `storedSessionId` 时必须调用 ACP `session/load`。仅把旧 session id 写回内存会产生 phantom session,后续 `session/prompt` 可能静默失败。 +- `AcpClient` 区分 JSON-RPC response、notification 和 incoming request;`session/request_permission` 通过 handler 返回真实 allow / deny 结果。 +- `session/update` 中的 `tool_call.locations[]` 会被解析保存;`tool_call_update completed` 后,`read` 打开 preview editor,`edit` 打开 persistent editor。 +- `session_info_update` 会更新会话标题;`plan` / todo tool 输出可渲染为任务 overlay,而不是丢弃。 +- Webview 对 slash command 有 allowlist,slash 响应渲染为居中的 system message,不混入普通 user / assistant 泡泡。 +- model / skill picker 来自 Hermes 本地缓存和 `SKILL.md` 目录,作为 agent 控制面的一部分。 +- Prompt 注入包含 cwd、active file、selection、open tabs、附件引用和 skills advisory,不把这些上下文当成长期正文真相。 + +## 3. MNote 当前状态 + +- Rust `AcpSessionManager` 已有 `session/new`、`session/prompt`、`session/cancel`、streaming dedup、typed event 和 usage / thought / tool / plan / session info 解析雏形。 +- `acp_bridge.rs` 已把 TextDelta、ThoughtDelta、ToolCall、ToolCallUpdate、UsageUpdate、PermissionRequest 转成 SSE,但 `SessionInfoUpdate` 和 `PlanUpdate` 当前仍被丢弃。 +- `layout.rs` Page AI 已有 session list/detail/search/resume/rename/delete、localStorage cache、Convex/local ACP runtime store、permission dialog、tool event UI、profile/skills 控制面和 usage summary。 +- `routes/hermes_client.rs` 已有 local-first AI session JSONL、Convex ACP runtime store、run queue、abort 和 local agent audit。 +- 当前 `resume_session` 只恢复 MNote/Convex/local 会话历史,不等同于对 ACP adapter 调用 `session/load` 注册底层 ACP session。 +- 当前 `AcpClient` 对 incoming `session/request_permission` 仍立即返回 unsupported/deny,前端按钮没有真正回传 ACP 决策。 +- 当前 `SessionUpdate::ToolCall` 类型未保留 `locations`,因此无法像 Hermes VSCode 一样把 read/edit 工具结果联动到主编辑器 tab。 + +## 4. P0 缺口 + +### P0-1 ACP `session/load` 恢复缺失 + +MNote 有会话列表、恢复按钮和 runtime store,但恢复更多是 MNote 会话历史层的恢复。对 ACP runtime 来说,若没有对 adapter 调用 `session/load { sessionId, cwd, mcpServers: [] }`,后续 prompt 可能对应不到 agent 侧真实 session。 + +建议:在 `AcpSessionManager` 增加 stored ACP session id / `load_session` / `ensure_session` 能力;在 `hermes_client` 的 ACP run 启动处尽量使用已保存 ACP session id 进行 load,失败再 `session/new`。 + +### P0-2 权限请求缺少真实 allow / deny 闭环 + +前端存在 permission dialog,但后端 `AcpClient` 对 incoming request 立即返回 JSON-RPC error,再广播 denied。用户点击允许/拒绝不能影响当前 ACP 请求。 + +建议:`AcpClient` 增加 incoming request handler;`AcpSessionManager` 把 `session/request_permission` 转成 pending request;`routes/hermes_client` 提供按 run/session/permission id 决策的短生命周期接口;超时默认 deny。 + +### P0-3 tool `locations[]` 与 read/edit 打开联动缺失 + +Hermes VSCode 会保存 tool locations,并在 read/edit 完成后打开对应文件。MNote 当前 tool SSE 只有 raw input / output,不保留 `locations`,也没有把 tool 完成联动到 main editor resource tab / file tree target。 + +建议:扩展 ACP 类型、event 和 SSE 数据,保留 `locations`;前端对 completed read/edit 工具提供“打开”动作,优先复用现有 main editor resource tab,不默认新浏览器窗口。 + +### P0-4 `session_info_update` / `plan` 被桥接层丢弃 + +MNote 已在 `AcpSessionManager` 解析 `SessionInfoUpdate` 和 `PlanUpdate`,但 `acp_bridge` 当前返回 `None`。会话自动标题、agent plan/todo 不能进入 UI,也不能进入 runtime event 记录。 + +建议:转发 `session.info.updated` 和 `plan.updated` SSE;前端更新当前会话标题,并将 plan/todo 作为轻量状态面板或 tool/system message 渲染。 + +## 5. P1 缺口 + +- slash command 响应仍更像普通聊天消息,缺少参考实现中的 system message 视觉语义和 allowlist 明确边界。 +- skills / model 控制面已存在,但未完全对齐 Hermes 本地 model cache 与 `SKILL.md` 目录;需要确认是否存在过期 profile / skill 混用。 +- busy queue / interrupt cancel 已有队列和 stop,但与参考实现“第二条消息排队、interrupt 立即 unblock”的交互语义仍需 smoke 覆盖。 +- Page AI session restore 需要区分 MNote session id、ACP adapter session id、run id,避免 UI 把任意历史 session 当作可继续底层 ACP 对话。 +- tool raw output 中的 todo JSON 还没有独立 overlay,计划状态只能混在 tool card 中。 + +## 6. P2 缺口 + +- Webview markdown rendering / sanitization 参考实现使用 `marked` + `DOMPurify`;MNote 当前对 assistant 消息仍偏纯文本逃逸,后续可评估富文本安全渲染。 +- active file / selection / open tabs 上下文注入需要继续与 MNote editor group / resource tab 模型对齐。 +- Hermes 插件中 attachments 以 `[Referenced file: path]` 进入 prompt;MNote local-first 已有文件 scope,但附件引用提示仍可更清晰。 +- session store 的 max 20 sessions / max 300 messages 可作为 MNote localStorage/local JSONL 裁剪策略参考。 + +## 7. 不建议照搬 + +- 不照搬 VSCode Webview 的 DOM/CSS 结构;MNote 仍使用 Wolai 主题 Page AI drawer。 +- 不把 VSCode `workspaceState` 当作 MNote 会话真相;local-first 下本地 JSONL / Convex control-plane / runtime registry 各自职责不变。 +- 不把 Hermes 插件中的自动打开文件行为做成不可控跳转;MNote 应默认在 main editor resource tab 打开,并保留显式新窗口入口。 +- 不在 UI 层新增第二套文件权限真相;权限和 allowed roots 仍由 Rust Web / local-first scope 决定。 +- 不把 Page AI block-edit workflow 扩成普通 Markdown 主路径;local folder 继续走文件引用 + agent 原生 patch/diff + watcher 同步。 + +## 8. 执行拆分 + +本 review 先拆四份 P0 checklist,允许并行推进: + +- `design/07-ai/process/7-30-acp-session-load-resume-checklist-v1.md` +- `design/07-ai/process/7-31-acp-permission-decision-loop-checklist-v1.md` +- `design/07-ai/process/7-32-acp-tool-location-and-open-action-checklist-v1.md` +- `design/07-ai/process/7-33-acp-session-info-plan-ui-checklist-v1.md` + +P0 收口后再拆 P1: + +- slash/system message 与 model/skill 控制面收口。 +- queue / interrupt cancel browser smoke。 +- assistant markdown 安全渲染评估。 diff --git a/rust/Cargo.lock b/rust/Cargo.lock index b276a942..49f3f76a 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1535,6 +1535,8 @@ dependencies = [ "comrak", "core-protocol", "futures-util", + "hyper", + "hyper-util", "leptos", "mnote-editor-core", "notify", diff --git a/rust/crates/mnote-web/Cargo.toml b/rust/crates/mnote-web/Cargo.toml index ca21dfb4..ec371ec7 100644 --- a/rust/crates/mnote-web/Cargo.toml +++ b/rust/crates/mnote-web/Cargo.toml @@ -11,6 +11,8 @@ axum = { version = "0.8", features = ["multipart", "ws"] } bridge-runtime = { path = "../bridge-runtime" } core-protocol = { path = "../core-protocol" } futures-util = "0.3" +hyper = "1" +hyper-util = { version = "0.1", features = ["tokio"] } leptos = { version = "0.8.14", default-features = false, features = ["ssr"] } mnote-editor-core = { path = "../mnote-editor-core" } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "stream"] } diff --git a/rust/crates/mnote-web/src/acp_bridge.rs b/rust/crates/mnote-web/src/acp_bridge.rs index 729a201b..3dc7346c 100644 --- a/rust/crates/mnote-web/src/acp_bridge.rs +++ b/rust/crates/mnote-web/src/acp_bridge.rs @@ -196,6 +196,7 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option { kind, status, raw_input, + locations, } => Some(SseEvent { event: "tool.started".into(), data: json!({ @@ -204,6 +205,7 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option { "kind": kind, "status": status, "input": raw_input, + "locations": locations, }), }), AcpSessionEvent::ToolCallUpdate { @@ -238,25 +240,30 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option { tool_name, params, decision, - } => Some(SseEvent { - event: if decision == "allowed" { - "permission.allowed".into() - } else { - "permission.denied".into() - }, - data: json!({ - "permissionId": permission_id, - "toolName": tool_name, - "params": params, - "decision": decision, - }), + } => { + let event = match decision.as_str() { + "allowed" => "permission.allowed", + "requested" => "permission.requested", + _ => "permission.denied", + }; + Some(SseEvent { + event: event.into(), + data: json!({ + "permissionId": permission_id, + "toolName": tool_name, + "params": params, + "decision": decision, + }), + }) + } + AcpSessionEvent::SessionInfoUpdate { title } => Some(SseEvent { + event: "session.info.updated".into(), + data: json!({ "title": title }), + }), + AcpSessionEvent::PlanUpdate { entries } => Some(SseEvent { + event: "plan.updated".into(), + data: json!({ "entries": entries }), }), - AcpSessionEvent::SessionInfoUpdate { .. } => { - None // Not forwarded to frontend - } - AcpSessionEvent::PlanUpdate { .. } => { - None // Not forwarded (Phase C) - } AcpSessionEvent::Disconnected { reason } if reason == "session closed" => None, AcpSessionEvent::Disconnected { reason } => Some(SseEvent { event: "run.failed".into(), @@ -327,6 +334,37 @@ mod tests { assert_eq!(sse.data["decision"], "denied"); } + #[test] + fn acp_permission_requested_emits_permission_requested_event() { + let event = AcpSessionEvent::PermissionRequest { + permission_id: "perm_2".into(), + tool_name: "mnote.page.get".into(), + params: json!({"documentId": "doc_2"}), + decision: "requested".into(), + }; + + let sse = acp_event_to_sse(event).expect("permission requested should be forwarded"); + assert_eq!(sse.event, "permission.requested"); + assert_eq!(sse.data["permissionId"], "perm_2"); + assert_eq!(sse.data["toolName"], "mnote.page.get"); + assert_eq!(sse.data["decision"], "requested"); + } + + #[test] + fn acp_permission_allowed_emits_permission_allowed_event() { + let event = AcpSessionEvent::PermissionRequest { + permission_id: "perm_3".into(), + tool_name: "mnote.page.save".into(), + params: json!({"documentId": "doc_1"}), + decision: "allowed".into(), + }; + + let sse = acp_event_to_sse(event).expect("permission allowed should be forwarded"); + assert_eq!(sse.event, "permission.allowed"); + assert_eq!(sse.data["permissionId"], "perm_3"); + assert_eq!(sse.data["decision"], "allowed"); + } + #[test] fn acp_tool_events_keep_detail_for_collapsible_ui() { let started = acp_event_to_sse(AcpSessionEvent::ToolCall { @@ -335,12 +373,18 @@ mod tests { kind: "read".into(), status: crate::acp_types::ToolCallStatus::InProgress, raw_input: Some(json!({"documentId": "doc_1", "includeBody": true})), + locations: vec!["/mnt/Data1T/mnote/src/main.rs".into()], }) .expect("tool start"); assert_eq!(started.event, "tool.started"); assert_eq!(started.data["tool"], "mnote.page.get"); assert_eq!(started.data["status"], "in_progress"); assert_eq!(started.data["input"]["documentId"], "doc_1"); + assert_eq!( + started.data["locations"][0], + "/mnt/Data1T/mnote/src/main.rs" + ); + assert_eq!(started.data["locations"].as_array().unwrap().len(), 1); let completed = acp_event_to_sse(AcpSessionEvent::ToolCallUpdate { tool_call_id: "tool_1".into(), @@ -367,4 +411,34 @@ mod tests { assert_eq!(running.event, "tool.started"); assert_eq!(running.data["status"], "in_progress"); } + + #[test] + fn acp_session_info_update_emits_session_info_updated_sse() { + let sse = acp_event_to_sse(AcpSessionEvent::SessionInfoUpdate { + title: "我的新会话标题".into(), + }) + .expect("session info update should be forwarded"); + assert_eq!(sse.event, "session.info.updated"); + assert_eq!(sse.data["title"], "我的新会话标题"); + } + + #[test] + fn acp_plan_update_emits_plan_updated_sse() { + let entries = vec![ + "步骤 1:读取文件".into(), + "步骤 2:修改配置".into(), + "步骤 3:验证更改".into(), + ]; + let sse = acp_event_to_sse(AcpSessionEvent::PlanUpdate { + entries: entries.clone(), + }) + .expect("plan update should be forwarded"); + assert_eq!(sse.event, "plan.updated"); + let sse_entries: Vec = + serde_json::from_value(sse.data["entries"].clone()).unwrap_or_default(); + assert_eq!(sse_entries.len(), 3); + assert_eq!(sse_entries[0], "步骤 1:读取文件"); + assert_eq!(sse_entries[1], "步骤 2:修改配置"); + assert_eq!(sse_entries[2], "步骤 3:验证更改"); + } } diff --git a/rust/crates/mnote-web/src/acp_client.rs b/rust/crates/mnote-web/src/acp_client.rs index e4c857d9..6c90909b 100644 --- a/rust/crates/mnote-web/src/acp_client.rs +++ b/rust/crates/mnote-web/src/acp_client.rs @@ -80,6 +80,17 @@ type NotificationHandler = Box; /// Thread-safe mutex for notification handler (std mutex — lightweight, never held across awaits). type NotificationHandlerMutex = std::sync::Mutex>; +// ── Incoming request handler type ──────────────────── +/// +/// agent 发送 JSON-RPC request(同时包含 `id` 与 `method`)时调用。 +/// 返回 `true` 表示 handler 已负责稍后响应;返回 `false` 则由 dispatch_message +/// 直接回复 method-not-found。handler 应通过 [`AcpClient::respond_to_incoming`] +/// 或 [`AcpClient::respond_to_incoming_error`] 回写响应。 +type IncomingRequestHandler = Box bool + Send + 'static>; + +/// incoming request handler 的线程安全容器。 +type IncomingRequestHandlerMutex = std::sync::Mutex>; + // ── Pending request entry ──────────────────────────── type PendingEntry = oneshot::Sender>; @@ -108,6 +119,9 @@ pub struct AcpClient { pending: Arc>>, next_id: AtomicU64, notification_handler: Arc, + /// agent 发来的 incoming JSON-RPC request handler(同时有 id 和 method)。 + /// 未设置时会直接回复 method-not-found。 + incoming_request_handler: Arc, } impl AcpClient { @@ -162,14 +176,24 @@ impl AcpClient { let pending: Arc>> = Arc::new(Mutex::new(HashMap::new())); let notification_handler: Arc = Arc::new(std::sync::Mutex::new(None)); + let incoming_request_handler: Arc = + Arc::new(std::sync::Mutex::new(None)); // Start background reader task let pending_clone = pending.clone(); let handler_clone = notification_handler.clone(); + let incoming_clone = incoming_request_handler.clone(); let writer_clone = writer.clone(); let child_pid = child.id().unwrap_or(0); tokio::spawn(async move { - Self::reader_loop(reader, writer_clone, pending_clone, handler_clone).await; + Self::reader_loop( + reader, + writer_clone, + pending_clone, + handler_clone, + incoming_clone, + ) + .await; info!("ACP reader loop ended (pid={})", child_pid); }); @@ -179,6 +203,7 @@ impl AcpClient { pending, next_id: AtomicU64::new(1), notification_handler, + incoming_request_handler, }; // Handshake: initialize (reference: acpClient.ts L108 → call('initialize', {protocolVersion: 1})) @@ -272,6 +297,53 @@ impl AcpClient { *guard = Some(Box::new(handler)); } + /// 注册 incoming JSON-RPC request handler(消息同时包含 `id` 与 `method`)。 + /// handler 接收原始 request id、method 和 params,并应稍后通过 + /// [`respond_to_incoming`] 或 [`respond_to_incoming_error`] 响应。 + /// 同一时间只保留一个 handler,后续注册会覆盖前一个。 + pub fn on_incoming_request(&self, handler: F) + where + F: Fn(Value, String, Value) -> bool + Send + 'static, + { + let mut guard = self.incoming_request_handler.lock().unwrap(); + *guard = Some(Box::new(handler)); + } + + /// 用 result 响应 agent 发来的 incoming JSON-RPC request。 + /// + /// 必须使用 incoming request handler 收到的原始 `id`,避免丢失字符串 id。 + pub async fn respond_to_incoming(&self, id: Value, result: Value) -> Result<(), AcpError> { + let msg = json!({ + "jsonrpc": "2.0", + "id": id.clone(), + "result": result, + }); + debug!("ACP <-- respond to incoming #{}", id); + Self::write_jsonrpc_message(&self.writer, &msg).await + } + + /// 用 error 响应 incoming JSON-RPC request。 + pub async fn respond_to_incoming_error( + &self, + id: Value, + code: i64, + message: &str, + ) -> Result<(), AcpError> { + let msg = json!({ + "jsonrpc": "2.0", + "id": id.clone(), + "error": { + "code": code, + "message": message, + }, + }); + debug!( + "ACP <-- respond error to incoming #{}: [{}] {}", + id, code, message + ); + Self::write_jsonrpc_message(&self.writer, &msg).await + } + /// Gracefully close the ACP connection and kill the subprocess. pub async fn close(&mut self) { if let Some(mut child) = self.child.take() { @@ -296,6 +368,7 @@ impl AcpClient { writer: Arc>>, pending: Arc>>, notification_handler: Arc, + incoming_request_handler: Arc, ) { let mut line_buf = String::new(); loop { @@ -328,7 +401,14 @@ impl AcpClient { } }; - Self::dispatch_message(msg, &writer, &pending, ¬ification_handler).await; + Self::dispatch_message( + msg, + &writer, + &pending, + ¬ification_handler, + &incoming_request_handler, + ) + .await; } // Process died or EOF — resolve all pending @@ -346,6 +426,7 @@ impl AcpClient { writer: &Arc>>, pending: &Arc>>, notification_handler: &Arc, + incoming_request_handler: &Arc, ) { let has_id = msg.get("id").is_some(); let has_method = msg @@ -355,33 +436,36 @@ impl AcpClient { .unwrap_or(false); if has_id && has_method { - // Incoming request from agent (e.g. session/request_permission) + // agent 发来的 incoming request,例如 session/request_permission。 let method = msg["method"].as_str().unwrap_or("unknown").to_string(); let params = msg.get("params").cloned().unwrap_or(Value::Null); - let id = msg.get("id").cloned().unwrap_or(Value::Null); - // 当前还没有权限确认 UI,必须明确拒绝,避免 agent 等待到超时。 - warn!("ACP incoming request not handled: {method} (params={params:?})"); - let response = json!({ - "jsonrpc": "2.0", - "id": id, - "error": { - "code": -32601, - "message": format!("ACP incoming request not supported: {method}") - } - }); - if let Err(error) = Self::write_jsonrpc_message(writer, &response).await { - warn!("ACP incoming request response write failed: {error}"); - } - if method == "session/request_permission" { - let mut event_params = params; - if let Some(object) = event_params.as_object_mut() { - object.insert("decision".into(), Value::String("denied".into())); - object.insert("method".into(), Value::String(method.clone())); - object.insert("jsonrpcId".into(), id); - } - let handler_guard = notification_handler.lock().unwrap(); + let id_val = msg.get("id").cloned().unwrap_or(Value::Null); + + // 若已注册 handler,则由 handler 决定是否负责稍后响应。 + let handled = { + let handler_guard = incoming_request_handler.lock().unwrap(); if let Some(ref handler) = *handler_guard { - handler(method, event_params); + handler(id_val.clone(), method.clone(), params.clone()) + } else { + false + } + }; + + if handled { + debug!("ACP incoming request dispatched: {method} #{}", id_val); + } else { + // 没有 handler 时必须立即响应,避免 agent 一直等待。 + warn!("ACP incoming request not handled (no handler registered): {method}"); + let response = json!({ + "jsonrpc": "2.0", + "id": id_val, + "error": { + "code": -32601, + "message": format!("ACP incoming request not supported: {method}") + } + }); + if let Err(error) = Self::write_jsonrpc_message(writer, &response).await { + warn!("ACP incoming request response write failed: {error}"); } } } else if has_id { @@ -528,6 +612,48 @@ rl.on('line', (line) => { .expect("spawn permission mock ACP") } + async fn spawn_string_id_permission_request_mock_server() -> AcpClient { + let script = r#" +import * as readline from 'node:readline'; +import { stdin as input, stdout as output } from 'node:process'; +let permissionResponse = null; +const rl = readline.createInterface({ input, output, terminal: false }); +function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); } +rl.on('line', (line) => { + const msg = JSON.parse(line); + if (msg.id !== undefined && msg.method === 'initialize') { + send({ + jsonrpc: '2.0', + id: msg.id, + result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] } + }); + setTimeout(() => send({ + jsonrpc: '2.0', + id: 'perm-string-id', + method: 'session/request_permission', + params: { reason: 'test permission' } + }), 10); + } else if (msg.id === 'perm-string-id' && msg.method === undefined) { + permissionResponse = msg; + } else if (msg.id !== undefined && msg.method === 'get_permission_response') { + send({ + jsonrpc: '2.0', + id: msg.id, + result: { permissionResponse } + }); + } +}); + "#; + + let dir = std::env::temp_dir(); + let script_path = dir.join("acp_permission_request_string_id_mock.mjs"); + std::fs::write(&script_path, script).expect("write permission string id mock script"); + + AcpClient::spawn("node", &[script_path.to_str().unwrap()]) + .await + .expect("spawn permission string id mock ACP") + } + #[tokio::test] async fn test_request_response() { let client = spawn_mock_acp_server().await; @@ -603,4 +729,18 @@ rl.on('line', (line) => { assert_eq!(response["id"], 77); assert!(response.get("result").is_some() || response.get("error").is_some()); } + + #[tokio::test] + async fn test_incoming_permission_request_preserves_string_id() { + let client = spawn_string_id_permission_request_mock_server().await; + tokio::time::sleep(Duration::from_millis(100)).await; + let result: Value = client + .request("get_permission_response", json!({})) + .await + .expect("permission response probe"); + let response = &result["permissionResponse"]; + assert_eq!(response["jsonrpc"], "2.0"); + assert_eq!(response["id"], "perm-string-id"); + assert!(response.get("result").is_some() || response.get("error").is_some()); + } } diff --git a/rust/crates/mnote-web/src/acp_session_manager.rs b/rust/crates/mnote-web/src/acp_session_manager.rs index b497ce46..188a3061 100644 --- a/rust/crates/mnote-web/src/acp_session_manager.rs +++ b/rust/crates/mnote-web/src/acp_session_manager.rs @@ -8,16 +8,16 @@ /// - `reference-code/hermes-vscode-main/src/protocol.ts` (dedup logic) use crate::acp_client::AcpClient; use crate::acp_types::{ - ContentBlock, ContentBlockWrapper, SessionNewParams, SessionNewResult, SessionPromptParams, - SessionPromptResult, SessionUpdate, ToolCallStatus, + ContentBlock, ContentBlockWrapper, SessionLoadParams, SessionNewParams, SessionNewResult, + SessionPromptParams, SessionPromptResult, SessionUpdate, ToolCallStatus, }; use serde_json::{json, Value}; +use std::collections::HashMap; use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::time::sleep; use tracing::{debug, info, warn}; -#[cfg(test)] -use tokio::time::{sleep, Duration}; - // ── Events ─────────────────────────────────────────── /// Strongly-typed event emitted by the session manager when a `session/update` arrives. @@ -27,34 +27,36 @@ pub enum AcpSessionEvent { TextDelta { text: String }, /// Streaming reasoning/thinking text. ThoughtDelta { text: String }, - /// Tool call started. + /// 工具调用开始。 ToolCall { tool_call_id: String, title: String, kind: String, status: ToolCallStatus, raw_input: Option, + /// 工具涉及的文件路径。 + locations: Vec, }, - /// Tool call status update (with optional result content). + /// 工具调用状态更新,可能携带结果内容。 ToolCallUpdate { tool_call_id: String, status: ToolCallStatus, content: Option>, }, - /// Context usage update. + /// 上下文用量更新。 UsageUpdate { used: u64, size: u64 }, - /// Agent 发起权限请求;当前 runtime 会自动拒绝并同步给前端。 + /// Agent 发起权限请求。 PermissionRequest { permission_id: String, tool_name: String, params: Value, decision: String, }, - /// Session metadata update (e.g. auto-title). + /// 会话元数据更新,例如自动标题。 SessionInfoUpdate { title: String }, - /// Plan entries update. + /// 计划条目更新。 PlanUpdate { entries: Vec }, - /// Connection closed/error. + /// 连接关闭或异常。 Disconnected { reason: String }, } @@ -81,6 +83,19 @@ pub enum SessionState { Closed, } +// ── Pending permission ─────────────────────────────── + +/// 等待前端决策的 `session/request_permission`。 +#[derive(Debug, Clone)] +pub struct PendingPermission { + /// incoming ACP request 的原始 JSON-RPC id,可能是数字或字符串。 + pub jsonrpc_id: Value, + /// `session/request_permission` 的原始参数。 + pub params: Value, + /// 请求创建时间,用于后续超时判断。 + pub created_at: std::time::Instant, +} + // ── AcpSessionManager ──────────────────────────────── /// Manages ACP sessions — create, prompt, cancel, and event dispatch. @@ -97,6 +112,8 @@ pub struct AcpSessionManager { accumulated: Arc>, /// Whether we're currently inside a prompt (for dedup gating). in_prompt: Arc>, + /// Pending permission requests keyed by permission_id. + pending_permissions: Arc>>, } impl AcpSessionManager { @@ -110,42 +127,125 @@ impl AcpSessionManager { let event_handler: Arc>> = Arc::new(Mutex::new(None)); let accumulated: Arc> = Arc::new(Mutex::new(String::new())); let in_prompt: Arc> = Arc::new(Mutex::new(false)); + let pending_permissions: Arc>> = + Arc::new(Mutex::new(HashMap::new())); - // Wire up the ACP notification handler + // ── Incoming request handler ────────────────── + // 处理 `session/request_permission`:先进入 pending,再由 HTTP + // resolve-permission 端点决定最终响应。 + let event_handler_for_incoming = event_handler.clone(); + let pending_for_incoming = pending_permissions.clone(); + let client_for_incoming = client.clone(); + client.on_incoming_request(move |id, method, params| { + if method != "session/request_permission" { + // 未知方法交回 dispatch_message 回复 method-not-found。 + return false; + } + + // 优先使用 agent 传来的 permission id;缺失时用 JSON-RPC id 派生稳定 id。 + let permission_id = params + .get("permissionId") + .or_else(|| params.get("permission_id")) + .or_else(|| params.get("requestId")) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| format!("acp_perm_{}", id)); + + let tool_name = params + .get("toolName") + .or_else(|| params.get("tool")) + .or_else(|| params.get("name")) + .or_else(|| params.get("method")) + .and_then(Value::as_str) + .unwrap_or("session/request_permission") + .to_string(); + + // 记录到 pending map,等待前端 allow / deny。 + { + let mut pending = pending_for_incoming.lock().unwrap(); + pending.insert( + permission_id.clone(), + PendingPermission { + jsonrpc_id: id.clone(), + params: params.clone(), + created_at: std::time::Instant::now(), + }, + ); + debug!( + "ACP permission pending: {} (jsonrpc_id={}, tool={})", + permission_id, id, tool_name + ); + } + + // 向前端发出 `permission.requested`。 + { + let handler = event_handler_for_incoming.lock().unwrap(); + if let Some(ref h) = *handler { + h(AcpSessionEvent::PermissionRequest { + permission_id: permission_id.clone(), + tool_name, + params, + decision: "requested".into(), + }); + } + } + + // 60 秒没有决策时自动 deny,避免 agent 永久等待。 + let pending_for_timeout = pending_for_incoming.clone(); + let event_handler_for_timeout = event_handler_for_incoming.clone(); + let client_for_timeout = client_for_incoming.clone(); + let timeout_permission_id = permission_id.clone(); + let timeout_jsonrpc_id = id.clone(); + tokio::spawn(async move { + sleep(Duration::from_secs(60)).await; + let should_deny = { + let mut pending = pending_for_timeout.lock().unwrap(); + if pending.remove(&timeout_permission_id).is_some() { + true + } else { + false + } + }; + if should_deny { + warn!( + "ACP permission timeout: {} (jsonrpc_id={}), auto-denying", + timeout_permission_id, timeout_jsonrpc_id + ); + // 向 ACP 回复 permission denied。 + let _ = client_for_timeout + .respond_to_incoming_error( + timeout_jsonrpc_id.clone(), + -32000, + "permission denied by timeout", + ) + .await; + // 同步 denied 事件给前端。 + let handler = event_handler_for_timeout.lock().unwrap(); + if let Some(ref h) = *handler { + h(AcpSessionEvent::PermissionRequest { + permission_id: timeout_permission_id, + tool_name: "session/request_permission".into(), + params: Value::Null, + decision: "denied".into(), + }); + } + } + }); + + true // handler took responsibility for responding + }); + + // ── Notification handler ────────────────────── + // Only handles `session/update` (streaming events from agent). + // Incoming request methods like `session/request_permission` are handled + // above via `on_incoming_request`. let session_id_clone = session_id.clone(); let event_handler_clone = event_handler.clone(); let accumulated_clone = accumulated.clone(); let in_prompt_clone = in_prompt.clone(); client.on_notification(move |method, params| { - if method == "session/request_permission" { - let permission_id = params - .get("permissionId") - .or_else(|| params.get("permission_id")) - .or_else(|| params.get("requestId")) - .or_else(|| params.get("id")) - .and_then(Value::as_str) - .unwrap_or("permission_auto_denied") - .to_string(); - let tool_name = params - .get("toolName") - .or_else(|| params.get("tool")) - .or_else(|| params.get("name")) - .or_else(|| params.get("method")) - .and_then(Value::as_str) - .unwrap_or("session/request_permission") - .to_string(); - let handler = event_handler_clone.lock().unwrap(); - if let Some(ref h) = *handler { - h(AcpSessionEvent::PermissionRequest { - permission_id, - tool_name, - params, - decision: "denied".into(), - }); - } - return; - } if method != "session/update" { return; } @@ -187,6 +287,7 @@ impl AcpSessionManager { event_handler, accumulated, in_prompt, + pending_permissions, } } @@ -200,6 +301,92 @@ impl AcpSessionManager { *guard = Some(Arc::new(handler)); } + /// 按用户决策解析 pending permission。 + /// + /// 根据 `permission_id` 找到 pending request,向 ACP 子进程回写 result/error, + /// 发出 `permission.allowed` / `permission.denied` 事件,并从 pending map 移除。 + /// + /// permission 存在且完成响应时返回 `Ok(())`,否则返回错误原因。 + pub async fn resolve_permission( + &self, + permission_id: &str, + decision: &str, + ) -> Result<(), String> { + let pending = { + let mut map = self.pending_permissions.lock().unwrap(); + map.remove(permission_id) + }; + let pending = pending.ok_or_else(|| { + format!("pending permission not found: {permission_id} (already timed out or invalid)") + })?; + + let tool_name = pending + .params + .get("toolName") + .or_else(|| pending.params.get("tool")) + .or_else(|| pending.params.get("name")) + .and_then(Value::as_str) + .unwrap_or("session/request_permission") + .to_string(); + + let normalized_decision = match decision { + "allow" | "allowed" => "allow", + "deny" | "denied" => "deny", + other => return Err(format!("unknown decision: {other} (expected allow/deny)")), + }; + + let response = permission_response_for_decision(&pending.params, normalized_decision) + .unwrap_or_else(|| { + if normalized_decision == "allow" { + Err((-32000, "permission allow option not available".into())) + } else { + Err((-32000, "permission denied by user".into())) + } + }); + + match response { + Ok(result) => { + self.client + .respond_to_incoming(pending.jsonrpc_id.clone(), result) + .await + .map_err(|e| format!("ACP respond failed: {e}"))?; + info!( + "ACP permission resolved: {} decision={} (jsonrpc_id={})", + permission_id, normalized_decision, pending.jsonrpc_id + ); + } + Err((code, message)) => { + self.client + .respond_to_incoming_error(pending.jsonrpc_id.clone(), code, &message) + .await + .map_err(|e| format!("ACP respond failed: {e}"))?; + info!( + "ACP permission rejected: {} decision={} (jsonrpc_id={})", + permission_id, normalized_decision, pending.jsonrpc_id + ); + } + } + + // 发出最终 decision 事件。 + { + let handler = self.event_handler.lock().unwrap(); + if let Some(ref h) = *handler { + h(AcpSessionEvent::PermissionRequest { + permission_id: permission_id.to_string(), + tool_name, + params: pending.params, + decision: if normalized_decision == "allow" { + "allowed".into() + } else { + "denied".into() + }, + }); + } + } + + Ok(()) + } + /// Create a new ACP session. /// /// Sends `session/new` to the agent and stores the returned `sessionId`. @@ -228,6 +415,108 @@ impl AcpSessionManager { Ok(result.session_id) } + /// Load/resume an existing ACP session. + /// + /// Sends `session/load { sessionId, cwd, mcpServers: [] }` to the agent. + /// Returns `true` if the session was loaded and the internal session_id updated, + /// `false` if the adapter returned null (session not found) or doesn't support the method. + /// + /// On success the internal `session_id` is set to the loaded id. + /// + /// Reference: `hermes-vscode-main/src/sessionManager.ts` `ensureSession()` + pub async fn load_session( + &self, + session_id: &str, + cwd: Option<&str>, + ) -> Result { + let project_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3) + .unwrap_or(std::path::Path::new("/mnt/Data1T/mnote")) + .to_string_lossy() + .to_string(); + let params = SessionLoadParams { + session_id: session_id.to_string(), + cwd: Some(cwd.map(str::to_string).unwrap_or(project_root)), + mcp_servers: Some(Vec::new()), + }; + + match self + .client + .request::<_, serde_json::Value>("session/load", params) + .await + { + Ok(result) => { + // Successful load: adapter returned { sessionId: "..." } + if let Some(sid) = result.get("sessionId").and_then(|v| v.as_str()) { + if !sid.is_empty() { + let mut sid_guard = self.session_id.lock().unwrap(); + *sid_guard = Some(sid.to_string()); + info!("ACP session loaded: {}", sid); + return Ok(true); + } + } + // Null or missing sessionId → session not found on adapter + info!( + "ACP session/load returned null for session_id={}", + session_id + ); + Ok(false) + } + Err(crate::acp_client::AcpError::JsonRpc { code, message }) => { + // Adapter doesn't support session/load or session expired + info!( + "ACP session/load not supported (code={code}, message={message}), will fallback" + ); + Ok(false) + } + Err(e) => { + warn!("ACP session/load transport error: {e}"); + Err(e) + } + } + } + + /// Ensure an active ACP session exists. + /// + /// 1. If there's already an active session, return it immediately. + /// 2. If a `stored_session_id` is provided, attempt `session/load` first. + /// 3. Fall back to `session/new`. + /// + /// Reference: `hermes-vscode-main/src/sessionManager.ts` `ensureSession()` + pub async fn ensure_session( + &self, + cwd: Option<&str>, + stored_session_id: Option<&str>, + ) -> Result { + // 1. Reuse active session if one exists + { + let sid = self.session_id.lock().unwrap(); + if let Some(ref sid) = *sid { + debug!("ACP ensure_session: reusing active session {}", sid); + return Ok(sid.clone()); + } + } + + // 2. Try to load a stored session + if let Some(stored) = stored_session_id { + if !stored.is_empty() { + debug!("ACP ensure_session: attempting session/load for {}", stored); + if self.load_session(stored, cwd).await.unwrap_or(false) { + return Ok(stored.to_string()); + } + info!( + "ACP ensure_session: stored session {} not found, creating new", + stored + ); + } + } + + // 3. Fallback: create new session + debug!("ACP ensure_session: creating new session"); + self.create_session(cwd, None).await + } + /// Send a prompt to the agent and stream events. /// /// The `prompt` is a list of content blocks (text + optional page context). @@ -424,6 +713,7 @@ impl AcpSessionManager { kind, status, raw_input, + locations, .. } => { let title = title.clone().unwrap_or_else(|| "tool".into()); @@ -432,12 +722,14 @@ impl AcpSessionManager { None => "other".into(), }; let status = status.clone().unwrap_or(ToolCallStatus::Pending); + let locations: Vec = locations.iter().map(|l| l.path.clone()).collect(); Some(AcpSessionEvent::ToolCall { tool_call_id: tool_call_id.clone(), title, kind: kind_str, status, raw_input: raw_input.clone(), + locations, }) } @@ -479,6 +771,76 @@ impl AcpSessionManager { } } +fn permission_response_for_decision( + params: &Value, + decision: &str, +) -> Option> { + let option_id = permission_option_id_by_decision(params, decision); + match (decision, option_id) { + ("allow", Some(option_id)) | ("deny", Some(option_id)) => Some(Ok(json!({ + "outcome": "selected", + "optionId": option_id + }))), + ("deny", None) => Some(Err((-32000, "permission denied by user".into()))), + _ => None, + } +} + +fn permission_option_id_by_decision(params: &Value, decision: &str) -> Option { + let options = params.get("options").and_then(Value::as_array)?; + let preferred: &[&str] = if decision == "allow" { + &["allow_once", "allow", "approve", "yes"] + } else { + &["deny_once", "reject_once", "deny", "reject", "no"] + }; + + for keyword in preferred.iter().copied() { + if let Some(option_id) = options.iter().find_map(|option| { + let id = permission_option_id(option)?; + let haystack = format!( + "{} {}", + id.to_ascii_lowercase(), + option + .get("kind") + .and_then(Value::as_str) + .unwrap_or("") + .to_ascii_lowercase() + ); + if haystack.contains(keyword) { + Some(id) + } else { + None + } + }) { + return Some(option_id); + } + } + + if decision == "allow" { + return options.iter().find_map(|option| { + let id = permission_option_id(option)?; + let lower = id.to_ascii_lowercase(); + if lower.contains("deny") || lower.contains("reject") || lower == "no" { + None + } else { + Some(id) + } + }); + } + + None +} + +fn permission_option_id(option: &Value) -> Option { + option + .get("optionId") + .or_else(|| option.get("option_id")) + .or_else(|| option.get("id")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) +} + // ── Tests ──────────────────────────────────────────── #[cfg(test)] @@ -605,6 +967,229 @@ rl.on('line', (line) => { ); } + /// Creates a mock ACP that supports session/load (success case). + async fn spawn_mock_acp_with_load() -> Arc { + let script = r#" +import * as readline from 'node:readline'; +import { stdin as input, stdout as output } from 'node:process'; +const rl = readline.createInterface({ input, output, terminal: false }); +rl.on('line', (line) => { + const msg = JSON.parse(line); + if (!msg.id) return; + if (msg.method === 'session/load') { + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', id: msg.id, + result: { sessionId: msg.params?.sessionId || 'loaded_session_1' } + }) + '\n'); + } else if (msg.method === 'session/new') { + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', id: msg.id, + result: { sessionId: 'test_session_1' } + }) + '\n'); + } else if (msg.method === 'session/prompt') { + const sessionId = msg.params?.sessionId || 'test'; + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Hello from mock ACP' } + } + } + }) + '\n'); + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', id: msg.id, + result: { stopReason: 'end_turn' } + }) + '\n'); + } else if (msg.method === 'session/cancel') { + // No response for notification + } else if (msg.method === 'initialize') { + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', id: msg.id, + result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] } + }) + '\n'); + } +}); + "#; + let dir = std::env::temp_dir(); + let script_path = dir.join("acp_session_load_success_mock.mjs"); + std::fs::write(&script_path, script).expect("write mock"); + let client = AcpClient::spawn("node", &[script_path.to_str().unwrap()]) + .await + .expect("spawn"); + Arc::new(client) + } + + /// Creates a mock ACP that returns null for session/load (session expired/not found) + async fn spawn_mock_acp_with_load_null() -> Arc { + let script = r#" +import * as readline from 'node:readline'; +import { stdin as input, stdout as output } from 'node:process'; +const rl = readline.createInterface({ input, output, terminal: false }); +rl.on('line', (line) => { + const msg = JSON.parse(line); + if (!msg.id) return; + if (msg.method === 'session/load') { + // Return null result → session not found on adapter + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', id: msg.id, + result: null + }) + '\n'); + } else if (msg.method === 'session/new') { + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', id: msg.id, + result: { sessionId: 'fallback_session_1' } + }) + '\n'); + } else if (msg.method === 'session/prompt') { + const sessionId = msg.params?.sessionId || 'fallback_session_1'; + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Hello from fallback session' } + } + } + }) + '\n'); + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', id: msg.id, + result: { stopReason: 'end_turn' } + }) + '\n'); + } else if (msg.method === 'session/cancel') { + // No response for notification + } else if (msg.method === 'initialize') { + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', id: msg.id, + result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] } + }) + '\n'); + } +}); + "#; + let dir = std::env::temp_dir(); + let script_path = dir.join("acp_session_load_null_mock.mjs"); + std::fs::write(&script_path, script).expect("write mock"); + let client = AcpClient::spawn("node", &[script_path.to_str().unwrap()]) + .await + .expect("spawn"); + Arc::new(client) + } + + #[tokio::test] + async fn test_load_session_success() { + let client = spawn_mock_acp_with_load().await; + let mgr = AcpSessionManager::new(client); + let loaded = mgr + .load_session("stored_session_1", Some("/test")) + .await + .expect("load_session"); + assert!(loaded, "session should be loaded successfully"); + assert_eq!(mgr.session_id().await, Some("stored_session_1".into())); + } + + #[tokio::test] + async fn test_load_session_null_fallback() { + let client = spawn_mock_acp_with_load_null().await; + let mgr = AcpSessionManager::new(client); + + // load_session returns false for null result + let loaded = mgr + .load_session("expired_session", Some("/test")) + .await + .expect("load_session"); + assert!(!loaded, "session should not be loaded"); + + // No session_id set after failed load + assert_eq!(mgr.session_id().await, None); + } + + #[tokio::test] + async fn test_ensure_session_reuses_active() { + let client = spawn_mock_acp().await; + let mgr = AcpSessionManager::new(client); + + // First, create a session + let created = mgr + .create_session(Some("/test"), None) + .await + .expect("create_session"); + assert_eq!(created, "test_session_1"); + + // ensure_session should reuse the active session, not create a new one + let ensured = mgr + .ensure_session(Some("/test"), Some("stored_but_ignored")) + .await + .expect("ensure_session"); + assert_eq!(ensured, "test_session_1"); + assert_eq!(mgr.session_id().await, Some("test_session_1".into())); + } + + #[tokio::test] + async fn test_ensure_session_loads_stored() { + let client = spawn_mock_acp_with_load().await; + let mgr = AcpSessionManager::new(client); + + // No active session, stored id provided → should load + let ensured = mgr + .ensure_session(Some("/test"), Some("stored_session_1")) + .await + .expect("ensure_session"); + assert_eq!(ensured, "stored_session_1"); + assert_eq!(mgr.session_id().await, Some("stored_session_1".into())); + } + + #[tokio::test] + async fn test_ensure_session_fallback_to_new() { + let client = spawn_mock_acp_with_load_null().await; + let mgr = AcpSessionManager::new(client); + + // No active session, stored id provided but load returns null → fallback to new + let ensured = mgr + .ensure_session(Some("/test"), Some("expired_session")) + .await + .expect("ensure_session"); + assert_eq!(ensured, "fallback_session_1"); + assert_eq!(mgr.session_id().await, Some("fallback_session_1".into())); + } + + #[tokio::test] + async fn test_ensure_session_no_stored_creates_new() { + let client = spawn_mock_acp().await; + let mgr = AcpSessionManager::new(client); + + // No active session, no stored id → should create new + let ensured = mgr + .ensure_session(Some("/test"), None) + .await + .expect("ensure_session"); + assert_eq!(ensured, "test_session_1"); + assert_eq!(mgr.session_id().await, Some("test_session_1".into())); + } + + #[tokio::test] + async fn test_ensure_session_fallback_then_prompt_works() { + let client = spawn_mock_acp_with_load_null().await; + let mgr = AcpSessionManager::new(client); + + // ensure_session with expired stored id → should create fallback + mgr.ensure_session(Some("/test"), Some("expired_session")) + .await + .expect("ensure_session"); + + // Verify prompt still works on the fallback session + let prompt = vec![ContentBlock::Text { + text: "Hello after fallback".into(), + }]; + let result = mgr + .run_prompt(prompt) + .await + .expect("run_prompt after fallback"); + assert_eq!(format!("{:?}", result.stop_reason), "EndTurn".to_string()); + } + #[test] fn test_thought_chunk_maps_to_thought_delta() { let accumulated = Arc::new(Mutex::new(String::new())); @@ -624,4 +1209,26 @@ rl.on('line', (line) => { other => panic!("expected ThoughtDelta, got {other:?}"), } } + + #[test] + fn permission_response_selects_allow_and_deny_options() { + let params = json!({ + "options": [ + {"optionId": "allow_once", "name": "Allow Once", "kind": "allow_once"}, + {"optionId": "reject_once", "name": "Reject Once", "kind": "reject_once"} + ] + }); + + let allow = permission_response_for_decision(¶ms, "allow") + .expect("allow response") + .expect("allow should select an option"); + assert_eq!(allow["outcome"], "selected"); + assert_eq!(allow["optionId"], "allow_once"); + + let deny = permission_response_for_decision(¶ms, "deny") + .expect("deny response") + .expect("deny should select an option"); + assert_eq!(deny["outcome"], "selected"); + assert_eq!(deny["optionId"], "reject_once"); + } } diff --git a/rust/crates/mnote-web/src/acp_types.rs b/rust/crates/mnote-web/src/acp_types.rs index 492f0121..178aaa00 100644 --- a/rust/crates/mnote-web/src/acp_types.rs +++ b/rust/crates/mnote-web/src/acp_types.rs @@ -131,6 +131,24 @@ pub struct SessionNewResult { pub session_id: String, } +// ── Session load ───────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionLoadParams { + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp_servers: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionLoadResult { + pub session_id: String, +} + // ── Content blocks ─────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] @@ -236,6 +254,8 @@ pub enum SessionUpdate { status: Option, #[serde(skip_serializing_if = "Option::is_none")] raw_input: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + locations: Vec, }, ToolCallUpdate { #[serde(rename = "sessionUpdate")] @@ -327,6 +347,8 @@ impl<'de> Deserialize<'de> for SessionUpdate { kind: Option, status: Option, raw_input: Option, + #[serde(default)] + locations: Vec, } let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?; @@ -337,6 +359,7 @@ impl<'de> Deserialize<'de> for SessionUpdate { kind: raw.kind, status: raw.status, raw_input: raw.raw_input, + locations: raw.locations, }) } SessionUpdate::TOOL_CALL_UPDATE => { @@ -456,6 +479,13 @@ pub enum ToolCallStatus { Failed, } +/// A file path location referenced by a tool call. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolLocation { + pub path: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PlanEntry { @@ -666,9 +696,47 @@ mod tests { }); let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap(); match &parsed.update { - SessionUpdate::ToolCall { title, kind, .. } => { + SessionUpdate::ToolCall { + title, + kind, + locations, + .. + } => { assert_eq!(title.as_deref(), Some("mnote.doc.fetch")); assert!(matches!(kind, Some(ToolCallKind::Read))); + assert!(locations.is_empty(), "no locations in this fixture"); + } + _ => panic!("expected ToolCall"), + } + } + + #[test] + fn test_session_update_tool_call_with_locations() { + let json = serde_json::json!({ + "sessionId": "test_1", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "tc_2", + "title": "mnote.doc.read", + "kind": "read", + "status": "in_progress", + "locations": [ + { "path": "/mnt/Data1T/mnote/src/main.rs" }, + { "path": "/mnt/Data1T/mnote/src/lib.rs" } + ] + } + }); + let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap(); + match &parsed.update { + SessionUpdate::ToolCall { + tool_call_id, + locations, + .. + } => { + assert_eq!(tool_call_id, "tc_2"); + assert_eq!(locations.len(), 2); + assert_eq!(locations[0].path, "/mnt/Data1T/mnote/src/main.rs"); + assert_eq!(locations[1].path, "/mnt/Data1T/mnote/src/lib.rs"); } _ => panic!("expected ToolCall"), } diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index 0fd910fe..b87ed36d 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -2367,10 +2367,10 @@ mod tests { .await .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); - assert!(html.contains("初始化的新页面")); + assert!(!html.contains("初始化的新页面")); assert!(html.contains(r#"data-mnote-source-kind="local_folder""#)); - assert!(!html.contains(r#"data-testid="mnote-create-default-local-workspace""#)); - assert!(!html.contains(r#"data-testid="mnote-open-local-folder-empty""#)); + assert!(html.contains(r#"data-testid="mnote-create-default-local-workspace""#)); + assert!(html.contains(r#"data-testid="mnote-open-local-folder-empty""#)); assert!(!html.contains("workspaces:ensureDefaultWorkspace")); let _ = std::fs::remove_dir_all(&base); } @@ -2583,17 +2583,17 @@ mod tests { .join("user_real") .join("workspaces") .join("my-space"); - assert!(expected_root - .join("初始化的新页面") - .join("初始化的新页面.md") - .exists()); - assert!(html.contains("初始化的新页面")); + assert!( + !expected_root.join("初始化的新页面").exists(), + "默认工作区不应创建'初始化的新页面'目录" + ); + assert!(!html.contains("初始化的新页面")); assert!(html.contains("local_folder")); assert!(html.contains(r#"data-mnote-source-kind="local_folder""#)); assert!(html.contains(r#"data-mnote-root-uri="file://"#)); - assert!(!html.contains(r#"data-testid="mnote-workspace-empty-state""#)); - assert!(!html.contains("当前还没有可显示的本地工作区")); - assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#)); + assert!(html.contains(r#"data-testid="mnote-workspace-empty-state""#)); + assert!(html.contains("当前还没有可显示的本地工作区")); + assert!(html.contains(r#"data-testid="mnote-empty-create-page""#)); assert!(html.contains(r#""transport":"disabled""#)); let _ = std::fs::remove_dir_all(&base); diff --git a/rust/crates/mnote-web/src/routes/hermes_client.rs b/rust/crates/mnote-web/src/routes/hermes_client.rs index 3124ef9c..2b15b711 100644 --- a/rust/crates/mnote-web/src/routes/hermes_client.rs +++ b/rust/crates/mnote-web/src/routes/hermes_client.rs @@ -1461,13 +1461,22 @@ async fn acp_stream_events( } }); - let acp_session_id = mgr.create_session(None, None).await.map_err(|e| { - WebError::bad_gateway_code( - "acp_session_create_failed", - format!("ACP session creation failed: {e}"), - ) - .with_context(&context) - })?; + // Try to resume stored ACP session, or create a new one. + // Reference: hermes-vscode-main sessionManager.ts ensureSession() + let stored_acp_session_id = payload + .get("acpSessionId") + .and_then(Value::as_str) + .filter(|s| !s.trim().is_empty()); + let acp_session_id = mgr + .ensure_session(None, stored_acp_session_id) + .await + .map_err(|e| { + WebError::bad_gateway_code( + "acp_session_ensure_failed", + format!("ACP session ensure failed: {e}"), + ) + .with_context(&context) + })?; let mnote_session_id = session_id_for_run(run_id).unwrap_or_else(|| run_id.to_string()); ACP_ACTIVE_RUNS.lock().expect("acp active runs").insert( run_id.to_string(), @@ -1810,6 +1819,82 @@ pub async fn abort_run( } } +/// Resolve a pending permission request from an ACP agent. +/// +/// POST /api/hermes/client/runs/{run_id}/resolve-permission +/// Body: { "permissionId": "...", "decision": "allow"|"deny" } +/// +/// Looks up the active run, forwards the decision to the ACP subprocess, +/// and emits `permission.allowed` / `permission.denied` SSE event. +pub async fn resolve_permission( + Extension(context): Extension, + Path(run_id): Path, + Json(payload): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_authenticated(&context)?; + let permission_id = payload + .get("permissionId") + .and_then(Value::as_str) + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| { + WebError::bad_request_code("hermes_client_bad_request", "缺少 permissionId") + .with_context(&context) + })?; + let decision = payload + .get("decision") + .and_then(Value::as_str) + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| { + WebError::bad_request_code("hermes_client_bad_request", "缺少 decision (allow/deny)") + .with_context(&context) + })?; + + let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into()); + if acp_runtime_for_run(&run_id, &profile).is_none() { + return Err(WebError::bad_request_code( + "hermes_client_not_acp_run", + format!("run_id={run_id} 不是 ACP run"), + ) + .with_context(&context)); + } + + let active = ACP_ACTIVE_RUNS + .lock() + .expect("acp active runs") + .get(&run_id) + .cloned(); + let Some(active) = active else { + return Err(WebError::bad_request_code( + "hermes_client_no_active_run", + format!("run_id={run_id} 没有活跃 ACP run"), + ) + .with_context(&context)); + }; + + active + .manager + .resolve_permission(permission_id, decision) + .await + .map_err(|e| { + WebError::bad_request_code( + "hermes_client_permission_resolve_failed", + format!("resolve permission 失败: {e}"), + ) + .with_context(&context) + })?; + + Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "runId": run_id, + "permissionId": permission_id, + "decision": decision, + })), + )) +} + pub async fn list_models( Extension(context): Extension, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index cbe96faf..cfc2302e 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -1554,7 +1554,6 @@ fn create_default_local_workspace_for_actor_at_base( let manifest = ensure_default_workspace_manifest(actor_id, &canonical_root)?; ensure_default_workspace_directories(&canonical_root)?; - ensure_default_workspace_home_page(&canonical_root)?; Ok(json!({ "ok": true, "workspace": { @@ -1782,36 +1781,6 @@ fn ensure_default_workspace_directories(root: &Path) -> Result<(), WebError> { Ok(()) } -fn ensure_default_workspace_home_page(root: &Path) -> Result<(), WebError> { - let page_dir = root.join("初始化的新页面"); - let page = page_dir.join("初始化的新页面.md"); - if page.exists() { - return Ok(()); - } - if !page.starts_with(root) { - return Err(WebError::bad_request_code( - "local_workspace_root_escape", - "默认本地工作区首页不能越过 root", - )); - } - fs::create_dir_all(&page_dir).map_err(|error| { - WebError::bad_request_code( - "local_workspace_create_failed", - format!( - "无法创建默认本地工作区首页目录 {}: {error}", - page_dir.display() - ), - ) - })?; - let content = ""; - fs::write(&page, content).map_err(|error| { - WebError::bad_request_code( - "local_workspace_create_failed", - format!("无法创建默认本地工作区首页 {}: {error}", page.display()), - ) - }) -} - fn local_workspace_manifest_path(root: &Path) -> PathBuf { root.join(".mnote").join("workspace.json") } @@ -3781,6 +3750,12 @@ fn move_local_directory( fn trash_local_entry(root: &Path, entry_id: &str) -> Result { if let Some(directory) = resolve_local_directory_id(root, entry_id)? { + // 若目录是页面 bundle(包含同名 .md),按 Markdown 页面生命周期入 trash + if let Some(main_md) = nested_bundle_main_markdown(&directory) { + let relative = normalize_relative_path(root, &main_md)?; + let page_id = local_markdown_path_page_id(&relative); + return trash_local_markdown_page(root, &page_id); + } return trash_local_directory(root, entry_id, &directory); } if let Some(file) = resolve_local_raw_file_id(root, entry_id)? { @@ -6961,13 +6936,13 @@ mod tests { execute_local_tree_command, execute_local_tree_command_with_sort, get_local_access_policy, get_share_grants, initialize_local_page_id, initialize_local_workspace_for_actor, load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot, - local_folder_watch_revision, local_resource_write_editor_blocks, local_workspace_id, - open_local_file, record_shared_cache, record_sync_pending_change, - resolve_local_markdown_page_aggregate, save_local_markdown_page, - update_local_markdown_title, validate_local_access_root, write_local_markdown_asset, - write_local_markdown_page_body, write_local_mindmap_data, write_sync_conflict_report, - LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery, - LocalResourceWriteRequest, LocalShareGrantRequest, LocalUploadFile, + local_folder_watch_revision, local_markdown_path_page_id, + local_resource_write_editor_blocks, local_workspace_id, open_local_file, + record_shared_cache, record_sync_pending_change, resolve_local_markdown_page_aggregate, + save_local_markdown_page, update_local_markdown_title, validate_local_access_root, + write_local_markdown_asset, write_local_markdown_page_body, write_local_mindmap_data, + write_sync_conflict_report, LocalAccessGrantRequest, LocalAccessValidateRootRequest, + LocalFileOpenQuery, LocalResourceWriteRequest, LocalShareGrantRequest, LocalUploadFile, SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest, }; use crate::context::RequestContext; @@ -8216,10 +8191,10 @@ fn main() {} assert_eq!(std::path::Path::new(root_path), expected_root.as_path()); assert!(expected_root.join(".mnote").join("workspace.json").exists()); - assert!(expected_root - .join("初始化的新页面") - .join("初始化的新页面.md") - .exists()); + assert!( + !expected_root.join("初始化的新页面").exists(), + "默认工作区不应创建'初始化的新页面'目录" + ); assert!(!expected_root.join("pages").exists()); assert!(!expected_root.join("assets").exists()); assert!(!expected_root.join("mindmaps").exists()); @@ -8516,6 +8491,39 @@ fn main() {} let _ = std::fs::remove_dir_all(&root); } + #[test] + fn local_tree_command_delete_page_bundle_directory_uses_page_trash() { + let root = temp_root("mnote-local-delete-page-bundle-dir"); + init_workspace(&root); + std::fs::create_dir_all(root.join("Page")).expect("create page bundle"); + std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write markdown"); + std::fs::write(root.join("Page").join("asset.txt"), "asset").expect("write asset"); + let root_uri = format!("file://{}", root.display()); + let folder_id = format!("local-dir:{}", encode_local_id_segment("Page")); + let page_id = local_markdown_path_page_id("Page/Page.md"); + + let result = execute_local_tree_command(&root_uri, "delete", &folder_id, None, None) + .expect("delete page bundle"); + + assert!( + !root.join("Page").exists(), + "页面 bundle 目录应整体移入垃圾箱" + ); + assert_eq!(result["id"].as_str(), Some(page_id.as_str())); + assert_eq!(result["documentId"].as_str(), Some(page_id.as_str())); + assert_eq!(result["resourceKind"].as_str(), Some("markdown_bundle")); + assert_ne!( + result["resourceKind"].as_str(), + Some("local_directory"), + "页面 bundle 目录不能作为普通资源目录进入垃圾箱" + ); + let trash_path = result["trashPath"].as_str().expect("trashPath"); + assert!(root.join(trash_path).join("Page.md").is_file()); + assert!(root.join(trash_path).join("asset.txt").is_file()); + + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn local_tree_command_delete_local_file_id_uses_trash_index() { let root = temp_root("mnote-local-delete-local-file-id"); diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 28c320f6..0cb513c9 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -311,6 +311,10 @@ pub fn build_router(state: AppState) -> Router { "/client/runs/{run_id}/abort", post(hermes_client::abort_run), ) + .route( + "/client/runs/{run_id}/resolve-permission", + post(hermes_client::resolve_permission), + ) .route("/client/models", get(hermes_client::list_models)) .route("/client/tools", get(hermes_client::list_tools)), ) diff --git a/rust/crates/mnote-web/src/routes/onlyoffice.rs b/rust/crates/mnote-web/src/routes/onlyoffice.rs index 5ef904d0..14d1dd45 100644 --- a/rust/crates/mnote-web/src/routes/onlyoffice.rs +++ b/rust/crates/mnote-web/src/routes/onlyoffice.rs @@ -1,19 +1,30 @@ use crate::app::AppConfig; use crate::app::AppState; use crate::error::WebError; -use adapter_onlyoffice::{prepare_proxy_request, sign_config, OnlyOfficeProxyPreparationInput}; +use adapter_onlyoffice::{ + prepare_callback, prepare_proxy_request, sign_config, OnlyOfficeCallbackPreparationInput, + OnlyOfficeProxyPreparationInput, +}; use axum::body::{Body, Bytes}; use axum::extract::{Path, Query, State}; -use axum::http::{header, HeaderMap, HeaderValue, Method, Request, Uri}; +use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri}; use axum::response::{Html, IntoResponse, Response}; use axum::Json; use base64::Engine; +use futures_util::{SinkExt, StreamExt}; +use hyper::upgrade::Upgraded; +use hyper_util::rt::TokioIo; use serde::Deserialize; use serde_json::{json, Value}; use std::env; use std::fs; use std::path::{Path as FsPath, PathBuf}; use std::time::Duration; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::handshake::derive_accept_key; +use tokio_tungstenite::tungstenite::protocol::Role; +use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; +use tokio_tungstenite::WebSocketStream; const ONLYOFFICE_PROBE_PATH: &str = "/web-apps/apps/api/documents/api.js"; const DEFAULT_ONLYOFFICE_INTERNAL_URL: &str = "http://127.0.0.1:8082"; @@ -136,6 +147,9 @@ pub struct OnlyOfficeCallbackQuery { asset_id: Option, #[serde(rename = "userId")] user_id: Option, + #[serde(rename = "rootUri")] + root_uri: Option, + path: Option, } #[derive(Debug, Deserialize)] @@ -429,6 +443,13 @@ pub async fn page(Query(query): Query) -> Result) -> Result {{ const script = document.createElement("script"); script.src = url; - script.onload = resolve; - script.onerror = () => reject(new Error("无法加载 ONLYOFFICE API: " + url)); + const cleanup = () => {{ script.onload = null; script.onerror = null; }}; + script.onload = () => {{ cleanup(); resolve(); }}; + script.onerror = () => {{ cleanup(); reject(new Error("无法加载 ONLYOFFICE API: " + url)); }}; document.head.appendChild(script); + if (timeoutMs > 0) {{ + const timer = window.setTimeout(() => {{ + cleanup(); + reject(new Error("ONLYOFFICE API 加载超时 (" + (timeoutMs / 1000) + "秒)")); + }}, timeoutMs); + resolve = ((orig) => (value) => {{ window.clearTimeout(timer); return orig(value); }})(resolve); + reject = ((orig) => (reason) => {{ window.clearTimeout(timer); return orig(reason); }})(reject); + }} }}); }} const MNOTE_ONLYOFFICE_FRAME_REV = "mnote-proxy-identity-20260511"; @@ -539,10 +585,15 @@ pub async fn page(Query(query): Query) -> Result {{ + if (Date.now() > bootDeadline) {{ + window.clearInterval(bootHeartbeat); + showError("ONLYOFFICE 页面初始化超时(" + (bootTimeoutMs / 1000) + "秒),请检查 DocumentServer 是否正常运行。"); + }} + }}, 5000); + try {{ + const userId = await resolveWhoami(); + const fileState = await resolveAssetUrlAndKey(); + if (!fileState.resolvedFileUrl) throw new Error("缺少 fileUrl 参数"); + await loadScript("/onlyoffice-server/web-apps/apps/api/documents/api.js", 15000); + await waitForDocEditorReady(60000); - const resolvedMode = initial.mode === "view" ? "view" : "edit"; - const config = {{ - width: "100%", - height: "100%", - documentType: docTypeFromExt(initial.fileType), - document: {{ + const resolvedMode = initial.mode === "view" ? "view" : "edit"; + const localFile = localFolderOpenParams(fileState.effectiveFileUrl || initial.fileUrl); + const displayUserId = String(userId || initial.userId || "mnote-local-user").trim() || "mnote-local-user"; + const displayUserName = displayUserId === "mnote-local-user" ? "MNote" : displayUserId; + const config = {{ + width: "100%", + height: "100%", + documentType: docTypeFromExt(initial.fileType), + document: {{ + fileType: initial.fileType, + title: initial.fileName, + url: fileState.resolvedFileUrl, + key: fileState.docKey, + permissions: {{ + edit: resolvedMode !== "view", + download: true, + print: true, + copy: true + }} + }}, + editorConfig: {{ + mode: resolvedMode, + lang: "zh-CN", + callbackUrl: buildCallbackUrl(initial.assetId, userId, localFile), + user: {{ + id: displayUserId, + name: displayUserName + }}, + customization: {{ + feedback: {{ visible: false }}, + anonymous: {{ request: false, label: "Guest" }}, + features: {{ featuresTips: false }}, + forcesave: resolvedMode !== "view" + }} + }}, + events: {{ + onDocumentReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }}, + onAppReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }}, + onError: (event) => showError(JSON.stringify(event)) + }} + }}; + window.__MNOTE_ONLYOFFICE_DEBUG__ = {{ + pageOrigin: location.origin, + baseUrl: "/onlyoffice-server", + documentUrlBase, + proxyOrigin, + callbackOrigin, + fileUrlInput: fileState.effectiveFileUrl, + resolvedFileUrl: fileState.resolvedFileUrl, + fileName: initial.fileName, fileType: initial.fileType, - title: initial.fileName, - url: fileState.resolvedFileUrl, - key: fileState.docKey, - permissions: {{ - edit: resolvedMode !== "view", - download: true, - print: true, - copy: true + mode: initial.mode, + resolvedMode, + assetId: initial.assetId, + documentId: initial.documentId, + docKey: fileState.docKey + }}; + const readyDeadline = Date.now() + 120000; + const timer = window.setInterval(() => {{ + const root = document.getElementById("onlyoffice-frame"); + const count = (root ? root.querySelectorAll("iframe,canvas").length : 0) + document.body.querySelectorAll("iframe,canvas").length; + if (count > 0) {{ + window.__MNOTE_ONLYOFFICE_READY__ = true; + window.clearInterval(timer); + }} else if (Date.now() > readyDeadline) {{ + window.clearInterval(timer); }} - }}, - editorConfig: {{ - mode: resolvedMode, - lang: "zh-CN", - callbackUrl: buildCallbackUrl(initial.assetId, userId), - customization: {{ - feedback: {{ visible: false }}, - forcesave: resolvedMode !== "view" - }} - }}, - events: {{ - onDocumentReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }}, - onAppReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }}, - onError: (event) => showError(JSON.stringify(event)) - }} - }}; - window.__MNOTE_ONLYOFFICE_DEBUG__ = {{ - pageOrigin: location.origin, - baseUrl: "/onlyoffice-server", - documentUrlBase, - proxyOrigin, - callbackOrigin, - fileUrlInput: fileState.effectiveFileUrl, - resolvedFileUrl: fileState.resolvedFileUrl, - fileName: initial.fileName, - fileType: initial.fileType, - mode: initial.mode, - resolvedMode, - assetId: initial.assetId, - documentId: initial.documentId, - docKey: fileState.docKey - }}; - const readyDeadline = Date.now() + 120000; - const timer = window.setInterval(() => {{ - const root = document.getElementById("onlyoffice-frame"); - const count = (root ? root.querySelectorAll("iframe,canvas").length : 0) + document.body.querySelectorAll("iframe,canvas").length; - if (count > 0) {{ - window.__MNOTE_ONLYOFFICE_READY__ = true; - window.clearInterval(timer); - }} else if (Date.now() > readyDeadline) {{ - window.clearInterval(timer); - }} - }}, 500); - const signResponse = await fetch("/api/onlyoffice/sign", {{ - method: "POST", - headers: {{ "content-type": "application/json" }}, - body: JSON.stringify({{ config }}) - }}); - const signPayload = await signResponse.json().catch(() => null); - if (!signResponse.ok) throw new Error(signPayload && signPayload.message || signPayload && signPayload.error || "OnlyOffice 签名失败"); - if (signPayload.token) config.token = signPayload.token; - if (signPayload.documentToken) config.document.token = signPayload.documentToken; - if (signPayload.editorConfigToken) config.editorConfig.token = signPayload.editorConfigToken; - installOnlyOfficeFrameSrcPatch(); - window.__MNOTE_ONLYOFFICE_EDITOR__ = new window.DocsAPI.DocEditor("onlyoffice-frame", config); + }}, 500); + const signResponse = await fetch("/api/onlyoffice/sign", {{ + method: "POST", + headers: {{ "content-type": "application/json" }}, + body: JSON.stringify({{ config }}) + }}); + const signPayload = await signResponse.json().catch(() => null); + if (!signResponse.ok) throw new Error(signPayload && signPayload.message || signPayload && signPayload.error || "OnlyOffice 签名失败"); + if (signPayload.token) config.token = signPayload.token; + if (signPayload.documentToken) config.document.token = signPayload.documentToken; + if (signPayload.editorConfigToken) config.editorConfig.token = signPayload.editorConfigToken; + installOnlyOfficeFrameSrcPatch(); + window.__MNOTE_ONLYOFFICE_EDITOR__ = new window.DocsAPI.DocEditor("onlyoffice-frame", config); + }} finally {{ + window.clearInterval(bootHeartbeat); + }} }} - boot().catch((error) => showError(error && error.message ? error.message : error)); + boot().catch((error) => {{ + showError(error && error.message ? error.message : error); + }}); "#, @@ -972,6 +1047,131 @@ fn proxy_local_folder_file_open( Ok(Some(response)) } +fn is_onlyoffice_local_asset_id(asset_id: &str) -> bool { + let value = asset_id.trim(); + value.starts_with("local:") || value.starts_with("local-file:") +} + +fn onlyoffice_callback_success(extra: Value) -> Response { + let mut payload = json!({ "error": 0 }); + if let (Some(object), Some(extra_object)) = (payload.as_object_mut(), extra.as_object()) { + for (key, value) in extra_object { + object.insert(key.clone(), value.clone()); + } + } + Json(payload).into_response() +} + +fn onlyoffice_callback_failure(error: WebError) -> Response { + Json(json!({ + "error": 1, + "code": error.code(), + "message": error.message(), + })) + .into_response() +} + +async fn download_onlyoffice_callback_body(download_url: &str) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(60)) + .build() + .map_err(|error| { + WebError::internal(format!("OnlyOffice callback HTTP 客户端创建失败: {error}")) + })?; + let response = client.get(download_url).send().await.map_err(|error| { + WebError::bad_gateway_code( + "onlyoffice_local_callback_download_failed", + format!("OnlyOffice 保存文件下载失败: {error}"), + ) + })?; + let status = response.status(); + if !status.is_success() { + return Err(WebError::bad_gateway_code( + "onlyoffice_local_callback_download_failed", + format!("OnlyOffice 保存文件下载失败: HTTP {status}"), + )); + } + response.bytes().await.map_err(|error| { + WebError::bad_gateway_code( + "onlyoffice_local_callback_body_failed", + format!("OnlyOffice 保存文件读取失败: {error}"), + ) + }) +} + +async fn local_folder_onlyoffice_callback( + query: &OnlyOfficeCallbackQuery, + body: &Value, + status: i64, +) -> Result { + let asset_id = query.asset_id.as_deref().unwrap_or("").trim(); + let root_uri = query + .root_uri + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code( + "onlyoffice_local_callback_root_missing", + "OnlyOffice 本地保存缺少 rootUri", + ) + })?; + let relative_path = query + .path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code( + "onlyoffice_local_callback_path_missing", + "OnlyOffice 本地保存缺少 path", + ) + })?; + let target = resolve_onlyoffice_local_file_path(root_uri, relative_path)?; + let onlyoffice_internal_url = resolve_onlyoffice_internal_url().await; + let prepared = prepare_callback(OnlyOfficeCallbackPreparationInput { + asset_id: asset_id.to_string(), + document_id: None, + workspace_id: None, + user_id: query.user_id.clone(), + session_id: None, + status, + url: body + .get("url") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + key: body + .get("key") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + onlyoffice_internal_url, + }) + .map_err(|error| WebError::bad_request_code("onlyoffice_local_callback_invalid", error))?; + if !prepared.should_write { + return Ok(onlyoffice_callback_success(json!({ + "localWrite": false, + "status": status, + }))); + } + let download_url = prepared.download_url.as_deref().ok_or_else(|| { + WebError::bad_request_code( + "onlyoffice_local_callback_url_missing", + "OnlyOffice 本地保存缺少下载地址", + ) + })?; + let bytes = download_onlyoffice_callback_body(download_url).await?; + fs::write(&target, &bytes).map_err(|error| { + WebError::bad_request_code( + "onlyoffice_local_callback_write_failed", + format!("写回本地 Office 文件失败: {error}"), + ) + })?; + Ok(onlyoffice_callback_success(json!({ + "localWrite": true, + "bytes": bytes.len(), + }))) +} + pub async fn callback( State(state): State, uri: Uri, @@ -988,6 +1188,19 @@ pub async fn callback( status, "OnlyOffice callback received by mnote-web" ); + let is_local_callback = query + .asset_id + .as_deref() + .map(is_onlyoffice_local_asset_id) + .unwrap_or(false) + || query.root_uri.as_deref().is_some() + || query.path.as_deref().is_some(); + if is_local_callback { + return match local_folder_onlyoffice_callback(&query, &body, status).await { + Ok(response) => response, + Err(error) => onlyoffice_callback_failure(error), + }; + } match proxy_legacy_onlyoffice_json( state.config(), "/api/onlyoffice/callback", @@ -1308,14 +1521,101 @@ async fn proxy_onlyoffice_path( Ok(response) } +fn onlyoffice_websocket_url(base: &str, upstream_path: &str, query: Option<&str>) -> String { + let mut target = append_path_and_query(base, upstream_path, query); + if let Some(rest) = target.strip_prefix("http://") { + target = format!("ws://{rest}"); + } else if let Some(rest) = target.strip_prefix("https://") { + target = format!("wss://{rest}"); + } + target +} + +async fn bridge_onlyoffice_websocket(upgraded: Upgraded, target: String) { + let client_io = TokioIo::new(upgraded); + let mut client_socket = WebSocketStream::from_raw_socket(client_io, Role::Server, None).await; + let Ok((mut upstream_socket, _response)) = connect_async(&target).await else { + let _ = client_socket.close(None).await; + return; + }; + + loop { + tokio::select! { + client_message = client_socket.next() => { + let Some(Ok(message)) = client_message else { + let _ = upstream_socket.send(TungsteniteMessage::Close(None)).await; + break; + }; + let is_close = matches!(message, TungsteniteMessage::Close(_)); + if upstream_socket.send(message).await.is_err() { + break; + } + if is_close { + break; + } + } + upstream_message = upstream_socket.next() => { + let Some(Ok(message)) = upstream_message else { + let _ = client_socket.send(TungsteniteMessage::Close(None)).await; + break; + }; + let is_close = matches!(message, TungsteniteMessage::Close(_)); + if client_socket.send(message).await.is_err() { + break; + } + if is_close { + break; + } + } + } + } +} + pub async fn server_proxy( State(_state): State, Path(path): Path, uri: Uri, method: Method, headers: HeaderMap, - request: Request, + mut request: Request, ) -> Result { + let upgrade = headers + .get(header::UPGRADE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.eq_ignore_ascii_case("websocket")) + .unwrap_or(false); + if upgrade { + let key = headers + .get(header::SEC_WEBSOCKET_KEY) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| { + WebError::bad_request_code( + "onlyoffice_websocket_key_missing", + "缺少 Sec-WebSocket-Key", + ) + })?; + let target = + onlyoffice_websocket_url(&resolve_onlyoffice_internal_url().await, &path, uri.query()); + let upgraded = hyper::upgrade::on(&mut request); + tokio::spawn(async move { + if let Ok(upgraded) = upgraded.await { + bridge_onlyoffice_websocket(upgraded, target).await; + } + }); + let response = Response::builder() + .status(StatusCode::SWITCHING_PROTOCOLS) + .header(header::CONNECTION, "Upgrade") + .header(header::UPGRADE, "websocket") + .header( + header::SEC_WEBSOCKET_ACCEPT, + derive_accept_key(key.as_bytes()), + ) + .body(Body::empty()) + .map_err(|error| { + WebError::internal(format!("构造 OnlyOffice WebSocket 响应失败: {error}")) + })?; + return Ok(response); + } proxy_onlyoffice_path("", &path, uri, method, headers, request).await } @@ -1332,13 +1632,43 @@ pub async fn cache_proxy( #[cfg(test)] pub fn stable_doc_key(asset_id: &str, storage_id: &str, file_url: &str, file_name: &str) -> String { - if !asset_id.trim().is_empty() { + let raw = if !asset_id.trim().is_empty() { if storage_id.trim().is_empty() { - return asset_id.trim().to_string(); + asset_id.trim().to_string() + } else { + format!("{}_{}", asset_id.trim(), js_hash_abs(storage_id)) } - return format!("{}_{}", asset_id.trim(), js_hash_abs(storage_id)); + } else { + js_hash_abs(&format!("{file_url}-{file_name}")) + }; + safe_onlyoffice_doc_key(&raw, file_name) +} + +#[cfg(test)] +fn safe_onlyoffice_doc_key(input: &str, file_name: &str) -> String { + let raw = input.trim(); + if raw.is_empty() { + return format!("mnote_{}", js_hash_abs(file_name)); } - js_hash_abs(&format!("{file_url}-{file_name}")) + let mut safe = String::new(); + let mut previous_underscore = false; + for ch in raw.chars() { + let allowed = ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-'); + let next = if allowed { ch } else { '_' }; + if next == '_' { + if previous_underscore { + continue; + } + previous_underscore = true; + } else { + previous_underscore = false; + } + safe.push(next); + } + if !safe.is_empty() && safe == raw && safe.len() <= 96 { + return safe; + } + format!("mnote_{}_{}", js_hash_abs(raw), js_hash_abs(file_name)) } #[cfg(test)] @@ -1374,6 +1704,24 @@ mod tests { assert!(key.starts_with("asset_1_")); } + #[test] + fn stable_doc_key_hashes_local_unicode_asset_ids() { + let key = stable_doc_key( + "local:asset:Alpha/重庆发展特殊化妆品可行性报告_政府汇报版.docx", + "", + "", + "重庆发展特殊化妆品可行性报告_政府汇报版.docx", + ); + assert!(key.len() <= 128); + assert!(key.starts_with("mnote_")); + assert!(key + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-'))); + assert!(!key.contains('/')); + assert!(!key.contains(':')); + assert!(!key.contains('重')); + } + #[tokio::test] async fn onlyoffice_object_shell_exposes_resource_identity() { let response = object_shell( @@ -1533,6 +1881,8 @@ mod tests { Query(OnlyOfficeCallbackQuery { asset_id: Some("asset_1".into()), user_id: None, + root_uri: None, + path: None, }), Json(json!({ "status": 2, @@ -1550,6 +1900,85 @@ mod tests { assert_eq!(payload["degraded"], true); } + #[tokio::test] + async fn onlyoffice_local_callback_writes_status_two_body_to_original_file() { + let root = std::env::temp_dir().join(format!( + "mnote-onlyoffice-local-callback-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("Page")).expect("create page"); + let target = root.join("Page").join("report.docx"); + fs::write(&target, b"old").expect("write old docx"); + let (download_url, _captured) = spawn_legacy_json_server("new docx bytes").await; + let response = callback( + State(test_state(None)), + format!( + "/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx", + root.display() + ) + .parse::() + .expect("uri"), + Query(OnlyOfficeCallbackQuery { + asset_id: Some("local:asset:Page/report.docx".into()), + user_id: None, + root_uri: Some(format!("file://{}", root.display())), + path: Some("Page/report.docx".into()), + }), + Json(json!({ + "status": 2, + "key": "doc_key", + "url": download_url + })), + ) + .await; + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + + assert_eq!(payload["error"], 0); + assert_eq!(fs::read(&target).expect("read target"), b"new docx bytes"); + let _ = fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn onlyoffice_local_callback_ignores_non_write_status() { + let root = std::env::temp_dir().join(format!( + "mnote-onlyoffice-local-callback-ignore-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("Page")).expect("create page"); + let target = root.join("Page").join("report.docx"); + fs::write(&target, b"old").expect("write old docx"); + let response = callback( + State(test_state(None)), + format!( + "/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx", + root.display() + ) + .parse::() + .expect("uri"), + Query(OnlyOfficeCallbackQuery { + asset_id: Some("local:asset:Page/report.docx".into()), + user_id: None, + root_uri: Some(format!("file://{}", root.display())), + path: Some("Page/report.docx".into()), + }), + Json(json!({ "status": 1 })), + ) + .await; + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + + assert_eq!(payload["error"], 0); + assert_eq!(fs::read(&target).expect("read target"), b"old"); + let _ = fs::remove_dir_all(&root); + } + #[tokio::test] async fn onlyoffice_callback_proxies_to_legacy_next_writeback() { let (base_url, captured) = spawn_legacy_json_server(r#"{"error":0}"#).await; @@ -1561,6 +1990,8 @@ mod tests { Query(OnlyOfficeCallbackQuery { asset_id: Some("asset_1".into()), user_id: Some("user_1".into()), + root_uri: None, + path: None, }), Json(json!({ "status": 2, @@ -1635,4 +2066,50 @@ mod tests { assert!(request .starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1")); } + + #[tokio::test] + async fn onlyoffice_page_skips_media_sign_for_local_folder_asset() { + let response = page(Query(OnlyOfficePageQuery { + file_url: Some( + "http://localhost:3000/api/local-folder/files/open?rootUri=file:///tmp&path=Page/report.docx" + .into(), + ), + file_name: Some("report.docx".into()), + file_type: Some("docx".into()), + asset_id: Some("local-file:Page/report.docx".into()), + document_id: Some("local-md:Page".into()), + user_id: None, + mode: Some("view".into()), + })) + .await + .expect("onlyoffice page"); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let html = String::from_utf8(body.to_vec()).expect("html"); + + // 1) Guard function is present + assert!(html.contains("function isLocalFolderAsset()")); + // 2) Condition uses guard to skip /api/media/sign + assert!(html.contains("if (initial.assetId && !isLocalFolderAsset())")); + // 3) local: prefix detection + assert!(html.contains("initial.assetId.indexOf(\"local:\") === 0")); + // 4) local-file: prefix detection + assert!(html.contains("initial.assetId.indexOf(\"local-file:\") === 0")); + // 5) fileUrl path detection for /api/local-folder/files/open + assert!(html.contains( + "initial.fileUrl && initial.fileUrl.indexOf(\"/api/local-folder/files/open\") !== -1" + )); + // 6) Non-local asset still fetches /api/media/sign (general code path preserved) + assert!(html.contains("/api/media/sign?assetId=")); + // 7) local rootUri/path are propagated into callback for save writeback + assert!(html.contains("function localFolderOpenParams(raw)")); + assert!(html.contains("callback.searchParams.set(\"rootUri\", localFile.rootUri);")); + assert!(html.contains("callback.searchParams.set(\"path\", localFile.path);")); + // 8) user name is explicit so OnlyOffice does not ask for collaboration name + assert!(html.contains("user: {")); + assert!(html.contains("name: displayUserName")); + assert!(html.contains("anonymous: { request: false, label: \"Guest\" }")); + assert!(html.contains("features: { featuresTips: false }")); + } } diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index f4f55c5b..106de1d5 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -465,6 +465,8 @@ pub(crate) fn render_document_title_controller_script() -> &'static str { document.title = title; const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]'); if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title; + const pageTabTitle = document.querySelector('[data-mnote-main-tab="page"] .mnote-main-tab-title'); + if (pageTabTitle instanceof HTMLElement) pageTabTitle.textContent = title; } if (documentId) { const escapedId = cssEscape(documentId); @@ -2616,6 +2618,16 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { document.title = title; const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]'); if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title; + const pageTab = document.querySelector('[data-mnote-main-tab="page"]'); + if (pageTab instanceof HTMLElement) { + pageTab.setAttribute('data-document-id', documentId); + pageTab.setAttribute('data-workspace-id', workspaceId); + const pageTabTitle = pageTab.querySelector('.mnote-main-tab-title'); + if (pageTabTitle instanceof HTMLElement) pageTabTitle.textContent = title; + } + window.dispatchEvent(new CustomEvent('mnote:primary-document-activated', { + detail: { documentId, workspaceId, title } + })); document.documentElement.removeAttribute('data-mnote-side-target-unsupported'); document.documentElement.removeAttribute('data-mnote-side-target-asset-id'); document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach((row) => { @@ -2986,12 +2998,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const anchorElement = anchorNode && anchorNode.nodeType === Node.ELEMENT_NODE ? anchorNode : anchorNode?.parentElement || null; - const roots = Array.from(document.querySelectorAll(ROOT_SELECTOR)).filter((node) => node instanceof HTMLElement); + const roots = Array.from(document.querySelectorAll(ROOT_SELECTOR)).filter((node) => ( + node instanceof HTMLElement + && node.offsetParent !== null + && node.getAttribute('data-mnote-side-target-unsupported') !== 'true' + )); if (anchorElement instanceof Element) { const activeRoot = roots.find((root) => root.contains(anchorElement)); if (activeRoot) return activeRoot; } - return roots.find((root) => root.offsetParent !== null) || roots[0] || null; + const focused = document.activeElement instanceof Element + ? roots.find((root) => root.contains(document.activeElement)) + : null; + if (focused) return focused; + return roots.find((root) => root.querySelector('.ProseMirror:focus-within')) || roots[0] || null; }; const slashMenuAnchorFromSelection = (root) => { @@ -3042,12 +3062,42 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { return fallback(); }; - const positionSlashMenuForRoot = (root) => { - if (!(root instanceof HTMLElement)) root = activeEditorRootForSlashMenu(); - if (!(root instanceof HTMLElement)) return; - const menu = root.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]') - || document.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]'); + const setSlashMenuInactive = (menu, inactive) => { if (!(menu instanceof HTMLElement)) return; + if (inactive) { + if (menu.getAttribute('data-mnote-slash-inactive') !== 'true') { + menu.setAttribute('data-mnote-slash-inactive', 'true'); + } + if (menu.style.display !== 'none') menu.style.display = 'none'; + return; + } + if (menu.getAttribute('data-mnote-slash-inactive') === 'true') { + menu.removeAttribute('data-mnote-slash-inactive'); + } + if (menu.style.display === 'none') menu.style.display = ''; + }; + + const hideSlashMenusOutsideRoot = (activeRoot) => { + document.querySelectorAll(`${ROOT_SELECTOR} [data-testid="mnote-leptos-tiptap-slash-menu"]`).forEach((menu) => { + const root = menu.closest(ROOT_SELECTOR); + if (root !== activeRoot) setSlashMenuInactive(menu, true); + }); + }; + + const positionSlashMenuForRoot = (root) => { + const activeRoot = activeEditorRootForSlashMenu(); + if (!(root instanceof HTMLElement)) root = activeRoot; + if (!(root instanceof HTMLElement)) return; + if (activeRoot instanceof HTMLElement && root !== activeRoot) { + const inactiveMenu = root.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]'); + setSlashMenuInactive(inactiveMenu, true); + hideSlashMenusOutsideRoot(activeRoot); + return; + } + hideSlashMenusOutsideRoot(root); + const menu = root.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]'); + if (!(menu instanceof HTMLElement)) return; + setSlashMenuInactive(menu, false); const anchor = slashMenuAnchorFromSelection(root); const gap = 8; const menuWidth = Math.min(316, Math.max(160, window.innerWidth - 16)); @@ -3611,6 +3661,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { } }; + const refreshExistingOfficeResourceTab = (entry, input) => { + if (!entry || entry.kind !== 'office') return false; + const nextHref = String(input.officeUrl || input.href || '').trim(); + if (!nextHref) return false; + const frame = entry.panel?.querySelector?.('iframe.mnote-resource-tab-frame'); + const currentHref = frame instanceof HTMLIFrameElement + ? String(frame.getAttribute('src') || frame.src || '').trim() + : ''; + if (currentHref !== nextHref) openPassiveResourceTab(entry, input); + return true; + }; + const openMindmapResourceTab = async (entry, input) => { const documentId = String(input.documentId || currentDocumentId() || '').trim(); const mindmapId = String(input.mindmapId || input.assetId || '').trim(); @@ -3668,6 +3730,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const title = String(input.title || input.fileName || input.assetId || '资源').trim() || '资源'; if (root instanceof HTMLElement) { root.replaceChildren(); + document.querySelectorAll('[data-document-pane="true"][data-pane-role="secondary"] [data-testid="mnote-leptos-tiptap-slash-menu"]').forEach((node) => { + if (node instanceof HTMLElement) node.remove(); + }); root.setAttribute('data-editor-host-kind', 'unsupported_resource_side_target'); root.setAttribute('data-mnote-side-target-unsupported', 'true'); root.setAttribute('data-mnote-side-target-asset-id', String(input.assetId || '')); @@ -3691,6 +3756,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (!objectIdentity) return false; const existing = resourceTabRegistry.get(objectIdentity); if (existing) { + refreshExistingOfficeResourceTab(existing, input); activateMainEditorTab(objectIdentity); return true; } @@ -3754,6 +3820,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const descriptor = descriptorFromCurrentUrl('primary', id, { workspaceId, sourceKind, rootUri }); await replacePaneDocument('primary', descriptor); updatePrimaryUrl(descriptor, url instanceof URL ? url : null); + activateMainEditorTab(''); return true; }, openPrimaryMindmap: async ({ documentId, mindmapId, workspaceId, url } = {}) => { @@ -3778,6 +3845,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { }, resolveResourceOpen: (input) => resolveResourceOpen(input), openResourceInActiveTab: openResourceInActiveTab, + activatePageTab: () => { + bindMainEditorPageTab(); + activateMainEditorTab(''); + return true; + }, openResourceAsSideTarget: async (input = {}) => { const resolved = resolveResourceOpen({ ...input, openTarget: 'side' }); if (resolved.editorKind === 'markdown' || resolved.editorKind === 'text' || resolved.editorKind === 'code') { @@ -4466,12 +4538,25 @@ mod tests { assert!(html.contains("data-mnote-main-tab-strip")); assert!(html.contains("class=\"mnote-main-tab-badge\"")); assert!(!html.contains(">description")); + assert!(html.contains("const hideSlashMenusOutsideRoot = (activeRoot) =>")); + assert!(html.contains("data-mnote-slash-inactive")); + assert!(html.contains("if (activeRoot instanceof HTMLElement && root !== activeRoot)")); + assert!(!html.contains( + "|| document.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]')" + )); + assert!(html.contains("data-mnote-side-target-unsupported') !== 'true'")); assert!( html.contains("runtimeDescriptor.root.addEventListener(STATE_EVENT, view.onState);") ); @@ -4509,6 +4594,12 @@ mod tests { assert!(html.contains("resourceTabRegistry.delete(key)")); assert!(html.contains("activateMainEditorTab(lastActiveResourceTabKey())")); assert!(html.contains("resourceTabBadgeKind(input, kind)")); + assert!(html.contains("const refreshExistingOfficeResourceTab = (entry, input) =>")); + assert!(html.contains("if (!entry || entry.kind !== 'office') return false;")); + assert!( + html.contains("if (currentHref !== nextHref) openPassiveResourceTab(entry, input);") + ); + assert!(html.contains("refreshExistingOfficeResourceTab(existing, input);")); } #[tokio::test] @@ -4769,7 +4860,7 @@ mod tests { assert!(html.contains("data-row-kind=\"markdown\"")); assert!(html.contains("data-page-openable=\"false\"")); assert!(html.contains("fileAction === 'open' && rowKind === 'folder'")); - assert!(html.contains("btn.getAttribute('data-page-openable') === 'false'")); + assert!(html.contains("openTrigger.getAttribute('data-page-openable') === 'false'")); assert!(html.contains("data-mnote-action=\"open-local-folder\"")); assert!(html.contains("refreshSessionFromExternalFileChange")); assert!(html.contains("refreshSessionFromExternalChange")); diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 13b16f37..bac80f50 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -1253,6 +1253,9 @@ const SIDEBAR_TREE_JS: &str = r##" var treeView = normalizeSidebarTreeMode(options && options.treeView ? options.treeView : activeSidebarTreeMode()); persistSidebarTreeMode(treeView); if (currentDocumentId() === nodeId && window.location.pathname.indexOf('/documents/') === 0) { + if (typeof window.__mnoteDocumentPaneRuntime?.activatePageTab === 'function') { + window.__mnoteDocumentPaneRuntime.activatePageTab(); + } document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach(function(row) { if (row instanceof HTMLElement) { row.setAttribute('data-active', 'false'); @@ -1268,6 +1271,7 @@ const SIDEBAR_TREE_JS: &str = r##" } } }); + selectSidebarFileTreeDocument(nodeId, { scrollIntoView: true }); return; } var targetUrl = new URL('/documents/' + encodeURIComponent(nodeId), window.location.origin); @@ -1289,6 +1293,7 @@ const SIDEBAR_TREE_JS: &str = r##" }).then(function(){ if (mnoteNavigationInFlight === url) mnoteNavigationInFlight = ''; document.documentElement.removeAttribute('data-mnote-navigation-pending'); + selectSidebarFileTreeDocument(nodeId, { scrollIntoView: true }); }).catch(function(error){ console.warn('mnote pane 内导航失败,将回退整页导航', error); if (window.__mnoteTreeLiveEventSource && typeof window.__mnoteTreeLiveEventSource.close === 'function') { @@ -1323,9 +1328,32 @@ const SIDEBAR_TREE_JS: &str = r##" window.__mnoteLocalFolderSelfChangeSuppressions = window.__mnoteLocalFolderSelfChangeSuppressions || new Map(); window.__mnoteLocalFolderSelfChangeSuppressions.set(nextDocumentId, Date.now() + 5000); } + if (nextDocumentId && currentSourceKind() === 'local_folder') { + await refreshLocalFolderSidebarSnapshot(); + selectSidebarFileTreeDocument(nextDocumentId, { scrollIntoView: true }); + document.documentElement.setAttribute('data-mnote-create-page-selected-document-id', nextDocumentId); + } navigateToDocument(nextDocumentId, nextWorkspaceId, { treeView: activeSidebarTreeMode() }); } + async function createFileTreeFolder(trigger, parentId) { + if (currentSourceKind() !== 'local_folder') return false; + var workspaceId = resolveWorkspaceId(trigger || document.body); + var effectiveParentId = String(parentId || '').trim(); + var title = window.prompt('新建文件夹', '新建文件夹'); + if (!title || !title.trim()) return false; + var result = await dispatchTreeCommand(trigger || document.body, { + action: 'create_folder', + workspaceId: workspaceId, + parentId: effectiveParentId || null, + title: title.trim() + }); + document.documentElement.setAttribute('data-mnote-filetree-folder-created', 'true'); + document.documentElement.setAttribute('data-mnote-filetree-folder-created-id', commandDocumentId(result, result.id || '')); + void refreshLocalFolderSidebarSnapshot(); + return true; + } + function applySidebarTreeTab(mode, shell) { mode = persistSidebarTreeMode(mode); shell = shell || document.querySelector('[data-testid="wolai-sidebar-page-tree-shell"]'); @@ -2063,7 +2091,7 @@ const SIDEBAR_TREE_JS: &str = r##" if (input.assetId) target.searchParams.set('assetId', input.assetId); if (input.documentId) target.searchParams.set('documentId', input.documentId); if (input.userId) target.searchParams.set('userId', input.userId); - target.searchParams.set('mode', input.mode || 'edit'); + target.searchParams.set('mode', input.mode || 'view'); return target.toString(); } @@ -2075,7 +2103,7 @@ const SIDEBAR_TREE_JS: &str = r##" if (input.assetId) params.set('assetId', input.assetId); if (input.documentId) params.set('documentId', input.documentId); if (input.userId) params.set('userId', input.userId); - params.set('mode', input.mode || 'edit'); + params.set('mode', input.mode || 'view'); if (input.documentId && input.assetId) { return '/office/' + encodeURIComponent(input.documentId) + '/' + encodeURIComponent(input.assetId) + '?' + params.toString(); } @@ -2094,7 +2122,7 @@ const SIDEBAR_TREE_JS: &str = r##" assetId: assetId || ('local-file:' + relativePath), documentId: documentId || currentDocumentId() || '', userId: '', - mode: mode || 'edit' + mode: mode || 'view' }); } @@ -2224,6 +2252,7 @@ const SIDEBAR_TREE_JS: &str = r##" if (!assetId) return; var openTarget = String(detail && detail.openTarget || '').trim().toLowerCase(); var forceNewWindow = openTarget === 'new-window'; + var forceEditMode = openTarget === 'edit-mode'; if (openTarget === 'side') { if (typeof window.__mnoteDocumentPaneRuntime?.openResourceAsSideTarget === 'function') { void window.__mnoteDocumentPaneRuntime.openResourceAsSideTarget({ @@ -2261,7 +2290,7 @@ const SIDEBAR_TREE_JS: &str = r##" navigateToMindmapObject(String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, String(detail.workspaceId || '').trim()); return; } - var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, forceNewWindow ? 'edit' : 'view'); + var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, forceEditMode ? 'edit' : 'view'); if (localOfficeUrl) { if (!forceNewWindow && await openLocalResourceInActiveTab({ path: localFilePath, @@ -2328,15 +2357,29 @@ const SIDEBAR_TREE_JS: &str = r##" var fileType = inferOnlyOfficeFileType(fileName, asset.mime_type); if (fileType) { var userId = await fetchCurrentOnlyOfficeUserId(); - window.open(buildOnlyOfficeOpenUrl({ + var officeUrl = buildOnlyOfficeOpenUrl({ fileUrl: fileUrl, fileName: fileName, fileType: fileType, assetId: assetId, documentId: String(asset.document_id || detail.documentId || '').trim(), userId: userId, - mode: 'edit' - }), '_blank', 'noopener,noreferrer'); + mode: forceEditMode ? 'edit' : 'view' + }); + if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') { + var didOpen = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({ + objectIdentity: 'resource:onlyoffice:' + String(asset.document_id || detail.documentId || '').trim() + ':' + assetId, + assetId: assetId, + title: fileName, + fileName: fileName, + kind: 'office', + officeUrl: officeUrl, + documentId: String(asset.document_id || detail.documentId || '').trim(), + workspaceId: String(detail.workspaceId || '').trim() + }); + if (didOpen) return; + } + window.open(officeUrl, '_blank', 'noopener,noreferrer'); return; } if (isCodeAttachmentFileName(fileName) && String(asset.document_id || detail.documentId || '').trim() === currentDocumentId()) { @@ -2695,7 +2738,7 @@ const SIDEBAR_TREE_JS: &str = r##" assetId: assetId, documentId: String(asset && (asset.document_id || asset.documentId) || currentDocumentId() || '').trim(), userId: userId || '', - mode: 'edit' + mode: 'view' }); } return buildOnlyOfficeOpenUrl({ @@ -2705,7 +2748,7 @@ const SIDEBAR_TREE_JS: &str = r##" assetId: assetId, documentId: String(asset && (asset.document_id || asset.documentId) || currentDocumentId() || '').trim(), userId: userId || '', - mode: 'edit' + mode: 'view' }); } @@ -2803,7 +2846,7 @@ const SIDEBAR_TREE_JS: &str = r##" assetId: detail.assetId, documentId: detail.documentId || currentDocumentId() || '', userId: '', - mode: 'edit' + mode: 'view' })); link.setAttribute('data-mnote-attachment-link', 'true'); link.setAttribute('data-asset-id', detail.assetId); @@ -3172,7 +3215,7 @@ const SIDEBAR_TREE_JS: &str = r##" assetId: assetId, documentId: String(asset && (asset.document_id || asset.documentId) || currentDocumentId() || '').trim(), userId: userId || '', - mode: 'edit' + mode: 'view' })) : href; var inserted = editor.chain().focus().insertContent({ @@ -3557,6 +3600,57 @@ const SIDEBAR_TREE_JS: &str = r##" }); } + function fileTreeCopyPath(detail, trigger) { + // 对 local_folder 复制真实相对路径,不复制标题 + if (currentSourceKind() === 'local_folder') { + if (detail.assetId) { + var path = localFilePathFromAssetId(detail.assetId); + if (path) { + try { + return decodeURIComponent(path.replace(/~2F/g, '/')); + } catch (_) { + return path.replace(/~2F/g, '/'); + } + } + } + if (detail.documentId) { + var docPath = String(detail.documentId || '') + .replace(/^local-md:/, '') + .replace(/^local-dir:/, '') + .replace(/~2F/g, '/'); + if (docPath) { + try { + return decodeURIComponent(docPath); + } catch (_) { + return docPath; + } + } + } + } + return detail.title || ''; + } + + function fileTreeMenuTargetParentId(detail, trigger) { + var rowKind = String(detail && detail.rowKind || '').trim(); + var rowId = String(detail && detail.rowId || '').trim(); + var documentId = String(detail && detail.documentId || '').trim(); + if (rowKind === 'folder' && rowId) return rowId; + if (rowKind === 'directory' && rowId) return rowId; + if (documentId) return documentId; + if (trigger && typeof trigger.getAttribute === 'function') { + var triggerKind = String(trigger.getAttribute('data-row-kind') || '').trim(); + var triggerRowId = String(trigger.getAttribute('data-row-id') || '').trim(); + if ((triggerKind === 'folder' || triggerKind === 'directory') && triggerRowId) return triggerRowId; + } + return ''; + } + + function withOfficeEditModeGuard(callback) { + document.documentElement.setAttribute('data-mnote-last-office-edit-mode-requested', 'true'); + document.documentElement.setAttribute('data-mnote-last-office-edit-mode-guard', 'silent'); + callback(); + } + function handleTreeContextMenuAction(action, detail, trigger) { closeTreeContextMenu(); detail = detail || {}; @@ -3577,6 +3671,18 @@ const SIDEBAR_TREE_JS: &str = r##" openEditorAttachmentNewWindow(detail); return; } + if (action === 'new-window-edit') { + withOfficeEditModeGuard(function() { + openEditorAttachmentNewWindow(detail, 'edit'); + }); + return; + } + if (action === 'open-edit-mode') { + withOfficeEditModeGuard(function() { + void openEditorAttachmentEditTab(detail); + }); + return; + } if (action === 'right-preview') { dispatchSidebarEvent('tree.attachment.open-right', detail); return; @@ -3597,6 +3703,13 @@ const SIDEBAR_TREE_JS: &str = r##" void openConvexAssetFromFileTree({ ...detail, openTarget: 'new-window' }); return; } + if (isAsset && action === 'open-edit-mode') { + withOfficeEditModeGuard(function() { + recordFileTreeAction('open-edit-mode', detail); + void openConvexAssetFromFileTree({ ...detail, openTarget: 'edit-mode' }); + }); + return; + } if (isAsset && action === 'open-right') { recordFileTreeAction('open-right', detail); void openConvexAssetFromFileTree({ ...detail, openTarget: 'side' }); @@ -3645,7 +3758,17 @@ const SIDEBAR_TREE_JS: &str = r##" return; } if (action === 'new-file') { - void createPage(trigger || document.body, documentId || null); + void createPage(trigger || document.body, fileTreeMenuTargetParentId(detail, trigger) || null); + return; + } + if (action === 'new-folder') { + recordFileTreeAction('new-folder', detail); + void createFileTreeFolder(trigger || document.body, fileTreeMenuTargetParentId(detail, trigger) || null).then(function(ok) { + recordFileTreeActionStatus(ok ? 'created' : 'skipped', detail); + }).catch(function(error) { + recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' })); + window.alert(error && error.message ? error.message : '新建文件夹失败'); + }); return; } if (action === 'paste-into') { @@ -3660,7 +3783,7 @@ const SIDEBAR_TREE_JS: &str = r##" return; } if (action === 'copy-path') { - void copyTreeContextValue(title, 'copy-path'); + void copyTreeContextValue(fileTreeCopyPath(detail, trigger), 'copy-path'); return; } if (action === 'refresh') { @@ -3790,6 +3913,8 @@ const SIDEBAR_TREE_JS: &str = r##" { separator: true }, { action: 'popup-preview', icon: 'preview', label: '弹窗预览' }, { action: 'right-preview', icon: 'right_panel_open', label: '右侧预览' }, + { action: 'open-edit-mode', icon: 'edit_note', label: '弹窗编辑' }, + { action: 'new-window-edit', icon: 'open_in_new', label: '新窗口编辑' }, { action: 'new-window', icon: 'open_in_new', label: '在新窗口打开' }, { action: 'download', icon: 'download', label: '下载' }, { action: 'replace-file', icon: 'sync', label: '更换文件' }, @@ -3799,7 +3924,7 @@ const SIDEBAR_TREE_JS: &str = r##" { separator: true }, { action: 'color', icon: 'format_paint', label: '颜色' } ] : isAsset ? [ - { action: 'open-right', icon: 'open_in_new', label: '在右侧边栏打开', shortcut: 'Alt + O' }, + { action: 'open-edit-mode', icon: 'edit_note', label: '使用编辑模式打开' }, { action: 'new-window', icon: 'open_in_new', label: '在新窗口打开' }, { action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2' }, { action: 'copy-id', icon: 'tag', label: '复制资源 ID' }, @@ -3817,14 +3942,12 @@ const SIDEBAR_TREE_JS: &str = r##" { action: 'copy-path', icon: 'content_copy', label: 'Copy Path' }, { separator: true }, { action: 'new-file', icon: 'note_add', label: 'New File' }, - { action: 'new-folder', icon: 'create_new_folder', label: 'New Folder', disabled: true, title: 'Convex 文件夹对象尚未进入正式 tree command' }, + { action: 'new-folder', icon: 'create_new_folder', label: 'New Folder', disabled: currentSourceKind() !== 'local_folder', title: currentSourceKind() === 'local_folder' ? '在当前目录下创建子文件夹' : '仅 local folder 支持创建文件夹' }, { action: 'paste-into', icon: 'content_paste', label: '粘贴到此处', disabled: !sidebarFileTreeClipboard, title: sidebarFileTreeClipboard ? '粘贴到当前文件树目标' : '剪贴板为空' }, { action: 'refresh', icon: 'refresh', label: 'Refresh' }, { action: 'collapse-all', icon: 'unfold_less', label: 'Collapse All' }, { action: 'reveal', icon: 'my_location', label: 'Reveal' }, { separator: true }, - { action: 'duplicate', icon: 'file_copy', label: '拷贝副本' }, - { separator: true }, { action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2' }, { separator: true }, { action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true } @@ -3835,8 +3958,6 @@ const SIDEBAR_TREE_JS: &str = r##" { action: 'copy-reference-inline', icon: 'content_copy', label: '复制页面引用链接' }, { action: 'copy-id', icon: 'tag', label: '复制页面ID' }, { separator: true }, - { action: 'duplicate', icon: 'file_copy', label: '拷贝副本' }, - { separator: true }, { action: 'rename', icon: 'edit', label: '重命名' }, { separator: true }, { action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true } @@ -3929,6 +4050,33 @@ const SIDEBAR_TREE_JS: &str = r##" return Array.from(selected); } + function selectSidebarFileTreeDocument(documentId, options) { + var id = String(documentId || '').trim(); + if (!id) return false; + var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + id) + '"]') + || document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-document-id="' + cssEscape(id) + '"]') + || document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-doc-id="' + cssEscape(id) + '"]'); + if (!(row instanceof HTMLElement)) return false; + var rowId = row.getAttribute('data-row-id') || ''; + if (!rowId) return false; + sidebarFileTreeSelection.selectedRowIds = new Set([rowId]); + sidebarFileTreeSelection.anchorRowId = rowId; + sidebarFileTreeSelection.focusedRowId = rowId; + document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]').forEach(function(activeRow) { + if (activeRow instanceof HTMLElement) activeRow.setAttribute('data-active', 'false'); + }); + syncSidebarFileTreeSelection(); + row.setAttribute('data-active', 'true'); + if (options && options.scrollIntoView !== false) { + try { + row.scrollIntoView({ block: 'nearest' }); + } catch (_) { + row.scrollIntoView(); + } + } + return true; + } + function selectedSidebarFileTreeRowIdsForDrag(row) { var rowId = row instanceof HTMLElement ? row.getAttribute('data-row-id') || '' : ''; if (rowId && sidebarFileTreeSelection.selectedRowIds.has(rowId)) { @@ -4773,6 +4921,22 @@ const SIDEBAR_TREE_JS: &str = r##" function pageAiResolvePermission(permissionId, decision) { permissionId = String(permissionId || '').trim(); if (!permissionId) return; + // 调用后端 resolve-permission 端点,让 ACP agent 得到真实响应 + var runId = pageUiState.pageAiCurrentRunId; + if (runId) { + fetch('/api/hermes/client/runs/' + encodeURIComponent(runId) + '/resolve-permission', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ permissionId: permissionId, decision: decision }) + }).then(function(response) { + if (!response.ok) console.warn('resolve-permission 后端返回异常', response.status); + }).catch(function(err) { + console.warn('resolve-permission 请求失败', err); + }); + } else { + console.warn('resolve-permission: 无活跃 runId,只能本地更新'); + } + // 本地乐观更新 UI pageUiState.pageAiMessages.forEach(function(item) { if (item.kind === 'permission' && item.permissionId === permissionId) { item.resolved = true; @@ -4986,6 +5150,7 @@ const SIDEBAR_TREE_JS: &str = r##" } if (eventType === 'tool.started' || eventType === 'tool.completed' || eventType === 'tool.failed') { var toolName = String(payload.toolName || payload.tool || payload.name || eventType).trim(); + var rawLocations = payload.locations; return { role: 'tool', content: toolName, @@ -4995,6 +5160,7 @@ const SIDEBAR_TREE_JS: &str = r##" status: eventType === 'tool.completed' ? 'completed' : (eventType === 'tool.failed' ? 'failed' : 'running'), argsSummary: pageAiPreviewValue(payload.args || payload.arguments || payload.input), resultSummary: pageAiPreviewValue(payload.summary || payload.result || payload.output || payload.error), + locations: Array.isArray(rawLocations) ? rawLocations.filter(function(l) { return typeof l === 'string' || (typeof l === 'object' && l && l.path); }).map(function(l) { return typeof l === 'string' ? l : l.path; }) : [], traceId: String(payload.traceId || payload.trace_id || ''), auditId: String(payload.auditId || payload.audit_id || '') }; @@ -5610,6 +5776,8 @@ const SIDEBAR_TREE_JS: &str = r##" traceId: traceId, auditId: auditId }; + var rawLocations = toolEvent && toolEvent.locations; + var locations = Array.isArray(rawLocations) ? rawLocations.filter(function(l) { return typeof l === 'string' || (typeof l === 'object' && l && l.path); }).map(function(l) { return typeof l === 'string' ? l : l.path; }) : []; var existing = pageUiState.pageAiMessages.find(function(item) { return item.role === 'tool' && item.toolCallId === toolCallId; }); @@ -5624,6 +5792,7 @@ const SIDEBAR_TREE_JS: &str = r##" status: status, argsSummary: '', resultSummary: '', + locations: locations, traceId: traceId, auditId: auditId }; @@ -5637,6 +5806,7 @@ const SIDEBAR_TREE_JS: &str = r##" existing.auditId = auditId || existing.auditId || ''; if (argsSummary) existing.argsSummary = argsSummary; if (resultSummary) existing.resultSummary = resultSummary; + if (locations.length) existing.locations = locations; if (status === 'completed') { pageAiNotifyToolWriteCompleted(toolName, toolEvent, runId, runTraceId); } @@ -6585,7 +6755,16 @@ const SIDEBAR_TREE_JS: &str = r##" var roleLabel = item.role === 'user' ? '你' : (item.role === 'tool' ? '工具' : 'AI'); if (item.role === 'tool') { var statusLabel = item.status === 'completed' ? '完成' : (item.status === 'failed' ? '失败' : '运行中'); + var locationRows = Array.isArray(item.locations) && item.locations.length + ? '
位置:' + item.locations.map(function(loc, idx) { + return '' + + '' + escapeHtml(loc) + '' + + '' + + ''; + }).join('') + '
' + : ''; var detailRows = [ + locationRows, item.argsSummary ? '
参数 ' + escapeHtml(item.argsSummary) + '
' : '', item.resultSummary ? '
结果 ' + escapeHtml(item.resultSummary) + '
' : '', item.changedFiles && item.changedFiles.length ? '
' + escapeHtml(pageAiFormatChangedFiles(item.changedFiles)) + '
' : '', @@ -6629,6 +6808,21 @@ const SIDEBAR_TREE_JS: &str = r##" '' + ''; } + if (item.kind === 'plan') { + var planEntries = Array.isArray(item.entries) ? item.entries : []; + var listHtml = planEntries.map(function(entry, idx) { + return '
  • ' + escapeHtml(String(entry || '')) + '
  • '; + }).join(''); + return '' + + '
    ' + + '
    ' + + '执行计划 · ' + planEntries.length + ' 步' + + '
    ' + + '
      ' + listHtml + '
    ' + + '
    ' + + '
    ' + + '
    '; + } var streamingAttr = item.streaming ? ' data-page-ai-streaming="true"' : ''; return '' + '
    ' + @@ -7046,6 +7240,40 @@ const SIDEBAR_TREE_JS: &str = r##" pageUiState.pageAiStoppedRunIds[runId] = true; pageAiSetRunStatus('aborted', runId); } + if (eventName === 'session.info.updated') { + try { + var infoPayload = JSON.parse(payloadText || 'null') || {}; + var newTitle = String(infoPayload.title || '').trim(); + if (newTitle) { + var sessionForTitle = pageAiCurrentSession(); + if (sessionForTitle) { + sessionForTitle.title = newTitle; + sessionForTitle.updatedAt = Date.now(); + pageAiPersistSessions(); + renderPageAiControls(); + } + } + } catch (_) {} + } + if (eventName === 'plan.updated') { + try { + var planPayload = JSON.parse(payloadText || 'null') || {}; + var planEntries = Array.isArray(planPayload.entries) ? planPayload.entries : []; + if (planEntries.length) { + var planMsgs = pageUiState.pageAiMessages; + var existingPlan = planMsgs.length > 0 && planMsgs[planMsgs.length - 1].kind === 'plan' ? planMsgs[planMsgs.length - 1] : null; + if (existingPlan) { + existingPlan.entries = planEntries; + existingPlan.updatedAt = Date.now(); + } else { + planMsgs.push({ role: 'system', kind: 'plan', entries: planEntries, createdAt: Date.now(), updatedAt: Date.now() }); + } + pageAiSyncCurrentSessionMessages(); + pageAiPersistSessions(); + renderPageAiConversation(); + } + } catch (_) {} + } if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') { pageAiApplyToolEvent(eventName, payloadText, runId, runTraceId); pageAiSyncCurrentSessionMessages(); @@ -7424,7 +7652,7 @@ const SIDEBAR_TREE_JS: &str = r##" assetId: url.searchParams.get('assetId') || '', documentId: url.searchParams.get('documentId') || currentDocumentId() || '', userId: url.searchParams.get('userId') || '', - mode: url.searchParams.get('mode') || 'edit' + mode: url.searchParams.get('mode') || 'view' }); } catch (_) { return String(href || ''); @@ -7453,7 +7681,7 @@ const SIDEBAR_TREE_JS: &str = r##" assetId: assetId, documentId: currentDocumentId() || '', userId: '', - mode: 'edit' + mode: 'view' }); } else if (isOnlyOfficeAttachmentHref(rawHref)) { href = normalizeOnlyOfficeAttachmentHref(rawHref); @@ -7494,7 +7722,7 @@ const SIDEBAR_TREE_JS: &str = r##" assetId: assetId, documentId: params.get('documentId') || currentDocumentId() || '', userId: params.get('userId') || '', - mode: params.get('mode') || 'edit' + mode: params.get('mode') || 'view' })); } attachmentClassForFileName(fileName).split(/\s+/).forEach(function(name) { @@ -7575,20 +7803,110 @@ const SIDEBAR_TREE_JS: &str = r##" void openCodeEditorAttachment(detail); return; } + var fileType = String(detail.fileType || '').trim(); + if (fileType && typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') { + void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({ + objectIdentity: 'resource:onlyoffice:' + (detail.documentId || '') + ':' + (detail.assetId || ''), + assetId: detail.assetId || '', + title: detail.fileName || '附件', + fileName: detail.fileName || '附件', + kind: 'office', + officeUrl: detail.href, + documentId: detail.documentId || '', + workspaceId: detail.workspaceId || '' + }); + return; + } window.open(detail.href, '_blank', 'noopener,noreferrer'); } - function openEditorAttachmentNewWindow(detail) { + function openEditorAttachmentNewWindow(detail, mode) { if (!detail) return; + var requestedMode = mode === 'edit' ? 'edit' : 'view'; var localFilePath = localFilePathFromAssetId(detail.assetId); if (localFilePath) { var localFileName = localFilePath.split('/').pop() || localFilePath; - var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, 'edit'); + var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, requestedMode); var localFileUrl = buildLocalFileOpenUrl(localFilePath, false); window.open(localOfficeUrl || localFileUrl || detail.href, '_blank', 'noopener,noreferrer'); return; } - window.open(detail.href || detail.fileUrl, '_blank', 'noopener,noreferrer'); + var href = detail.href || detail.fileUrl; + if (detail.fileType && isOnlyOfficeAttachmentHref(href)) { + try { + var url = new URL(href, window.location.origin); + url.searchParams.set('mode', requestedMode); + href = url.toString(); + } catch (_) {} + } else if (detail.fileType) { + href = buildOnlyOfficeOpenUrl({ + fileUrl: detail.fileUrl || href || '', + fileName: detail.fileName || '未命名附件', + fileType: detail.fileType, + assetId: detail.assetId || '', + documentId: detail.documentId || currentDocumentId() || '', + userId: '', + mode: requestedMode + }); + } + window.open(href, '_blank', 'noopener,noreferrer'); + } + + async function openEditorAttachmentEditTab(detail) { + if (!detail) return false; + var localFilePath = localFilePathFromAssetId(detail.assetId); + if (localFilePath) { + var localFileName = localFilePath.split('/').pop() || localFilePath; + var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, 'edit'); + if (localOfficeUrl) { + var didOpenLocalEditTab = await openLocalResourceInActiveTab({ + path: localFilePath, + title: detail.fileName || localFileName, + kind: 'office', + assetId: detail.assetId, + documentId: detail.documentId || currentDocumentId() || '', + workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '', + href: buildLocalFileOpenUrl(localFilePath, false), + officeUrl: localOfficeUrl + }); + if (!didOpenLocalEditTab) window.open(localOfficeUrl, '_blank', 'noopener,noreferrer'); + return didOpenLocalEditTab; + } + } + var href = detail.href || detail.fileUrl; + if (detail.fileType && isOnlyOfficeAttachmentHref(href)) { + try { + var url = new URL(href, window.location.origin); + url.searchParams.set('mode', 'edit'); + href = url.toString(); + } catch (_) {} + } else if (detail.fileType) { + href = buildOnlyOfficeOpenUrl({ + fileUrl: detail.fileUrl || href || '', + fileName: detail.fileName || '未命名附件', + fileType: detail.fileType, + assetId: detail.assetId || '', + documentId: detail.documentId || currentDocumentId() || '', + userId: '', + mode: 'edit' + }); + } + if (detail.fileType && typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') { + var didOpenEditTab = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({ + objectIdentity: 'resource:onlyoffice:' + (detail.documentId || '') + ':' + (detail.assetId || ''), + assetId: detail.assetId || '', + title: detail.fileName || '附件', + fileName: detail.fileName || '附件', + kind: 'office', + officeUrl: href, + documentId: detail.documentId || '', + workspaceId: detail.workspaceId || '' + }); + if (!didOpenEditTab && href) window.open(href, '_blank', 'noopener,noreferrer'); + return didOpenEditTab; + } + if (href) window.open(href, '_blank', 'noopener,noreferrer'); + return false; } async function resolveEditorAttachmentUrl(detail) { @@ -7999,6 +8317,21 @@ const SIDEBAR_TREE_JS: &str = r##" return; } + var pageAiOpenLocation = closestAction(e.target, '[data-page-ai-open-location]'); + if (pageAiOpenLocation) { + e.preventDefault(); + var loc = String(pageAiOpenLocation.getAttribute('data-page-ai-open-location') || '').trim(); + if (loc) { + openLocalResourceInActiveTab({ path: loc }).then(function(opened) { + if (!opened) { + var href = buildLocalFileOpenUrl(loc, false); + if (href) window.open(href, '_blank', 'noopener,noreferrer'); + } + }); + } + return; + } + var pageAiSession = closestAction(e.target, '[data-page-ai-session]'); if (pageAiSession) { e.preventDefault(); @@ -8181,10 +8514,11 @@ const SIDEBAR_TREE_JS: &str = r##" var tree = document.getElementById('sidebar-tree-root'); if (!tree || !tree.contains(e.target)) return; + var pageRow = closestAction(e.target, '.tree-row[data-shell-mode="page"]'); var btn = closestAction(e.target, '[data-rust-action]'); - if (!btn) return; - var nodeId = btn.getAttribute('data-node-id'); - var action = btn.getAttribute('data-rust-action'); + if (!btn && !pageRow) return; + var nodeId = (btn && btn.getAttribute('data-node-id')) || (pageRow && pageRow.getAttribute('data-node-id')) || ''; + var action = btn ? btn.getAttribute('data-rust-action') : 'open'; if (action === 'toggle') { var row = btn.closest('.tree-row'); @@ -8192,11 +8526,12 @@ const SIDEBAR_TREE_JS: &str = r##" toggleChildren(row, btn); e.preventDefault(); } else if (action === 'open') { - if (btn.getAttribute('data-page-openable') === 'false') { + var openTrigger = btn || pageRow; + if (openTrigger && openTrigger.getAttribute('data-page-openable') === 'false') { e.preventDefault(); return; } - var workspaceId = resolveWorkspaceId(btn); + var workspaceId = resolveWorkspaceId(openTrigger); navigateToDocument(nodeId, workspaceId, { treeView: 'page' }); e.preventDefault(); } else if (action === 'create') { @@ -8714,6 +9049,10 @@ const SIDEBAR_TREE_JS: &str = r##" sidebarFileTreeSelection.focusedRowId = rowId; }); syncSidebarFileTreeSelection(); + window.addEventListener('mnote:primary-document-activated', function(event) { + var detail = event && event.detail ? event.detail : {}; + selectSidebarFileTreeDocument(detail.documentId, { scrollIntoView: false }); + }); restoreSidebarTreeTab(); startLocalFolderSidebarWatch(); })(); @@ -9162,7 +9501,7 @@ mod tests { assert!(SIDEBAR_TREE_JS.contains("/api/auth/whoami")); assert!(SIDEBAR_TREE_JS.contains("buildOnlyOfficeOpenUrl")); assert!(SIDEBAR_TREE_JS.contains("target.searchParams.set('userId'")); - assert!(SIDEBAR_TREE_JS.contains("window.open(buildOnlyOfficeOpenUrl")); + assert!(SIDEBAR_TREE_JS.contains("window.open(officeUrl,")); assert!(SIDEBAR_TREE_JS.contains("tree.filetree.internal-drop")); assert!(SIDEBAR_TREE_JS.contains("tree.filetree.external-drop")); assert!(SIDEBAR_TREE_JS.contains("beginFileTreeInlineRename")); @@ -9240,6 +9579,22 @@ mod tests { !SIDEBAR_TREE_JS.contains("(item.resolved ? ' disabled' : '')"), "已决 ACP permission 事件不能继续展示假审批按钮" ); + assert!( + SIDEBAR_TREE_JS.contains("session.info.updated"), + "ACP SessionInfoUpdate 事件应通过 session.info.updated SSE 转发到前端" + ); + assert!( + SIDEBAR_TREE_JS.contains("plan.updated"), + "ACP PlanUpdate 事件应通过 plan.updated SSE 转发到前端" + ); + assert!( + SIDEBAR_TREE_JS.contains("data-page-ai-plan"), + "plan 消息应渲染为 data-page-ai-plan 标记的轻量系统状态面板" + ); + assert!( + SIDEBAR_TREE_JS.contains("执行计划 · "), + "plan 面板标题应显示执行计划和步数" + ); } #[test] @@ -9358,6 +9713,18 @@ mod tests { assert!(SIDEBAR_TREE_JS.contains("mnote-tree-context-menu")); assert!(SIDEBAR_TREE_JS.contains("复制访问链接")); assert!(SIDEBAR_TREE_JS.contains("删除到垃圾桶")); + assert!(SIDEBAR_TREE_JS.contains("function createFileTreeFolder")); + assert!(SIDEBAR_TREE_JS.contains("action: 'create_folder'")); + assert!(SIDEBAR_TREE_JS.contains("function fileTreeMenuTargetParentId")); + assert!(SIDEBAR_TREE_JS.contains("if (action === 'new-folder')")); + assert!(SIDEBAR_TREE_JS.contains("fileTreeCopyPath(detail, trigger)")); + assert!(SIDEBAR_TREE_JS.contains( + "var pageRow = closestAction(e.target, '.tree-row[data-shell-mode=\"page\"]');" + )); + assert!(SIDEBAR_TREE_JS + .contains("var action = btn ? btn.getAttribute('data-rust-action') : 'open';")); + assert!(SIDEBAR_TREE_JS.contains("var openTrigger = btn || pageRow;")); + assert!(SIDEBAR_TREE_JS.contains("var workspaceId = resolveWorkspaceId(openTrigger);")); assert!(SIDEBAR_TREE_JS.contains( r#".tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title"# )); @@ -9491,6 +9858,24 @@ mod tests { "return '/office/' + encodeURIComponent(input.documentId) + '/' + encodeURIComponent(input.assetId)" )); assert!(SIDEBAR_TREE_JS.contains("return '/onlyoffice?' + params.toString();")); + assert!(SIDEBAR_TREE_JS.contains("target.searchParams.set('mode', input.mode || 'view');")); + assert!(SIDEBAR_TREE_JS.contains("params.set('mode', input.mode || 'view');")); + assert!(SIDEBAR_TREE_JS.contains( + "{ action: 'open-edit-mode', icon: 'edit_note', label: '使用编辑模式打开' }" + )); + assert!(SIDEBAR_TREE_JS + .contains("{ action: 'open-edit-mode', icon: 'edit_note', label: '弹窗编辑' }")); + assert!(SIDEBAR_TREE_JS + .contains("{ action: 'new-window-edit', icon: 'open_in_new', label: '新窗口编辑' }")); + assert!(SIDEBAR_TREE_JS.contains("function withOfficeEditModeGuard(callback)")); + assert!(SIDEBAR_TREE_JS.contains("data-mnote-last-office-edit-mode-requested")); + assert!(SIDEBAR_TREE_JS.contains("function openEditorAttachmentEditTab(detail)")); + assert!(SIDEBAR_TREE_JS.contains("void openEditorAttachmentEditTab(detail);")); + assert!(SIDEBAR_TREE_JS.contains("buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, 'edit')")); + assert!(SIDEBAR_TREE_JS.contains("openEditorAttachmentNewWindow(detail, 'edit')")); + assert!(SIDEBAR_TREE_JS.contains("forceEditMode ? 'edit' : 'view'")); + assert!(SIDEBAR_TREE_JS.contains("url.searchParams.set('mode', requestedMode);")); + assert!(SIDEBAR_TREE_JS.contains("mode: requestedMode")); } #[test] diff --git a/scripts/task463-onlyoffice-resolver-smoke.js b/scripts/task463-onlyoffice-resolver-smoke.js index cb38d573..95fbe053 100644 --- a/scripts/task463-onlyoffice-resolver-smoke.js +++ b/scripts/task463-onlyoffice-resolver-smoke.js @@ -191,6 +191,40 @@ async function main() { ); console.log(` fileUrl uses Rust-accessible URL: ${fileUrlParam}`); + // ======== Test 1b: existing office tab refreshes from view to edit ======== + console.log("Test 1b: existing docx tab refreshes from view mode to edit mode"); + await page.evaluate(({ asset, documentId }) => { + window.dispatchEvent(new CustomEvent("tree.asset.open", { + detail: { + assetId: asset.id, + documentId, + title: asset.file_name || "report.docx", + assetType: asset.asset_type || "attachment", + openTarget: "edit-mode", + }, + })); + }, { asset: officeAssets.docx, documentId }); + await page.waitForFunction(() => { + const panel = document.querySelector('.mnote-resource-tab-panel:not([hidden])'); + const iframe = panel?.querySelector('iframe.mnote-resource-tab-frame'); + if (!(iframe instanceof HTMLIFrameElement)) return false; + const url = new URL(iframe.getAttribute("src") || iframe.src, window.location.origin); + return url.searchParams.get("mode") === "edit"; + }, null, { timeout: UI_TIMEOUT_MS }); + const refreshedTabInfo = await page.evaluate(() => { + const tabs = Array.from(document.querySelectorAll('.mnote-main-tab[data-mnote-tab-kind="office"]')); + const panel = document.querySelector('.mnote-resource-tab-panel:not([hidden])'); + const iframe = panel?.querySelector('iframe.mnote-resource-tab-frame'); + return { + officeTabCount: tabs.length, + iframeSrc: iframe?.getAttribute("src") || "", + }; + }); + const refreshedUrl = new URL(refreshedTabInfo.iframeSrc, BASE_URL); + console.log(` Refreshed active tab URL: ${refreshedUrl.toString()}`); + assert.equal(refreshedUrl.searchParams.get("mode"), "edit", "existing Office tab should refresh to mode=edit"); + assert.equal(refreshedTabInfo.officeTabCount, 1, "edit-mode should refresh the existing docx tab instead of creating another tab"); + // Close the docx tab await page.evaluate(() => { const officeTab = document.querySelector('.mnote-main-tab.is-active[data-mnote-tab-kind="office"]'); @@ -269,8 +303,8 @@ async function main() { }); await page.waitForTimeout(300); - // ======== Test 4: new-window opens /onlyoffice with edit mode ======== - console.log("Test 4: new-window opens /onlyoffice with edit mode"); + // ======== Test 4: new-window defaults to view mode ======== + console.log("Test 4: new-window opens /onlyoffice with view mode"); const [officePopup] = await Promise.all([ page.waitForEvent("popup", { timeout: UI_TIMEOUT_MS }).catch(() => null), page.evaluate(({ asset, documentId }) => { @@ -292,9 +326,9 @@ async function main() { console.log(` New-window URL: ${popupUrl.toString()}`); assert.equal(popupUrl.pathname, "/onlyoffice", `new-window pathname should be /onlyoffice: ${popupUrl.pathname}`); - // new-window should use edit mode + // new-window 是浏览器目标,不代表编辑权限;默认仍应只读 const popupMode = popupUrl.searchParams.get("mode") || ""; - assert.equal(popupMode, "edit", `new-window mode should be 'edit', got '${popupMode}'`); + assert.equal(popupMode, "view", `new-window mode should be 'view', got '${popupMode}'`); // Check assetId is present const popupAssetId = popupUrl.searchParams.get("assetId") || ""; @@ -305,6 +339,45 @@ async function main() { console.warn(" No popup triggered — this may mean the run is headless and popup was blocked. Acceptable in CI."); } + // ======== Test 4b: explicit edit-mode opens /onlyoffice with edit mode ======== + console.log("Test 4b: explicit edit-mode opens /onlyoffice with edit mode"); + const [editPopup] = await Promise.all([ + page.waitForEvent("popup", { timeout: UI_TIMEOUT_MS }).catch(() => null), + page.evaluate(({ asset, documentId }) => { + window.dispatchEvent(new CustomEvent("tree.asset.open", { + detail: { + assetId: asset.id, + documentId, + title: asset.file_name || "report.docx", + assetType: asset.asset_type || "attachment", + openTarget: "edit-mode", + }, + })); + }, { asset: officeAssets.docx, documentId }), + ]); + + if (editPopup) { + await editPopup.waitForLoadState("domcontentloaded", { timeout: UI_TIMEOUT_MS }).catch(() => undefined); + const editUrl = new URL(editPopup.url()); + console.log(` Edit-mode URL: ${editUrl.toString()}`); + assert.equal(editUrl.pathname, "/onlyoffice", `edit-mode pathname should be /onlyoffice: ${editUrl.pathname}`); + const editMode = editUrl.searchParams.get("mode") || ""; + assert.equal(editMode, "edit", `edit-mode target should use mode=edit, got '${editMode}'`); + await editPopup.close().catch(() => undefined); + } else { + const editTabInfo = await page.evaluate(() => { + const panel = document.querySelector('.mnote-resource-tab-panel:not([hidden])'); + const iframe = panel?.querySelector('iframe.mnote-resource-tab-frame'); + return { + iframeSrc: iframe?.getAttribute("src") || "", + }; + }); + const editUrl = new URL(editTabInfo.iframeSrc, BASE_URL); + const editMode = editUrl.searchParams.get("mode") || ""; + console.log(` Edit-mode active tab URL: ${editUrl.toString()}`); + assert.equal(editMode, "edit", `edit-mode active tab target should use mode=edit, got '${editMode}'`); + } + // ======== Test 5: OnlyOffice /onlyoffice page self-test (config URL/document URL/callback URL) ======== console.log("Test 5: /onlyoffice page structure validation"); const onlyofficeUrl = new URL("/onlyoffice", BASE_URL);