From 1094433a7d62bdfe222ba5af2dda77c32e7e1714 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sun, 27 Sep 2026 08:17:54 +0800 Subject: [PATCH] feat: connect conversations with Magic Notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let users retain useful assistant replies or complete conversations without leaving their chat workflow. Preview and edit captured text, choose an existing note or atomically create a note with its first entry, and return to the source conversation or message from saved records. Add an application-scoped Notes workbar tab with title/body search and quick text appends. Preserve drafts across conversation switches and guard closing, replacement and disabling. Schema 48 stores optional source metadata without rewriting file-backed bodies; source deletion retains the saved note. Older clients cannot reopen an upgraded database without a pre-upgrade backup. Release note: 可将助手回答或完整会话预览、编辑后加入魔法笔记,并从笔记返回原会话或消息。右侧快速笔记支持搜索和追加文字,切换会话保留草稿;采集本身不调用模型,保存后自动评论仍遵循已有设置。 Validation: isolated HEAD-based candidate passed 22 files / 930 focused tests, plus 20 gateway tests after including its create-result fixture adjustment. Real Electron App/preload/IPC/SQLite acceptance passed for all 502 historical messages, exact-entry/source navigation, native keyboard input in a 214px panel, reopen and source-deletion retention, with zero capture model calls. Repository lint and web TypeScript passed. Cached whitespace and secret-pattern checks passed; native-client changes and unrelated IPC extraction are excluded. Validation limits: the isolated source tree reuses installed node_modules, where a parallel task upgraded DSH from the committed 0.1.2-rc.1 to 0.1.7-rc.2. Full typecheck reports 14 DSH errors in three unchanged files; the full-suite attempt with --bail=1 also fails in deepseek-harness-host.test.ts. These are not all-green repository or release-package results. Earlier execution-time full suite failures and fixture repairs remain recorded in the feature progress doc. --- FEATURES.md | 18 +- FEATURES.zh-CN.md | 5 +- UI-DESIGN.md | 6 +- docs/features/assistant-workbar/README.md | 7 +- docs/features/assistant-workbar/prd.md | 2 + .../assistant-workbar/terminal-tabs-prd.md | 11 +- docs/features/magic-notes/README.md | 19 +- docs/features/magic-notes/logic-design.md | 35 +++ docs/features/magic-notes/prd.md | 32 ++ docs/features/magic-notes/progress.md | 73 +++++ docs/features/magic-notes/technical-design.md | 120 +++++++- docs/features/magic-notes/ui-design.md | 50 ++++ src/main/agent/knowledge-mcp-gateway.test.ts | 4 +- .../assistant/activity-history-io.test.ts | 1 + src/main/assistant/assistant-database.test.ts | 18 ++ src/main/assistant/assistant-database.ts | 58 +++- .../assistant-storage-upgrade.test.ts | 13 +- src/main/assistant/heartbeat-database.test.ts | 2 + src/main/assistant/incremental-review.test.ts | 2 +- .../assistant/supervision-activity.test.ts | 1 + .../assistant/supervision-history.test.ts | 1 + src/main/ipc.ts | 35 ++- .../canvas-ipc.integration.test.ts | 95 ++++++ .../magic-notes/magic-note-storage.test.ts | 35 ++- src/preload/index.ts | 6 +- src/renderer/src/App.test.tsx | 144 +++++++++ src/renderer/src/App.tsx | 138 ++++++++- src/renderer/src/ChatTimeline.test.tsx | 18 ++ src/renderer/src/ChatTimeline.tsx | 6 + src/renderer/src/MagicNoteSource.tsx | 29 ++ src/renderer/src/MagicNotesPanel.test.tsx | 210 +++++++++++++ src/renderer/src/MagicNotesPanel.tsx | 282 ++++++++++++++++++ src/renderer/src/MagicNotesWorkspace.test.tsx | 10 + src/renderer/src/MagicNotesWorkspace.tsx | 14 + src/renderer/src/RightAssistantSidebar.tsx | 45 ++- src/renderer/src/WorkbarShell.tsx | 2 + .../src/i18n/locales/en-US/magicNotes.ts | 15 + .../src/i18n/locales/en-US/workspace.ts | 1 + .../src/i18n/locales/zh-CN/magicNotes.ts | 15 + .../src/i18n/locales/zh-CN/workspace.ts | 1 + src/renderer/src/magic-notes-panel.css | 27 ++ src/renderer/src/use-magic-note-draft.tsx | 60 ++++ src/shared/contracts.ts | 6 +- src/shared/ipc-channels.ts | 1 + src/shared/magic-notes-contracts.ts | 39 ++- src/shared/workbar-contracts.test.ts | 9 + src/shared/workbar-contracts.ts | 18 ++ tests/assistant-storage-worker.test.ts | 5 +- tests/magic-notes-capture.electron.test.ts | 69 +++++ tests/support/magic-notes-capture-main.ts | 183 ++++++++++++ .../support/magic-notes-capture-renderer.tsx | 9 + 51 files changed, 1949 insertions(+), 56 deletions(-) create mode 100644 docs/features/magic-notes/logic-design.md create mode 100644 docs/features/magic-notes/prd.md create mode 100644 docs/features/magic-notes/ui-design.md create mode 100644 src/renderer/src/MagicNoteSource.tsx create mode 100644 src/renderer/src/MagicNotesPanel.test.tsx create mode 100644 src/renderer/src/MagicNotesPanel.tsx create mode 100644 src/renderer/src/magic-notes-panel.css create mode 100644 src/renderer/src/use-magic-note-draft.tsx create mode 100644 tests/magic-notes-capture.electron.test.ts create mode 100644 tests/support/magic-notes-capture-main.ts create mode 100644 tests/support/magic-notes-capture-renderer.tsx diff --git a/FEATURES.md b/FEATURES.md index 7bb09bdd..b3d9b971 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -674,6 +674,16 @@ records are listed separately and do not introduce another feature status. navigation can show the incomplete-todo count, and create, save, and comment results use application-wide notifications. Agent/MCP note writes automatically refresh the open workbench while preserving selection and unsaved drafts. +- **Conversation capture and quick notes** (implemented; real Electron acceptance passed): + Preview and edit an assistant response or complete conversation text before + appending to an existing note or creating one with its first entry. The compact + Notes workbar tab supports title/body search, quick text appends and full-workspace + navigation. Saved sources link back to conversations and messages; drafts survive + conversation switches. Real App/preload/IPC/SQLite acceptance passed, including + complete 502-message history and native keyboard use in a 214px panel, with zero + model calls. Repository validation is not all green; the + [validation record](./docs/features/magic-notes/progress.md) separates focused + passes, fixture repairs and execution-time test/typecheck failures. - [x] **Paged canvas notes**: The integrated PeopleLib Fabric + Quill editor combines flowing body text with pen/highlighter, object selection/transforms, floating text, images and paper templates. Editors and read-only viewers support @@ -696,13 +706,13 @@ records are listed separately and do not introduce another feature status. invalidated by layout changes and reanalyzed in after-save-auto mode. MCP rejects plain-text replacement of canvas entries. Implementation is complete; final validation status is tracked in the [feature progress record](./docs/features/magic-notes/progress.md). - The first upgrade migrates stored bodies into files and raises the database - schema to 39 together with attachment changes. Older clients cannot reopen + Historical body migration moved stored bodies into files; schema 48 now adds + conversation-source metadata. Older clients cannot reopen that database; rollback requires a pre-upgrade backup. - [ ] **MCP Server Control Plane** (planned): Unified MCP lifecycle, health checks, reconnection, schema cache, isolation, approval, and audit. -- [ ] **Traceable note excerpts and AI editing** (planned): Collects sourced - excerpts from conversations, knowledge, and the web and provides +- [ ] **Knowledge/web excerpts and AI editing** (planned): Extend capture beyond + the implemented conversation-text path to knowledge and web excerpts, with confirmation-based summarization, rewriting, and organization. ### Work management, long-term collaboration, and workflows diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md index 4adedad5..28a826b4 100644 --- a/FEATURES.zh-CN.md +++ b/FEATURES.zh-CN.md @@ -99,13 +99,14 @@ - [x] **受控知识本体**:每个知识库可定义实体、关系、别名和端点约束,保留证据偏移、置信度和抽取来源,并显式提示图谱重建。 - [x] **强制检索与引用上下文**:对话可按需或每次先检索,显示零结果、降级、失败与取消状态,并可查看引用上下文或安全打开来源。 - [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论。笔记从自适应卡片总览打开;独立待办页提供搜索、状态筛选、分组列表和直接勾选,宽屏列表稳定在左侧,任务说明、来源记录及 AI 评论在右侧同一区域显示,窄屏按需显示详情并提供返回列表。紧凑页签放在页头新建按钮左侧,筛选放在搜索右侧。打开原笔记再返回可恢复任务上下文;切换任务不会混入上一项的来源内容,读取失败使用应用通知并提供就地重试,来源缺失有明确提示。左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。Agent / MCP 写入后,已打开的工作台自动更新,并保留当前选择与未保存草稿。 +- **会话采集与快速笔记**(已实现,真实 Electron 验收通过):单条助手回答或完整会话正文可先预览、编辑,再追加到已有笔记或连同首条记录创建新笔记。右侧笔记 Tab 支持标题与正文搜索、快速追加及打开完整工作区,记录来源可返回会话和消息;切换会话保留草稿。真实 App/preload/IPC/SQLite 验收覆盖完整 502 条历史及 214px 面板原生键盘操作,模型调用为 0。全仓验证未全绿,[验证进度](./docs/features/magic-notes/progress.md)分别记录聚焦通过、fixture 修复与执行时的测试和类型检查失败。 - 待办宽屏列表与详情之间可拖动分隔条并记住宽度;来源笔记分组可独立折叠,不清除当前选中任务的详情。 - [x] **连续记录工作区**:笔记详情连续展示全部记录,左侧索引默认 168px,可拖动右边界或用键盘在 140–320px 内调宽,并记住偏好;索引与右侧 AI 评论独立收起。每条画布记录显示真实首页缩略图、页数和时间,文字记录显示摘要;点击索引只滚动定位,保留当前编辑器和草稿。打开笔记就在标题下方显示空白输入区,保留文字/画布选择和保存操作;新增保存后清空,供下一条记录使用,旧记录通过编辑入口修改。空白输入区和保存后未再修改的记录返回时不提示;保存失败和真实修改仍受保护。窄屏索引使用固定宽度折叠抽屉,不显示调宽分隔条,AI 评论移至底部。 - [x] **分页画布笔记**:已集成 PeopleLib Fabric + Quill 编辑器,支持跨页正文、笔迹、高亮、对象选择及变换、浮动文字、图片和纸张模板。PDF 可作为分页底版导入并提取原生文字;正文和批注可导出为栅格 PDF,导出文件不保留可搜索文字层。用户手动保存后,记录正文与二进制资源存入本地笔记文件,SQLite 保留元数据、索引、修订、待办和评论。备份须协调保存 SQLite 与笔记目录,产品无一键备份入口。撤销/重做限当前批注页或 Quill 模式,不提供全局文档撤销、无限画布或 PNG 下载按钮。草稿、已保存记录和来源为画布的待办按默认模型图像输入能力使用页面图像加文字,或明确标注仅文字降级;“发送画布页数”可选 1~8,默认 1,按当前顺序发送前 N 页,文字和图片范围一致,保存与导出仍支持 50 页。纯视觉记录/草稿需要支持图像输入的模型。即时评论模式提供手动画布分析,保存后自动分析失败不阻断或回滚保存。提取文字不变时保留文字评论;布局变化使视觉评论失效,并在保存后自动评论模式下重新分析。MCP 禁止以纯文字覆写画布记录。实现已完成,最终验证状态见[功能进展](./docs/features/magic-notes/progress.md)。 编辑与只读画布均支持 25%–300% 视图缩放、100% 还原及随视口调整的“适应宽度”,不改变保存内容或导出尺寸。 - 首次升级将历史正文迁移到文件,并随附件变更把数据库升级至 schema 39;旧客户端不能再打开,回退需使用升级前备份。 + 历史正文迁移已改用文件存储,当前 schema 48 新增会话来源元数据;旧客户端不能再打开,回退需使用升级前备份。 - [ ] **MCP Server Control Plane**(规划中):统一 MCP 生命周期、健康检查、重连、Schema 缓存、隔离、审批和审计。 -- [ ] **可追溯笔记摘录与 AI 编辑**(规划中):从对话、知识和网页收集带来源的摘录,并提供需确认的总结、改写和整理操作。 +- [ ] **知识/网页摘录与 AI 编辑**(规划中):在已实现的会话正文采集之外,扩展知识和网页摘录,并提供需确认的总结、改写和整理操作。 ### 工作管理、长期协作与工作流 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 27347c79..c55348ea 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -348,8 +348,8 @@ “当前项目”和“所有项目”;无项目任务在“所有项目”中标注“未绑定项目”。范围变化不得创建第二个 Task Center。每个 Task 只 关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容,也 不得把 Job/Run 提升为可导航 UI 对象。 -- 各栏目读取当前会话或项目的对应内容;当前内容不可用时仍保留栏目入口并说明原因。 -- 能力、连接和内容可以动态变化,栏目入口不能随之自动隐藏。不可用状态必须说明原因、影响和可执行入口。 +- 各栏目按声明的范围读取内容;笔记使用应用级数据,选择和草稿不跟随项目或会话变化。当前内容不可用时仍保留栏目入口并说明原因。 +- 能力、连接和内容可以动态变化,栏目入口不能随之自动隐藏。不可用状态必须说明原因、影响和可执行入口。笔记遵循应用中心的业务启用设置,禁用前处理草稿,禁用后隐藏目录入口并移除笔记 Tab。 - 用户可以主动排序栏目,并可关闭可关闭实例或恢复默认布局;任务中心和工作区不能隐藏或 关闭。应用不能用用户偏好机制实现自动能力裁剪。 - 已打开实例保留 `tablist`、`tab`、`tabpanel`、方向键、Home、End 和焦点恢复语义。 @@ -759,6 +759,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 ### 13.7 魔法笔记 +会话联动已接入:右侧提供可关闭的单实例笔记 Tab,左下角“快速笔记”打开同一面板,侧栏收起时由聊天页头提供入口。助手消息和会话标题菜单可打开文字采集预览;保存记录在面板和完整工作区显示独立来源条。草稿由 App 保留,关闭、替换及禁用前确认。具体交互由[魔法笔记会话联动界面设计](./docs/features/magic-notes/ui-design.md)维护。真实 Electron 验收已通过,全仓验证未全绿,见[验证进度](./docs/features/magic-notes/progress.md)。下列规则描述独立工作区。 + - 笔记总览与待办使用一个普通按钮切换,放在页头“新建笔记”按钮左边。按钮显示目标“切换到待办”或“切换到笔记”及对应图标,按内容宽度显示,不拉伸;窄窗口操作组可换行到标题下方,保留控件高度。 - 切换按钮不使用页签语义,切换后焦点回到同一个按钮;内容 section 持续提供当前列表的 `aria-label`。切换沿用草稿保护入口,待办状态仍使用独立的 `SegmentedControl`。 - 文字记录编辑器保留 Quill 原生列表快捷输入:输入 `-`、`*`、`1.`、`[ ]` 或 `[x]` 后按空格,分别转换为项目符号、编号或待办清单。快捷输入只生成现有 Delta 富文本,不引入 Markdown 源码模式、第二套文字记录持久化格式或第三方 Markdown 插件;新建文字记录的操作提示收进类型选择旁的 `InlineHelp`。 diff --git a/docs/features/assistant-workbar/README.md b/docs/features/assistant-workbar/README.md index c73a70f9..957c0d62 100644 --- a/docs/features/assistant-workbar/README.md +++ b/docs/features/assistant-workbar/README.md @@ -1,6 +1,11 @@ # 助手工作栏 -助手工作栏统一承载本机与远程执行空间、Task、成果、文件、浏览器和终端等持续工作界面。 +助手工作栏统一承载本机与远程执行空间、Task、成果、文件、浏览器、终端和笔记等持续工作界面。 + +“笔记”为应用级、可关闭的单实例 Tab,新布局默认不打开。它提供搜索、浏览和快速追加文字, +与主页面魔法笔记共用存储;会话采集、来源跳转与草稿规则由 +[魔法笔记文档](../magic-notes/README.md)维护。该应用已接入并通过真实 Electron 验收; +全仓验证未全绿,聚焦复跑与执行时失败见[笔记验证进度](../magic-notes/progress.md)。 ## 文档导航 diff --git a/docs/features/assistant-workbar/prd.md b/docs/features/assistant-workbar/prd.md index 8c93488e..c3e5a375 100644 --- a/docs/features/assistant-workbar/prd.md +++ b/docs/features/assistant-workbar/prd.md @@ -86,6 +86,7 @@ Runtime 资源监控 成果 文档 +笔记 ``` - 应用不得根据当前项目、会话、Runtime、主机或探测结果无提示地增删能力目录项。 @@ -96,6 +97,7 @@ Runtime Conversation、Job、Run 或心跳事项。 - 当前能力、连接、数据和空状态可以动态变化。 - 能力不可用时,目录项或已打开面板显示原因、影响和可执行的配置或切换入口,不能只通过隐藏表示。 +- 笔记遵循魔法笔记的业务启用设置,启用时提供应用级单实例 Tab,禁用前处理草稿,禁用后移除入口和实例;取消主侧栏常驻不影响工作栏可用性。采集与来源规则见[魔法笔记逻辑设计](../magic-notes/logic-design.md)。 - 用户可以在设置中调整目录顺序;恢复默认布局恢复标准目录与默认打开面板,不强制打开全部能力。 - 浏览器、终端及其他声明为多实例的能力可以创建多个实例,每个实例拥有独立身份和范围绑定。 diff --git a/docs/features/assistant-workbar/terminal-tabs-prd.md b/docs/features/assistant-workbar/terminal-tabs-prd.md index 09f6325f..e645fc14 100644 --- a/docs/features/assistant-workbar/terminal-tabs-prd.md +++ b/docs/features/assistant-workbar/terminal-tabs-prd.md @@ -44,7 +44,7 @@ SSH 连接池,但尚不存在工作栏应用实例模型、终端 IPC、终端 ### 3.1 工作栏应用 工作栏应用是可以在助手工作栏中打开的一个能力,例如任务中心、工作区、浏览器、成果或 -终端。每项应用通过统一注册表声明: +终端、笔记。每项应用通过统一注册表声明: - 稳定应用 ID。 - 用户可见名称、图标和说明。 @@ -54,7 +54,8 @@ SSH 连接池,但尚不存在工作栏应用实例模型、终端 IPC、终端 - 当前不可用时的原因和修复入口。 目录中的应用可发现性与实例是否已经打开相互独立。应用不可用时保留入口并说明原因,不以 -隐藏代替状态。 +隐藏代替状态。笔记遵循其业务启用设置:关闭魔法笔记时隐藏目录入口并移除对应 Tab, +关闭前处理草稿,规则见[会话联动逻辑](../magic-notes/logic-design.md)。 注册表字段如下: @@ -76,14 +77,15 @@ SSH 连接池,但尚不存在工作栏应用实例模型、终端 IPC、终端 `defaultOpen`、`required` 和 `closable` 不互相推导。例如浏览器默认打开但不是必需实例;任务 中心和工作区既默认打开又必须存在。`required` 不表示锁定顺序,顺序只由 `reorderable` 控制。 -当前五个应用的 `visibleAcrossContextSwitches` 均为 `true`。该属性记录应用目录的显示策略, +当前六个应用的 `visibleAcrossContextSwitches` 均为 `true`。该属性记录应用目录的显示策略, 不改变内容归属,也不授予其他会话的 MCP 访问权限;当前没有使用 `false` 的应用,尚未实现 按上下文隐藏 Tab 的策略。 浏览器在创建时绑定当前 Conversation;切换会话后仍保留原 Tab、选中状态和页面,导航操作 继续使用原 `targetRef.conversationId`。此时新建浏览器才绑定新会话。默认或旧布局中尚未绑定 的浏览器在首次使用时绑定活动会话,此后不再随会话切换改绑。终端同样保留创建时执行目标; -任务中心、工作区和成果的内容继续跟随当前项目。 +任务中心、工作区和成果的内容继续跟随当前项目。笔记使用应用级范围,选择与草稿不随项目或 +会话切换,不接受 `targetRef`;重启仅恢复工作栏 Tab 描述,不恢复笔记选择和草稿。 ### 3.2 工作栏 Tab 实例 @@ -218,6 +220,7 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或 | 浏览器 | `multiple` | 当前 Conversation | 是,一个实例 | 否 | 是 | 是 | 每次创建新 Tab | | 成果 | `single` | 当前项目 | 是 | 否 | 是 | 是 | 聚焦已有 Tab | | 终端 | `multiple` | 当前项目 | 否 | 否 | 是 | 是 | 每次创建新 Tab | +| 笔记 | `single` | 应用级 | 否 | 否 | 是 | 是 | 聚焦已有 Tab | 实例策略、默认状态和可关闭策略都是应用注册数据,不能分散硬编码在多个点击处理器中。 diff --git a/docs/features/magic-notes/README.md b/docs/features/magic-notes/README.md index e665b4d4..41b613fa 100644 --- a/docs/features/magic-notes/README.md +++ b/docs/features/magic-notes/README.md @@ -26,9 +26,26 @@ context and protects unsaved editor changes. A successful save followed by no further edits does not prompt again on return. Narrow note layouts use an index drawer and bounded bottom AI pane. +Conversation capture and the compact Notes workbar panel are implemented. Users +can preview and edit one assistant response or the complete conversation text, +then append it to an existing note or create a note with its first entry. Quick +notes opens the same panel for search, browsing and text appends. Saved entries +carry structured conversation sources with return navigation. The panel and full +workspace share storage and change notifications; drafts remain in App memory. +Real Electron acceptance passed through the App UI, production preload, IPC and +SQLite. Repository validation is not all green; the [progress record](./progress.md) +separates focused passes, repaired schema fixtures and execution-time failures. + ## Documents -- [Technical design](./technical-design.md): file storage, schema 38 migration, +- [Conversation integration PRD](./prd.md): scope and acceptance for + conversation capture and the compact workbar panel. +- [Conversation integration logic](./logic-design.md): capture boundaries, source + lifetime, panel state, draft protection and application availability. +- [Conversation integration UI](./ui-design.md): implemented entry points, compact + panel, capture preview and source navigation, following the root UI system. +- [Technical design](./technical-design.md): file storage, schema 38 body migration, + schema 48 source metadata, desktop search and atomic initial-entry creation, backup boundaries, database notifications, process boundaries, canvas and AI contracts, refresh lifecycle and regression coverage. - [Progress](./progress.md): implementation and validation evidence. diff --git a/docs/features/magic-notes/logic-design.md b/docs/features/magic-notes/logic-design.md new file mode 100644 index 00000000..13d671d7 --- /dev/null +++ b/docs/features/magic-notes/logic-design.md @@ -0,0 +1,35 @@ +# 会话联动逻辑设计 + +状态:已按当前实现核对,验证进度见 [progress.md](./progress.md)。需求编号来自 [PRD](./prd.md)。 + +## 内容与来源 + +FR-2、FR-3:单条采集读取所选助手消息的非空正文;整段采集先加载该会话完整历史,再按消息顺序收录用户和助手的非空正文,保留角色分隔。预览建立后,切换会话或收到新消息不会替换正文及来源标识。生成中的消息不可采集,停止或失败后已有正文可保存,单条预览注明回答未完成。整个会话正在生成时禁用整段采集,提示等待完成或停止生成。 + +首版按文字保存,保留原始 Markdown 文本、换行和代码文本,不承诺还原聊天渲染效果。推理、工具调用日志、系统提示、附件二进制和图片不进入正文;预览持续说明保存范围。用户可编辑预览,来源仍指向本次采集的会话。读取完整会话失败时不以屏幕上已有消息代替。 + +FR-5:来源独立于可编辑正文。单条来源包含会话和消息标识;整段来源包含会话标识、此次收录的消息标识及采集时间。预览使用采集时的会话标题和项目名称;保存时主进程校验消息归属,并以当时数据库中的会话标题和项目名称写入来源,无项目时显示全局。若预览期间源会话或消息已删除,保存失败并保留预览。保存完成后,修改或删除源消息不回写笔记正文和来源。 + +点击来源时读取完整会话。消息不存在但会话仍在时打开会话并提示“原消息已不存在”;确认会话不存在后保留来源文字,并在当前来源条禁用跳转。其他读取失败显示可重试错误。打开未渲染的来源消息时扩大聊天历史显示范围,再滚动定位并聚焦。编辑笔记正文不移除来源;删除笔记不影响源会话。 + +## 面板与草稿 + +FR-1、FR-4:笔记应用为可关闭、可排序的单实例工作栏 Tab,新布局默认不打开。再次触发入口激活现有 Tab;笔记选择属于应用级状态,切换项目或会话不改写它。列表按置顶优先、最近更新时间排列,搜索匹配标题与记录文字,同一笔记只显示一次。当前应用会话内保留最近选择,重启不恢复笔记选择或草稿。该笔记已删除时回到列表,清除失效保存目标并保留待保存文字。 + +FR-2、FR-3:面板有浏览、采集编辑、保存中三个状态。触发采集进入编辑态;选择目标只改变写入位置;明确点击“加入笔记”才写入。每次提交新增一条记录,不覆盖旧记录。新建目标时先填写标题,会话标题作为可修改默认值;提交一次创建笔记和首条记录,取消不遗留空笔记。普通“新建笔记”仍可显式创建空笔记。 + +保存中禁用重复提交和面板离开操作,结果返回后可再次操作。成功后清除本次草稿,显示响应返回的新增记录;失败保留预览、标题和目标。目标已删除时提示重新选择。容量校验使用现有文字记录限制,超限由用户缩减正文后重试;一次提交始终对应一条完整记录。 + +关闭 Tab、返回面板列表、从面板打开完整工作区、禁用应用或以另一条采集替换草稿前,显示“继续编辑 / 放弃草稿并切换”确认。普通主页面、项目和会话切换以及隐藏工作栏保留 App 持有的草稿及来源。取消采集只在正文或标题相对预览初值有修改时确认;未修改预览可直接取消。完整工作区自身的编辑草稿仍由其离开确认保护。 + +FR-6:成功写入触发现有通知刷新视图;面板重新挂载或工作栏重新展开时读取最新数据。面板挂载期间保留搜索词;笔记选择和草稿由 App 保留,不受搜索响应或后台刷新覆盖。既有记录编辑继续使用原修订号处理冲突。此处同步仅指本机共享存储的视图更新。 + +## 启用与评论 + +FR-1~FR-4:遵循魔法笔记应用的启用状态。禁用时隐藏会话采集、快速笔记及工作栏目录入口,并移除笔记 Tab;禁用前处理草稿。外部设置变更要求禁用时,选择继续编辑会尝试恢复启用设置,失败显示设置错误。应用未常驻不影响快速入口或工作栏可用性。设置继续由应用中心管理。 + +会话采集和快速追加本身不调用模型。保存后沿用现有 AI 评论模式;只有已有设置要求保存后自动评论时才触发,失败不撤销保存,也不自动重新追加。 + +## 完整性检查 + +当前实现覆盖首次创建、追加、取消、重复点击、写入失败、目标删除、来源删除、跨会话切换及刷新与草稿的关系。完整历史读取、来源定位和存储接口已接入;产品范围无待决项。真实 Electron 已验证采集、保存、定位、窄面板键盘操作和持久化,具体范围见进展记录。全仓测试和类型检查仍有执行时记录的失败,聚焦复跑通过不等于全仓通过。 diff --git a/docs/features/magic-notes/prd.md b/docs/features/magic-notes/prd.md new file mode 100644 index 00000000..7d4431ed --- /dev/null +++ b/docs/features/magic-notes/prd.md @@ -0,0 +1,32 @@ +# 魔法笔记与会话联动 + +状态:会话联动前后端已实现,真实 Electron 验收通过;全仓验证未全绿。聚焦结果、fixture 修复后复跑及执行时的全仓失败见 [progress.md](./progress.md)。 + +## 目标与范围 + +用户在阅读回答时,可将内容保存到已有笔记或新笔记,并从笔记返回原会话。会话界面提供文字预览和保存位置选择,与已有 Agent 笔记工具共用存储。 + +独立工作区继续承载完整编辑、画布、待办和 AI 评论;右侧笔记面板提供浏览和快速追加。笔记仍属于全局,不因入口所在项目改变归属。 + +## 功能要求 + +| 编号 | 要求 | +| --- | --- | +| FR-1 | 右侧助手工作栏增加“笔记”应用,支持搜索、打开已有笔记、新建笔记、查看记录摘要、快速追加文字,以及打开对应完整工作区。 | +| FR-2 | 助手消息操作栏提供“加入笔记”,打开可编辑预览,用户选择已有笔记或新建后明确提交。 | +| FR-3 | 会话标题的更多菜单提供“保存当前对话到笔记”,按触发时的会话内容生成预览,保存为目标笔记中的一条记录。 | +| FR-4 | 左侧底部提供“快速笔记”,侧栏收起时由聊天页头提供同名图标入口;打开右侧面板并恢复当前应用会话中最近选择的笔记,首次使用显示列表。此入口不隐式保存当前对话。 | +| FR-5 | 从会话保存的记录保留结构化来源,显示会话名称、所属项目(如有)和采集时间,支持返回原会话及单条来源消息。源会话删除后保留笔记内容。 | +| FR-6 | 面板、完整工作区和 Agent 写入使用同一份笔记数据,成功写入后更新已打开视图,同时保护未保存草稿。 | + +首版不包含自动总结、自动分类、自动选择保存目标、选中文本采集、多条消息勾选、附件文件复制或跨设备同步。“总结后加入”和选区采集作为后续扩展,不预留不可用按钮。 + +## 产品验收 + +- 首次使用且没有笔记时,可从一条助手回答创建笔记并保存首条记录;已有笔记时可搜索并追加,原记录保持不变。 +- 保存当前对话包含未在屏幕上渲染的历史正文;预览范围明确,超出容量时提示缩减内容,不静默截断。 +- 保存成功后显示目标笔记并定位新增记录;取消不写入,失败保留内容和目标供重试。 +- 从来源条可返回对应会话;会话已删除时显示“原会话已不存在”,已保存正文仍可阅读和编辑。 +- 面板追加后完整工作区可见,Agent 修改后面板可见;后台刷新和切换会话不覆盖未保存文字。 + +状态与内容范围见 [逻辑设计](./logic-design.md),入口和操作细节见 [界面设计](./ui-design.md),接口与存储见 [技术设计](./technical-design.md#conversation-integration)。 diff --git a/docs/features/magic-notes/progress.md b/docs/features/magic-notes/progress.md index 3295196e..33e63d05 100644 --- a/docs/features/magic-notes/progress.md +++ b/docs/features/magic-notes/progress.md @@ -1,5 +1,78 @@ # Magic Notes Progress +## 2026-09-26: Conversation Capture and Compact Notes Panel + +- FR-1/FR-4: `notes` is an application-scoped optional singleton in the workbar. + The compact panel supports title/body search, browsing, text appends and full + workspace navigation. Quick notes uses the same tab; app enablement controls + availability independently of sidebar pinning. +- FR-2/FR-3: Assistant-message and conversation-header actions open editable text + previews. Whole-conversation capture loads complete history. App owns drafts + and selection; close, replacement and disable actions use the draft guard. + Success focuses the returned entry ID; failed saves retain input. +- FR-5: Schema 48 adds nullable source metadata. Main validates message ownership + and resolves display labels at save time. Panel and workspace share source + navigation, including missing-message and missing-conversation handling. +- FR-6: Desktop search, preload/IPC and note-level SQL results are connected. + Creating a note with initial content commits note/entry membership in one + transaction. Both views use existing change notifications; file reconciliation + and workspace optimistic revisions retain their existing boundaries. See + [the implemented contract](./technical-design.md#conversation-integration). + +Validation reported by implementation subagents in this session: + +| Scope | Reported result | Evidence boundary | +| --- | --- | --- | +| Backend focused checks | 362 tests passed | Subagent handoff; not a repository-wide result | +| Frontend focused checks | 8 files / 592 tests passed | Subagent handoff; not real Electron acceptance | +| Web TypeScript and lint | Passed | Subagent handoff; does not establish full-repository typecheck/lint | + +The coordinating agent subsequently reported real Electron acceptance passing. +[magic-notes-capture.electron.test.ts](../../../tests/magic-notes-capture.electron.test.ts) +and its [Main driver](../../../tests/support/magic-notes-capture-main.ts) / +[renderer](../../../tests/support/magic-notes-capture-renderer.tsx) mount the real +App UI and use production preload, registered IPC and SQLite/file storage. +Unrelated services use test dependencies; the renderer bridge is not replaced. + +- Saving an assistant response to an existing note retains its earlier entry; + whole-conversation capture creates a new note with its first entry. +- All 502 historical messages are compared verbatim in the preview and saved + content, including history initially outside the rendered timeline. +- Full-workspace navigation locates the exact saved entry; source navigation + restores the conversation and focuses the source message. The coordinating + agent also reports source-history expansion coverage. +- Native Electron keyboard input covers returning to the list, opening a note + and appending text in a measured 214px panel, without horizontal overflow. +- SQLite close/reopen preserves both notes. Deleting the source conversation + retains their saved content and source metadata. Model calls: **0**. + +The combined Electron, `canvas-ipc.integration`, `MagicNotesPanel` and +`ChatTimeline` run passed **4 files / 70 tests**. Lint and web TypeScript passed. +This validates the App capture path in a test Electron window with isolated data; +it does not establish release-package or cross-platform acceptance. + +Repository validation and subsequent fixture repairs: + +| Check | Result at execution time | Follow-up | +| --- | --- | --- | +| `npm test` | 457 files / 5,359 cases: 5,272 passed, 4 failed, 83 skipped | No all-green full-suite rerun reported | +| Three schema-fixture failures | Activity-history and two worker fixture variants omitted `DROP COLUMN source_json` when reconstructing old schemas | Fixed; the five-file regression run below passed | +| Remaining test failure | `tests/build-release.test.ts`: missing DSH resources | Unrelated to notes; not fixed in this validation pass | +| Full-repository typecheck | Failed on parallel SSH mocks missing `forwardLoopback`, an IPC native-terminal fixture referencing undefined `terminalManager`, and mismatched arguments in `obsidian-runtime-acceptance` | Execution-time findings; later concurrent changes may alter them | + +After fixture repairs, **5 files / 58 tests passed**: +`src/main/assistant/activity-history-io.test.ts`, +`tests/assistant-storage-worker.test.ts`, +`src/main/assistant/assistant-storage-upgrade.test.ts`, +`src/main/magic-notes/magic-note-storage.test.ts` and +`src/main/magic-notes/canvas-ipc.integration.test.ts`. +This focused rerun does not replace the failed full-suite/typecheck results. + +Status: **implemented; real Electron acceptance passed; repository validation is +not all green**. Results above come from the session handoffs, not a rerun by this +documentation task. Later fixes require their own verification evidence; older +Electron/full-suite results below do not validate this integration. + ## 2026-09-24: Resizable Todo List and Collapsible Note Groups - Todo list/detail uses a 1px divider with a transparent 9px pointer target and diff --git a/docs/features/magic-notes/technical-design.md b/docs/features/magic-notes/technical-design.md index 7a022b50..52d07d5e 100644 --- a/docs/features/magic-notes/technical-design.md +++ b/docs/features/magic-notes/technical-design.md @@ -449,6 +449,124 @@ These note tools run in the desktop gateway. Remote gbagent ACP sessions currently inject only the image MCP server, so this change requires no daemon implementation update. +## Conversation Integration + +The desktop API and renderer integration are implemented. Scope and behavior +are owned by the [PRD](./prd.md), [logic design](./logic-design.md) and +[UI design](./ui-design.md). Real Electron acceptance passed; focused results, +fixture repairs and non-green repository checks are recorded in [progress](./progress.md). + +### Workbar and Capture + +`WORKBAR_APP_DEFINITIONS` registers `notes` as an optional, closable, reorderable +singleton with application scope, no `targetRef` and `defaultOpen: false`. +`RightAssistantSidebar` opens or activates it through the existing +[workbar tab model](../assistant-workbar/terminal-tabs-prd.md), and removes it when +Magic Notes is disabled. Sidebar pinning does not control this availability. +`MagicNotesPanel` renders the compact view; `WorkbarShell` supplies the shared tab +controls. The footer shortcut and collapsed-sidebar chat-header shortcut both +activate the same instance. + +`App.captureToNote` receives the message identity from `ChatTimeline`, or calls +`ensureConversationHistory` for whole-conversation capture. It freezes text and +source IDs before opening the preview and waits for queued conversation persistence +so Main can validate newly completed messages. Whole-conversation text includes +nonempty user/assistant bodies with role headings. It excludes reasoning, tool +logs and attachment binaries, and rejects streaming conversations. + +`useMagicNoteDraft` lives in App and owns the draft, selected note, saving state +and one shared confirmation promise. Selection and drafts survive panel remounts +and conversation/project switches, but are not persisted across app restarts. +The guard rejects leaving while saving and confirms discarding pending input; +explicit cancellation compares text/title with their initial preview values. +Application disablement and tab closing call the same guard. Workspace navigation +also passes through the full workspace's existing leave check. + +`capturedNoteContent` converts text into v1 Quill string inserts, preserving +newlines and adding the terminal newline when needed. It splits inserts at the +200,000-code-unit operation limit without splitting surrogate pairs; the complete +record still passes the shared rich-content schema. Markdown stays literal text. +No summarization or attachment copying runs during capture. New-note titles are +limited to 100 characters. A synchronous submission ref prevents duplicate writes. + +`magicNotes.create({ title, content?, source? })` returns `MagicNoteCreateResult`, +with `createdEntryId` when initial content exists. `createMagicNote` inserts the +note, first entry, source and derived todos inside one SQLite transaction; failure +leaves no committed empty note and sends no success notification. This atomicity +applies to database membership; filesystem writes retain the reconciliation +boundary in [File Storage and Writes](#file-storage-and-writes). +`magicNotes.createEntry({ noteId, content, source? })` always returns the exact +`createdEntryId`. The panel uses that ID to scroll, focus and briefly highlight the +new record. Opening the full workspace passes `{noteId, entryId?, requestId}` into +its existing guarded note-navigation path. + +`magicNotes.search({ query, limit? })` is exposed by `DesktopApi`, preload and +`magic-notes:search`. Trusted-sender and shared-schema validation precede +`AssistantDatabase.searchMagicNoteSummaries`. The trimmed query has a 1,000-character +limit; result limits are 1-200, defaulting to 200. The panel requests 100 results +and debounces nonempty queries by 180 ms. SQL searches note titles or indexed entry +text using `EXISTS`, so each note occurs once before `LIMIT`. Empty queries list +notes; ordering is pinned first, then updated time and row ID descending. +`%`, `_` and backslashes are escaped for literal matching. Agent `note_search` +retains its separate entry-level output and 1-100 limit. + +### Source Metadata + +`magicNoteSourceSchema` defines `kind` (`message` or `conversation`), +`conversationId`, nonempty unique `messageIds`, ISO `capturedAt`, +`conversationTitle`, and optional `projectId` / `projectName`. A message source +requires exactly one message ID. Create/create-entry inputs accept `source`; +note creation requires initial content when source is supplied. Entry reads +return it independently of editable content, while update inputs cannot replace it. + +The IPC helper `resolveMagicNoteSource` loads the stored conversation and verifies +that every message ID belongs to it; a single-message source must identify an +assistant message. It replaces renderer-supplied title/project labels with current +database values while retaining the supplied capture time and IDs. Preview labels +therefore describe capture time, but stored labels describe save time. Missing +source rows reject creation; later source deletion does not block note reads or edits. + +Schema 48 adds nullable `magic_note_entries.source_json` in a transaction and +advances `PRAGMA user_version`. Existing entries read without a source. It adds no +conversation foreign key or deletion cascade, and does not rewrite file-backed +bodies. Schema 38 remains the historical body migration. Structure-only upgrade +does not independently trigger body conversion or VACUUM; see the +[database migration guide](../../development/database-migrations.md). Coordinated +SQLite/files backups include the source metadata. + +`MagicNoteSource` is shared by panel summaries and workspace records. Its callback +`App.openNoteSource` reads `conversations.get`, then navigates through the workspace +leave guard. Message navigation increases the mounted timeline range before +scrolling and focusing. A missing message opens its conversation with a notice; +a confirmed missing conversation disables that source bar's jump buttons. Other +read errors remain retryable. No deleted-source flag is stored. + +### Refresh and Validation + +Both views use the same Main store and `magic-notes:changed` notification. The +panel reloads search and selected detail when active, and ignores superseded +responses. Search/loading state belongs to the mounted panel; input and selected +note belong to App. A deleted selected note clears the selection and matching +target ID while preserving draft text. Read and save failures have local recovery +feedback. The full workspace retains its existing revision and dirty-editor rules. + +After a successful save, only `after-save-auto` starts entry analysis using the +returned `createdEntryId` and configured comment format. Analysis errors notify +without undoing the save or recreating the entry. Other comment modes perform no +panel analysis. + +Regression cases are in `MagicNotesPanel.test.tsx`, `App.test.tsx`, +`ChatTimeline.test.tsx`, `MagicNotesWorkspace.test.tsx`, workbar contract tests, +database/storage tests and production preload/IPC integration tests. +`tests/magic-notes-capture.electron.test.ts` mounts the real App with production +preload and capture IPC, backed by SQLite/files. It validates existing/new-note +saves, verbatim 502-message history, workspace/source navigation, native keyboard +input in a narrow panel, database reopen and retention after source deletion, +with zero model calls. Failed writes and external refresh retain their focused +regression coverage; this Electron result does not claim every failure path. +Repository test/typecheck failures and repaired migration fixtures are documented +separately in [progress](./progress.md). + ## Validation - `magic-note-storage.test.ts` uses real files and SQLite for migration equality, @@ -476,6 +594,6 @@ daemon implementation update. coverage includes portal placement, keyboard navigation, dismissal, delete confirmation, target selection, revision failures and draft preservation. -Schema 38 and the file layout above govern current persistence; refresh +Schema 48 adds source metadata to the schema 38 file-backed body layout; refresh notifications need no separate storage or network service. Dated validation results and remaining repository checks are owned by [progress](./progress.md). diff --git a/docs/features/magic-notes/ui-design.md b/docs/features/magic-notes/ui-design.md new file mode 100644 index 00000000..76ad2e91 --- /dev/null +++ b/docs/features/magic-notes/ui-design.md @@ -0,0 +1,50 @@ +# 会话联动界面设计 + +状态:已接入前端,真实 Electron 验收通过;全仓验证未全绿,结果见 [progress.md](./progress.md)。遵循根目录 [UI-DESIGN.md](../../../UI-DESIGN.md),需求和行为分别由 [PRD](./prd.md) 与 [逻辑设计](./logic-design.md)定义。独立工作区布局继续由根文档 13.7 节维护。 + +## 入口 + +| 位置 | 文案与动作 | +| --- | --- | +| 助手工作栏“+”菜单 | 增加“笔记”,打开或激活单个笔记 Tab。 | +| 助手消息底部,复制旁 | 图标按钮“加入笔记”,进入该消息的采集预览。 | +| 会话标题更多菜单 | “保存当前对话到笔记”,加载完整历史后进入整段采集预览;生成、采集加载或保存期间禁用。 | +| 左侧底部,应用中心与设置操作组上方 | “快速笔记”,打开面板;侧栏收起时在聊天页头显示带同名提示的图标。 | +| 面板笔记标题旁 | “在完整工作区打开”,进入当前笔记;有待保存文字时先处理草稿。 | + +左侧主导航中的“魔法笔记”继续打开独立工作区,两处入口通过名称说明不同目的。快速笔记不读取文本选区,不根据选区改变动作。 + +## 紧凑面板 + +面板按单列显示列表或笔记详情,不嵌入完整工作区的三栏布局。搜索位于列表顶部,“新建笔记”在列表之后;笔记行显示标题、置顶状态、两行摘要和更新时间。无笔记时提供新建动作;搜索无结果时保留搜索词并允许创建。 + +详情顶部提供返回列表、笔记标题、全局范围标识和打开完整工作区。中间以可滚动记录摘要供浏览,每条文字摘要最多显示前 600 个字符,画布显示首页缩略图及页数;记录按钮可在完整工作区定位该记录。底部提供快速追加文字输入区与“加入笔记”按钮,画布编辑、待办管理和 AI 评论阅读仍在完整工作区。 + +从会话触发时,面板切到采集编辑视图: + +```text +加入笔记 +来自:项目 / 会话标题 · 单条回答或当前对话 +保存范围:用户与助手正文,不含推理、工具日志和附件文件 + +待加入内容 +[可编辑文字预览] + +保存到 +[搜索或选择已有笔记] +[新建笔记 → 标题输入] + +[取消] [加入笔记] +``` + +范围说明随单条或整段来源调整。预览初始包含完整待保存文字,可滚动编辑。新建目标标题默认取会话标题前 100 个字符,可修改;已有目标与新建目标使用共享 `SegmentedControl` 切换。采集内容为空或超限、缺少目标、新建标题为空或超限时就地提示,并禁用保存;普通新建允许创建空笔记。 + +读取失败显示就地重试按钮;保存失败保留输入并在提交区显示错误,用户可再次点击“加入笔记”。成功通过应用通知反馈,切回目标笔记详情,滚动定位、聚焦并高亮新增记录约 2.4 秒。仅“保存后自动评论”模式发起后续分析,分析失败另行通知,已保存内容保留。 + +## 来源与可访问性 + +记录摘要和完整工作区的已保存记录均显示“来自会话”来源条,内容含保存时标题、项目及采集时间。单条来源提供返回会话和定位原消息按钮,整段来源提供返回会话按钮。点击后确认原会话不存在才显示对应文案并禁用跳转;其他错误提供就地重试。来源条与正文分开,编辑正文时仍可识别来源。 + +Tab 使用工作栏现有的共享页签、焦点和关闭模式。搜索有持久标签,列表用可键盘访问的按钮;打开采集后聚焦预览,取消返回触发按钮,触发按钮已不可见时尝试返回会话标题菜单按钮。新建标题出现后聚焦输入框。面板中的 Escape 取消当前草稿,或从详情返回列表;需要放弃输入时先显示草稿确认。确认框初始聚焦“继续编辑”,限制 Tab 焦点范围,Escape 保留草稿并关闭确认。 + +面板服从工作栏现有宽度和停靠规则,以单列切换列表、详情和采集编辑。按钮组可换行,文字摘要保留换行并折行长文本;Markdown 和代码以原始文字显示。头部与快速追加区固定,中间内容独立滚动;完整预览输入区可纵向调整大小。配色、按钮、焦点环、通知和确认使用共享组件与令牌。真实 Electron 已在实测 214px 面板验证无横向溢出,以及原生键盘返回列表、打开笔记和追加保存。 diff --git a/src/main/agent/knowledge-mcp-gateway.test.ts b/src/main/agent/knowledge-mcp-gateway.test.ts index c1fe1163..791d1d90 100644 --- a/src/main/agent/knowledge-mcp-gateway.test.ts +++ b/src/main/agent/knowledge-mcp-gateway.test.ts @@ -889,10 +889,10 @@ describe('KnowledgeMcpGateway', () => { gateway.getMagicNote(readToken, { noteId: created.id }) ).toThrow('笔记不存在') - const canvas = database.createMagicNote({ title: 'Canvas', content: { + const canvas = database.getMagicNote(database.createMagicNote({ title: 'Canvas', content: { version: 2, kind: 'paged-canvas', assets: [], pages: [{ id: 'page', width: 794, height: 1123, background: { type: 'template', template: 'blank' }, objects: [{ type: 'IText', text: 'Canvas text' }] }] - } }) + } }).id) const canvasEntry = gateway.getMagicNote(readToken, { noteId: canvas.id }).entries[0]! expect(canvasEntry).toMatchObject({ content: 'Canvas text', contentKind: 'paged-canvas', contentVersion: 2, plainTextEditable: false }) expect(() => gateway.updateMagicNoteEntry(writeToken, { entryId: canvasEntry.id, expectedRevision: canvasEntry.revision, content: 'overwrite' })).toThrow('画布记录不能') diff --git a/src/main/assistant/activity-history-io.test.ts b/src/main/assistant/activity-history-io.test.ts index 4895384e..4843106c 100644 --- a/src/main/assistant/activity-history-io.test.ts +++ b/src/main/assistant/activity-history-io.test.ts @@ -31,6 +31,7 @@ it('migrates activity history and writes only changed records during streaming', DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version=43`) const legacySave = raw.prepare('UPDATE activity_history SET records_json = ?, legacy_history_may_be_incomplete = 1') legacySave.run(JSON.stringify(records)) diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 9378e882..0024bb61 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -178,6 +178,7 @@ it('migrates schema 35 conversations to unpinned while retaining existing data a DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 35;`) legacy.close() try { @@ -332,6 +333,7 @@ it('preserves existing supervision data when upgrading schema 41 and reopening', DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 41;`) legacy.close() try { @@ -1098,6 +1100,7 @@ describe('AssistantDatabase', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 3; `) oldDatabase.close() @@ -1264,6 +1267,7 @@ describe('AssistantDatabase', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 26; `) legacy.close() @@ -1383,6 +1387,7 @@ describe('AssistantDatabase', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 30; `) legacy.close() @@ -1464,6 +1469,7 @@ describe('AssistantDatabase', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 30; `) legacy.close() @@ -1564,6 +1570,7 @@ describe('AssistantDatabase', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 24; `) legacy.close() @@ -1614,6 +1621,7 @@ describe('AssistantDatabase', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 24; `) legacy.close() @@ -1665,6 +1673,7 @@ describe('AssistantDatabase', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 24; `) legacy.close() @@ -1712,6 +1721,7 @@ describe('AssistantDatabase', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 24; `) legacy.close() @@ -1748,6 +1758,7 @@ describe('AssistantDatabase', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 24; `) legacy.close() @@ -1781,6 +1792,7 @@ describe('AssistantDatabase', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 5; `) versionFive.close() @@ -1911,6 +1923,7 @@ describe('AssistantDatabase', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 21; COMMIT; `) @@ -2010,6 +2023,7 @@ describe('AssistantDatabase', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 9; `) legacy.close() @@ -2071,6 +2085,7 @@ describe('AssistantDatabase', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 16`) legacy.close() @@ -3026,6 +3041,7 @@ describe('AssistantDatabase', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 23`) legacy.close() @@ -3193,6 +3209,7 @@ describe('AssistantDatabase', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 18; `) legacy.close() @@ -3709,6 +3726,7 @@ describe('AssistantDatabase', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 31; `) legacy.close() diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 7ed0f179..9a8d2a19 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -86,6 +86,9 @@ import { } from '../../shared/runtime-selection-contracts' import { MAGIC_NOTE_MAX_NOTE_EMBED_BYTES, + magicNoteSourceSchema, + type MagicNoteSource, + type MagicNoteCreateResult, type MagicNoteComment, type MagicNoteDetail, type MagicNoteEntryCreateResult, @@ -119,7 +122,7 @@ import { SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION } from './subagent-progress-storage' -export const ASSISTANT_DATABASE_SCHEMA_VERSION = 47 +export const ASSISTANT_DATABASE_SCHEMA_VERSION = 48 export type RemoteTaskEventInput = { taskId: string @@ -256,6 +259,7 @@ type MagicNoteEntryRow = { id: string note_id: string content_json: string + source_json: string | null plain_text: string comments_json: string analyzed_at: string | null @@ -666,6 +670,7 @@ function toMagicNoteEntry(row: MagicNoteEntryRow, content: MagicNoteContent): Ma id: row.id, noteId: row.note_id, content, + ...(row.source_json ? { source: magicNoteSourceSchema.parse(JSON.parse(row.source_json)) } : {}), plainText: row.plain_text, comments: JSON.parse(row.comments_json) as MagicNoteComment[], analyzedAt: row.analyzed_at ?? undefined, @@ -3920,8 +3925,12 @@ export class AssistantDatabase { createMagicNote(input: { title: string content?: MagicNoteContent - }): MagicNoteDetail { + source?: MagicNoteSource + }): MagicNoteCreateResult { const id = randomUUID() + const entryId = input.content ? randomUUID() : undefined + const source = input.source ? magicNoteSourceSchema.parse(input.source) : undefined + if (source && !input.content) throw new Error('Source requires initial content') const now = new Date().toISOString() const database = this.requireDatabase() if (input.content) { @@ -3945,16 +3954,15 @@ export class AssistantDatabase { VALUES (?, ?, ?, 0, ?, ?, ?)` ) .run(id, null, input.title, input.content ? 1 : 0, now, now) - if (input.content) { - const entryId = randomUUID() + if (input.content && entryId) { const pointer = this.writeMagicNoteBody(id, entryId, input.content, 0, now) database .prepare( `INSERT INTO magic_note_entries (id, note_id, content_json, plain_text, comments_json, actions_json, analyzed_at, revision, created_at, updated_at, - image_bytes) - VALUES (?, ?, ?, ?, '[]', '[]', NULL, 0, ?, ?, ?)` + image_bytes, source_json) + VALUES (?, ?, ?, ?, '[]', '[]', NULL, 0, ?, ?, ?, ?)` ) .run( entryId, @@ -3963,7 +3971,8 @@ export class AssistantDatabase { magicNotePlainText(input.content), now, now, - embeddedBytes + embeddedBytes, + source ? JSON.stringify(source) : null ) this.syncMagicNoteTodos( database, @@ -3984,7 +3993,7 @@ export class AssistantDatabase { if (input.content) { this.notifyMagicTodosChanged() } - return detail + return { ...detail, ...(entryId ? { createdEntryId: entryId } : {}) } } updateMagicNote(input: { @@ -4034,7 +4043,9 @@ export class AssistantDatabase { noteId: string content: MagicNoteContent plainText: string + source?: MagicNoteSource }): MagicNoteEntryCreateResult { + const source = input.source ? magicNoteSourceSchema.parse(input.source) : undefined const database = this.requireDatabase() const entryId = randomUUID() const content = validateMagicNoteContent(input.content) @@ -4059,8 +4070,8 @@ export class AssistantDatabase { `INSERT INTO magic_note_entries (id, note_id, content_json, plain_text, comments_json, actions_json, analyzed_at, revision, created_at, updated_at, - image_bytes) - VALUES (?, ?, ?, ?, '[]', '[]', NULL, 0, ?, ?, ?)` + image_bytes, source_json) + VALUES (?, ?, ?, ?, '[]', '[]', NULL, 0, ?, ?, ?, ?)` ) .run( entryId, @@ -4069,7 +4080,8 @@ export class AssistantDatabase { input.plainText, now, now, - magicNoteEmbeddedBytes(input.content) + magicNoteEmbeddedBytes(input.content), + source ? JSON.stringify(source) : null ) this.syncMagicNoteTodos( database, @@ -4287,6 +4299,23 @@ export class AssistantDatabase { return { incompleteCount: row.incomplete_count } } + searchMagicNoteSummaries(query: string, limit = 200): MagicNoteSummary[] { + const pattern = `%${query.replace(/[\\%_]/gu, '\\$&')}%` + const rows = this.requireDatabase().prepare( + `SELECT n.*, + (SELECT COUNT(*) FROM magic_note_entries e WHERE e.note_id = n.id) AS entry_count, + (SELECT plain_text FROM magic_note_entries e WHERE e.note_id = n.id + ORDER BY e.created_at DESC, e.rowid DESC LIMIT 1) AS latest_plain_text + FROM magic_notes n + WHERE n.title LIKE ? ESCAPE '\\' + OR EXISTS (SELECT 1 FROM magic_note_entries e + WHERE e.note_id = n.id AND e.plain_text LIKE ? ESCAPE '\\') + ORDER BY n.pinned DESC, n.updated_at DESC, n.rowid DESC + LIMIT ?` + ).all(pattern, pattern, limit) as MagicNoteRow[] + return rows.map(toMagicNoteSummary) + } + searchMagicNotes(query: string, limit: number): MagicNoteSearchResult[] { const pattern = `%${query.replace(/[\\%_]/gu, '\\$&')}%` return ( @@ -11426,6 +11455,13 @@ export class AssistantDatabase { database.exec('PRAGMA user_version = 47; COMMIT;') } catch (error) { database.exec('ROLLBACK'); throw error } } + if (version.user_version < 48) { + database.exec('BEGIN IMMEDIATE') + try { + database.exec('ALTER TABLE magic_note_entries ADD COLUMN source_json TEXT;') + database.exec('PRAGMA user_version = 48; COMMIT;') + } catch (error) { database.exec('ROLLBACK'); throw error } + } } supervisionReviewStore(): SupervisionReviewStore { diff --git a/src/main/assistant/assistant-storage-upgrade.test.ts b/src/main/assistant/assistant-storage-upgrade.test.ts index a59e463e..87fa7846 100644 --- a/src/main/assistant/assistant-storage-upgrade.test.ts +++ b/src/main/assistant/assistant-storage-upgrade.test.ts @@ -55,12 +55,12 @@ function subagent(requestId: string): SubagentEvent { } describe('subagent progress storage', () => { - it.each([45, 46])('upgrades schema %i without reconverting history or vacuuming ordinary free pages', async (sourceVersion) => { + it.each([45, 46, 47])('upgrades schema %i without reconverting history or vacuuming ordinary free pages', async (sourceVersion) => { const { path, database, taskId } = await fixture() const event = subagent(randomUUID()) event.progress = [{ id: randomUUID(), type: 'text', content: 'Keep current-format progress' }] database.appendTaskEvent(taskId, 'subagent', event) - const note = database.createMagicNote({ title: 'Already migrated', content: { version: 1, ops: [{ insert: 'Keep note\n' }] } }) + const note = database.getMagicNote(database.createMagicNote({ title: 'Already migrated', content: { version: 1, ops: [{ insert: 'Keep note\n' }] } }).id) const header = { id: randomUUID(), title: 'Keep chat', updatedAt: 1000 } const message = { id: randomUUID(), role: 'assistant' as const, state: 'complete' as const, content: 'Normal chat update'.repeat(100_000), createdAt: 1000 } @@ -70,14 +70,15 @@ describe('subagent progress storage', () => { database.close() const sql = new DatabaseSync(path) try { - sql.exec(`DROP VIEW supervision_review_current; + if (sourceVersion < 47) sql.exec(`DROP VIEW supervision_review_current; DROP TABLE supervision_review_navigation; DROP TABLE supervision_review_batches; DROP TABLE supervision_review_sources; DROP TABLE supervision_review_runs;`) if (sourceVersion === 45) sql.exec(` DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update; DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;`) - sql.exec(`PRAGMA user_version = ${sourceVersion}; PRAGMA wal_checkpoint(TRUNCATE)`) + sql.exec(`ALTER TABLE magic_note_entries DROP COLUMN source_json; + PRAGMA user_version = ${sourceVersion}; PRAGMA wal_checkpoint(TRUNCATE)`) expect(sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBeGreaterThan(0) const events = sql.prepare('SELECT * FROM task_events WHERE task_id = ? ORDER BY id').all(taskId) expect(events.length).toBeGreaterThan(0) @@ -143,7 +144,7 @@ describe('subagent progress storage', () => { it('does not re-show migration after a note is already migrated and later chats free pages', async () => { const { path, database } = await fixture() - const note = database.createMagicNote({ title: 'Legacy note', content: { version: 1, ops: [{ insert: 'note body\n' }] } }) + const note = database.getMagicNote(database.createMagicNote({ title: 'Legacy note', content: { version: 1, ops: [{ insert: 'note body\n' }] } }).id) database.close() const legacy = new DatabaseSync(path) try { @@ -158,6 +159,7 @@ describe('subagent progress storage', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 37`) } finally { legacy.close() } expect(hasPendingAssistantStorageUpgrade(path)).toBe(true) @@ -479,6 +481,7 @@ describe('subagent progress storage', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = ${sourceVersion}; BEGIN`) const insert = legacy.prepare( `INSERT INTO task_events( diff --git a/src/main/assistant/heartbeat-database.test.ts b/src/main/assistant/heartbeat-database.test.ts index c7dbe395..70565c91 100644 --- a/src/main/assistant/heartbeat-database.test.ts +++ b/src/main/assistant/heartbeat-database.test.ts @@ -86,6 +86,7 @@ describe('AssistantDatabase heartbeat persistence', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 2`) raw.close() @@ -148,6 +149,7 @@ describe('AssistantDatabase heartbeat persistence', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 20`) raw.close() diff --git a/src/main/assistant/incremental-review.test.ts b/src/main/assistant/incremental-review.test.ts index ebb5ca32..a59440f3 100644 --- a/src/main/assistant/incremental-review.test.ts +++ b/src/main/assistant/incremental-review.test.ts @@ -193,7 +193,7 @@ it('preserves schema-45 plans, reports, IDs and foreign keys while extending sta sql.exec(`INSERT INTO ${table}_old SELECT * FROM ${table}; DROP TABLE ${table}; ALTER TABLE ${table}_old RENAME TO ${table};`) for (const index of indexes) sql.exec(String(index.sql)) } - sql.exec('PRAGMA user_version = 45; COMMIT; PRAGMA foreign_keys = ON;') + sql.exec('ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 45; COMMIT; PRAGMA foreign_keys = ON;') db.initialize(directory) expect(db.getHeartbeatConfig(config.id)).toEqual(savedConfig) expect(db.listHeartbeatEntries(config.id)).toEqual(savedEntries) diff --git a/src/main/assistant/supervision-activity.test.ts b/src/main/assistant/supervision-activity.test.ts index 145cc1d3..8265c4e0 100644 --- a/src/main/assistant/supervision-activity.test.ts +++ b/src/main/assistant/supervision-activity.test.ts @@ -134,6 +134,7 @@ it('upgrades schema 44 without inventing historical links and preserves runs on DROP TABLE supervision_review_navigation; DROP TABLE supervision_review_batches; DROP TABLE supervision_review_sources; DROP TABLE supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 44;`) legacy.close() db.initialize(process.cwd()) diff --git a/src/main/assistant/supervision-history.test.ts b/src/main/assistant/supervision-history.test.ts index d8562609..db72dd12 100644 --- a/src/main/assistant/supervision-history.test.ts +++ b/src/main/assistant/supervision-history.test.ts @@ -46,6 +46,7 @@ it('supervision preserves result history and protected identities within scope t DROP TABLE supervision_review_navigation; DROP TABLE supervision_review_batches; DROP TABLE supervision_review_sources; DROP TABLE supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 42;`) legacy.close() db.initialize(process.cwd()) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 1a61750e..454974ed 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -198,6 +198,8 @@ import { import { magicNoteAnalyzeSchema, magicNoteCreateSchema, + magicNoteSearchSchema, + type MagicNoteSource, magicNoteDeleteSchema, magicNoteDraftAnalyzeSchema, magicNoteEntryCreateSchema, @@ -8430,6 +8432,33 @@ export function registerIpcHandlers( contextManager.remove(requestIdSchema.parse(input)) }) + const resolveMagicNoteSource = (source?: MagicNoteSource): MagicNoteSource | undefined => { + if (!source) return undefined + const conversation = assistantDatabase.getConversation(source.conversationId) + const messages = new Map(conversation.messages.map((message) => [message.id, message])) + for (const id of source.messageIds) { + const message = messages.get(id) + if (!message || (source.kind === 'message' && message.role !== 'assistant')) { + throw new Error('Invalid conversation source message') + } + } + const project = conversation.projectId ? assistantDatabase.getProject(conversation.projectId) : undefined + return { + kind: source.kind, + conversationId: conversation.id, + messageIds: source.messageIds, + capturedAt: source.capturedAt, + conversationTitle: conversation.title, + ...(project ? { projectId: project.id, projectName: project.name } : {}) + } + } + + registerHandler(ipcChannels.magicNotesSearch, (event, input: unknown) => { + assertTrustedSender(event, window) + const { query, limit } = magicNoteSearchSchema.parse(input) + return assistantDatabase.searchMagicNoteSummaries(query, limit) + }) + registerHandler(ipcChannels.magicNotesList, (event) => { assertTrustedSender(event, window) return { notes: assistantDatabase.listMagicNotes() } @@ -8443,9 +8472,8 @@ export function registerIpcHandlers( registerHandler(ipcChannels.magicNotesCreate, (event, input: unknown) => { assertTrustedSender(event, window) - return assistantDatabase.createMagicNote( - magicNoteCreateSchema.parse(input) - ) + const parsed = magicNoteCreateSchema.parse(input) + return assistantDatabase.createMagicNote({ ...parsed, source: resolveMagicNoteSource(parsed.source) }) }) registerHandler(ipcChannels.magicNotesUpdate, (event, input: unknown) => { @@ -8470,6 +8498,7 @@ export function registerIpcHandlers( return assistantDatabase.createMagicNoteEntry({ noteId: parsed.noteId, content, + source: resolveMagicNoteSource(parsed.source), plainText: magicNotePlainText(content) }) } diff --git a/src/main/magic-notes/canvas-ipc.integration.test.ts b/src/main/magic-notes/canvas-ipc.integration.test.ts index 8d1138a6..5f5f5260 100644 --- a/src/main/magic-notes/canvas-ipc.integration.test.ts +++ b/src/main/magic-notes/canvas-ipc.integration.test.ts @@ -1,5 +1,8 @@ // @vitest-environment node import { mkdtemp, rm } from 'node:fs/promises' +import fs from 'node:fs' +import { randomUUID } from 'node:crypto' +import { DatabaseSync } from 'node:sqlite' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -91,12 +94,104 @@ describe('production preload -> registered IPC -> SQLite canvas persistence', () open() }) afterEach(async () => { + vi.restoreAllMocks() await dispose?.() database?.close() bridge.handlers.clear() await rm(directory, { recursive: true, force: true }) }) + it('captures verified source labels and preserves them after source deletion, edits and reopen', async () => { + const project = database.createProject({ name: 'Actual project', rootPath: directory, + description: '', defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' } }) + const header = { id: randomUUID(), title: 'Actual conversation', projectId: project.id, updatedAt: 1 } + const messages = Array.from({ length: 120 }, (_, index) => ({ id: randomUUID(), + role: index % 2 ? 'assistant' as const : 'user' as const, + content: `History ${index}`, state: 'complete' as const, createdAt: index })) + database.saveLocalConversations([{ header, messages }]) + const source = { kind: 'conversation' as const, conversationId: header.id, + messageIds: messages.map(message => message.id), capturedAt: '2026-09-26T01:02:03.000Z', + conversationTitle: 'Forged title', projectId: randomUUID(), projectName: 'Forged project' } + const content = { version: 1 as const, ops: [{ insert: messages.map(message => `${message.role}: ${message.content}\n`).join('') }] } + const created = await api.create({ title: 'Captured history', content, source }) + expect(created.createdEntryId).toBe(created.entries[0]!.id) + const expectedSource = { ...source, conversationTitle: header.title, projectId: project.id, projectName: project.name } + expect(created.entries[0]!.source).toEqual(expectedSource) + expect(created.entries[0]!.content).toEqual(content) + const appended = await api.createEntry({ noteId: created.id, content, + source: { ...source, kind: 'message', messageIds: [messages[1]!.id] } }) + expect(appended.entries.find(entry => entry.id === appended.createdEntryId)?.source) + .toEqual({ ...expectedSource, kind: 'message', messageIds: [messages[1]!.id] }) + database.deleteLocalConversation(header.id) + const edited = await api.updateEntry({ entryId: created.createdEntryId!, expectedRevision: 0, + content: { version: 1, ops: [{ insert: 'Edited copy\n' }] } }) + expect(edited.entries[0]!.source).toEqual(expectedSource) + await reopen() + expect(await api.get(created.id)).toEqual(edited) + const sql = new DatabaseSync(join(directory, 'assistant.sqlite')) + try { + const row = sql.prepare('SELECT source_json FROM magic_note_entries WHERE id = ?').get(created.createdEntryId!) as { source_json: string } + expect(JSON.parse(row.source_json)).toEqual(expectedSource) + } finally { sql.close() } + await api.remove(created.id) + await expect(api.get(created.id)).rejects.toThrow() + }) + + it('rejects invalid source identities and source-only creates without leaving empty notes', async () => { + const header = { id: randomUUID(), title: 'Global conversation', updatedAt: 1 } + const message = { id: randomUUID(), role: 'assistant' as const, content: 'Answer', state: 'error' as const, createdAt: 1 } + database.saveLocalConversations([{ header, messages: [message] }]) + const content = { version: 1 as const, ops: [{ insert: 'Edited preview\n' }] } + const source = { kind: 'message' as const, conversationId: header.id, messageIds: [message.id], + capturedAt: new Date().toISOString(), conversationTitle: 'Untrusted', projectId: randomUUID(), projectName: 'Untrusted' } + await expect(api.create({ title: 'Invalid', source })).rejects.toThrow() + await expect(api.create({ title: 'Invalid', content, source: { ...source, messageIds: [randomUUID()] } })).rejects.toThrow() + await expect(api.create({ title: 'Invalid', content, source: { ...source, messageIds: [message.id, message.id] } })).rejects.toThrow() + await expect(api.create({ title: 'Invalid', content, source: { ...source, conversationId: randomUUID() } })).rejects.toThrow() + expect((await api.list()).notes).toEqual([]) + const note = await api.create({ title: 'Partial answer', content, source }) + expect(note.entries[0]!.source).toEqual({ kind: 'message', conversationId: header.id, + messageIds: [message.id], capturedAt: source.capturedAt, conversationTitle: header.title }) + await api.remove(note.id) + expect(database.getConversation(header.id).messages).toEqual([message]) + }) + + it('rolls back a new note when writing its initial body fails and allows retry', async () => { + const rename = fs.renameSync + const failure = vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + if (String(to).includes('entries')) throw new Error('Initial body write failed') + rename(from, to) + }) + const input = { title: 'Atomic capture', content: { version: 1 as const, ops: [{ insert: 'Saved once\n' }] } } + await expect(api.create(input)).rejects.toThrow('Initial body write failed') + expect((await api.list()).notes).toEqual([]) + failure.mockRestore() + const note = await api.create(input) + expect(note.entries).toHaveLength(1) + expect(note.createdEntryId).toBe(note.entries[0]!.id) + expect((await api.list()).notes).toHaveLength(1) + }) + + it('searches titles and all entry bodies with note-level limits while preserving Agent entry results', async () => { + const titleOnly = await api.create({ title: 'Needle title' }) + expect(titleOnly.createdEntryId).toBeUndefined() + const bodyOnly = await api.create({ title: 'Body only', content: { version: 1, ops: [{ insert: 'Needle body\n' }] } }) + for (let index = 0; index < 4; index++) { + await api.createEntry({ noteId: bodyOnly.id, content: { version: 1, ops: [{ insert: `Needle ${index}\n` }] } }) + } + await api.createEntry({ noteId: bodyOnly.id, content: { version: 1, ops: [{ insert: 'Latest unrelated preview\n' }] } }) + const results = await api.search({ query: ' Needle ', limit: 2 }) + expect(new Set(results.map(note => note.id))).toEqual(new Set([bodyOnly.id, titleOnly.id])) + expect(results.find(note => note.id === bodyOnly.id)).toMatchObject({ entryCount: 6, preview: 'Latest unrelated preview' }) + expect(database.searchMagicNotes('Needle', 20).filter(result => result.noteId === bodyOnly.id)).toHaveLength(5) + await api.update({ noteId: titleOnly.id, pinned: true, expectedRevision: titleOnly.revision }) + expect((await api.search({ query: 'Needle', limit: 1 })).map(note => note.id)).toEqual([titleOnly.id]) + expect(await api.search({ query: '%' })).toEqual([]) + expect(await api.search({ query: '' })).toHaveLength(2) + await expect(api.search({ query: 'Needle', limit: 0 })).rejects.toThrow() + await expect(api.search({ query: 'Needle', limit: 201 })).rejects.toThrow() + }) + it.each([true, false])('limits saved, draft and todo input using persisted page order and count (vision=%s)', async (supportsImageInput) => { getResolvedSettings.mockResolvedValue({ workspacePath: directory, supportsImageInput }) const requests: AgentExecutionRequest[] = [] diff --git a/src/main/magic-notes/magic-note-storage.test.ts b/src/main/magic-notes/magic-note-storage.test.ts index d3e2310d..4157e2d9 100644 --- a/src/main/magic-notes/magic-note-storage.test.ts +++ b/src/main/magic-notes/magic-note-storage.test.ts @@ -53,6 +53,30 @@ function canvas(size = 40): MagicNoteCanvasContent { } describe('Magic note SQLite and filesystem storage', () => { + it('upgrades schema 47 with nullable sources without rewriting bodies and reopens idempotently', () => { + const { database, sql, path, directory } = setup() + const created = database.createMagicNote({ title: 'Existing note', content: rich }) + const before = database.getMagicNote(created.id) + const bodyPath = join(directory, 'notes', created.id, 'entries', `${created.createdEntryId}.json`) + const body = readFileSync(bodyPath) + const modifiedAt = statSync(bodyPath).mtimeMs + database.close() + // Reconstruct the previous released schema; production never downgrades databases. + sql.exec('ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 47;') + upgradeAssistantStorage(path, () => undefined) + expect(sql.prepare('PRAGMA user_version').get()).toEqual({ user_version: ASSISTANT_DATABASE_SCHEMA_VERSION }) + expect(sql.prepare('SELECT source_json FROM magic_note_entries').get()).toEqual({ source_json: null }) + expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' }) + database.initialize(directory) + expect(database.getMagicNote(created.id)).toEqual(before) + expect(database.getMagicNote(created.id).entries[0]).not.toHaveProperty('source') + expect(readFileSync(bodyPath)).toEqual(body) + expect(statSync(bodyPath).mtimeMs).toBe(modifiedAt) + database.close() + database.initialize(directory) + expect(database.getMagicNote(created.id)).toEqual(before) + }) + it('preserves textual data URLs and arbitrary $asset objects through save, migration and reopen', () => { const { database, sql, path, directory } = setup() const text = 'data:text/plain;base64,aGVsbG8' @@ -61,7 +85,7 @@ describe('Magic note SQLite and filesystem storage', () => { const drawing = canvas() drawing.pages[0]!.objects.push({ type: 'IText', ...custom }, { type: 'Group', objects: [{ type: 'Image', src: png, name: text, custom }] }) drawing.flow!.ops.push({ insert: text }, { insert: { image: png } }, { insert: { custom } }) - const notes = [richText, drawing].map((content) => database.createMagicNote({ title: 'Literal URLs', content })) + const notes = [richText, drawing].map((content) => database.getMagicNote(database.createMagicNote({ title: 'Literal URLs', content }).id)) for (const [index, note] of notes.entries()) expect(note.entries[0]!.content).toEqual([richText, drawing][index]) const disk = JSON.parse(readFileSync(join(directory, 'notes', notes[1]!.id, 'entries', `${notes[1]!.entries[0]!.id}.json`), 'utf8')) expect(disk.content.pages[0].objects[2]).toEqual({ type: 'IText', ...custom }) @@ -125,7 +149,7 @@ describe('Magic note SQLite and filesystem storage', () => { expect(existsSync(root)).toBe(true) expect(logged).toHaveBeenCalled() // A delayed reset cleanup must not remove notes created afterwards. - const retained = database.createMagicNote({ title: 'New note', content: canvas() }) + const retained = database.getMagicNote(database.createMagicNote({ title: 'New note', content: canvas() }).id) failing = false database.listMagicNotes() expect(existsSync(root)).toBe(false) @@ -134,7 +158,7 @@ describe('Magic note SQLite and filesystem storage', () => { it('still rejects authoritative body rename failures before committing SQL', () => { const { database } = setup() - const note = database.createMagicNote({ title: 'Body error', content: rich }) + const note = database.getMagicNote(database.createMagicNote({ title: 'Body error', content: rich }).id) const entry = note.entries[0]! vi.spyOn(fs, 'renameSync').mockImplementation((source, target) => { if (String(target).endsWith(`${entry.id}.json`)) throw new Error('body rename blocked') @@ -229,6 +253,7 @@ describe('Magic note SQLite and filesystem storage', () => { DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; + ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 37`) rmSync(join(directory, 'notes'), { recursive: true }) const progress: number[] = [] @@ -270,7 +295,7 @@ describe('Magic note SQLite and filesystem storage', () => { it('reclaims migrated SQLite payload space and restores a coordinated database/files backup', async () => { const { database, sql, directory, path } = setup() - const note = database.createMagicNote({ title: 'Large legacy PDF', content: canvas(2 * 1024 * 1024) }) + const note = database.getMagicNote(database.createMagicNote({ title: 'Large legacy PDF', content: canvas(2 * 1024 * 1024) }).id) const entry = note.entries[0]! database.close() sql.prepare('UPDATE magic_note_entries SET content_json = ? WHERE id = ?').run(JSON.stringify(entry.content), entry.id) @@ -340,7 +365,7 @@ describe('Magic note SQLite and filesystem storage', () => { it('rolls back failed creates and cleans their unaccepted files without affecting existing notes', () => { const { database, sql, directory } = setup() - const retained = database.createMagicNote({ title: 'Keep', content: rich }) + const retained = database.getMagicNote(database.createMagicNote({ title: 'Keep', content: rich }).id) sql.exec(`CREATE TRIGGER fail_note_insert BEFORE INSERT ON magic_note_entries BEGIN SELECT RAISE(ABORT, 'insert unavailable'); END`) expect(() => database.createMagicNote({ title: 'Failed', content: canvas() })).toThrow('insert unavailable') diff --git a/src/preload/index.ts b/src/preload/index.ts index 613ed122..938e0472 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -156,6 +156,8 @@ import type { MagicNoteAnalysisStreamEvent, MagicNoteDraftAnalysis, MagicNoteDetail, + MagicNoteCreateResult, + MagicNoteSummary, MagicNoteEntryCreateResult, MagicNotesSnapshot, MagicTodoItem, @@ -1625,6 +1627,8 @@ const desktopApi: DesktopApi = { } }, magicNotes: { + search: (input) => + ipcRenderer.invoke(ipcChannels.magicNotesSearch, input) as Promise, list: () => ipcRenderer.invoke( ipcChannels.magicNotesList @@ -1637,7 +1641,7 @@ const desktopApi: DesktopApi = { ipcRenderer.invoke( ipcChannels.magicNotesCreate, input - ) as Promise, + ) as Promise, update: (input) => ipcRenderer.invoke( ipcChannels.magicNotesUpdate, diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 1b2d7e54..e0c38c2f 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -868,6 +868,7 @@ const api: DesktopApi = { remove: vi.fn(async () => {}), }, magicNotes: { + search: vi.fn(async () => []), list: vi.fn(async () => ({ notes: [] })), get: vi.fn(async () => { throw new Error("not used"); @@ -1160,6 +1161,7 @@ describe("App", () => { delete document.documentElement.dataset.theme; document.documentElement.style.colorScheme = ""; vi.clearAllMocks(); + vi.mocked(api.magicNotes.search).mockReset().mockResolvedValue([]); vi.mocked(api.knowledge.getSnapshot).mockReset(); vi.mocked(api.knowledge.externalInstancesList).mockReset(); vi.mocked(api.agent.respondQuestion).mockReset().mockResolvedValue(); @@ -13874,6 +13876,148 @@ describe("App", () => { }) }) + it('captures all conversation history, freezes the source, and preserves the preview across hidden workbar and new conversations', async () => { + await api.updates!.updateSettings({ magicNotesEnabled: true }) + const messages = Array.from({ length: 502 }, (_, index) => ({ id: crypto.randomUUID(), role: index % 2 ? 'assistant' as const : 'user' as const, content: `Capture history ${index}`, createdAt: 1_775_000_000_000 + index, state: 'complete' as const })) + const conversation = { id: crypto.randomUUID(), projectId, title: 'Captured discussion', updatedAt: 1_775_000_000_502, messages } + vi.mocked(api.conversations.list).mockResolvedValueOnce([conversation]) + render() + await screen.findByText('Capture history 501') + expect(screen.queryByText('Capture history 0')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '会话操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '保存当前对话到笔记' })) + const preview = await screen.findByLabelText('待加入内容') + expect(preview).toHaveValue(messages.map(message => `## ${message.role === 'user' ? '用户' : '助手'}\n\n${message.content}`).join('\n\n')) + expect(screen.getAllByRole('tab', { name: '笔记' })).toHaveLength(1) + fireEvent.click(screen.getByRole('button', { name: '快速笔记' })) + expect(screen.getAllByRole('tab', { name: '笔记' })).toHaveLength(1) + fireEvent.change(preview, { target: { value: 'Frozen edited capture' } }) + fireEvent.click(screen.getByRole('button', { name: /收起工作栏|助手工作栏/ })) + fireEvent.click(screen.getByRole('button', { name: '快速笔记' })) + expect(await screen.findByLabelText('待加入内容')).toHaveValue('Frozen edited capture') + fireEvent.click(screen.getByRole('button', { name: /新建对话/ })) + expect(await screen.findByLabelText('待加入内容')).toHaveValue('Frozen edited capture') + fireEvent.click(screen.getByRole('button', { name: '新建笔记' })) + expect(screen.getByLabelText('笔记标题')).toHaveValue('Captured discussion') + fireEvent.click(within(screen.getByRole('region', { name: '快速笔记' })).getByRole('button', { name: '加入笔记' })) + await waitFor(() => expect(api.magicNotes.create).toHaveBeenCalledWith(expect.objectContaining({ source: expect.objectContaining({ kind: 'conversation', conversationId: conversation.id, messageIds: messages.map(message => message.id) }), content: { version: 1, ops: [{ insert: 'Frozen edited capture\n' }] } }))) + }) + + it('adds an assistant reply to an explicitly selected note and opens the returned entry in the full workspace', async () => { + await api.updates!.updateSettings({ magicNotesEnabled: true, magicNoteCommentMode: 'after-save-manual' }) + const message = { id: crypto.randomUUID(), role: 'assistant' as const, content: 'Reusable **answer**', createdAt: 1, state: 'complete' as const } + const conversation = { id: crypto.randomUUID(), projectId, title: 'Source answer discussion', updatedAt: 2, messages: [message] } + vi.mocked(api.conversations.list).mockResolvedValueOnce([conversation]) + const noteId = crypto.randomUUID() + const oldEntry = { id: crypto.randomUUID(), noteId, content: { version: 1 as const, ops: [{ insert: 'Keep existing entry\n' }] }, plainText: 'Keep existing entry', comments: [], revision: 1, createdAt: '2026-09-26T00:00:00Z', updatedAt: '2026-09-26T00:00:00Z' } + const note = { id: noteId, title: 'Collected answers', preview: oldEntry.plainText, entryCount: 1, pinned: false, revision: 1, createdAt: oldEntry.createdAt, updatedAt: oldEntry.updatedAt, entries: [oldEntry] } + const createdEntryId = crypto.randomUUID() + const saved = { ...note, entryCount: 2, entries: [oldEntry, { ...oldEntry, id: createdEntryId, content: { version: 1 as const, ops: [{ insert: `${message.content}\n` }] }, plainText: message.content }], createdEntryId } + vi.mocked(api.magicNotes.search).mockResolvedValue([note]) + vi.mocked(api.magicNotes.get).mockResolvedValue(note) + vi.mocked(api.magicNotes.createEntry).mockImplementationOnce(async () => { + vi.mocked(api.magicNotes.get).mockResolvedValue(saved) + vi.mocked(api.magicNotes.list).mockResolvedValue({ notes: [saved] }) + return saved + }) + render() + fireEvent.click(await screen.findByRole('button', { name: '加入笔记' })) + const panel = within(await screen.findByRole('region', { name: '快速笔记' })) + expect(panel.getByLabelText('待加入内容')).toHaveValue(message.content) + expect(panel.getByRole('button', { name: '加入笔记' })).toBeDisabled() + fireEvent.click(await panel.findByRole('button', { name: /Collected answers/ })) + fireEvent.click(panel.getByRole('button', { name: '加入笔记' })) + await waitFor(() => expect(api.magicNotes.createEntry).toHaveBeenCalledWith({ noteId, content: { version: 1, ops: [{ insert: `${message.content}\n` }] }, source: expect.objectContaining({ kind: 'message', conversationId: conversation.id, messageIds: [message.id] }) })) + expect(await panel.findByText('Keep existing entry')).toBeVisible() + const added = await waitFor(() => { + const element = document.getElementById(`compact-note-entry-${createdEntryId}`) + expect(element).not.toBeNull() + return element! + }) + const originalScroll = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView') + const scrollIntoView = vi.fn() + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: scrollIntoView }) + try { + fireEvent.click(within(added).getByRole('button', { name: '在完整工作区打开' })) + await waitFor(() => { + expect(document.getElementById(`magic-note-entry-${createdEntryId}`)).toBeVisible() + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center' }) + }) + } finally { + if (originalScroll) Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', originalScroll) + else Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView') + } + expect(api.magicNotes.analyze).not.toHaveBeenCalled() + }) + + it('does not capture partial history when loading the full conversation fails', async () => { + await api.updates!.updateSettings({ magicNotesEnabled: true }) + const id = crypto.randomUUID() + vi.mocked(api.conversations.listSummaries).mockResolvedValue([ + { id: crypto.randomUUID(), projectId, title: 'Opened discussion', updatedAt: 2, messages: [{ id: crypto.randomUUID(), role: 'assistant', state: 'complete', content: 'Opened discussion text', createdAt: 1 }] }, + { id, projectId, title: 'Unavailable history', updatedAt: 1, messages: [], messageSummary: { count: 10, firstRole: 'user' } } + ]) + vi.mocked(api.conversations.get).mockRejectedValue(new Error('History read failed')) + render() + await screen.findByText('Opened discussion text') + fireEvent.click(screen.getByText('Unavailable history')) + await screen.findByText('History read failed') + fireEvent.click(screen.getByRole('button', { name: '会话操作' })) + expect(screen.getByRole('menuitem', { name: '保存当前对话到笔记' })).toBeEnabled() + fireEvent.click(screen.getByRole('menuitem', { name: '保存当前对话到笔记' })) + await waitFor(() => expect(api.conversations.get).toHaveBeenCalledTimes(2)) + expect(screen.queryByLabelText('待加入内容')).not.toBeInTheDocument() + expect(api.magicNotes.create).not.toHaveBeenCalled() + expect(api.magicNotes.createEntry).not.toHaveBeenCalled() + }) + + it('opens note sources in folded history and distinguishes a deleted message from a deleted conversation', async () => { + await api.updates!.updateSettings({ magicNotesEnabled: true }) + const messages = Array.from({ length: 102 }, (_, index) => ({ id: crypto.randomUUID(), role: index % 2 ? 'assistant' as const : 'user' as const, content: `Source history ${index}`, createdAt: 1_775_000_000_000 + index, state: 'complete' as const })) + const conversation = { id: crypto.randomUUID(), projectId, title: 'Source discussion', updatedAt: 1_775_000_000_502, messages } + vi.mocked(api.conversations.list).mockResolvedValueOnce([conversation]) + const noteId = crypto.randomUUID() + const note = { id: noteId, title: 'Source note', preview: '', entryCount: 1, pinned: false, revision: 1, createdAt: '2026-09-26T00:00:00Z', updatedAt: '2026-09-26T00:00:00Z', entries: [{ id: crypto.randomUUID(), noteId, content: { version: 1 as const, ops: [{ insert: 'Saved text\n' }] }, plainText: 'Saved text', comments: [], revision: 1, createdAt: '2026-09-26T00:00:00Z', updatedAt: '2026-09-26T00:00:00Z', source: { kind: 'message' as const, conversationId: conversation.id, messageIds: [messages[1]!.id], capturedAt: '2026-09-26T00:00:00Z', conversationTitle: conversation.title } }] } + vi.mocked(api.magicNotes.search).mockResolvedValue([note]) + vi.mocked(api.magicNotes.get).mockResolvedValue(note) + render() + await screen.findByText('Source history 101') + expect(screen.queryByText('Source history 1')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '快速笔记' })) + fireEvent.click(await screen.findByRole('button', { name: /Source note/ })) + fireEvent.click(await screen.findByRole('button', { name: '查看来源消息' })) + await waitFor(() => { + expect(screen.getByText('Source history 1')).toBeVisible() + expect(document.activeElement).toHaveTextContent('Source history 1') + }) + vi.mocked(api.conversations.get).mockResolvedValue({ ...conversation, messages: [] }) + fireEvent.click(screen.getByRole('button', { name: '查看来源消息' })) + expect(await screen.findByText('原消息已不存在')).toBeVisible() + vi.mocked(api.conversations.get).mockRejectedValue(new Error('对话不存在')) + fireEvent.click(screen.getByRole('button', { name: '返回原会话' })) + expect(await screen.findByRole('button', { name: '原会话已不存在' })).toBeDisabled() + expect(screen.getByText('Saved text')).toBeVisible() + }) + + it('guards disabling Magic Notes with an unsaved quick draft before saving application settings', async () => { + await api.updates!.updateSettings({ magicNotesEnabled: true }) + render() + fireEvent.click(await screen.findByRole('button', { name: '快速笔记' })) + fireEvent.click(await screen.findByRole('button', { name: '新建笔记' })) + fireEvent.change(screen.getByLabelText('笔记标题'), { target: { value: 'Unsaved quick note' } }) + fireEvent.click(screen.getByRole('button', { name: '应用中心' })) + fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) + const center = await screen.findByRole('dialog', { name: '应用中心' }) + fireEvent.click(within(center).getByRole('button', { name: /魔法笔记.*设置|设置.*魔法笔记/ })) + const enabled = await screen.findByRole('switch', { name: /启用/ }) + const calls = vi.mocked(api.updates!.updateSettings).mock.calls.length + fireEvent.click(enabled) + const guard = await screen.findByRole('dialog', { name: /放弃/ }) + expect(vi.mocked(api.updates!.updateSettings).mock.calls).toHaveLength(calls) + fireEvent.click(within(guard).getByRole('button', { name: '继续编辑' })) + expect(vi.mocked(api.updates!.updateSettings).mock.calls).toHaveLength(calls) + }) + it('keeps Magic Notes free of a generic application settings shortcut', async () => { await api.updates!.updateSettings({ magicNotesEnabled: true }) render() diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 33f1761f..29c8037a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,8 @@ import { ImageCapabilityNotice } from "./ImageCapabilityNotice"; +import { MagicNotesPanel } from './MagicNotesPanel'; +import { AnchoredMenu } from './AnchoredMenu'; +import { useMagicNoteDraft } from './use-magic-note-draft'; +import type { MagicNoteSource } from '../../shared/magic-notes-contracts'; import LocalInferencePage from "./LocalInferencePage"; import { ApplicationMenu } from './ApplicationMenu'; import { AttachmentResultButton } from './AttachmentResultButton'; @@ -861,6 +865,8 @@ function ConversationHistoryLoader({ conversationId, active, load }: { } function ChatHistoryPane({ + onAddToNote, + noteMessageNavigation, onOpenImageModelSettings, onReselectImageSources, onEditImage, @@ -885,6 +891,8 @@ function ChatHistoryPane({ taskStrip, visibleMessageCount, }: { + onAddToNote?: (conversationId: string, message: Message, trigger: HTMLElement) => void; + noteMessageNavigation?: { conversationId: string; messageId: string; requestId: number }; onOpenImageModelSettings: () => void; onReselectImageSources: (operation: ImageOperation) => void; onEditImage: (artifact: AssistantArtifact) => void; @@ -951,6 +959,18 @@ function ChatHistoryPane({ ); const hiddenMessageCount = visibleMessageStartIndex; + useEffect(() => { + if (!active || noteMessageNavigation?.conversationId !== conversation.id) return; + const element = messageArticleRefs.current.get(noteMessageNavigation.messageId); + if (!element) return; + const frame = requestAnimationFrame(() => { + element.scrollIntoView?.({ block: 'center' }); + element.tabIndex = -1; + element.focus({ preventScroll: true }); + }); + return () => cancelAnimationFrame(frame); + }, [active, conversation.id, noteMessageNavigation, visibleMessageCount]); + const handleArticleRef = useCallback( (messageId: string, element: HTMLElement | null): void => { if (element) { @@ -1183,6 +1203,7 @@ function ChatHistoryPane({ messageStartIndex={visibleMessageStartIndex} onArticleRef={handleArticleRef} onCopyMessage={onCopyMessage} + onAddToNote={onAddToNote} onDownloadImage={onDownloadImage} onOpenCitationContext={onOpenCitationContext} onOpenCitationSource={onOpenCitationSource} @@ -2450,6 +2471,16 @@ function App(): React.JSX.Element { undefined, ); const notesLeaveRequesterRef = useRef(undefined); + const noteDraft = useMagicNoteDraft(); + const { guard: guardNoteDraft, setDraft: setNoteDraft } = noteDraft; + const [notesOpenRequest, setNotesOpenRequest] = useState(0); + const [notesNavigation, setNotesNavigation] = useState<{ noteId: string; entryId?: string; requestId: number }>(); + const [noteMessageNavigation, setNoteMessageNavigation] = useState<{ conversationId: string; messageId: string; requestId: number }>(); + const [noteCaptureLoading, setNoteCaptureLoading] = useState(false); + const noteCapturePending = useRef(false); + const noteCaptureTrigger = useRef(null); + const noteTitleMenuRef = useRef(null); + const [noteTitleMenuOpen, setNoteTitleMenuOpen] = useState(false); const registerNotesLeaveRequester = useCallback((requester: SettingsLeaveRequester | undefined): void => { notesLeaveRequesterRef.current = requester; }, []); @@ -2631,11 +2662,23 @@ function App(): React.JSX.Element { ), [applicationNavigation, applicationSettings, magicNotesEnabled]); const applyApplicationSettings = useCallback((settings: ApplicationSettings): void => { setApplicationSettings(settings); - setMagicNotesEnabled(settings.magicNotesEnabled); + if (settings.magicNotesEnabled) setMagicNotesEnabled(true); + else { + const revision = applicationSettingsRevisionRef.current; + void guardNoteDraft().then(async allowed => { + if (revision !== applicationSettingsRevisionRef.current) return; + if (allowed) { setNoteDraft(undefined); setMagicNotesEnabled(false); } + else if (window.goodbuddy.updates) { + // Continuing an externally disabled draft restores the same application setting. + try { await window.goodbuddy.updates.updateSettings({ magicNotesEnabled: true }); } + catch (reason) { setApplicationSettingsError(displayErrorMessage(reason, tRef.current('applications.saveFailed'))); } + } + }); + } setMagicNotesShowIncompleteTodoCount(settings.magicNotesShowIncompleteTodoCount); setConversationHtmlRenderingEnabled(settings.conversationHtmlRenderingEnabled !== false); setRemoteProjectsEnabled(settings.remoteProjectsEnabled); - }, []); + }, [guardNoteDraft, setNoteDraft]); const reloadApplicationSettings = useCallback(async (): Promise => { const revision = ++applicationSettingsRevisionRef.current; applicationSettingsPendingRef.current++; @@ -2657,6 +2700,10 @@ function App(): React.JSX.Element { }, [applyApplicationSettings, t]); const updateApplicationSettings = useCallback(async (patch: ApplicationSettingsUpdate): Promise => { if (applicationSettingsPendingRef.current || applicationSettingsUnconfirmed) return false; + if (patch.magicNotesEnabled === false) { + if (!await guardNoteDraft()) return false; + setNoteDraft(undefined); + } const revision = ++applicationSettingsRevisionRef.current; applicationSettingsPendingRef.current++; setApplicationSettingsPending(true); @@ -2683,7 +2730,7 @@ function App(): React.JSX.Element { } finally { setApplicationSettingsPending(--applicationSettingsPendingRef.current > 0); } - }, [applicationSettingsUnconfirmed, applyApplicationSettings, t]); + }, [applicationSettingsUnconfirmed, applyApplicationSettings, guardNoteDraft, setNoteDraft, t]); const [searchQuery, setSearchQuery] = useState(""); const searchInputRef = useRef(null); const deferredSearchQuery = useDeferredValue(searchQuery); @@ -8454,6 +8501,71 @@ function App(): React.JSX.Element { } }; + const openQuickNotes = useCallback((): void => { + if (!magicNotesEnabled) return; + setAssistantSidebarOpen(true); + setNotesOpenRequest(value => value + 1); + }, [magicNotesEnabled]); + const captureToNote = useCallback(async (conversationId: string, message?: Message, trigger?: HTMLElement): Promise => { + if (!magicNotesEnabled || noteCapturePending.current || noteDraft.saving) return; + const snapshot = conversationsRef.current.find(item => item.id === conversationId); + if (!snapshot || (message && (message.state === 'streaming' || !message.content.trim()))) return; + if (!message && snapshot.messages.some(item => item.state === 'streaming')) { + notify({ tone: 'info', message: t('magicNotes:capture.wait') }); + return; + } + noteCapturePending.current = true; + setNoteCaptureLoading(true); + const capturedAt = new Date().toISOString(); + try { + const conversation = message ? snapshot : await ensureConversationHistory(conversationId); + const messages = message ? [message] : conversation.messages.filter(item => (item.role === 'user' || item.role === 'assistant') && item.content.trim()); + if (!message && messages.some(item => item.state === 'streaming')) throw new Error(t('magicNotes:capture.wait')); + const text = message ? message.content : messages.map(item => `## ${t(item.role === 'user' ? 'magicNotes:capture.user' : 'magicNotes:capture.assistant')}\n\n${item.content}`).join('\n\n'); + if (!text.trim()) return; + // Main validates source IDs against persisted messages, including newly completed replies. + persistLocalConversationChanges(); + await conversationPersistenceQueueRef.current; + if (!await guardNoteDraft()) return; + const title = conversation.title.slice(0, 100); + setNoteDraft({ text, initialText: text, title, initialTitle: title, targetId: '', newNote: false, + incomplete: message ? message.state !== 'complete' : undefined, + source: { kind: message ? 'message' : 'conversation', conversationId, messageIds: messages.map(item => item.id), capturedAt, + conversationTitle: conversation.title, projectId: conversation.projectId, projectName: conversation.projectId ? projectNames.get(conversation.projectId) : undefined } }); + noteCaptureTrigger.current = trigger ?? noteTitleMenuRef.current; + openQuickNotes(); + } catch (reason) { notify({ tone: 'error', message: displayErrorMessage(reason, t('magicNotes:errors.operationFailed')) }); } + finally { noteCapturePending.current = false; setNoteCaptureLoading(false); } + }, [magicNotesEnabled, noteDraft.saving, ensureConversationHistory, persistLocalConversationChanges, guardNoteDraft, setNoteDraft, projectNames, openQuickNotes, notify, t]); + const openNoteSource = async (source: MagicNoteSource, messageId?: string): Promise<'opened' | 'missing'> => { + let snapshot; + try { snapshot = await window.goodbuddy.conversations.get(source.conversationId); } + catch (reason) { + if (reason instanceof Error && /对话不存在(?:$|["'])/u.test(reason.message)) return 'missing'; + throw reason; + } + requestWorkspaceLeave('chat', () => { + const next = mergePersistedConversations(conversationsRef.current, [snapshot], persistedLocalConversationsRef.current, new Set([source.conversationId, ...retainedConversationDetailIds()])); + conversationsRef.current = next; + setConversations(next); + setActiveProjectId(snapshot.projectId ?? ''); + setActiveId(source.conversationId); + setSearchQuery(''); + commitView('chat'); + const targetId = messageId ?? (source.kind === 'message' ? source.messageIds[0] : undefined); + if (targetId) { + const conversation = next.find(item => item.id === source.conversationId)!; + const index = conversation.messages.findIndex(item => item.id === targetId); + if (index < 0 || !snapshot.messages.some(message => message.id === targetId)) notify({ tone: 'info', message: t('magicNotes:capture.missingMessage') }); + else { + setVisibleMessageCounts(current => ({ ...current, [source.conversationId]: Math.max(current[source.conversationId] ?? messageRenderBatchSize, conversation.messages.length - index) })); + setNoteMessageNavigation({ conversationId: source.conversationId, messageId: targetId, requestId: Date.now() }); + } + } + }); + return 'opened'; + }; + const openActivityConversation = (conversationId: string): void => { const open = async (): Promise => { let conversation = conversationsRef.current.find( @@ -9597,6 +9709,7 @@ function App(): React.JSX.Element { ) : null} + {magicNotesEnabled && }
+ {magicNotesEnabled && activeConversation && <> + + {noteTitleMenuOpen && setNoteTitleMenuOpen(false)}> + + } + } )}
+ {magicNotesEnabled && !sidebarOpen && }
+ {noteDraft.confirmation} ); diff --git a/src/renderer/src/ChatTimeline.test.tsx b/src/renderer/src/ChatTimeline.test.tsx index 1e66aacc..13055779 100644 --- a/src/renderer/src/ChatTimeline.test.tsx +++ b/src/renderer/src/ChatTimeline.test.tsx @@ -80,6 +80,24 @@ function createMessages(): Message[] { } describe('ChatTimeline', () => { + it('captures only nonempty settled assistant replies, including incomplete replies', () => { + const onAddToNote = vi.fn() + const messages: Message[] = [ + { id: 'complete', role: 'assistant', content: 'Complete text', createdAt: 1, state: 'complete' }, + { id: 'partial', role: 'assistant', content: 'Partial text', createdAt: 2, state: 'error' }, + { id: 'streaming', role: 'assistant', content: 'Streaming text', createdAt: 3, state: 'streaming' }, + { id: 'empty', role: 'assistant', content: '', createdAt: 4, state: 'complete' }, + { id: 'user', role: 'user', content: 'User text', createdAt: 5, state: 'complete' } + ] + const props = { ...callbacks, artifactById: new Map(), conversationId: 'capture-conversation', hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const, messageStartIndex: 0, totalMessageCount: messages.length, messages } + const view = render() + const buttons = screen.getAllByRole('button', { name: '加入笔记' }) + expect(buttons).toHaveLength(2) + fireEvent.click(buttons[1]!) + expect(onAddToNote).toHaveBeenCalledWith('capture-conversation', messages[1], buttons[1]) + view.rerender() + expect(screen.queryByRole('button', { name: '加入笔记' })).not.toBeInTheDocument() + }) it('keeps a question before later tools and text when its answer arrives and history reopens', () => { const props = { artifactById: new Map(), conversationId: 'question-order', diff --git a/src/renderer/src/ChatTimeline.tsx b/src/renderer/src/ChatTimeline.tsx index cfe6d8d5..2fe96714 100644 --- a/src/renderer/src/ChatTimeline.tsx +++ b/src/renderer/src/ChatTimeline.tsx @@ -526,6 +526,7 @@ function SubagentStatusList({ } type ChatMessageRowProps = { + onAddToNote?: (conversationId: string, message: Message, trigger: HTMLElement) => void onOpenImageModelSettings?: () => void onReselectImageSources?: (operation: ImageOperation) => void onEditImage?: (artifact: AssistantArtifact) => void @@ -563,6 +564,7 @@ type ChatMessageRowProps = { } function ChatMessageRowView({ + onAddToNote, onOpenImageModelSettings, onReselectImageSources, onEditImage, @@ -1274,6 +1276,7 @@ function ChatMessageRowView({ message.state !== 'streaming' && Boolean(message.content.trim()) && (
+ {!greeting && onAddToNote && } + {source.kind === 'message' && } +
} + {error &&

{error}

} + +} diff --git a/src/renderer/src/MagicNotesPanel.test.tsx b/src/renderer/src/MagicNotesPanel.test.tsx new file mode 100644 index 00000000..70ce55c8 --- /dev/null +++ b/src/renderer/src/MagicNotesPanel.test.tsx @@ -0,0 +1,210 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { useState } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DesktopApi } from '../../shared/contracts' +import type { MagicNoteDetail, MagicNoteSource } from '../../shared/magic-notes-contracts' +import { magicNoteRichContentSchema } from '../../shared/magic-notes-contracts' +import { capturedNoteContent, MagicNotesPanel } from './MagicNotesPanel' +import { useMagicNoteDraft } from './use-magic-note-draft' +import { MagicNoteSource as SourceBar } from './MagicNoteSource' +import i18n from './i18n' + +const note: MagicNoteDetail = { id: '00000000-0000-4000-8000-000000000601', title: 'Release decisions', preview: 'Existing text', entryCount: 0, entries: [], pinned: true, revision: 1, createdAt: '2026-09-26T00:00:00Z', updatedAt: '2026-09-26T00:00:00Z' } +const source: MagicNoteSource = { kind: 'message', conversationId: 'conversation-a', messageIds: ['message-a'], capturedAt: '2026-09-26T00:00:00Z', conversationTitle: 'Discussion A', projectName: 'Project A' } +const entry = { id: '00000000-0000-4000-8000-000000000602', noteId: note.id, content: capturedNoteContent('Captured text'), plainText: 'Captured text', comments: [], revision: 1, createdAt: note.createdAt, updatedAt: note.updatedAt, source } +const search = vi.fn() +const get = vi.fn() +const create = vi.fn() +const createEntry = vi.fn() +const analyze = vi.fn() +const onNotify = vi.fn() +const onOpenWorkspace = vi.fn() +let onChanged: () => void + +function Harness({ mode = 'immediate' }: { mode?: 'immediate' | 'after-save-auto' | 'after-save-manual' }): React.JSX.Element { + const state = useMagicNoteDraft() + const [visible, setVisible] = useState(true) + return <> + + + + + {state.confirmation} + +} + +beforeEach(async () => { + vi.resetAllMocks() + await i18n.changeLanguage('en-US') + search.mockResolvedValue([note]) + get.mockResolvedValue(note) + const saved = { ...note, entries: [entry], entryCount: 1, createdEntryId: entry.id } + create.mockImplementation(async () => { get.mockResolvedValue(saved); return saved }) + createEntry.mockImplementation(async () => { get.mockResolvedValue(saved); return saved }) + analyze.mockResolvedValue(note) + Object.defineProperty(window, 'goodbuddy', { configurable: true, value: { magicNotes: { search, get, create, createEntry, analyze, onChanged: (listener: () => void) => { onChanged = listener; return vi.fn() } } } }) +}) +afterEach(cleanup) + +describe('compact notes', () => { + it('creates the note and captured entry in one explicit submit and focuses the returned entry', async () => { + render() + fireEvent.click(screen.getByText('Capture')) + await screen.findByDisplayValue('Captured text') + expect(create).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'New note' })) + expect(screen.getByLabelText('Note title')).toHaveValue('Discussion A') + fireEvent.click(screen.getByRole('button', { name: 'Add to note' })) + await waitFor(() => { + expect(create).toHaveBeenCalledWith({ title: 'Discussion A', content: capturedNoteContent('Captured text'), source }) + expect(createEntry).not.toHaveBeenCalled() + expect(document.activeElement).toHaveAttribute('id', `compact-note-entry-${entry.id}`) + }) + expect(analyze).not.toHaveBeenCalled() + expect(onNotify).toHaveBeenCalledWith({ tone: 'success', message: 'Entry saved' }) + }) + + it('preserves edited content and source through hiding, refresh, failure, and retry', async () => { + render() + fireEvent.click(screen.getByText('Capture')) + const text = await screen.findByLabelText('Text to add') + fireEvent.change(text, { target: { value: 'Edited **Markdown**\n```ts\nconst a = 1\n```' } }) + fireEvent.click(await screen.findByRole('button', { name: /Release decisions/ })) + await waitFor(() => expect(get).toHaveBeenCalledWith(note.id)) + fireEvent.click(screen.getByText('Hide panel')) + act(() => onChanged()) + fireEvent.click(screen.getByText('Hide panel')) + expect(await screen.findByLabelText('Text to add')).toHaveValue('Edited **Markdown**\n```ts\nconst a = 1\n```') + createEntry.mockRejectedValueOnce(new Error('Disk full')) + fireEvent.click(screen.getByRole('button', { name: 'Add to note' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Disk full') + expect(screen.getByLabelText('Text to add')).toHaveValue('Edited **Markdown**\n```ts\nconst a = 1\n```') + fireEvent.click(screen.getByRole('button', { name: 'Add to note' })) + await waitFor(() => expect(createEntry).toHaveBeenCalledTimes(2)) + expect(createEntry).toHaveBeenLastCalledWith({ noteId: note.id, content: capturedNoteContent('Edited **Markdown**\n```ts\nconst a = 1\n```'), source }) + }) + + it('guards close, replacement, modified cancellation, and workspace navigation', async () => { + render() + fireEvent.click(screen.getByText('Capture')) + await screen.findByDisplayValue('Captured text') + for (const action of ['Close tab', 'Capture']) { + fireEvent.click(screen.getByText(action)) + const dialog = await screen.findByRole('dialog') + fireEvent.click(within(dialog).getByRole('button', { name: 'Continue editing' })) + expect(screen.getByLabelText('Text to add')).toHaveValue('Captured text') + } + fireEvent.change(screen.getByLabelText('Text to add'), { target: { value: 'Changed preview' } }) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Discard and switch' })) + fireEvent.click(await screen.findByRole('button', { name: /Release decisions/ })) + fireEvent.change(await screen.findByLabelText('Text to add'), { target: { value: 'Quick draft' } }) + fireEvent.click(screen.getByRole('button', { name: 'Open in full workspace' })) + expect(onOpenWorkspace).not.toHaveBeenCalled() + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Discard and switch' })) + await waitFor(() => expect(onOpenWorkspace).toHaveBeenCalledWith(note.id, undefined)) + }) + + it.each(['immediate', 'after-save-manual', 'after-save-auto'] as const)('respects %s without analyzing the capture draft', async mode => { + render() + fireEvent.click(await screen.findByRole('button', { name: /Release decisions/ })) + fireEvent.change(await screen.findByLabelText('Text to add'), { target: { value: 'Quick text' } }) + expect(analyze).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Add to note' })) + await waitFor(() => expect(createEntry).toHaveBeenCalledOnce()) + expect(createEntry).toHaveBeenCalledWith({ noteId: note.id, content: capturedNoteContent('Quick text'), source: undefined }) + await waitFor(() => expect(onNotify).toHaveBeenCalled()) + expect(analyze).toHaveBeenCalledTimes(mode === 'after-save-auto' ? 1 : 0) + }) + + it('keeps a successful save when automatic comments fail', async () => { + analyze.mockRejectedValue(new Error('Model offline')) + render() + fireEvent.click(await screen.findByRole('button', { name: /Release decisions/ })) + fireEvent.change(await screen.findByLabelText('Text to add'), { target: { value: 'Quick text' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add to note' })) + await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ tone: 'error', message: expect.stringContaining('Model offline') }))) + expect(createEntry).toHaveBeenCalledOnce() + expect(screen.getByLabelText('Text to add')).toHaveValue('') + }) + + it('locks duplicate submission, cancellation, and closing until the save completes', async () => { + let finish!: (result: Awaited>) => void + createEntry.mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + render() + fireEvent.click(await screen.findByRole('button', { name: /Release decisions/ })) + fireEvent.change(await screen.findByLabelText('Text to add'), { target: { value: 'In-flight text' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add to note' })) + expect(screen.getByRole('button', { name: 'Saving...' })).toBeDisabled() + expect(screen.getByLabelText('Text to add')).toBeDisabled() + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled() + fireEvent.click(screen.getByText('Close tab')) + await act(async () => {}) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.getByLabelText('Text to add')).toBeVisible() + fireEvent.click(screen.getByRole('button', { name: 'Saving...' })) + expect(createEntry).toHaveBeenCalledOnce() + await act(async () => finish({ ...note, entries: [entry], entryCount: 1, createdEntryId: entry.id })) + expect(screen.getByLabelText('Text to add')).toHaveValue('') + }) + + it('shows an oversized preview unchanged and allows correction before saving', async () => { + render() + fireEvent.click(screen.getByText('Capture')) + const preview = await screen.findByLabelText('Text to add') + const oversized = 'x'.repeat(512_001) + fireEvent.change(preview, { target: { value: oversized } }) + fireEvent.click(screen.getByRole('button', { name: 'New note' })) + expect(preview).toHaveValue(oversized) + expect(screen.getByRole('button', { name: 'Add to note' })).toBeDisabled() + expect(screen.getByText(/Each entry supports up to 500 KB/)).toBeVisible() + fireEvent.change(preview, { target: { value: 'Within capacity' } }) + expect(screen.getByRole('button', { name: 'Add to note' })).toBeEnabled() + expect(create).not.toHaveBeenCalled() + }) + + it('preserves text when the target disappears and requires a new selection', async () => { + render() + fireEvent.click(screen.getByText('Capture')) + fireEvent.click(await screen.findByRole('button', { name: /Release decisions/ })) + await waitFor(() => expect(get).toHaveBeenCalled()) + get.mockRejectedValue(new Error('笔记不存在')) + search.mockResolvedValue([]) + act(() => onChanged()) + await waitFor(() => expect(screen.getByRole('button', { name: 'Add to note' })).toBeDisabled()) + expect(screen.getByLabelText('Text to add')).toHaveValue('Captured text') + expect(createEntry).not.toHaveBeenCalled() + }) + + it('searches entry text, cancels stale requests, and does not replace the draft', async () => { + let finishOld!: (notes: MagicNoteDetail[]) => void + search.mockImplementationOnce(() => new Promise(resolve => { finishOld = resolve })) + render() + await waitFor(() => expect(search).toHaveBeenCalled()) + fireEvent.change(screen.getByLabelText('Search notes'), { target: { value: 'deep entry text' } }) + await waitFor(() => expect(search).toHaveBeenLastCalledWith({ query: 'deep entry text', limit: 100 })) + await screen.findByRole('button', { name: /Release decisions/ }) + await act(async () => finishOld([{ ...note, title: 'Stale result' }])) + expect(screen.queryByText('Stale result')).not.toBeInTheDocument() + }) + + it('retains large previews and validates without truncating or splitting entries', () => { + const text = 'a'.repeat(199_999) + '😀' + 'b'.repeat(200_000) + const content = capturedNoteContent(text) + expect(magicNoteRichContentSchema.safeParse(content).success).toBe(true) + expect(content.ops.map(op => op.insert).join('')).toBe(text + '\n') + expect(magicNoteRichContentSchema.safeParse(capturedNoteContent('中'.repeat(180_000))).success).toBe(false) + }) +}) + +it('distinguishes deleted source from transient failure and preserves source labels', async () => { + const open = vi.fn().mockRejectedValueOnce(new Error('Read failed')).mockResolvedValue('missing') + render() + fireEvent.click(screen.getByRole('button', { name: 'View source message' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Read failed') + expect(screen.getByRole('button', { name: 'Return to conversation' })).toBeEnabled() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(await screen.findByRole('button', { name: 'The original conversation no longer exists' })).toBeDisabled() + expect(screen.getByText('From conversation: Discussion A')).toBeVisible() + expect(open).toHaveBeenLastCalledWith(source, 'message-a') +}) diff --git a/src/renderer/src/MagicNotesPanel.tsx b/src/renderer/src/MagicNotesPanel.tsx new file mode 100644 index 00000000..b40eb97f --- /dev/null +++ b/src/renderer/src/MagicNotesPanel.tsx @@ -0,0 +1,282 @@ +import { ArrowLeft, ExternalLink, Pin, Plus } from 'lucide-react' +import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { magicNoteRichContentSchema, type MagicNoteCommentFormat, type MagicNoteDetail, type MagicNoteRichContent, type MagicNoteSummary } from '../../shared/magic-notes-contracts' +import type { MagicNoteCommentMode } from '../../shared/application-settings-contracts' +import { MagicNoteSource, type OpenMagicNoteSource } from './MagicNoteSource' +import type { useMagicNoteDraft } from './use-magic-note-draft' +import type { AppNotificationInput } from './notifications' +import { ScopeBadge, SegmentedControl } from './WorkspacePrimitives' +import './magic-notes-panel.css' + +const MagicCanvasThumbnail = lazy(async () => ({ default: (await import('./MagicCanvasThumbnail')).MagicCanvasThumbnail })) + +// Split operations at the existing rich-text operation limit, without truncating text. +export function capturedNoteContent(text: string): MagicNoteRichContent { + const value = text.endsWith('\n') ? text : `${text}\n` + const ops: MagicNoteRichContent['ops'] = [] + for (let start = 0; start < value.length;) { + let end = Math.min(start + 200_000, value.length) + if (end < value.length && /[\uD800-\uDBFF]/u.test(value[end - 1]!)) end-- + ops.push({ insert: value.slice(start, end) }) + start = end + } + return { version: 1, ops } +} + +export type MagicNotesPanelProps = { + state: ReturnType + active: boolean + commentMode?: MagicNoteCommentMode + commentFormat?: MagicNoteCommentFormat + onNotify: (notification: AppNotificationInput) => void + onOpenWorkspace: (noteId: string, entryId?: string) => void + onOpenSource: OpenMagicNoteSource + onCancelCapture: () => void +} + +export function MagicNotesPanel({ state, active, commentMode, commentFormat = 'combined', onNotify, onOpenWorkspace, onOpenSource, onCancelCapture }: MagicNotesPanelProps): React.JSX.Element { + const { t, i18n } = useTranslation('magicNotes') + const { draft, setDraft, saving, setSaving, selectedNoteId, setSelectedNoteId, guard } = state + const [query, setQuery] = useState('') + const [notes, setNotes] = useState([]) + const [detail, setDetail] = useState() + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [saveError, setSaveError] = useState('') + const [previousSource, setPreviousSource] = useState(draft?.source) + if (previousSource !== draft?.source) { + setPreviousSource(draft?.source) + setSaveError('') + } + const [refresh, setRefresh] = useState(0) + const [highlight, setHighlight] = useState() + const submitting = useRef(false) + const preview = useRef(null) + const title = useRef(null) + const currentDetail = detail?.id === selectedNoteId ? detail : undefined + const quickDraft = Boolean(draft && !draft.source && !draft.newNote && draft.targetId === selectedNoteId) + const content = useMemo(() => draft ? capturedNoteContent(draft.text) : undefined, [draft]) + const tooLong = useMemo(() => Boolean(content && !magicNoteRichContentSchema.safeParse(content).success), [content]) + const valid = Boolean(draft && !tooLong && (draft.newNote ? draft.title.trim().length > 0 && draft.title.trim().length <= 100 : draft.targetId) && (draft.text.trim() || (draft.newNote && !draft.source))) + + useEffect(() => window.goodbuddy.magicNotes.onChanged(() => setRefresh(value => value + 1)), []) + useEffect(() => { + if (!active) return + let current = true + const timer = setTimeout(() => { + setLoading(true) + setError('') + void Promise.all([ + window.goodbuddy.magicNotes.search({ query, limit: 100 }), + selectedNoteId ? window.goodbuddy.magicNotes.get(selectedNoteId).catch(reason => { + if (reason instanceof Error && reason.message.includes('笔记不存在')) { + if (current) { + setSelectedNoteId(''); setDetail(undefined); setSaveError(t('capture.deletedTarget')) + setDraft(value => value?.targetId === selectedNoteId ? { ...value, targetId: '' } : value) + } + return undefined + } + throw reason + }) : undefined + ]).then(([items, note]) => { if (current) { setNotes(items); setDetail(note) } }) + .catch(reason => { if (current) setError(reason instanceof Error ? reason.message : t('errors.operationFailed')) }) + .finally(() => { if (current) setLoading(false) }) + }, query ? 180 : 0) + return () => { current = false; clearTimeout(timer) } + // Drafts are independent of external refreshes and search responses. + }, [active, query, selectedNoteId, refresh, setSelectedNoteId, setDraft, t]) + + const captureSource = draft?.source + const newNote = draft?.newNote + useEffect(() => { if (active && captureSource) preview.current?.focus() }, [active, captureSource]) + useEffect(() => { if (active && newNote) title.current?.focus() }, [active, newNote]) + useEffect(() => { + if (!highlight || !active) return + const article = document.getElementById(`compact-note-entry-${highlight}`) + article?.scrollIntoView?.({ block: 'nearest' }) + article?.focus({ preventScroll: true }) + const timer = setTimeout(() => setHighlight(undefined), 2400) + return () => clearTimeout(timer) + }, [highlight, active]) + + const startNew = (): void => { + if (draft) { setDraft({ ...draft, newNote: true }); return } + setDraft({ text: '', initialText: '', title: '', initialTitle: '', targetId: '', newNote: true }) + } + const select = async (noteId: string): Promise => { + if (draft) { setDraft({ ...draft, targetId: noteId, newNote: false }); setSelectedNoteId(noteId); setSaveError(''); return } + setSelectedNoteId(noteId) + } + const cancel = async (): Promise => { + if (!await guard(true)) return + setDraft(undefined) + setSaveError('') + if (draft?.source) onCancelCapture() + else requestAnimationFrame(() => (document.getElementById('compact-note-append') ?? document.getElementById('compact-note-search'))?.focus()) + } + const save = async (): Promise => { + if (!draft || !content || !valid || submitting.current) return + submitting.current = true + setSaving(true) + setSaveError('') + try { + const saved = draft.newNote + ? await window.goodbuddy.magicNotes.create({ title: draft.title.trim(), ...(draft.text.trim() ? { content } : {}), source: draft.source }) + : await window.goodbuddy.magicNotes.createEntry({ noteId: draft.targetId, content, source: draft.source }) + setSelectedNoteId(saved.id) + setDetail(saved) + setDraft(undefined) + setHighlight(saved.createdEntryId) + setRefresh(value => value + 1) + onNotify({ tone: 'success', message: t(saved.createdEntryId ? 'notifications.entrySaved' : 'notifications.noteCreated') }) + if (commentMode === 'after-save-auto' && saved.createdEntryId) { + void window.goodbuddy.magicNotes.analyze(saved.createdEntryId, { requestId: crypto.randomUUID(), direction: 'general', format: commentFormat }) + .catch(reason => onNotify({ tone: 'error', message: t('canvas.savedAnalysisFailed', { error: reason instanceof Error ? reason.message : t('errors.operationFailed') }) })) + } + } catch (reason) { + setSaveError(reason instanceof Error && reason.message.includes('笔记不存在') ? t('capture.deletedTarget') : reason instanceof Error ? reason.message : t('errors.operationFailed')) + } finally { submitting.current = false; setSaving(false) } + } + const openWorkspace = async (entryId?: string): Promise => { + if (!currentDetail || !await guard()) return + setDraft(undefined) + onOpenWorkspace(currentDetail.id, entryId) + } + const backToList = async (): Promise => { + if (!await guard()) return + setDraft(undefined) + setSelectedNoteId('') + requestAnimationFrame(() => document.getElementById('compact-note-search')?.focus()) + } + const validationId = `compact-note-validation${saveError ? ' compact-note-save-error' : ''}` + const list = ( + <> + + setQuery(event.target.value)} disabled={saving} /> + {loading ?

{t('status.loadingNotes')}

: notes.length === 0 ? ( + !error &&

{t(query ? 'capture.noResults' : 'capture.empty')}

+ ) : ( +
+ {notes.map(note => ( + + ))} +
+ )} + + ) + return ( +
{ + if (event.key !== 'Escape') return + event.stopPropagation() + if (draft) void cancel() + else if (currentDetail) void backToList() + }}> +
+ {draft?.source ? t('capture.add') : currentDetail?.title ?? t('page.title')} + + {(!draft || quickDraft) && currentDetail && ( +
+ + +
+ )} +
+ {error &&

{error}

} +
+ {draft && !quickDraft ? ( + <> + {draft.source &&
+ {draft.source.projectName ?? t('capture.global')} / {draft.source.conversationTitle} +

{t(draft.source.kind === 'message' ? 'capture.messageScope' : 'capture.conversationScope')}

+ {draft.incomplete &&

{t('capture.incomplete')}

} +
} + +