diff --git a/FEATURES.md b/FEATURES.md
index eea93afa..31f4d8bd 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -105,9 +105,9 @@ records are listed separately and do not introduce another feature status.
managed SSH project with bounded output, resizing, termination, and explicit
reconnection. Closing a terminal tab ends its Shell; restarting the app
restores only an ended tab description and never restarts the Shell. The
- main sidebar and Magic Notes list support pointer and keyboard resizing;
- the notes list can be hidden, remembers its layout, and stacks at narrow
- widths.
+ main sidebar and Magic Notes editor's AI pane support pointer and keyboard
+ resizing. The AI pane remembers its visibility and width, and stacks below
+ the editor at narrow widths.
- [ ] **Project Agent Space** (planned): Unifies roles, knowledge, Skills/MCP,
models, approval policy, budgets, and timeouts in a Project, with reusable
templates.
@@ -599,10 +599,15 @@ records are listed separately and do not introduce another feature status.
demand or before every response, show zero-result, degraded, failed, and
cancelled states, and expose cited context or safely open its source.
- [x] **Magic Notes**: A local-first notes and todo workbench with scope
- management, editing, filtering, and controlled AI comments. The todo view
- displays the source entry inside the selected item's details, without
+ management, editing, filtering, and controlled AI comments. Notes open from a
+ responsive card overview. The separate To-dos tab offers search, status filters,
+ grouped tasks and direct completion, with instructions, source entries and AI
+ comments in a right detail pane beside a stable task list. Narrow views offer
+ on-demand detail with an explicit return to the list. Content-sized tabs sit
+ beside New note in the header; status filters follow search. Source-note
+ navigation returns to the same task context, without
showing the previous item's source after a selection change. Failed reads
- use application notifications, and missing sources have an explicit fallback. The left
+ use application notifications with inline retry, and missing sources have an explicit fallback. The left
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.
diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md
index 8cf01e1b..56cb4579 100644
--- a/FEATURES.zh-CN.md
+++ b/FEATURES.zh-CN.md
@@ -28,7 +28,7 @@
- [x] **工作区文件与 Git 管理**:支持文件和目录的新建、重命名、移动、删除、属性查看,以及本地/远程分支搜索与切换、新建分支、显式 Fetch、提交历史和单文件提交差异。工具按文件/Git 视图分组,分支搜索使用统一紧凑表单,子目录保留文字导航而根目录不再显示孤立图标。提交历史贴底,首次展开占 Git 内容区一半,支持调整比例、独立滚动和统一刷新;切换项目自动检测 Git,不沿用其他项目状态。移动限于工作区内,删除须确认,分支冲突不自动 stash 或丢弃改动;托管 SSH 操作需要 Agent `0.11.23`。
- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式、受控 Mermaid 图表和会话内 HTML 静态预览。Agent 完成回复后,完整 HTML 与 HTML 代码块可在原位置预览,并通过图标按钮查看源码或打开全屏预览;该能力默认开启,可在“平台功能 / 通用设置”关闭,预览不执行脚本、联网、表单提交或窗口操作。
- [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。
-- [x] **助手工作栏、多终端与可调布局**:工作栏开关位于明暗主题按钮旁;右侧工作栏使用持久“+”能力目录和应用 Tab,任务中心与工作区为不可关闭的单实例,浏览器可打开多个独立实例。任务中心可切换当前项目和所有项目,范围随布局保存;无项目任务在所有项目中标注“未绑定项目”。用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记列表支持鼠标与键盘调宽,魔法笔记列表可隐藏并记住布局,窄宽度下自动堆叠。
+- [x] **助手工作栏、多终端与可调布局**:工作栏开关位于明暗主题按钮旁;右侧工作栏使用持久“+”能力目录和应用 Tab,任务中心与工作区为不可关闭的单实例,浏览器可打开多个独立实例。任务中心可切换当前项目和所有项目,范围随布局保存;无项目任务在所有项目中标注“未绑定项目”。用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记编辑器的 AI 评论栏支持鼠标与键盘调宽;AI 评论栏记住显示状态及宽度,窄窗口下移至编辑器底部。
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
- [ ] **应用中心与导航**(整体验收待完成):底部应用中心向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻筛选。点击应用行关闭菜单,本机推理监控打开保留底层工作区的独立 Modal,其余应用打开主内容区;底部“管理应用”打开可搜索卡片及设置详情,布局与交互见 [UI 设计](./docs/features/application-tool-navigation/ui-design.md)。四张应用卡片均支持上下移动和拖动排序,不受启用或常驻状态限制;卡片、菜单和经过过滤的侧栏共用一份保存顺序。知识库和智能心跳始终启用并常驻,标注“始终显示”,提供“打开”和排序,不提供启用、常驻开关或通用空设置;原工作区配置和心跳单条计划 enabled 不变。可选启用、常驻和共同设置仅适用于魔法笔记、本机推理监控。旧可选应用顺序补齐默认项并保留已有相对顺序;未发布的 `knowledgeEnabled`、`heartbeatEnabled` 已移除,不增加开关键迁移。设置持久化后的事件同步各入口,包括工具配置写入;重开中心刷新期间锁定修改,新快照覆盖迟到的旧读取。`local-inference` Modal 直接显示服务列表,不展示任务历史或外部连接;向量服务显示独立进程 CPU 与工作集内存,ASR/OCR 说明共享进程无法独立统计的原因。ASR 就绪状态未知,不从活动请求推断运行中。受支持的服务操作保留影响确认,失败后刷新列表并要求重新确认;TTS 显示不可用。取消中的推理在 Worker 确认或退出前仍计入活动任务及服务停止影响。此前类型检查和 `npx eslint src` 通过;完整 `npm test` 为 4,448 项通过、67 项跳过、2 项失败,仍为未改动的心跳迁移 `duplicate column name: pinned`;全仓 lint 仍有无关 story-graph Demo 的 22 个 `document` 错误。8 个 Electron fixture 主题/尺寸场景覆盖键盘、焦点、几何、原生视图遮挡及真实设置 Store 持久化,不代表完整生产 App 验收。最新验证及剩余边界见[验证进度](./docs/features/application-tool-navigation/progress.md)。
- [ ] **通用助手工作栏与执行空间后续能力**(规划中):在现有工作栏和多终端基础上继续加入监督、统一 Runtime、受管进程、静态安全 HTML 预览、可固定目标的工作区/浏览器/成果实例、底部停靠与独立窗口。Task Center 保持 Task 的单例索引,附件与知识库继续由会话输入区管理,未来的记忆与历史执行上下文归入关联 Task。详见 [Feature PRD](./docs/features/assistant-workbar/prd.md)。
@@ -90,7 +90,7 @@
- [x] **分块、维护与评估**:支持固定、结构化和父子分块,分块维护、可取消重建及双语检索评估。
- [x] **受控知识本体**:每个知识库可定义实体、关系、别名和端点约束,保留证据偏移、置信度和抽取来源,并显式提示图谱重建。
- [x] **强制检索与引用上下文**:对话可按需或每次先检索,显示零结果、降级、失败与取消状态,并可查看引用上下文或安全打开来源。
-- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;来源记录直接显示在所选待办的详情中,切换事项不会混入上一项的来源内容,读取失败使用应用通知,来源缺失有明确提示。左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。Agent / MCP 写入后,已打开的工作台自动更新,并保留当前选择与未保存草稿。
+- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论。笔记从自适应卡片总览打开;独立待办页提供搜索、状态筛选、分组列表和直接勾选,宽屏列表稳定在左侧,任务说明、来源记录及 AI 评论在右侧同一区域显示,窄屏按需显示详情并提供返回列表。紧凑页签放在页头新建按钮左侧,筛选放在搜索右侧。打开原笔记再返回可恢复任务上下文;切换任务不会混入上一项的来源内容,读取失败使用应用通知并提供就地重试,来源缺失有明确提示。左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。Agent / MCP 写入后,已打开的工作台自动更新,并保留当前选择与未保存草稿。
- [ ] **MCP Server Control Plane**(规划中):统一 MCP 生命周期、健康检查、重连、Schema 缓存、隔离、审批和审计。
- [ ] **可追溯笔记摘录与 AI 编辑**(规划中):从对话、知识和网页收集带来源的摘录,并提供需确认的总结、改写和整理操作。
diff --git a/UI-DESIGN.md b/UI-DESIGN.md
index 9b4a99d5..5e7d112d 100644
--- a/UI-DESIGN.md
+++ b/UI-DESIGN.md
@@ -714,20 +714,21 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
### 13.7 魔法笔记
-- “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs` 的 `segmented` 视觉变体,与模型设置的分段控件保持同一外观。
+- “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs` 的 `segmented` 视觉变体,放在页头“新建笔记”按钮左边,不另占一行导航。按内容宽度显示,不拉伸;窄窗口操作组可换行到标题下方,保留控件高度。
- 页签切换保留 `tablist`、`tab` 和 `tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。
- 笔记编辑器保留 Quill 原生列表快捷输入:输入 `-`、`*`、`1.`、`[ ]` 或 `[x]` 后按空格,分别转换为项目符号、编号或待办清单。快捷输入只生成现有 Delta 富文本,不引入 Markdown 源码模式、第二套持久化格式或第三方 Markdown 插件;编辑区附近持续提供简短提示。
-- 左侧笔记与待办列表在双栏布局中允许在 `180px` 至 `360px` 范围内通过指针或键盘调整,
- 也可从页面标题区隐藏和恢复;宽度与显示状态均记住用户选择。分隔条保留 `separator`
- 语义及方向键、Home、End 操作,窄窗口堆叠布局不显示拖拽分隔条。
-- 左侧笔记列表依次显示标题、最多两行内容预览,以及同一行的记录数和本地化更新时间。记录数靠左、更新时间靠右,窄宽度下均保持单行省略,不挤压标题或扩大列表栏。
-- 整篇笔记的置顶、取消置顶和删除放在列表项的“更多”菜单,标题编辑区不重复提供入口。按钮在悬停、键盘聚焦或菜单展开时显示,触屏设备常显;菜单沿用会话列表样式,通过 Portal 避免滚动区域裁切,支持方向键、Home、End 和 Escape。删除在菜单内二次确认并说明笔记标题及全部记录;打开菜单不切换笔记,置顶或删除其他笔记不丢弃当前草稿。
+- 默认进入笔记总览,不自动打开第一篇笔记。笔记以自适应卡片网格平铺,卡片显示标题、最多两行内容预览、记录数和本地化更新时间。点击笔记进入详情,详情不保留内部左列表;“返回总览”恢复搜索、滚动位置和入口焦点。新建入口在笔记总览与待办页可见,创建成功后进入笔记详情。
+- 待办页是独立任务视图:搜索任务标题、说明或来源笔记,搜索框右侧同一工具栏放置“全部 / 未完成 / 已完成”筛选,显示匹配数与总数;任务按来源笔记分组。列表独立滚动,页头、搜索和筛选保持可见,窄屏工具栏空间不足时换行。直接勾选完成或恢复未完成,不自动选择任务;状态变化后按当前筛选更新列表。
+- 待办内容容器宽于 `700px` 时,任务列表稳定在左侧,右侧显示当前任务说明、来源记录和 AI 评论;未选择时右侧提示选择任务。点击任务只更新右侧,不改变列表宽度、行位置或滚动位置,再次点击取消选择。AI 不增加第三栏。容器不超过 `700px` 时,按需详情替换同一内容区域,并提供“返回待办列表”;列表保持挂载,返回恢复滚动和任务行焦点,不将列表与详情上下堆叠。任务按钮提供 `aria-expanded` 和 `aria-controls`。
+- 待办 AI 评论保留方向选择、流式输出、重新分析和已有结果;来源加载失败通过应用通知提示,并提供就地重试。从待办打开原笔记后,“返回待办”恢复任务选择、搜索、筛选和列表滚动;宽屏焦点返回任务行,窄屏返回待办详情中的返回按钮。
+- 笔记详情保留可隐藏的 AI 点评栏,宽度和显示状态记住用户选择。宽布局分隔条保留 `separator` 语义及方向键、Home、End 操作;内容容器不超过 `800px` 时 AI 移至底部,高度不超过可用布局的 40% 且最多 `280px`,不显示分隔条。
+- 整篇笔记的置顶、取消置顶和删除放在卡片的“更多”菜单,标题编辑区不重复提供入口。按钮在悬停、键盘聚焦或菜单展开时显示,触屏设备常显;菜单沿用会话列表样式,通过 Portal 避免滚动区域裁切,支持方向键、Home、End 和 Escape。删除在菜单内二次确认并说明笔记标题及全部记录;打开菜单不进入详情。
- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。
- 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。
- 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。
- Agent / MCP 或其他入口成功写入笔记后,已打开的工作台自动更新笔记列表、当前详情、待办及来源记录,不增加刷新按钮。保留仍存在的笔记与待办选择、搜索和筛选,以及未保存的标题、输入区和记录编辑草稿;普通后台刷新不重建编辑器。
-- 外部删除当前笔记时,无草稿则选择剩余笔记;有草稿则保留编辑区并提示先复制草稿。外部删除正在编辑的记录时,同样保留该编辑器并明确提示记录已删除;保存失败不得清空草稿。
-- 当前记录草稿非空时,切换笔记、待办或内容面板必须先在编辑器旁就地确认;继续编辑时保留草稿并恢复编辑焦点,只有明确选择放弃后才切换。
+- 外部删除当前笔记时,无草稿则返回总览;有草稿则保留编辑区并提示先复制草稿。外部删除正在编辑的记录时,同样保留该编辑器并明确提示记录已删除;保存失败不得清空草稿。
+- 当前记录草稿非空或标题尚未保存时,返回总览必须先就地确认;编辑另一条记录等会丢弃正文草稿的操作沿用同一保护。继续编辑时保留草稿并恢复编辑焦点,只有明确选择放弃后才切换。
- 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。
- 标题或正文校验、删除确认、同步进度和可就地恢复的错误仍靠近对应编辑器或操作呈现。
diff --git a/docs/features/magic-notes/README.md b/docs/features/magic-notes/README.md
index 97694d20..3c5a84ce 100644
--- a/docs/features/magic-notes/README.md
+++ b/docs/features/magic-notes/README.md
@@ -5,6 +5,15 @@ actions and authorized Agent/MCP tools write to the same desktop SQLite store.
The open workbench subscribes to successful writes so users can see external
changes without leaving the page or clicking a refresh button.
+The workbench opens on a responsive note-card overview. Opening a note replaces
+the overview with its detail and optional AI comments. The separate To-dos tab
+keeps search, status filters and grouped tasks on one page, with direct completion
+and a stable left list/right detail layout for instructions, source content and AI
+comments. Narrow containers use on-demand detail with an explicit return to the
+mounted list. Content-sized tabs sit in the page header immediately before New
+note; status filters sit beside search and wrap when needed. Returning from a source note restores the task
+context and protects unsaved editor changes.
+
## Documents
- [Technical design](./technical-design.md): database notifications, process
diff --git a/docs/features/magic-notes/progress.md b/docs/features/magic-notes/progress.md
index 0e56fdb8..4251554b 100644
--- a/docs/features/magic-notes/progress.md
+++ b/docs/features/magic-notes/progress.md
@@ -1,5 +1,70 @@
# Magic Notes Progress
+## 2026-09-18: Stable Todo Split View and Header Navigation
+
+- Supersedes the inline task expansion described in the earlier entry below.
+ Wide containers now keep the grouped task list on the left and instructions,
+ source and AI in one right detail pane. Narrow containers retain the mounted
+ list and offer on-demand detail with an explicit return. Source-note return
+ preserves task context. Header tabs precede New note; filters follow search.
+- Focused workspace, editor and shared-primitives suites passed all 93 tests.
+ `npm run typecheck` and targeted ESLint passed. No full `npm test` was run.
+- Reused the temporary Electron renderer probe at 1280x820, 600x760,
+ 600x520 dark and 420x640. The 134x36 header switch precedes New note at all
+ four sizes. Search/filter share a row at 1280 and 600; filters wrap at 420.
+ The wide list's width, top, scroll and adjacent row coordinates remain equal
+ before and after task selection/switching. Narrow returns preserve nonzero
+ list scroll and restore focus. No horizontal overflow or console errors.
+- Also verified source-note round trips at all sizes, actual Quill draft
+ confirmation, note-card return, AI visibility, task completion/status filters,
+ AI fixture results, creation and Chromium keyboard activation.
+- Visible-window capture initially failed with Electron `UnknownVizError`.
+ Final probe uses Electron offscreen rendering at scale 1, pointer input and
+ Chromium DevTools keyboard input. It runs production React/Quill/CSS with
+ in-memory API fixtures, not the full App/Main/SQLite/model path. Model calls: 0.
+ Evidence remains under the temporary `opencode` directory in
+ `magic-notes-render-results.json` and `magic-todos-*-expanded.png`.
+
+## 2026-09-18: Independent Todo Task View
+
+- Replaced todo full-page navigation with a grouped task list and one inline
+ expansion for instructions, source content and AI comments. Completion works
+ directly in each row. Search/status controls stay above the scrolling list;
+ source-note navigation retains expansion, filters and initiating focus.
+- Kept the note-card overview and note editor/AI layout. Notes/To-dos PageTabs
+ now size to their contents at desktop and narrow widths. Source failures offer
+ an inline retry alongside the existing notification.
+- Focused command: `npx vitest run src/renderer/src/MagicNotesWorkspace.test.tsx src/renderer/src/MagicNoteEditor.test.tsx src/renderer/src/WorkspacePrimitives.test.tsx`:
+ 93 tests passed. `npm run typecheck` and ESLint on the workspace, its tests and
+ both changed locale modules passed. Full `npm test` was not rerun, as requested.
+- Reused the isolated Electron renderer probe at requested content sizes
+ 1280x820, 600x760, 600x520 (dark) and 420x640. All four todo views retained
+ 18 tasks, inline expansion, independently scrolling lists and no horizontal
+ overflow or detail/AI sidebar. Tabs measured approximately 133x35 CSS pixels.
+ Note-card navigation, actual Quill draft confirmation, AI visibility, source
+ navigation, direct completion, status filters and inline AI fixture output passed;
+ the renderer reported no console errors.
+- The probe uses production React, Quill and CSS with in-memory API fixtures and
+ isolated Electron sessions; it does not validate Main/SQLite or a live model.
+ Model calls: 0. Scripts, screenshots and JSON results remain in the existing
+ temporary `opencode/magic-notes-render-*` probe location.
+
+## 2026-09-18: Card Overview and Detail Navigation
+
+- Replaced the persistent inner list with a responsive card overview and explicit
+ detail navigation. Search, todo filters, overview scroll and initiating focus
+ survive a round trip. Creation opens the new note; todo source navigation returns
+ to the todo overview. Back navigation protects entry and title drafts.
+- Kept AI visibility and resizing preferences, removed obsolete list resizing,
+ and capped stacked AI height so short windows retain usable editor space.
+- Focused workspace, editor and shared UI suites passed 90 tests. Typecheck and
+ full lint passed. The first full test invocation exceeded its 200-second limit.
+- Isolated Electron rendering exercised the actual workspace, Quill and CSS at
+ 1280x820, 600x760, 600x520 (dark), and 420x640. Navigation, creation, todo-source
+ jumps, draft confirmation, AI visibility, scroll and focus restoration passed.
+ These checks used in-memory API fixtures, not Main/SQLite integration; no real
+ model calls were made. Canvas functionality is outside this layout change.
+
## 2026-09-17: Note List Action Menu
- Moved pin/unpin and whole-note deletion from the editor header to each note's
diff --git a/docs/features/magic-notes/technical-design.md b/docs/features/magic-notes/technical-design.md
index 10572a40..753f732a 100644
--- a/docs/features/magic-notes/technical-design.md
+++ b/docs/features/magic-notes/technical-design.md
@@ -22,6 +22,15 @@ objects and returns a function removing that listener. The existing
## Refresh Lifecycle
+The overview's `libraryView` and the optional note-only `detailView` are independent:
+opening a todo's source note does not lose the originating task view.
+The overview remains mounted but hidden while detail is open, retaining scroll
+position and filters. Returning restores the initiating control's focus and
+invalidates outstanding detail requests. Initial loading fetches summaries and
+todos without selecting the first note or task. Navigation away from unsaved content or
+an unsaved title uses the existing draft confirmation; active writes finish
+before navigation. No new persistence or IPC contract is required.
+
`MagicNotesWorkspace` subscribes on mount and coalesces notifications with a
100 ms debounce. Pending notifications wait for an active local save or analysis
to finish. Cleanup removes the listener, clears its timer and invalidates pending
@@ -45,20 +54,47 @@ Read failures use the existing retryable refresh error and retain successful
data and drafts. Retry uses the same draft-preserving loader. Interaction rules
are owned by [the UI design system](../../../UI-DESIGN.md#137-魔法笔记).
+## Todo Task View
+
+The To-dos panel has its own search/status toolbar and independently scrolling
+task list, grouped by source note. `selectedTodoId` controls one sibling detail pane;
+the selected item is derived from the filtered list. Completion updates the
+existing item and source cache without selecting or opening another task. Items
+that no longer match the status filter leave the list. A removed focused control
+returns focus to an available list/filter control.
+
+Detail content includes instructions, the source entry, its existing note
+navigation action, and AI controls/comments. AI requests still use the existing
+analysis options and streaming subscription; failed analysis retains saved
+comments. Source reads are guarded against superseded selections, report failures
+through notifications and offer inline retry. The grid reserves both columns even
+without a selection, so selecting or switching tasks cannot move list rows. The
+detail is keyed by task ID to start each newly selected task at its top.
+At a page container width of 700px or less, CSS hides the mounted list while a
+task is selected and shows an explicit return button in the detail area. Returning
+clears selection and restores row focus without scrolling; source-note return
+retains selection and focuses this visible return button on narrow layouts.
+There is no todo detail route, separate AI sidebar or resize handle. Note detail
+retains its existing AI pane preferences.
+
+The shared segmented `PageTabs` lives in `PageHeader.actions` before New note,
+retaining tab/panel IDs, roving focus and arrow-key navigation. Its width follows
+content at all sizes. The shared status `SegmentedControl` follows search in one
+wrapping toolbar. These layout changes do not change Main, preload or storage.
+
## Note List Actions
-Each note row has a selection button and a sibling action-menu button. The menu
+Each overview card has an open-detail button and a sibling action-menu button. The menu
uses the conversation action styles and shared `DestructiveConfirmActions`, and
is portalled to `document.body`. Its position follows the trigger on scrolling,
-window resizing and confirmation-size changes. Hiding the list, filtering out the
+window resizing and confirmation-size changes. Entering detail, filtering out the
target or leaving the notes panel closes the menu.
Pinning uses the target summary's ID and revision through the existing update
IPC. It updates the sorted summary and matching selected detail without resetting
title, composer or entry-edit drafts. Deleting uses the explicitly confirmed
-target ID; deleting another note uses the draft-preserving refresh path. Deleting
-the selected note clears its entry drafts only after the write succeeds, then
-selects a remaining note. These are renderer changes; database and Agent contracts
+target ID and refreshes the overview after success, without opening another note.
+These actions are available only in the overview. Database and Agent contracts
are unchanged.
## Agent Search Contract
diff --git a/src/renderer/src/MagicNotesWorkspace.test.tsx b/src/renderer/src/MagicNotesWorkspace.test.tsx
index dd7cc89e..0168dd69 100644
--- a/src/renderer/src/MagicNotesWorkspace.test.tsx
+++ b/src/renderer/src/MagicNotesWorkspace.test.tsx
@@ -1,385 +1,366 @@
-import {
- act,
- cleanup,
- fireEvent,
- render,
- screen,
- within,
- waitFor
-} from '@testing-library/react'
+import { act, cleanup, fireEvent, render, screen, within, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { readFileSync } from 'node:fs'
import i18n from './i18n'
import type { DesktopApi } from '../../shared/contracts'
-import {
- defaultLocalToolEnvironmentSettings,
- defaultApplicationNavigation,
- type ApplicationSettings
-} from '../../shared/application-settings-contracts'
-import type {
- MagicNoteDetail,
- MagicNotesSnapshot,
- MagicNoteRichContent,
- MagicTodoItem,
- MagicTodoUpdateResult,
- MagicTodosSnapshot
-} from '../../shared/magic-notes-contracts'
+import { defaultLocalToolEnvironmentSettings, defaultApplicationNavigation, type ApplicationSettings } from '../../shared/application-settings-contracts'
+import type { MagicNoteDetail, MagicNoteRichContent, MagicNotesSnapshot, MagicTodoItem } from '../../shared/magic-notes-contracts'
import { MagicNotesWorkspace } from './MagicNotesWorkspace'
vi.mock('./MagicNoteEditor', () => ({
- MagicNoteEditor: ({
- onChange,
- onParagraphCommit
- }: {
- onChange: (content: MagicNoteDetail['entries'][number]['content']) => void
- onParagraphCommit?: (
- content: MagicNoteDetail['entries'][number]['content']
- ) => void
- }) => (
- {
- const content = {
- version: 1 as const,
- ops: [{ insert: '新的句子\n' }]
- }
- onChange(content)
- onParagraphCommit?.(content)
- }}
- type="button"
- >
- 模拟输入并回车
-
- )
+ MagicNoteEditor: ({ onChange, onParagraphCommit }: {
+ onChange: (content: MagicNoteRichContent) => void
+ onParagraphCommit?: (content: MagicNoteRichContent) => void
+ }) => {
+ const content = { version: 1 as const, ops: [{ insert: '新的句子\n' }] }
+ onChange(content)
+ onParagraphCommit?.(content)
+ }}>模拟输入并回车
}))
vi.mock('./MagicNoteContent', () => ({
- MagicNoteContent: ({ content }: { content: MagicNoteRichContent }) => {
- const checklistState =
- content.ops.find(
- (operation) =>
- operation.attributes?.list === 'checked' ||
- operation.attributes?.list === 'unchecked'
- )?.attributes?.list ?? 'none'
- return (
-
- 记录正文
-
- {checklistState}
-
-
- )
- }
+ MagicNoteContent: ({ content }: { content: MagicNoteRichContent }) =>
+ 记录正文
+ {content.ops.find((op) => op.attributes?.list)?.attributes?.list ?? 'none'}
+
}))
const noteId = '00000000-0000-4000-8000-000000000601'
const entryId = '00000000-0000-4000-8000-000000000602'
-const noteTodoId = '00000000-0000-4000-8000-000000000603'
-const manualTodoId = '00000000-0000-4000-8000-000000000604'
const secondNoteId = '00000000-0000-4000-8000-000000000608'
-const thirdNoteId = '00000000-0000-4000-8000-000000000609'
const createdEntryId = '00000000-0000-4000-8000-000000000613'
-
+const content: MagicNoteRichContent = { version: 1, ops: [{ insert: '新的句子\n' }] }
const detail: MagicNoteDetail = {
- id: noteId,
- title: '发布笔记',
- preview: '整理发布清单',
- entryCount: 1,
- pinned: false,
- revision: 1,
- createdAt: '2026-08-01T00:00:00.000Z',
- updatedAt: '2026-08-01T00:01:00.000Z',
- entries: [
- {
- id: entryId,
- noteId,
- content: {
- version: 1,
- ops: [{ insert: '整理发布清单\n' }]
- },
- plainText: '整理发布清单',
- comments: [
- {
- id: '00000000-0000-4000-8000-000000000605',
- kind: 'suggestion',
- content: '先核对发布材料。'
- }
- ],
- analyzedAt: '2026-08-01T00:02:00.000Z',
- revision: 1,
- createdAt: '2026-08-01T00:01:00.000Z',
- updatedAt: '2026-08-01T00:02:00.000Z'
- }
- ]
+ id: noteId, title: '发布笔记', preview: '整理发布清单', entryCount: 1,
+ pinned: false, revision: 1, createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-01T00:01:00.000Z',
+ entries: [{
+ id: entryId, noteId, content: { version: 1, ops: [{ insert: '整理发布清单\n' }] }, plainText: '整理发布清单',
+ comments: [{ id: 'comment', kind: 'suggestion', content: '先核对发布材料。' }],
+ analyzedAt: '2026-08-01T00:02:00.000Z', revision: 1,
+ createdAt: '2026-08-01T00:01:00.000Z', updatedAt: '2026-08-01T00:02:00.000Z'
+ }]
}
-
-const noteTodo: MagicTodoItem = {
- id: noteTodoId,
- noteId,
- noteTitle: detail.title,
- entryId,
- sourceIndex: 0,
- source: 'note',
- title: '核对发布材料',
- instructions: '',
- completed: false,
- comments: [],
- revision: 1,
- createdAt: '2026-08-01T00:01:00.000Z',
- updatedAt: '2026-08-01T00:02:00.000Z'
+const second: MagicNoteDetail = { ...detail, id: secondNoteId, title: '演示笔记', preview: '', entryCount: 0, entries: [] }
+const todo: MagicTodoItem = {
+ id: '00000000-0000-4000-8000-000000000603', noteId, noteTitle: detail.title, entryId,
+ sourceIndex: 0, source: 'note', title: '核对发布材料', instructions: '', completed: false,
+ comments: [], revision: 1, createdAt: detail.createdAt, updatedAt: detail.updatedAt
}
-
-const manualTodo: MagicTodoItem = {
- id: manualTodoId,
- noteId: secondNoteId,
- noteTitle: '演示笔记',
- entryId: '00000000-0000-4000-8000-000000000610',
- sourceIndex: 0,
- source: 'note',
- title: '准备演示',
- instructions: '确认演示环境和样例数据。',
- completed: false,
- comments: [],
- revision: 0,
- createdAt: '2026-08-01T00:03:00.000Z',
- updatedAt: '2026-08-01T00:03:00.000Z'
+const otherTodo: MagicTodoItem = { ...todo, id: '00000000-0000-4000-8000-000000000604', noteId: secondNoteId, noteTitle: second.title, title: '准备演示' }
+const settings: ApplicationSettings = {
+ checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
+ localToolEnvironment: defaultLocalToolEnvironmentSettings, applicationNavigation: defaultApplicationNavigation,
+ localInferenceEnabled: true, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false,
+ magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true,
+ magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined'
}
-
-const alternateDetail = (
- id: string,
- title: string
-): MagicNoteDetail => ({
- ...detail,
- id,
- title,
- preview: '',
- entryCount: 0,
- entries: []
-})
-
-const summaryFromDetail = (
- note: MagicNoteDetail
-): MagicNotesSnapshot['notes'][number] => ({
- id: note.id,
- title: note.title,
- preview: note.preview,
- entryCount: note.entryCount,
- pinned: note.pinned,
- revision: note.revision,
- createdAt: note.createdAt,
- updatedAt: note.updatedAt
-})
-
-const list = vi.fn<() => Promise>()
-const get = vi.fn<(noteId: string) => Promise>()
-const listTodos = vi.fn<() => Promise>()
+const list = vi.fn()
+const get = vi.fn()
+const listTodos = vi.fn()
const create = vi.fn()
const remove = vi.fn()
const update = vi.fn()
const createEntry = vi.fn()
const updateEntry = vi.fn()
+const removeEntry = vi.fn()
const analyze = vi.fn()
const updateTodo = vi.fn()
const analyzeTodo = vi.fn()
const analyzeDraft = vi.fn()
-let analysisEventListener:
- | Parameters[0]
- | undefined
-const onAnalysisEvent = vi.fn<
- DesktopApi['magicNotes']['onAnalysisEvent']
->((listener) => {
- analysisEventListener = listener
- return vi.fn()
-})
-const getApplicationSettings = vi.fn<() => Promise>(async () => ({
- checkUpdatesOnStartup: false,
- updateSource: 'github',
- modelDownloadSource: 'modelscope',
- localToolEnvironment: defaultLocalToolEnvironmentSettings,
- applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
- conversationHtmlRenderingEnabled: true,
- remoteProjectsEnabled: false,
- magicNotesEnabled: true,
- magicNotesShowIncompleteTodoCount: true,
- magicNoteCommentMode: 'immediate',
- magicNoteCommentFormat: 'combined'
-}))
+const getSettings = vi.fn<() => Promise>()
const onNotify = vi.fn()
-let changeListener: (() => void) | undefined
const unsubscribeChanges = vi.fn()
-const onChanged = vi.fn((listener) => {
- changeListener = listener
- return unsubscribeChanges
-})
+let changeListener: (() => void) | undefined
+let analysisListener: Parameters[0] | undefined
+const originalScrollIntoView = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView')
-beforeEach(() => {
+beforeEach(async () => {
+ vi.resetAllMocks()
+ Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() })
localStorage.clear()
- analysisEventListener = undefined
+ await i18n.changeLanguage('zh-CN')
changeListener = undefined
- getApplicationSettings.mockResolvedValue({
- checkUpdatesOnStartup: false,
- updateSource: 'github',
- modelDownloadSource: 'modelscope',
- localToolEnvironment: defaultLocalToolEnvironmentSettings,
- applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
- conversationHtmlRenderingEnabled: true,
- remoteProjectsEnabled: false,
- magicNotesEnabled: true,
- magicNotesShowIncompleteTodoCount: true,
- magicNoteCommentMode: 'immediate',
- magicNoteCommentFormat: 'combined'
- })
- list.mockResolvedValue({ notes: [detail] })
- get.mockResolvedValue(detail)
- listTodos.mockResolvedValue({ todos: [noteTodo, manualTodo] })
- create.mockResolvedValue(alternateDetail(thirdNoteId, '新笔记'))
+ analysisListener = undefined
+ list.mockResolvedValue({ notes: [detail, second] })
+ get.mockImplementation(async (id) => id === secondNoteId ? second : detail)
+ listTodos.mockResolvedValue({ todos: [todo, otherTodo] })
+ create.mockResolvedValue({ ...second, title: '新笔记' })
remove.mockResolvedValue()
update.mockResolvedValue({ ...detail, pinned: true, revision: 2 })
- const createdDetail: MagicNoteDetail = {
- ...detail,
- revision: detail.revision + 1,
- entryCount: 2,
- entries: [
- ...detail.entries,
- {
- ...detail.entries[0]!,
- id: createdEntryId,
- content: {
- version: 1,
- ops: [{ insert: '新的句子\n' }]
- },
- plainText: '新的句子',
- comments: [],
- analyzedAt: undefined,
- revision: 0,
- createdAt: '2026-08-01T00:05:00.000Z',
- updatedAt: '2026-08-01T00:05:00.000Z'
- }
- ]
- }
- createEntry.mockResolvedValue(createdDetail)
- updateEntry.mockResolvedValue({
- ...detail,
- revision: detail.revision + 1,
- entries: detail.entries.map((entry) => ({
- ...entry,
- content: {
- version: 1,
- ops: [{ insert: '新的句子\n' }]
- },
- plainText: '新的句子',
- revision: entry.revision + 1
- }))
- })
- updateTodo.mockImplementation(
- async (input): Promise => ({
- todo: {
- ...noteTodo,
- completed: input.completed,
- revision: input.expectedRevision + 1
- },
- note: {
- ...detail,
- revision: detail.revision + 1,
- entries: detail.entries.map((entry) =>
- entry.id === noteTodo.entryId
- ? {
- ...entry,
- content: {
- version: 1,
- ops: [
- { insert: noteTodo.title },
- {
- insert: '\n',
- attributes: {
- list: input.completed ? 'checked' : 'unchecked'
- }
- }
- ]
- },
- comments: [],
- analyzedAt: undefined,
- revision: entry.revision + 1
- }
- : entry
- )
- }
- })
- )
- analyze.mockResolvedValue({
- ...createdDetail,
- entries: createdDetail.entries.map((entry) =>
- entry.id === createdEntryId
- ? {
- ...entry,
- comments: [
- {
- id: '00000000-0000-4000-8000-000000000614',
- kind: 'suggestion',
- content: '保存后的自动评论。'
- }
- ],
- analyzedAt: '2026-08-01T00:06:00.000Z',
- revision: 1
- }
- : entry
- )
- })
- analyzeDraft.mockResolvedValue({
- id: '00000000-0000-4000-8000-000000000611',
- comments: [
- {
- id: '00000000-0000-4000-8000-000000000612',
- kind: 'summary',
- content: '这是最新的草稿评论。'
- }
- ],
- analyzedAt: '2026-08-01T00:05:00.000Z'
- })
- analyzeTodo.mockResolvedValue({
- ...noteTodo,
- comments: [
- {
- id: '00000000-0000-4000-8000-000000000607',
- kind: 'suggestion',
- content: '先补充明确的验收条件。'
- }
- ],
- analyzedAt: '2026-08-01T00:04:00.000Z',
- revision: 2
- })
- Object.defineProperty(window, 'goodbuddy', {
- configurable: true,
- value: {
- magicNotes: {
- list,
- get,
- listTodos,
- create,
- remove,
- update,
- createEntry,
- updateEntry,
- analyze,
- updateTodo,
- analyzeTodo,
- analyzeDraft,
- onChanged,
- onAnalysisEvent
- },
- updates: {
- getSettings: getApplicationSettings
- }
- } as unknown as DesktopApi
- })
+ const saved = { ...detail, revision: 2, entryCount: 2, entries: [...detail.entries, { ...detail.entries[0]!, id: createdEntryId, content, comments: [] }] }
+ createEntry.mockResolvedValue(saved)
+ updateEntry.mockResolvedValue({ ...detail, revision: 2 })
+ removeEntry.mockResolvedValue({ ...detail, revision: 2, entries: [] })
+ analyze.mockResolvedValue({ ...saved, entries: saved.entries.map((entry) => entry.id === createdEntryId ? {
+ ...entry, comments: [{ id: 'saved-comment', kind: 'suggestion', content: '保存后的自动评论。' }]
+ } : entry) })
+ analyzeDraft.mockResolvedValue({ id: 'draft-analysis', comments: [{ id: 'draft-comment', kind: 'summary', content: '这是最新的草稿评论。' }], analyzedAt: detail.updatedAt })
+ analyzeTodo.mockResolvedValue({ ...todo, comments: [{ id: 'todo-comment', kind: 'suggestion', content: '先补充明确的验收条件。' }], revision: 2 })
+ updateTodo.mockImplementation(async (input) => ({
+ todo: { ...todo, completed: input.completed, revision: input.expectedRevision + 1 },
+ note: { ...detail, revision: 2, entries: [{ ...detail.entries[0]!, content: {
+ version: 1, ops: [{ insert: todo.title }, { insert: '\n', attributes: { list: input.completed ? 'checked' : 'unchecked' } }]
+ } }] }
+ }))
+ getSettings.mockResolvedValue(settings)
+ Object.defineProperty(window, 'goodbuddy', { configurable: true, value: {
+ magicNotes: { list, get, listTodos, create, remove, update, createEntry, updateEntry, removeEntry, analyze, updateTodo, analyzeTodo, analyzeDraft,
+ onChanged: (listener: () => void) => { changeListener = listener; return unsubscribeChanges },
+ onAnalysisEvent: (listener: NonNullable) => { analysisListener = listener; return vi.fn() }
+ }, updates: { getSettings }
+ } as unknown as DesktopApi })
})
afterEach(() => {
vi.useRealTimers()
cleanup()
- vi.clearAllMocks()
+ vi.restoreAllMocks()
+ if (originalScrollIntoView) Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', originalScrollIntoView)
+ else Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
})
-describe('MagicNotesWorkspace', () => {
+async function openNote(title = detail.title): Promise {
+ fireEvent.click(await screen.findByRole('button', { name: new RegExp(`${title}.*条记录`) }))
+ await screen.findByDisplayValue(title)
+}
+
+function back(): void {
+ fireEvent.click(screen.getByRole('button', { name: '返回总览' }))
+}
+
+async function openTodo(): Promise {
+ await screen.findByText(detail.title)
+ fireEvent.click(screen.getByRole('tab', { name: '待办' }))
+ fireEvent.click(screen.getByRole('button', { name: /核对发布材料.*发布笔记/ }))
+ await screen.findByRole('heading', { name: todo.title })
+}
+
+describe('MagicNotesWorkspace overview navigation', () => {
+ it('starts with adaptive cards, overview tabs and search without fetching a detail', async () => {
+ const { container } = render( )
+ await screen.findByText(detail.title)
+ expect(get).not.toHaveBeenCalled()
+ expect(screen.getByRole('tablist', { name: '魔法笔记内容' })).toHaveClass('page-tabs--segmented')
+ expect(screen.getByRole('searchbox', { name: '搜索当前范围的笔记' })).toBeVisible()
+ expect(screen.getByRole('button', { name: '新建笔记' })).toBeVisible()
+ expect(screen.queryByLabelText('笔记标题')).not.toBeInTheDocument()
+ expect(screen.queryByLabelText('AI 评论')).not.toBeInTheDocument()
+ expect(screen.queryByRole('separator')).not.toBeInTheDocument()
+ expect(container.querySelector('.magic-notes-card-grid')).toBeVisible()
+ const card = screen.getByRole('button', { name: /发布笔记.*条记录/ })
+ expect(card.querySelector('time')).toHaveAttribute('datetime', detail.updatedAt)
+ expect(card).not.toHaveAttribute('aria-pressed')
+ const css = readFileSync('src/renderer/src/styles.css', 'utf8')
+ expect(css).toMatch(/\.magic-notes-card-grid\s*\{[^}]*grid-template-columns: repeat\(auto-fill, minmax\(min\(100%, 240px\), 1fr\)\)/)
+ expect(css).toMatch(/\.magic-notes-page > \.page-header \.page-tabs\s*\{[^}]*flex: 0 0 auto/)
+ expect(css).toMatch(/\.magic-notes-page > \.page-header \.page-tabs\s*\{[^}]*width: fit-content/)
+ expect(screen.getByRole('tablist').parentElement).toBe(screen.getByRole('button', { name: '新建笔记' }).parentElement)
+ expect(screen.getByRole('tablist').nextElementSibling).toBe(screen.getByRole('button', { name: '新建笔记' }))
+ expect(css).not.toContain('magic-notes-list-resize-handle')
+ expect(css).not.toContain('--magic-notes-list-width')
+ expect(css).toMatch(/grid-template-rows: minmax\(0, 1fr\) minmax\(0, min\(40%, 280px\)\)/)
+ expect(css).toMatch(/\.magic-notes-layout--overview,\s*\.magic-notes-layout--ai-hidden\s*\{\s*grid-template-rows: minmax\(0, 1fr\)/)
+ })
+
+ it('opens a card and restores overview search, scroll and keyboard focus on return', async () => {
+ render( )
+ await screen.findByText(detail.title)
+ const overview = screen.getByRole('region', { name: '笔记列表' })
+ const search = screen.getByRole('searchbox')
+ fireEvent.change(search, { target: { value: '发布' } })
+ overview.scrollTop = 180
+ const card = screen.getByRole('button', { name: /发布笔记.*条记录/ })
+ await openNote()
+ expect(overview).not.toBeVisible()
+ expect(screen.queryByRole('tab')).not.toBeInTheDocument()
+ expect(screen.queryByRole('searchbox')).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: '新建笔记' })).not.toBeInTheDocument()
+ expect(screen.getByRole('region', { name: '笔记记录' })).toBeVisible()
+ await waitFor(() => expect(screen.getByRole('button', { name: '返回总览' })).toHaveFocus())
+ back()
+ expect(overview).toBeVisible()
+ expect(search).toHaveValue('发布')
+ expect(overview.scrollTop).toBe(180)
+ expect(screen.queryByRole('region', { name: '笔记记录' })).not.toBeInTheDocument()
+ await waitFor(() => expect(card).toHaveFocus())
+ await openNote()
+ expect(get).toHaveBeenCalledTimes(2)
+ })
+
+ it.each(['composer', 'entry', 'title'] as const)('guards returning with a dirty %s and cancels or discards through the existing dialog', async (draft) => {
+ const { container } = render( )
+ await openNote()
+ let editor = screen.getByTestId('magic-note-editor')
+ if (draft === 'entry') {
+ fireEvent.click(screen.getByRole('button', { name: '编辑' }))
+ editor = container.querySelector('.magic-note-entry__editor [data-testid="magic-note-editor"]')!
+ }
+ if (draft === 'title') fireEvent.change(screen.getByLabelText('笔记标题'), { target: { value: '未保存标题' } })
+ else fireEvent.click(editor)
+ back()
+ const dialog = screen.getByRole('alertdialog', { name: '放弃当前未保存草稿?' })
+ expect(dialog).toHaveAttribute('aria-modal', 'true')
+ expect(dialog).toHaveAccessibleDescription('切换后,当前记录草稿中的文字和附件将被丢弃。')
+ expect(container.querySelector('.page-header')?.inert).toBe(true)
+ const cancel = screen.getByRole('button', { name: '继续编辑' })
+ const discard = screen.getByRole('button', { name: '放弃草稿并切换' })
+ expect(cancel).toHaveFocus()
+ discard.focus()
+ fireEvent.keyDown(discard, { key: 'Tab' })
+ expect(cancel).toHaveFocus()
+ fireEvent.keyDown(cancel, { key: 'Tab', shiftKey: true })
+ expect(discard).toHaveFocus()
+ fireEvent.keyDown(dialog, { key: 'Escape' })
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
+ expect(container.querySelector('.page-header')?.inert).toBe(false)
+ if (draft !== 'title') await waitFor(() => expect(editor).toHaveFocus())
+ back()
+ fireEvent.click(screen.getByRole('button', { name: '放弃草稿并切换' }))
+ expect(screen.getByRole('tab', { name: '笔记' })).toBeVisible()
+ expect(createEntry).not.toHaveBeenCalled()
+ expect(updateEntry).not.toHaveBeenCalled()
+ await openNote()
+ back()
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
+ })
+
+ it('creates from either overview tab and enters the new note, retaining input after failure', async () => {
+ render( )
+ await screen.findByText(detail.title)
+ fireEvent.click(screen.getByRole('tab', { name: '待办' }))
+ fireEvent.click(screen.getByRole('button', { name: '新建笔记' }))
+ fireEvent.click(screen.getByRole('button', { name: '创建笔记' }))
+ expect(screen.getByText('请输入笔记标题')).toBeVisible()
+ fireEvent.change(screen.getByLabelText('笔记标题'), { target: { value: '新笔记' } })
+ create.mockRejectedValueOnce(new Error('create failed'))
+ fireEvent.click(screen.getByRole('button', { name: '创建笔记' }))
+ await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ message: 'create failed' })))
+ expect(screen.getByLabelText('笔记标题')).toHaveValue('新笔记')
+ fireEvent.click(screen.getByRole('button', { name: '创建笔记' }))
+ await waitFor(() => expect(screen.getByRole('region', { name: '笔记记录' })).toBeVisible())
+ expect(screen.getByLabelText('笔记标题')).toHaveValue('新笔记')
+ expect(create).toHaveBeenLastCalledWith({ title: '新笔记' })
+ back()
+ await waitFor(() => expect(screen.getByRole('button', { name: '新建笔记' })).toHaveFocus())
+ })
+
+ it('keeps the first pending entry switch and saves the original draft after cancellation', async () => {
+ const anotherEntry = { ...detail.entries[0]!, id: createdEntryId }
+ get.mockResolvedValue({ ...detail, entries: [...detail.entries, anotherEntry] })
+ render( )
+ await openNote()
+ const original = within(document.getElementById(`magic-note-entry-${entryId}`)!)
+ let other = within(document.getElementById(`magic-note-entry-${createdEntryId}`)!)
+ fireEvent.click(original.getByRole('button', { name: '编辑' }))
+ fireEvent.click(original.getByTestId('magic-note-editor'))
+ fireEvent.click(other.getByRole('button', { name: '编辑' }))
+ back()
+ fireEvent.click(screen.getByRole('button', { name: '继续编辑' }))
+ expect(original.getByTestId('magic-note-editor')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
+ await waitFor(() => expect(updateEntry).toHaveBeenCalledWith({ entryId, content, expectedRevision: 1 }))
+
+ get.mockResolvedValue({ ...detail, entries: [...detail.entries, anotherEntry] })
+ act(() => changeListener?.())
+ await waitFor(() => expect(screen.getAllByRole('button', { name: '编辑' })).toHaveLength(2))
+ other = within(document.getElementById(`magic-note-entry-${createdEntryId}`)!)
+ fireEvent.click(original.getByRole('button', { name: '编辑' }))
+ fireEvent.click(original.getByTestId('magic-note-editor'))
+ fireEvent.click(other.getByRole('button', { name: '编辑' }))
+ back()
+ fireEvent.click(screen.getByRole('button', { name: '放弃草稿并切换' }))
+ expect(other.getByTestId('magic-note-editor')).toBeInTheDocument()
+ expect(screen.getByLabelText('笔记标题')).toBeVisible()
+ })
+
+ it('saves the composer after cancelling return without fetching another note or losing content', async () => {
+ render( )
+ await openNote()
+ fireEvent.click(screen.getByTestId('magic-note-editor'))
+ back()
+ expect(get).toHaveBeenCalledOnce()
+ expect(create).not.toHaveBeenCalled()
+ fireEvent.click(screen.getByRole('button', { name: '继续编辑' }))
+ fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
+ await waitFor(() => expect(createEntry).toHaveBeenCalledWith({ noteId, content }))
+ })
+
+ it('keeps todo filters and search when opening its source note and returning', async () => {
+ render( )
+ await screen.findByText(detail.title)
+ fireEvent.keyDown(screen.getByRole('tab', { name: '笔记' }), { key: 'ArrowRight' })
+ expect(screen.getByRole('tab', { name: '待办' })).toHaveAttribute('aria-selected', 'true')
+ fireEvent.click(screen.getByRole('button', { name: '全部' }))
+ fireEvent.change(screen.getByRole('searchbox'), { target: { value: '核对' } })
+ const item = screen.getByRole('button', { name: /核对发布材料.*发布笔记/ })
+ fireEvent.click(item)
+ const source = await screen.findByRole('region', { name: '对应的笔记记录' })
+ await within(source).findByText('记录正文')
+ expect(screen.getByRole('region', { name: '待办详情' })).toContainElement(source)
+ expect(screen.getByRole('heading', { name: todo.title })).toBeVisible()
+ expect(screen.getByRole('searchbox')).toBeVisible()
+ expect(screen.queryByRole('complementary')).not.toBeInTheDocument()
+ expect(item).toHaveAttribute('aria-expanded', 'true')
+ const scroll = vi.mocked(HTMLElement.prototype.scrollIntoView)
+ fireEvent.click(screen.getByRole('button', { name: '打开原笔记修改' }))
+ await screen.findByDisplayValue(detail.title)
+ await waitFor(() => expect(scroll).toHaveBeenCalledWith({ block: 'center' }))
+ expect(document.getElementById(`magic-note-entry-${entryId}`)).toBeVisible()
+ fireEvent.click(screen.getByRole('button', { name: '返回待办' }))
+ expect(screen.getByRole('tab', { name: '待办' })).toHaveAttribute('aria-selected', 'true')
+ expect(screen.getByRole('searchbox')).toHaveValue('核对')
+ expect(screen.getByRole('button', { name: '全部' })).toHaveAttribute('aria-pressed', 'true')
+ await waitFor(() => expect(item).toHaveFocus())
+ expect(screen.getByRole('heading', { name: todo.title })).toBeVisible()
+ fireEvent.click(item)
+ expect(screen.queryByRole('heading', { name: todo.title })).not.toBeInTheDocument()
+ expect(item).toHaveAttribute('aria-expanded', 'false')
+ })
+
+ it('clears searches and status filters when the overview has no results', async () => {
+ listTodos.mockResolvedValue({ todos: [{ ...todo, completed: true }] })
+ render( )
+ await screen.findByText(detail.title)
+ fireEvent.change(screen.getByRole('searchbox'), { target: { value: '不存在' } })
+ expect(screen.getByText('没有符合条件的笔记')).toBeVisible()
+ fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
+ expect(screen.getByRole('searchbox')).toHaveValue('')
+ fireEvent.click(screen.getByRole('tab', { name: '待办' }))
+ expect(screen.getByText('没有符合条件的待办')).toBeVisible()
+ fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
+ expect(screen.getByRole('button', { name: '全部' })).toHaveAttribute('aria-pressed', 'true')
+ expect(screen.getByText(todo.title)).toBeVisible()
+ })
+
+ it('supports retrying initial overview and detail failures independently', async () => {
+ list.mockRejectedValueOnce(new Error('list failed'))
+ render( )
+ await screen.findByText('魔法笔记加载失败')
+ expect(screen.queryByText('还没有笔记')).not.toBeInTheDocument()
+ expect(screen.queryByText('还没有待办')).not.toBeInTheDocument()
+ expect(onNotify).not.toHaveBeenCalled()
+ fireEvent.click(screen.getByRole('button', { name: '重试' }))
+ await screen.findByText(detail.title)
+ expect(get).not.toHaveBeenCalled()
+ get.mockRejectedValueOnce(new Error('detail failed'))
+ fireEvent.click(screen.getByRole('button', { name: /发布笔记.*条记录/ }))
+ await screen.findByText('魔法笔记加载失败')
+ fireEvent.click(screen.getByRole('button', { name: '重试' }))
+ await screen.findByDisplayValue(detail.title)
+ expect(get).toHaveBeenCalledTimes(2)
+ })
+
+ it('ignores a late detail response after returning and opening another card', async () => {
+ let resolveDetail!: (value: MagicNoteDetail) => void
+ get.mockImplementationOnce(() => new Promise((resolve) => { resolveDetail = resolve }))
+ render( )
+ fireEvent.click(await screen.findByRole('button', { name: /发布笔记.*条记录/ }))
+ back()
+ await openNote(second.title)
+ await act(async () => resolveDetail(detail))
+ expect(screen.getByLabelText('笔记标题')).toHaveValue(second.title)
+ })
+
it('portals note actions with keyboard navigation, confirmation and outside dismissal', async () => {
render( )
const trigger = await screen.findByRole('button', { name: `更多笔记操作 ${detail.title}` })
@@ -391,219 +372,570 @@ describe('MagicNotesWorkspace', () => {
expect(parseFloat(menu.style.top)).toBeLessThanOrEqual(window.innerHeight - 8)
expect(screen.getByRole('menuitem', { name: '置顶笔记' })).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'End' })
- const deletion = screen.getByRole('menuitem', { name: '删除笔记' })
- expect(deletion).toHaveFocus()
- fireEvent.click(deletion)
+ expect(screen.getByRole('menuitem', { name: '删除笔记' })).toHaveFocus()
+ fireEvent.click(document.activeElement!)
expect(screen.getByRole('alertdialog')).toHaveTextContent(detail.title)
expect(screen.getByRole('button', { name: '取消' })).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
- expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: '删除笔记' })).toHaveFocus()
- expect(remove).not.toHaveBeenCalled()
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(trigger).toHaveFocus()
- expect(screen.queryByRole('menu')).not.toBeInTheDocument()
fireEvent.click(trigger)
fireEvent.pointerDown(document.body)
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
fireEvent.click(trigger)
- act(() => screen.getByLabelText('笔记标题').focus())
+ act(() => screen.getByRole('searchbox').focus())
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
fireEvent.click(trigger)
- fireEvent.click(screen.getByRole('button', { name: '隐藏左侧列表' }))
+ fireEvent.click(screen.getByRole('tab', { name: '待办' }))
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument()
+ expect(remove).not.toHaveBeenCalled()
+ fireEvent.click(screen.getByRole('tab', { name: '笔记' }))
+ fireEvent.click(screen.getByRole('button', { name: `更多笔记操作 ${detail.title}` }))
+ await openNote()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
- it.each([false, true])('pins a note without replacing selected drafts (selected: %s)', async (selected) => {
- const second = alternateDetail(secondNoteId, '第二篇笔记')
- list.mockResolvedValue({ notes: [detail, second] })
- const target = selected ? detail : second
- update.mockResolvedValue({ ...target, pinned: true, revision: 2 })
- const { container } = render( )
- const title = await screen.findByLabelText('笔记标题')
- act(() => title.focus())
- fireEvent.change(title, { target: { value: '未保存标题' } })
- const composer = screen.getByTestId('magic-note-editor')
- fireEvent.click(composer)
- fireEvent.click(screen.getByRole('button', { name: '编辑' }))
- const editors = screen.getAllByTestId('magic-note-editor')
- fireEvent.click(editors[1]!)
- const trigger = screen.getByRole('button', { name: `更多笔记操作 ${target.title}` })
+ it('pins and deletes overview cards without opening a detail, retaining failed confirmation', async () => {
+ render( )
+ const trigger = await screen.findByRole('button', { name: `更多笔记操作 ${detail.title}` })
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('menuitem', { name: '置顶笔记' }))
- await waitFor(() => expect(update).toHaveBeenCalledWith({ noteId: target.id, pinned: true, expectedRevision: 1 }))
- expect(update).toHaveBeenCalledTimes(1)
- await waitFor(() => expect(container.querySelector('.magic-note-list-item__title')).toHaveTextContent(target.title))
- expect(title).toHaveValue('未保存标题')
- expect(screen.getAllByTestId('magic-note-editor')).toEqual(editors)
- expect(document.getElementById(`magic-note-select-${noteId}`)).toHaveAttribute('aria-pressed', 'true')
+ await waitFor(() => expect(update).toHaveBeenCalledWith({ noteId, pinned: true, expectedRevision: 1 }))
fireEvent.click(trigger)
+ expect(screen.getByRole('menuitem', { name: '取消置顶' })).toBeVisible()
update.mockRejectedValueOnce(new Error('revision conflict'))
fireEvent.click(screen.getByRole('menuitem', { name: '取消置顶' }))
- await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ tone: 'error', message: 'revision conflict' })))
- expect(update).toHaveBeenLastCalledWith({ noteId: target.id, pinned: false, expectedRevision: 2 })
- expect(title).toHaveValue('未保存标题')
- fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
- await waitFor(() => expect(updateEntry).toHaveBeenCalledWith(expect.objectContaining({ expectedRevision: 1, content: { version: 1, ops: [{ insert: '新的句子\n' }] } })))
- })
-
- it('deletes an unselected note without clearing the selected composer or dirty title', async () => {
- const second = alternateDetail(secondNoteId, '第二篇笔记')
- list.mockResolvedValue({ notes: [detail, second] })
- render( )
- const title = await screen.findByLabelText('笔记标题')
- fireEvent.change(title, { target: { value: '未保存标题' } })
- const editor = screen.getByTestId('magic-note-editor')
- fireEvent.click(editor)
- fireEvent.click(screen.getByRole('button', { name: `更多笔记操作 ${second.title}` }))
- fireEvent.click(screen.getByRole('menuitem', { name: '删除笔记' }))
- list.mockResolvedValue({ notes: [detail] })
- fireEvent.click(screen.getByRole('button', { name: '删除笔记' }))
- await waitFor(() => expect(remove).toHaveBeenCalledWith(second.id))
- await waitFor(() => expect(screen.queryByText(second.title)).not.toBeInTheDocument())
- expect(title).toHaveValue('未保存标题')
- expect(screen.getByTestId('magic-note-editor')).toBe(editor)
- fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
- await waitFor(() => expect(createEntry).toHaveBeenCalledWith({ noteId, content: { version: 1, ops: [{ insert: '新的句子\n' }] } }))
- })
-
- it('retains drafts on delete failure and selects a remaining note after confirmed deletion', async () => {
- const second = alternateDetail(secondNoteId, '第二篇笔记')
- list.mockResolvedValue({ notes: [detail, second] })
- render( )
- await screen.findByLabelText('笔记标题')
- const editor = screen.getByTestId('magic-note-editor')
- fireEvent.click(editor)
- fireEvent.click(screen.getByRole('button', { name: `更多笔记操作 ${detail.title}` }))
+ await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ message: 'revision conflict' })))
+ expect(update).toHaveBeenLastCalledWith({ noteId, pinned: false, expectedRevision: 2 })
+ expect(document.querySelector('.magic-note-list-item__title')).toHaveTextContent(detail.title)
+ fireEvent.click(trigger)
fireEvent.click(screen.getByRole('menuitem', { name: '删除笔记' }))
remove.mockRejectedValueOnce(new Error('delete failed'))
fireEvent.click(screen.getByRole('button', { name: '删除笔记' }))
- await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ tone: 'error', message: 'delete failed' })))
- expect(screen.getByTestId('magic-note-editor')).toBe(editor)
- expect(screen.getByRole('alertdialog')).toBeInTheDocument()
+ await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ message: 'delete failed' })))
+ expect(screen.getByRole('alertdialog')).toBeVisible()
list.mockResolvedValue({ notes: [second] })
- get.mockResolvedValue(second)
fireEvent.click(screen.getByRole('button', { name: '删除笔记' }))
- await screen.findByDisplayValue(second.title)
- expect(screen.queryByRole('menu')).not.toBeInTheDocument()
- expect(screen.getByTestId('magic-note-editor')).not.toBe(editor)
- await waitFor(() => expect(document.getElementById(`magic-note-select-${second.id}`)).toHaveFocus())
+ await waitFor(() => expect(screen.queryByText(detail.title)).not.toBeInTheDocument())
+ expect(screen.getByText(second.title)).toBeVisible()
+ expect(get).not.toHaveBeenCalled()
+ })
+})
+
+describe('MagicNotesWorkspace detail behavior', () => {
+ it('keeps a stable sibling list and detail, searches instructions and translates without reloading', async () => {
+ listTodos.mockResolvedValue({ todos: [{ ...todo, instructions: '确认验收条件' }, otherTodo] })
+ render( )
+ await openTodo()
+ const first = screen.getByRole('button', { name: /核对发布材料.*发布笔记/ })
+ expect(screen.getByRole('tab', { name: '待办' })).toBeVisible()
+ expect(screen.queryByRole('separator')).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: '返回总览' })).not.toBeInTheDocument()
+ expect(first).toHaveAttribute('aria-controls', `magic-todo-detail-${todo.id}`)
+ const list = first.closest('.magic-notes-list')!
+ const pane = screen.getByRole('region', { name: '待办详情' })
+ expect(list.parentElement).toContainElement(pane)
+ expect(list).not.toContainElement(pane)
+ const toolbar = screen.getByRole('searchbox').closest('.magic-todo-toolbar')!
+ expect(toolbar).toContainElement(screen.getByRole('group', { name: '筛选待办' }))
+ const css = readFileSync('src/renderer/src/styles.css', 'utf8')
+ expect(css).toMatch(/\.magic-todo-workspace\s*\{[^}]*grid-template-columns: minmax\(280px, 34%\) minmax\(0, 1fr\)/)
+ expect(css).toContain('@container magic-notes-page (max-width: 700px)')
+ expect(css).toMatch(/\.magic-todo-workspace--selected > \.magic-notes-list,\s*\.magic-todo-detail--empty\s*\{\s*display: none/)
+ fireEvent.click(screen.getByRole('button', { name: /准备演示.*演示笔记/ }))
+ expect(first).toHaveAttribute('aria-expanded', 'false')
+ expect(screen.getAllByRole('region', { name: '待办详情' })).toHaveLength(1)
+ expect(first.closest('.magic-notes-list')).toBe(list)
+ fireEvent.click(screen.getByRole('button', { name: '返回待办列表' }))
+ await waitFor(() => expect(screen.getByRole('button', { name: /准备演示.*演示笔记/ })).toHaveFocus())
+ expect(screen.queryByRole('region', { name: '待办详情' })).not.toBeInTheDocument()
+ fireEvent.change(screen.getByRole('searchbox'), { target: { value: '验收' } })
+ expect(screen.queryByRole('region', { name: '待办详情' })).not.toBeInTheDocument()
+ expect(screen.getByText('1 / 2 项')).toBeVisible()
+ fireEvent.click(first)
+ expect(screen.getByText('确认验收条件')).toBeVisible()
+ await act(async () => { await i18n.changeLanguage('en-US') })
+ expect(screen.getByRole('region', { name: 'To-do details' })).toBeVisible()
+ expect(screen.getByPlaceholderText('Search tasks, instructions, or source notes')).toHaveValue('验收')
+ expect(screen.getByText('1 / 2 items')).toBeVisible()
+ expect(listTodos).toHaveBeenCalledOnce()
})
- it('coalesces external writes, keeps selection and refreshes the selected detail', async () => {
+ it('retries source failures and ignores a late source from a previously expanded task', async () => {
+ get.mockRejectedValueOnce(new Error('source failed'))
render( )
+ await openTodo()
+ fireEvent.click(await screen.findByRole('button', { name: '重试加载来源' }))
+ await within(screen.getByRole('region', { name: '对应的笔记记录' })).findByText('记录正文')
+ expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ message: 'source failed' }))
+ let finish!: (value: MagicNoteDetail) => void
+ get.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve }))
+ fireEvent.click(screen.getByRole('button', { name: /准备演示.*演示笔记/ }))
+ await waitFor(() => expect(get).toHaveBeenLastCalledWith(secondNoteId))
+ fireEvent.click(screen.getByRole('button', { name: /核对发布材料.*发布笔记/ }))
+ await waitFor(() => expect(get).toHaveBeenLastCalledWith(noteId))
+ await act(async () => finish(second))
+ expect(within(screen.getByRole('region', { name: '对应的笔记记录' })).getByText('记录正文')).toBeVisible()
+ })
+
+ it('retains the expanded task and saved comments after completion or analysis failures', async () => {
+ listTodos.mockResolvedValue({ todos: [{ ...todo, comments: [{ id: 'saved', kind: 'suggestion', content: '已保存的建议' }] }, otherTodo] })
+ updateTodo.mockRejectedValueOnce(new Error('revision conflict'))
+ analyzeTodo.mockRejectedValueOnce(new Error('model failed'))
+ render( )
+ await openTodo()
+ const check = screen.getByRole('button', { name: `标记为已完成:${todo.title}` })
+ fireEvent.click(check)
+ await waitFor(() => expect(check).toBeEnabled())
+ expect(check).toHaveAttribute('aria-pressed', 'false')
+ fireEvent.click(screen.getByRole('button', { name: 'AI 分析' }))
+ await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ message: 'model failed' })))
+ expect(screen.getByText('已保存的建议')).toBeVisible()
+ expect(screen.getByRole('button', { name: /核对发布材料.*发布笔记/ })).toHaveAttribute('aria-expanded', 'true')
+ })
+
+ it('retains AI visibility and width across navigation and remounts', async () => {
+ const view = render( )
+ await openNote()
+ expect(screen.queryByRole('group', { name: 'AI 评论形式' })).not.toBeInTheDocument()
+ expect(screen.getByRole('combobox', { name: 'AI 评论方向' })).toBeVisible()
+ fireEvent.keyDown(screen.getByRole('separator'), { key: 'End' })
+ const width = screen.getByRole('separator').getAttribute('aria-valuenow')
+ fireEvent.click(screen.getByRole('button', { name: '关闭 AI 评论面板' }))
+ expect(screen.queryByRole('complementary', { name: 'AI 评论' })).not.toBeInTheDocument()
+ back()
+ await openNote()
+ expect(screen.getByRole('button', { name: '显示 AI 评论' })).toBeVisible()
+ view.unmount()
+ render( )
+ await openNote()
+ fireEvent.click(screen.getByRole('button', { name: '显示 AI 评论' }))
+ expect(screen.getByRole('separator')).toHaveAttribute('aria-valuenow', width)
+ expect(JSON.parse(localStorage.getItem('goodbuddy.magic-notes-layout.v1')!)).toEqual({ aiPaneOpen: true, aiPaneWidth: Number(width) })
+ })
+
+ it('resizes AI with pointer and keyboard and disables resizing in narrow layouts', async () => {
+ vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
+ render( )
+ await openNote()
+ const separator = screen.getByRole('separator', { name: '调整编辑区与 AI 评论宽度' })
+ const layout = separator.closest('.magic-notes-layout') as HTMLElement
+ const release = vi.fn()
+ Object.defineProperties(separator, { setPointerCapture: { value: vi.fn() }, hasPointerCapture: { value: () => true }, releasePointerCapture: { value: release } })
+ fireEvent.pointerDown(separator, { button: 0, clientX: 920, pointerId: 12 })
+ fireEvent.pointerMove(separator, { clientX: 600, pointerId: 12 })
+ expect(layout).toHaveClass('magic-notes-layout--resizing')
+ expect(layout.style.getPropertyValue('--magic-notes-ai-width')).toBe('520px')
+ fireEvent.pointerUp(separator, { pointerId: 12 })
+ expect(release).toHaveBeenCalledWith(12)
+ expect(layout).not.toHaveClass('magic-notes-layout--resizing')
+ fireEvent.keyDown(separator, { key: 'Home' })
+ expect(separator).toHaveAttribute('aria-valuenow', '240')
+ fireEvent.keyDown(separator, { key: 'ArrowLeft' })
+ expect(separator).toHaveAttribute('aria-valuenow', '256')
+ fireEvent.keyDown(separator, { key: 'End' })
+ expect(separator).toHaveAttribute('aria-valuenow', '520')
+ vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(700)
+ back()
+ await openNote()
+ expect(screen.getByRole('separator')).toHaveAttribute('aria-disabled', 'true')
+ expect(screen.getByRole('separator')).toHaveAttribute('tabindex', '-1')
+ })
+
+ it('shows loading then the AI empty state for a note without entries', async () => {
+ let resolveDetail!: (value: MagicNoteDetail) => void
+ get.mockImplementationOnce(() => new Promise((resolve) => { resolveDetail = resolve }))
+ render( )
+ fireEvent.click(await screen.findByRole('button', { name: /发布笔记.*条记录/ }))
+ const pane = screen.getByRole('complementary', { name: 'AI 评论' })
+ expect(within(pane).getByText('正在加载笔记…')).toBeVisible()
+ await act(async () => resolveDetail({ ...detail, entries: [], entryCount: 0 }))
+ expect(within(pane).getByText('写完一句并停止输入 5 秒后,评论会显示在这里。')).toBeVisible()
+ })
+
+ it('keeps entry editing contained and supports deletion confirmation', async () => {
+ const { container } = render( )
+ await openNote()
+ expect(container.querySelector('.magic-note-detail-header button')).toBeNull()
+ expect(screen.getByRole('button', { name: '删除记录' })).toHaveClass('danger-button--quiet')
+ fireEvent.click(screen.getByRole('button', { name: '编辑' }))
+ expect(container.querySelector('.magic-note-entry__editor')).toBeVisible()
+ fireEvent.click(screen.getByRole('button', { name: '删除记录' }))
+ expect(screen.getByText('删除这条记录?此操作不可撤销。')).toBeVisible()
+ expect(container.querySelector('.magic-note-entry__editor')).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: '编辑' }))
+ expect(screen.queryByText('删除这条记录?此操作不可撤销。')).not.toBeInTheDocument()
+ })
+
+ it('preserves composer content across save failures and language changes', async () => {
+ createEntry.mockRejectedValueOnce(new Error('save failed'))
+ render( )
+ await openNote()
+ fireEvent.click(screen.getByTestId('magic-note-editor'))
+ await act(async () => { await i18n.changeLanguage('en-US') })
+ fireEvent.click(screen.getByRole('button', { name: 'Save entry' }))
+ await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ message: 'save failed' })))
+ fireEvent.click(screen.getByRole('button', { name: 'Save entry' }))
+ await waitFor(() => expect(createEntry).toHaveBeenCalledTimes(2))
+ expect(createEntry).toHaveBeenNthCalledWith(1, { noteId, content })
+ expect(createEntry).toHaveBeenNthCalledWith(2, { noteId, content })
+ expect(list).toHaveBeenCalledOnce()
+ })
+
+ it.each(['create', 'edit'] as const)('finalizes %s before a todo refresh failure', async (operation) => {
+ const { container } = render( )
+ await openNote()
+ listTodos.mockRejectedValueOnce(new Error('todo refresh failed'))
+ if (operation === 'edit') fireEvent.click(screen.getByRole('button', { name: '编辑' }))
+ fireEvent.click(operation === 'create' ? screen.getByTestId('magic-note-editor') : container.querySelector('.magic-note-entry__editor [data-testid="magic-note-editor"]')!)
+ fireEvent.click(screen.getByRole('button', { name: operation === 'create' ? '保存记录' : '保存修改' }))
+ await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ message: 'todo refresh failed' })))
+ expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({
+ tone: 'success', message: operation === 'create' ? '记录已保存' : '记录已更新,原 AI 评论已清除'
+ }))
+ if (operation === 'create') {
+ fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
+ expect(createEntry).toHaveBeenCalledOnce()
+ expect(screen.getByText('请先输入记录内容')).toBeVisible()
+ } else {
+ expect(updateEntry).toHaveBeenCalledWith({ entryId, content, expectedRevision: 1 })
+ expect(screen.queryByRole('button', { name: '保存修改' })).not.toBeInTheDocument()
+ }
+ })
+
+ it('synchronizes completion in the list without opening another task, and applies status filters', async () => {
+ render( )
+ await openTodo()
+ fireEvent.click(screen.getByRole('button', { name: '全部' }))
+ fireEvent.click(screen.getByRole('button', { name: `标记为已完成:${todo.title}` }))
+ await waitFor(() => expect(updateTodo).toHaveBeenCalledWith({ todoId: todo.id, completed: true, expectedRevision: 1 }))
+ expect(screen.getByTestId('magic-note-checklist-state')).toHaveTextContent('checked')
+ fireEvent.click(screen.getByRole('button', { name: `标记为未完成:${todo.title}` }))
+ await waitFor(() => expect(updateTodo).toHaveBeenLastCalledWith({ todoId: todo.id, completed: false, expectedRevision: 2 }))
+ expect(screen.getByTestId('magic-note-checklist-state')).toHaveTextContent('unchecked')
+ fireEvent.click(screen.getByRole('button', { name: /核对发布材料.*发布笔记/ }))
+ fireEvent.click(screen.getByRole('button', { name: '未完成' }))
+ fireEvent.click(screen.getByRole('button', { name: `标记为已完成:${todo.title}` }))
+ await waitFor(() => expect(updateTodo).toHaveBeenCalledTimes(3))
+ expect(screen.getByRole('tab', { name: '待办' })).toBeVisible()
+ expect(screen.queryByRole('region', { name: '待办详情' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /核对发布材料.*发布笔记/ })).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /准备演示.*演示笔记/ })).toHaveAttribute('aria-expanded', 'false')
+ })
+
+ it('reuses AI for todo details with snapshotted streaming options', async () => {
+ getSettings.mockResolvedValue({ ...settings, magicNoteCommentFormat: 'narrative' })
+ let finish!: (value: MagicTodoItem) => void
+ analyzeTodo.mockImplementationOnce((_id, options) => new Promise((resolve) => {
+ finish = resolve
+ analysisListener?.({ requestId: options.requestId, type: 'text', delta: '正在扩展内容。', direction: 'expand', format: 'narrative' })
+ }))
+ render( )
+ await openTodo()
+ fireEvent.change(screen.getByRole('combobox', { name: 'AI 评论方向' }), { target: { value: 'expand' } })
+ fireEvent.click(screen.getByRole('button', { name: 'AI 分析' }))
+ await screen.findByText('正在扩展内容。')
+ fireEvent.change(screen.getByRole('combobox', { name: 'AI 评论方向' }), { target: { value: 'polish' } })
+ expect(screen.getByText(/正在生成 ·/)).toHaveTextContent('扩展写作')
+ expect(screen.getByRole('button', { name: '打开原笔记修改' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: /准备演示.*演示笔记/ })).toBeDisabled()
+ expect(screen.queryByRole('button', { name: '返回总览' })).not.toBeInTheDocument()
+ await act(async () => finish({ ...todo, comments: [{ id: 'result', kind: 'narrative', content: '扩展完成。', direction: 'expand', format: 'narrative' }] }))
+ expect(screen.getByText('扩展完成。')).toBeVisible()
+ expect(document.querySelector('.magic-note-comment__direction')).toHaveTextContent('扩展写作')
+ expect(analyzeTodo).toHaveBeenCalledWith(todo.id, expect.objectContaining({ direction: 'expand', format: 'narrative' }))
+ expect(screen.getByRole('combobox', { name: 'AI 评论方向' })).toHaveValue('polish')
+ })
+
+ it.each([true, false])('shows the updated checklist when opening the source after setting completion to %s', async (completed) => {
+ listTodos.mockResolvedValue({ todos: [{ ...todo, completed: !completed }] })
+ render( )
+ await screen.findByText(detail.title)
+ fireEvent.click(screen.getByRole('tab', { name: '待办' }))
+ fireEvent.click(screen.getByRole('button', { name: '全部' }))
+ fireEvent.click(screen.getByRole('button', { name: /核对发布材料.*发布笔记/ }))
+ fireEvent.click(screen.getByRole('button', { name: `${completed ? '标记为已完成' : '标记为未完成'}:${todo.title}` }))
+ await waitFor(() => expect(updateTodo).toHaveBeenCalledWith({ todoId: todo.id, completed, expectedRevision: 1 }))
+ const result = await updateTodo.mock.results[0]!.value
+ get.mockResolvedValue(result.note)
+ expect(screen.getByRole('button', { name: `${completed ? '标记为未完成' : '标记为已完成'}:${todo.title}` })).toHaveAttribute('aria-pressed', String(completed))
+ expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ tone: 'success', message: completed ? '待办已完成' : '待办已恢复为未完成' }))
+ fireEvent.click(screen.getByRole('button', { name: '打开原笔记修改' }))
await screen.findByDisplayValue(detail.title)
- const added = alternateDetail(secondNoteId, 'Agent-created note')
- const updated = { ...detail, title: 'Agent-renamed note', pinned: true, revision: 2 }
- list.mockResolvedValue({ notes: [added, updated] })
- get.mockResolvedValue(updated)
- act(() => {
- changeListener?.()
- changeListener?.()
- changeListener?.()
- })
- await screen.findByDisplayValue(updated.title)
- expect(screen.getByText(added.title)).toBeInTheDocument()
+ expect(within(screen.getByRole('region', { name: '笔记记录' })).getByTestId('magic-note-checklist-state')).toHaveTextContent(completed ? 'checked' : 'unchecked')
+ })
+
+ it('renders a completed todo analysis inline with default options', async () => {
+ getSettings.mockResolvedValue({ ...settings, magicNoteCommentMode: 'after-save-manual' })
+ render( )
+ await openTodo()
+ fireEvent.click(screen.getByRole('button', { name: 'AI 分析' }))
+ await within(screen.getByRole('region', { name: 'AI 评论' })).findByText('先补充明确的验收条件。')
+ expect(analyzeTodo).toHaveBeenCalledWith(todo.id, expect.objectContaining({ requestId: expect.any(String), direction: 'general', format: 'combined' }))
+ expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ tone: 'success', message: 'AI 评论已添加' }))
+ })
+
+ it('automatically analyzes a saved entry in auto mode', async () => {
+ getSettings.mockResolvedValue({ ...settings, magicNoteCommentMode: 'after-save-auto' })
+ render( )
+ await openNote()
+ fireEvent.click(screen.getByTestId('magic-note-editor'))
+ fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
+ await screen.findByText('保存后的自动评论。')
+ expect(analyze).toHaveBeenCalledWith(createdEntryId, expect.objectContaining({ direction: 'general', format: 'combined' }))
+ })
+
+ it('retains manual entry analysis and the overview translations without reloading data', async () => {
+ getSettings.mockResolvedValue({ ...settings, magicNoteCommentMode: 'after-save-manual' })
+ render( )
+ await openNote()
+ fireEvent.click(screen.getByRole('button', { name: '重新分析' }))
+ await waitFor(() => expect(analyze).toHaveBeenCalledWith(entryId, expect.objectContaining({ direction: 'general', format: 'combined' })))
+ await waitFor(() => expect(screen.getByRole('button', { name: '返回总览' })).toBeEnabled())
+ back()
+ await act(async () => { await i18n.changeLanguage('en-US') })
+ expect(screen.getByRole('heading', { name: 'Magic Notes' })).toBeVisible()
+ expect(screen.getByRole('tab', { name: 'Notes' })).toHaveAttribute('aria-selected', 'true')
+ expect(screen.getByRole('button', { name: 'New note' })).toBeVisible()
+ expect(list).toHaveBeenCalledOnce()
+ })
+
+ it('does not show a discarded draft analysis in the next note', async () => {
+ let finish!: (value: Awaited>) => void
+ analyzeDraft.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve }))
+ render( )
+ await openNote()
+ vi.useFakeTimers()
+ fireEvent.click(screen.getByTestId('magic-note-editor'))
+ await act(() => vi.advanceTimersByTimeAsync(5000))
+ expect(analyzeDraft).toHaveBeenCalledOnce()
+ back()
+ fireEvent.click(screen.getByRole('button', { name: '放弃草稿并切换' }))
+ vi.useRealTimers()
+ await openNote(second.title)
+ expect(screen.queryByText(/正在生成 ·/)).not.toBeInTheDocument()
+ await act(async () => finish({ id: 'old-analysis', comments: [{ id: 'old-comment', kind: 'summary', content: 'Discarded comment' }], analyzedAt: detail.updatedAt }))
+ expect(screen.queryByText('Discarded comment')).not.toBeInTheDocument()
+ })
+
+ it('analyzes an unsaved draft five seconds after Enter and cancels queued work when leaving', async () => {
+ render( )
+ await openNote()
+ vi.useFakeTimers()
+ fireEvent.click(screen.getByTestId('magic-note-editor'))
+ await act(() => vi.advanceTimersByTimeAsync(4999))
+ expect(analyzeDraft).not.toHaveBeenCalled()
+ await act(() => vi.advanceTimersByTimeAsync(1))
+ expect(analyzeDraft).toHaveBeenCalledWith(content, expect.objectContaining({ direction: 'general', format: 'combined' }))
+ expect(screen.getByText('这是最新的草稿评论。')).toBeVisible()
+ fireEvent.click(screen.getByTestId('magic-note-editor'))
+ back()
+ fireEvent.click(screen.getByRole('button', { name: '放弃草稿并切换' }))
+ await act(() => vi.advanceTimersByTimeAsync(6000))
+ expect(analyzeDraft).toHaveBeenCalledOnce()
+ })
+})
+
+describe('MagicNotesWorkspace external refresh', () => {
+ it('coalesces writes in detail, keeps the selected note, and returns to overview after clean external deletion', async () => {
+ render( )
+ await openNote()
+ const renamed = { ...detail, title: 'Agent-renamed note', pinned: true, revision: 2 }
+ list.mockResolvedValue({ notes: [second, renamed] })
+ get.mockResolvedValue(renamed)
+ act(() => { changeListener?.(); changeListener?.(); changeListener?.() })
+ await screen.findByDisplayValue(renamed.title)
expect(get).toHaveBeenLastCalledWith(noteId)
expect(list).toHaveBeenCalledTimes(2)
- fireEvent.click(screen.getByRole('button', { name: `更多笔记操作 ${updated.title}` }))
- expect(screen.getByRole('menuitem', { name: '取消置顶' })).toBeInTheDocument()
-
- list.mockResolvedValue({ notes: [added] })
- get.mockResolvedValue(added)
+ expect(screen.queryByRole('tab')).not.toBeInTheDocument()
+ list.mockResolvedValue({ notes: [second] })
act(() => changeListener?.())
- await screen.findByDisplayValue(added.title)
- expect(screen.queryByDisplayValue(updated.title)).not.toBeInTheDocument()
+ await screen.findByRole('tab', { name: '笔记' })
+ expect(screen.queryByLabelText('笔记标题')).not.toBeInTheDocument()
+ expect(screen.getByText(second.title)).toBeVisible()
+ expect(get).not.toHaveBeenCalledWith(secondNoteId)
})
- it('preserves title and composer drafts when external entries arrive', async () => {
+ it('keeps overview data and filters after a post-deletion refresh fails, then retries', async () => {
render( )
- const title = await screen.findByDisplayValue(detail.title)
- fireEvent.change(title, { target: { value: 'Unsaved title' } })
- const composer = screen.getByTestId('magic-note-editor')
- fireEvent.click(composer)
- const updated = {
- ...detail,
- title: 'External title',
- revision: 2,
- entries: [...detail.entries, { ...detail.entries[0]!, id: createdEntryId, comments: [] }]
- }
- list.mockResolvedValue({ notes: [updated] })
- get.mockResolvedValue(updated)
- act(() => changeListener?.())
- await waitFor(() => expect(screen.getAllByText('记录正文')).toHaveLength(2))
- expect(title).toHaveValue('Unsaved title')
- expect(screen.getByTestId('magic-note-editor')).toBe(composer)
- fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
- await waitFor(() => expect(createEntry).toHaveBeenCalledWith({
- noteId,
- content: { version: 1, ops: [{ insert: '新的句子\n' }] }
- }))
+ await screen.findByText(detail.title)
+ fireEvent.change(screen.getByRole('searchbox'), { target: { value: '发布' } })
+ fireEvent.click(screen.getByRole('button', { name: `更多笔记操作 ${detail.title}` }))
+ fireEvent.click(screen.getByRole('menuitem', { name: '删除笔记' }))
+ list.mockRejectedValueOnce(new Error('refresh failed'))
+ fireEvent.click(screen.getByRole('button', { name: '删除笔记' }))
+ await screen.findByText(/刷新失败,已保留当前内容:refresh failed/)
+ expect(screen.getByRole('searchbox')).toHaveValue('发布')
+ expect(screen.getByRole('button', { name: /发布笔记.*条记录/ })).toBeVisible()
+ expect(onNotify).not.toHaveBeenCalledWith(expect.objectContaining({ tone: 'error', message: 'refresh failed' }))
+ list.mockResolvedValue({ notes: [second] })
+ fireEvent.click(screen.getByRole('button', { name: '重试' }))
+ await screen.findByText('没有符合条件的笔记')
+ expect(screen.queryByText(/refresh failed/)).not.toBeInTheDocument()
+ expect(screen.getByRole('searchbox')).toHaveValue('发布')
})
- it.each(['updated', 'deleted'] as const)('keeps an entry draft mounted when externally %s', async (change) => {
- const { container } = render( )
- await screen.findByText('记录正文')
- fireEvent.click(screen.getByRole('button', { name: '编辑' }))
- const editor = container.querySelector('.magic-note-entry__editor [data-testid="magic-note-editor"]')!
- fireEvent.click(editor)
- const updated = {
- ...detail,
- title: 'External change',
- revision: 2,
- entries: change === 'deleted' ? [] : detail.entries.map((entry) => ({ ...entry, revision: 2 }))
- }
- list.mockResolvedValue({ notes: [updated] })
- get.mockResolvedValue(updated)
- act(() => changeListener?.())
- await screen.findByDisplayValue(updated.title)
- expect(container.querySelector('.magic-note-entry__editor [data-testid="magic-note-editor"]')).toBe(editor)
- if (change === 'deleted') {
- expect(screen.getByText(/正在编辑的记录已在其他入口删除/)).toBeInTheDocument()
- }
- updateEntry.mockRejectedValueOnce(new Error('Revision conflict or deleted entry'))
- fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
- await waitFor(() => expect(updateEntry).toHaveBeenCalledWith({
- entryId,
- content: { version: 1, ops: [{ insert: '新的句子\n' }] },
- expectedRevision: 1
- }))
- expect(container.querySelector('.magic-note-entry__editor [data-testid="magic-note-editor"]')).toBe(editor)
- })
-
- it('retains a deleted note with drafts instead of moving them into another note', async () => {
+ it('does not override a newer selection when a retried detail refresh resolves late', async () => {
render( )
- const title = await screen.findByDisplayValue(detail.title)
- fireEvent.change(title, { target: { value: 'Unsaved title' } })
- const composer = screen.getByTestId('magic-note-editor')
- fireEvent.click(composer)
+ await openNote()
+ list.mockRejectedValueOnce(new Error('refresh failed'))
+ act(() => changeListener?.())
+ await screen.findByText(/refresh failed/)
+ let finish!: (value: MagicNoteDetail) => void
+ get.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve }))
+ fireEvent.click(screen.getByRole('button', { name: '重试' }))
+ await waitFor(() => expect(get).toHaveBeenCalledTimes(2))
+ back()
+ await openNote(second.title)
+ await act(async () => finish(detail))
+ expect(screen.getByLabelText('笔记标题')).toHaveValue(second.title)
+ expect(screen.queryByText(/refresh failed/)).not.toBeInTheDocument()
+ })
+
+ it('retains a dirty title alone when its note is externally deleted', async () => {
+ render( )
+ await openNote()
+ fireEvent.change(screen.getByLabelText('笔记标题'), { target: { value: 'Unsaved title' } })
list.mockResolvedValue({ notes: [] })
listTodos.mockResolvedValue({ todos: [] })
act(() => changeListener?.())
await screen.findByText(/这篇笔记已在其他入口删除/)
+ expect(screen.getByLabelText('笔记标题')).toHaveValue('Unsaved title')
+ back()
+ expect(screen.getByRole('alertdialog')).toBeVisible()
+ })
+
+ it('coalesces overview refreshes without opening a note', async () => {
+ render( )
+ await screen.findByText(detail.title)
+ list.mockResolvedValue({ notes: [{ ...detail, title: 'Agent note' }] })
+ act(() => { changeListener?.(); changeListener?.(); changeListener?.() })
+ await screen.findByText('Agent note')
+ expect(list).toHaveBeenCalledTimes(2)
+ expect(get).not.toHaveBeenCalled()
+ })
+
+ it('preserves title and composer drafts when external entries arrive', async () => {
+ render( )
+ await openNote()
+ const title = screen.getByLabelText('笔记标题')
+ fireEvent.change(title, { target: { value: 'Unsaved title' } })
+ const editor = screen.getByTestId('magic-note-editor')
+ fireEvent.click(editor)
+ get.mockResolvedValue({ ...detail, title: 'External title', revision: 2, entries: [...detail.entries, { ...detail.entries[0]!, id: createdEntryId, comments: [] }] })
+ act(() => changeListener?.())
+ await waitFor(() => expect(screen.getAllByText('记录正文')).toHaveLength(2))
expect(title).toHaveValue('Unsaved title')
- expect(screen.getByTestId('magic-note-editor')).toBe(composer)
- expect(screen.queryByRole('button', { name: /发布笔记.*整理发布清单/ })).not.toBeInTheDocument()
+ expect(screen.getByTestId('magic-note-editor')).toBe(editor)
+ fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
+ await waitFor(() => expect(createEntry).toHaveBeenCalledWith({ noteId, content }))
+ })
+
+ it.each(['updated', 'deleted'] as const)('retains an entry draft externally %s and its original revision', async (change) => {
+ const { container } = render( )
+ await openNote()
+ fireEvent.click(screen.getByRole('button', { name: '编辑' }))
+ const editor = container.querySelector('.magic-note-entry__editor [data-testid="magic-note-editor"]')!
+ fireEvent.click(editor)
+ get.mockResolvedValue({ ...detail, title: 'External change', revision: 2, entries: change === 'deleted' ? [] : detail.entries.map((entry) => ({ ...entry, revision: 2 })) })
+ act(() => changeListener?.())
+ await screen.findByDisplayValue('External change')
+ expect(container.querySelector('.magic-note-entry__editor [data-testid="magic-note-editor"]')).toBe(editor)
+ if (change === 'deleted') expect(screen.getByText(/正在编辑的记录已在其他入口删除/)).toBeVisible()
+ updateEntry.mockRejectedValueOnce(new Error('revision conflict'))
+ fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
+ await waitFor(() => expect(updateEntry).toHaveBeenCalledWith({ entryId, content, expectedRevision: 1 }))
+ expect(container.querySelector('.magic-note-entry__editor [data-testid="magic-note-editor"]')).toBe(editor)
+ })
+
+ it('retains a deleted note with drafts until the user returns to the overview', async () => {
+ render( )
+ await openNote()
+ const editor = screen.getByTestId('magic-note-editor')
+ fireEvent.click(editor)
+ list.mockResolvedValue({ notes: [second] })
+ act(() => changeListener?.())
+ await screen.findByText(/这篇笔记已在其他入口删除/)
+ expect(screen.getByTestId('magic-note-editor')).toBe(editor)
+ back()
+ fireEvent.click(screen.getByRole('button', { name: '放弃草稿并切换' }))
+ expect(screen.getByText(second.title)).toBeVisible()
+ await waitFor(() => expect(screen.getByRole('button', { name: '新建笔记' })).toHaveFocus())
})
it('preserves the selected todo and reloads its source after external writes', async () => {
render( )
- await screen.findByText('记录正文')
+ await screen.findByText(detail.title)
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
+ expect(screen.queryByRole('button', { name: '新建待办' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('group', { name: '待办列表方式' })).not.toBeInTheDocument()
+ expect(screen.getByText(second.title)).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: /准备演示.*演示笔记/ }))
await waitFor(() => expect(get).toHaveBeenLastCalledWith(secondNoteId))
- const source = alternateDetail(secondNoteId, '演示笔记')
- get.mockResolvedValue(source)
- listTodos.mockResolvedValue({ todos: [noteTodo, { ...manualTodo, title: 'External todo' }] })
+ const callsBeforeRefresh = get.mock.calls.length
+ listTodos.mockResolvedValue({ todos: [todo, { ...otherTodo, title: 'External todo' }] })
act(() => changeListener?.())
await screen.findByRole('heading', { name: 'External todo' })
- expect(screen.getByRole('button', { name: /External todo.*演示笔记/ })).toHaveAttribute('aria-pressed', 'true')
- await waitFor(() => expect(get).toHaveBeenLastCalledWith(secondNoteId))
+ await waitFor(() => expect(get).toHaveBeenCalledTimes(callsBeforeRefresh + 1))
+ expect(get).toHaveBeenLastCalledWith(secondNoteId)
+ })
+
+ it.each(['return', 'select'] as const)('does not let a delayed refresh undo a newer %s action', async (action) => {
+ render( )
+ await openNote()
+ let finish!: (snapshot: MagicNotesSnapshot) => void
+ list.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve }))
+ act(() => changeListener?.())
+ await waitFor(() => expect(list).toHaveBeenCalledTimes(2))
+ back()
+ if (action === 'select') await openNote(second.title)
+ await act(async () => finish({ notes: [detail, second] }))
+ if (action === 'select') expect(screen.getByLabelText('笔记标题')).toHaveValue(second.title)
+ else {
+ expect(screen.getByRole('tab', { name: '笔记' })).toBeVisible()
+ expect(screen.queryByLabelText('笔记标题')).not.toBeInTheDocument()
+ }
+ })
+
+ it('keeps drafts after refresh failure and while typing during retry', async () => {
+ render( )
+ await openNote()
+ const editor = screen.getByTestId('magic-note-editor')
+ fireEvent.click(editor)
+ list.mockRejectedValueOnce(new Error('refresh failed'))
+ act(() => changeListener?.())
+ await screen.findByText(/refresh failed/)
+ let finish!: (value: MagicNoteDetail) => void
+ get.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve }))
+ fireEvent.click(screen.getByRole('button', { name: '重试' }))
+ await waitFor(() => expect(get).toHaveBeenCalledTimes(2))
+ fireEvent.change(screen.getByLabelText('笔记标题'), { target: { value: 'Typed during refresh' } })
+ await act(async () => finish({ ...detail, title: 'External title' }))
+ expect(screen.getByLabelText('笔记标题')).toHaveValue('Typed during refresh')
+ expect(screen.getByTestId('magic-note-editor')).toBe(editor)
+ })
+
+ it('defers refresh until an active save completes and prevents leaving mid-save', async () => {
+ render( )
+ await openNote()
+ let finish!: (value: MagicNoteDetail) => void
+ createEntry.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve }))
+ fireEvent.click(screen.getByTestId('magic-note-editor'))
+ fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
+ expect(screen.getByRole('button', { name: '返回总览' })).toBeDisabled()
+ vi.useFakeTimers()
+ act(() => changeListener?.())
+ await act(() => vi.advanceTimersByTimeAsync(300))
+ expect(list).toHaveBeenCalledOnce()
+ await act(async () => finish(detail))
+ await act(() => vi.advanceTimersByTimeAsync(100))
+ expect(list).toHaveBeenCalledTimes(2)
})
it('unsubscribes and cancels queued refreshes on unmount', async () => {
const { unmount } = render( )
- await screen.findByDisplayValue(detail.title)
+ await screen.findByText(detail.title)
vi.useFakeTimers()
act(() => changeListener?.())
unmount()
@@ -611,1185 +943,4 @@ describe('MagicNotesWorkspace', () => {
expect(unsubscribeChanges).toHaveBeenCalledOnce()
expect(list).toHaveBeenCalledOnce()
})
-
- it('does not let an in-flight external refresh undo a newer note selection', async () => {
- const second = alternateDetail(secondNoteId, 'Second note')
- list.mockResolvedValue({ notes: [detail, second] })
- get.mockImplementation(async (id) => id === secondNoteId ? second : detail)
- render( )
- await screen.findByDisplayValue(detail.title)
- let resolveList!: (snapshot: MagicNotesSnapshot) => void
- list.mockImplementationOnce(() => new Promise((resolve) => { resolveList = resolve }))
- act(() => changeListener?.())
- await waitFor(() => expect(list).toHaveBeenCalledTimes(2))
- fireEvent.click(screen.getByRole('button', { name: /Second note/, pressed: false }))
- await screen.findByDisplayValue(second.title)
- await act(async () => resolveList({ notes: [detail, second] }))
- expect(screen.getByDisplayValue(second.title)).toBeInTheDocument()
- })
-
- it('keeps drafts after an event refresh fails and on retry while the user keeps typing', async () => {
- render( )
- const title = await screen.findByDisplayValue(detail.title)
- const composer = screen.getByTestId('magic-note-editor')
- fireEvent.click(composer)
- list.mockRejectedValueOnce(new Error('External refresh failed'))
- act(() => changeListener?.())
- await screen.findByText(/External refresh failed/)
- expect(screen.getByTestId('magic-note-editor')).toBe(composer)
- let resolveDetail!: (note: MagicNoteDetail) => void
- get.mockImplementationOnce(() => new Promise((resolve) => { resolveDetail = resolve }))
- fireEvent.click(screen.getByRole('button', { name: '重试' }))
- await waitFor(() => expect(get).toHaveBeenCalledTimes(2))
- fireEvent.change(title, { target: { value: 'Typed during refresh' } })
- await act(async () => resolveDetail({ ...detail, title: 'External title', revision: 2 }))
- expect(title).toHaveValue('Typed during refresh')
- expect(screen.getByTestId('magic-note-editor')).toBe(composer)
- expect(screen.queryByText(/External refresh failed/)).not.toBeInTheDocument()
- })
-
- it('defers event refreshes until an active save completes without losing the event', async () => {
- render( )
- await screen.findByDisplayValue(detail.title)
- let resolveSave!: (note: MagicNoteDetail) => void
- createEntry.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
- fireEvent.click(screen.getByTestId('magic-note-editor'))
- fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
- await waitFor(() => expect(createEntry).toHaveBeenCalledOnce())
- vi.useFakeTimers()
- act(() => changeListener?.())
- await act(() => vi.advanceTimersByTimeAsync(300))
- expect(list).toHaveBeenCalledOnce()
- await act(async () => resolveSave(detail))
- await act(() => vi.advanceTimersByTimeAsync(100))
- expect(list).toHaveBeenCalledTimes(2)
- })
-
- it('shows a retryable EmptyState when the initial load fails', async () => {
- get.mockRejectedValueOnce(new Error('详情暂时不可用'))
-
- render(
-
- )
-
- expect(
- await screen.findByText('魔法笔记加载失败')
- ).toBeInTheDocument()
- expect(screen.getByText(/详情暂时不可用/)).toBeInTheDocument()
- expect(screen.queryByText('还没有笔记')).not.toBeInTheDocument()
- expect(screen.queryByText('还没有待办')).not.toBeInTheDocument()
- expect(screen.queryByText('还没有选择笔记')).not.toBeInTheDocument()
- expect(onNotify).not.toHaveBeenCalled()
-
- fireEvent.click(screen.getByRole('button', { name: '重试' }))
-
- expect(await screen.findByText('记录正文')).toBeInTheDocument()
- expect(screen.queryByText('魔法笔记加载失败')).not.toBeInTheDocument()
- })
-
- it('keeps successful data and selection when a refresh fails', async () => {
- render(
-
- )
-
- await screen.findByText('记录正文')
- list.mockRejectedValueOnce(new Error('刷新暂时不可用'))
- fireEvent.click(screen.getByRole('button', { name: `更多笔记操作 ${detail.title}` }))
- fireEvent.click(screen.getByRole('menuitem', { name: '删除笔记' }))
- fireEvent.click(screen.getByRole('button', { name: '删除笔记' }))
-
- expect(
- await screen.findByText(/刷新失败,已保留当前内容:刷新暂时不可用/)
- ).toBeInTheDocument()
- expect(screen.getByLabelText('笔记标题')).toHaveValue(detail.title)
- expect(
- screen.getByRole('button', { name: /发布笔记/, pressed: true })
- ).toHaveAttribute('aria-pressed', 'true')
- const updatedTime = screen
- .getByRole('button', { name: /发布笔记/, pressed: true })
- .querySelector('time')
- expect(updatedTime).toHaveAttribute('datetime', detail.updatedAt)
- expect(updatedTime).toHaveTextContent('更新于')
- fireEvent.click(screen.getByRole('tab', { name: '待办' }))
- expect(screen.getByText('准备演示')).toBeInTheDocument()
- const selectedTodoButton = screen
- .getAllByText('核对发布材料')
- .find((element) => element.tagName === 'STRONG')
- ?.closest('button')
- expect(
- selectedTodoButton
- ).toHaveAttribute('aria-pressed', 'true')
- expect(onNotify).not.toHaveBeenCalledWith(
- expect.objectContaining({
- tone: 'error',
- message: '刷新暂时不可用'
- })
- )
- })
-
- it('shows note-backed todos with the title above its source', async () => {
- render(
-
- )
-
- expect(await screen.findByText('先核对发布材料。')).toBeInTheDocument()
- expect(
- screen.queryByRole('button', { name: '创建待办' })
- ).not.toBeInTheDocument()
- expect(screen.getByText('全部笔记')).toBeInTheDocument()
-
- fireEvent.click(screen.getByRole('tab', { name: '待办' }))
- expect(
- screen.getByRole('tablist', { name: '魔法笔记内容' })
- ).toHaveClass('page-tabs--segmented')
- expect(await screen.findAllByText('核对发布材料')).toHaveLength(2)
- expect(screen.getByText('准备演示')).toBeInTheDocument()
- const todoTitle = screen.getByRole('heading', {
- name: '核对发布材料'
- })
- const todoSource = todoTitle.parentElement!.querySelector('span')!
- expect(
- todoTitle.compareDocumentPosition(todoSource) &
- Node.DOCUMENT_POSITION_FOLLOWING
- ).toBeTruthy()
-
- expect(
- screen.getAllByRole('button', {
- name: `标记为已完成:${noteTodo.title}`
- })
- ).toHaveLength(2)
- expect(
- screen.getByRole('button', { name: '打开原笔记修改' })
- ).toBeInTheDocument()
- const sourceEntry = screen.getByRole('region', {
- name: '对应的笔记记录'
- })
- const todoDetail = screen.getByRole('region', { name: '待办详情' })
- const aiComments = screen.getByRole('complementary', { name: 'AI 评论' })
- expect(todoDetail).toContainElement(sourceEntry)
- expect(aiComments).not.toContainElement(sourceEntry)
- expect(within(sourceEntry).getByText('发布笔记')).toBeInTheDocument()
- expect(within(sourceEntry).getByText('记录正文')).toBeInTheDocument()
- })
-
- it('keeps history editing contained and de-emphasizes note deletion', async () => {
- const { container } = render(
-
- )
-
- await screen.findByText('记录正文')
- expect(container.querySelector('.magic-note-detail-header button')).toBeNull()
- const deleteEntry = screen.getByRole('button', {
- name: '删除记录'
- })
- expect(deleteEntry).toHaveClass('danger-button', 'danger-button--quiet')
- expect(deleteEntry).toHaveTextContent('删除记录')
-
- fireEvent.click(screen.getByRole('button', { name: '编辑' }))
- await waitFor(() =>
- expect(
- container.querySelector(
- '.magic-note-entry__editor > [data-testid="magic-note-editor"]'
- )
- ).toBeInTheDocument()
- )
- expect(
- container.querySelector(
- '.magic-note-entry__editor-actions'
- )
- ).toBeInTheDocument()
-
- fireEvent.click(screen.getByRole('button', { name: '删除记录' }))
- expect(
- screen.getByText('删除这条记录?此操作不可撤销。')
- ).toBeInTheDocument()
- expect(
- container.querySelector('.magic-note-entry__editor')
- ).not.toBeInTheDocument()
-
- fireEvent.click(screen.getByRole('button', { name: '编辑' }))
- expect(
- screen.queryByText('删除这条记录?此操作不可撤销。')
- ).not.toBeInTheDocument()
- })
-
- it('can hide and restore the AI comments pane', async () => {
- render(
-
- )
-
- const pane = await screen.findByLabelText('AI 评论')
- expect(
- screen.queryByRole('group', { name: 'AI 评论形式' })
- ).not.toBeInTheDocument()
- expect(
- screen.getByRole('combobox', { name: 'AI 评论方向' })
- ).toBeInTheDocument()
- fireEvent.click(
- screen.getByRole('button', { name: '关闭 AI 评论面板' })
- )
- expect(pane).not.toBeVisible()
-
- fireEvent.click(
- screen.getByRole('button', { name: '显示 AI 评论' })
- )
- expect(pane).toBeVisible()
- })
-
- it('hides, restores, and remembers the left list pane', async () => {
- const firstRender = render(
-
- )
-
- const pane = await screen.findByLabelText('笔记列表')
- const hideButton = screen.getByRole('button', {
- name: '隐藏左侧列表'
- })
- expect(hideButton).toHaveAttribute('aria-expanded', 'true')
- expect(pane).toBeVisible()
-
- fireEvent.click(hideButton)
- expect(pane).not.toBeVisible()
- expect(
- screen.queryByRole('separator', {
- name: '调整笔记列表与编辑区宽度'
- })
- ).not.toBeInTheDocument()
- expect(
- JSON.parse(
- localStorage.getItem('goodbuddy.magic-notes-layout.v1') ?? '{}'
- )
- ).toMatchObject({ listPaneOpen: false })
-
- firstRender.unmount()
- render( )
- const restoredPane = await screen.findByLabelText('笔记列表')
- expect(restoredPane).not.toBeVisible()
-
- const showButton = screen.getByRole('button', {
- name: '显示左侧列表'
- })
- expect(showButton).toHaveAttribute('aria-expanded', 'false')
- fireEvent.click(showButton)
- expect(restoredPane).toBeVisible()
- expect(
- screen.getByRole('separator', {
- name: '调整笔记列表与编辑区宽度'
- })
- ).toBeInTheDocument()
- })
-
- it('resizes the left list pane with pointer and keyboard controls', async () => {
- const originalInnerWidth = window.innerWidth
- Object.defineProperty(window, 'innerWidth', {
- configurable: true,
- value: 1200
- })
- render( )
-
- try {
- await screen.findByText('记录正文')
- const separator = screen.getByRole('separator', {
- name: '调整笔记列表与编辑区宽度'
- })
- const layout =
- separator.closest('.magic-notes-layout') as HTMLElement
- const setPointerCapture = vi.fn()
- const releasePointerCapture = vi.fn()
- Object.defineProperties(separator, {
- setPointerCapture: { value: setPointerCapture },
- hasPointerCapture: { value: () => true },
- releasePointerCapture: { value: releasePointerCapture }
- })
-
- expect(separator).toHaveAttribute('aria-valuenow', '220')
- expect(separator).toHaveAttribute('aria-valuemin', '180')
- expect(separator).toHaveAttribute('aria-valuemax', '360')
-
- fireEvent.pointerDown(separator, {
- button: 0,
- clientX: 220,
- pointerId: 13
- })
- fireEvent.pointerMove(separator, {
- clientX: 350,
- pointerId: 13
- })
-
- expect(setPointerCapture).toHaveBeenCalledWith(13)
- expect(layout).toHaveClass('magic-notes-layout--resizing')
- expect(layout).toHaveClass('magic-notes-layout--list-resizing')
- expect(
- layout.style.getPropertyValue('--magic-notes-list-width')
- ).toBe('350px')
-
- fireEvent.pointerUp(separator, { pointerId: 13 })
- expect(releasePointerCapture).toHaveBeenCalledWith(13)
- expect(layout).not.toHaveClass('magic-notes-layout--resizing')
- expect(
- JSON.parse(
- localStorage.getItem(
- 'goodbuddy.magic-notes-layout.v1'
- ) ?? '{}'
- )
- ).toMatchObject({ listPaneWidth: 350 })
-
- fireEvent.keyDown(separator, { key: 'Home' })
- expect(
- layout.style.getPropertyValue('--magic-notes-list-width')
- ).toBe('180px')
-
- fireEvent.keyDown(separator, { key: 'ArrowRight' })
- expect(
- layout.style.getPropertyValue('--magic-notes-list-width')
- ).toBe('196px')
-
- fireEvent.keyDown(separator, { key: 'End' })
- expect(
- layout.style.getPropertyValue('--magic-notes-list-width')
- ).toBe('360px')
- expect(separator).toHaveAttribute('aria-valuenow', '360')
- } finally {
- Object.defineProperty(window, 'innerWidth', {
- configurable: true,
- value: originalInnerWidth
- })
- }
- })
-
- it('disables horizontal pane resizing in the narrow stacked layout', async () => {
- const originalInnerWidth = window.innerWidth
- Object.defineProperty(window, 'innerWidth', {
- configurable: true,
- value: 700
- })
- render( )
-
- try {
- await screen.findByText('记录正文')
- const listSeparator = screen.getByRole('separator', {
- name: '调整笔记列表与编辑区宽度'
- })
- expect(listSeparator).toHaveAttribute('aria-disabled', 'true')
- expect(listSeparator).toHaveAttribute('tabindex', '-1')
- const aiSeparator = screen.getByRole('separator', {
- name: '调整编辑区与 AI 评论宽度'
- })
- expect(aiSeparator).toHaveAttribute('aria-disabled', 'true')
- expect(aiSeparator).toHaveAttribute('tabindex', '-1')
- } finally {
- Object.defineProperty(window, 'innerWidth', {
- configurable: true,
- value: originalInnerWidth
- })
- }
- })
-
- it('shows a concise empty state before a note has AI comments', async () => {
- let resolveDetail:
- | ((value: MagicNoteDetail) => void)
- | undefined
- const delayedDetail = new Promise((resolve) => {
- resolveDetail = resolve
- })
- get.mockReturnValueOnce(delayedDetail)
-
- render( )
-
- const pane = await screen.findByLabelText('AI 评论')
- expect(within(pane).getByText('正在加载')).toBeInTheDocument()
- expect(within(pane).getByText('正在加载笔记…')).toBeInTheDocument()
- expect(
- within(pane).queryByText('暂无 AI 评论')
- ).not.toBeInTheDocument()
-
- await act(async () => {
- resolveDetail?.(alternateDetail(noteId, detail.title))
- await delayedDetail
- })
-
- expect(
- await within(pane).findByText(
- '写完一句并停止输入 5 秒后,评论会显示在这里。'
- )
- ).toBeInTheDocument()
- expect(within(pane).getByText('写下一句话')).toBeInTheDocument()
- expect(
- within(pane).queryByText('暂无 AI 评论')
- ).not.toBeInTheDocument()
- expect(within(pane).queryByText('正在加载')).not.toBeInTheDocument()
- })
-
- it('resizes the AI comments pane with pointer and keyboard controls', async () => {
- const originalInnerWidth = window.innerWidth
- Object.defineProperty(window, 'innerWidth', {
- configurable: true,
- value: 1200
- })
- render( )
-
- await screen.findByText('记录正文')
- const separator = screen.getByRole('separator', {
- name: '调整编辑区与 AI 评论宽度'
- })
- const layout = separator.closest('.magic-notes-layout') as HTMLElement
- const setPointerCapture = vi.fn()
- const releasePointerCapture = vi.fn()
- Object.defineProperties(separator, {
- setPointerCapture: { value: setPointerCapture },
- hasPointerCapture: { value: () => true },
- releasePointerCapture: { value: releasePointerCapture }
- })
-
- expect(separator).toHaveAttribute('aria-valuenow', '280')
-
- fireEvent.pointerDown(separator, {
- button: 0,
- clientX: 800,
- pointerId: 12
- })
- fireEvent.pointerMove(separator, {
- clientX: 600,
- pointerId: 12
- })
-
- expect(setPointerCapture).toHaveBeenCalledWith(12)
- expect(layout).toHaveClass('magic-notes-layout--resizing')
- expect(
- layout.style.getPropertyValue('--magic-notes-ai-width')
- ).toBe('520px')
-
- fireEvent.pointerUp(separator, { pointerId: 12 })
- expect(releasePointerCapture).toHaveBeenCalledWith(12)
- expect(layout).not.toHaveClass('magic-notes-layout--resizing')
-
- fireEvent.keyDown(separator, { key: 'Home' })
- expect(
- layout.style.getPropertyValue('--magic-notes-ai-width')
- ).toBe('240px')
- expect(separator).toHaveAttribute('aria-valuenow', '240')
-
- fireEvent.keyDown(separator, { key: 'ArrowLeft' })
- expect(
- layout.style.getPropertyValue('--magic-notes-ai-width')
- ).toBe('256px')
-
- fireEvent.keyDown(separator, { key: 'End' })
- expect(
- layout.style.getPropertyValue('--magic-notes-ai-width')
- ).toBe('520px')
-
- Object.defineProperty(window, 'innerWidth', {
- configurable: true,
- value: originalInnerWidth
- })
- })
-
- it('keeps the selected note aligned with the latest detail request', async () => {
- const second = alternateDetail(secondNoteId, '第二篇笔记')
- const third = alternateDetail(thirdNoteId, '第三篇笔记')
- list.mockResolvedValue({
- notes: [
- summaryFromDetail(detail),
- summaryFromDetail(second),
- summaryFromDetail(third)
- ]
- })
- let resolveSecond: (value: MagicNoteDetail) => void = () => undefined
- const delayedSecond = new Promise((resolve) => {
- resolveSecond = resolve
- })
- get.mockImplementation((requestedId) => {
- if (requestedId === second.id) {
- return delayedSecond
- }
- return Promise.resolve(requestedId === third.id ? third : detail)
- })
-
- render(
-
- )
-
- await screen.findByText('记录正文')
- fireEvent.click(screen.getByText(second.title).closest('button')!)
- fireEvent.click(screen.getByText(third.title).closest('button')!)
- expect(await screen.findByDisplayValue(third.title)).toBeInTheDocument()
-
- resolveSecond(second)
- await waitFor(() =>
- expect(screen.getByLabelText('笔记标题')).toHaveValue(third.title)
- )
- })
-
- it('keeps a non-empty composer draft until note switching is confirmed', async () => {
- const second = alternateDetail(secondNoteId, '第二篇笔记')
- list.mockResolvedValue({
- notes: [summaryFromDetail(detail), summaryFromDetail(second)]
- })
- get.mockImplementation((requestedId) =>
- Promise.resolve(requestedId === second.id ? second : detail)
- )
-
- render( )
-
- await screen.findByText('记录正文')
- fireEvent.click(screen.getByText('模拟输入并回车'))
- const callsBeforeSwitch = get.mock.calls.length
- fireEvent.click(screen.getByText(second.title).closest('button')!)
-
- const confirmation = screen.getByRole('alertdialog', {
- name: '放弃当前未保存草稿?'
- })
- const continueEditing = screen.getByRole('button', {
- name: '继续编辑'
- })
- const discardAndSwitch = screen.getByRole('button', {
- name: '放弃草稿并切换'
- })
- expect(confirmation).toHaveAccessibleDescription(
- '切换后,当前记录草稿中的文字和附件将被丢弃。'
- )
- expect(continueEditing).toHaveFocus()
- expect(get).toHaveBeenCalledTimes(callsBeforeSwitch)
- expect(
- screen.getByRole('button', { name: /发布笔记/, pressed: true })
- ).toHaveAttribute('aria-pressed', 'true')
-
- discardAndSwitch.focus()
- fireEvent.keyDown(discardAndSwitch, { key: 'Tab' })
- expect(continueEditing).toHaveFocus()
- fireEvent.keyDown(continueEditing, { key: 'Tab', shiftKey: true })
- expect(discardAndSwitch).toHaveFocus()
- fireEvent.keyDown(confirmation, { key: 'Escape' })
-
- expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
- await waitFor(() =>
- expect(screen.getByTestId('magic-note-editor')).toHaveFocus()
- )
- fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
- await waitFor(() =>
- expect(createEntry).toHaveBeenCalledWith({
- noteId,
- content: {
- version: 1,
- ops: [{ insert: '新的句子\n' }]
- }
- })
- )
-
- fireEvent.click(screen.getByText('模拟输入并回车'))
- fireEvent.click(screen.getByText(second.title).closest('button')!)
- fireEvent.click(
- screen.getByRole('button', { name: '放弃草稿并切换' })
- )
-
- expect(await screen.findByDisplayValue(second.title)).toBeInTheDocument()
- expect(
- screen.getByRole('button', { name: /第二篇笔记/, pressed: true })
- ).toHaveAttribute('aria-pressed', 'true')
- })
-
- it('guards dirty existing-entry edits and keeps the first pending target', async () => {
- const second = alternateDetail(secondNoteId, '第二篇笔记')
- const third = alternateDetail(thirdNoteId, '第三篇笔记')
- list.mockResolvedValue({
- notes: [
- summaryFromDetail(detail),
- summaryFromDetail(second),
- summaryFromDetail(third)
- ]
- })
- get.mockImplementation((requestedId) =>
- Promise.resolve(
- requestedId === second.id
- ? second
- : requestedId === third.id
- ? third
- : detail
- )
- )
- const { container } = render(
-
- )
-
- await screen.findByText('记录正文')
- fireEvent.click(screen.getByRole('button', { name: '编辑' }))
- const editEditor = container.querySelector(
- '.magic-note-entry__editor [data-testid="magic-note-editor"]'
- )!
- fireEvent.click(editEditor)
- const secondButton = screen.getByText(second.title).closest('button')!
- const thirdButton = screen.getByText(third.title).closest('button')!
- fireEvent.click(secondButton)
-
- const dialog = screen.getByRole('alertdialog', {
- name: '放弃当前未保存草稿?'
- })
- expect(dialog).toHaveAttribute('aria-modal', 'true')
- expect(
- container.querySelector('.magic-notes-list-pane')?.inert
- ).toBe(true)
- fireEvent.click(thirdButton)
- fireEvent.keyDown(dialog, { key: 'Escape' })
-
- expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
- expect(
- container.querySelector('.magic-notes-list-pane')?.inert
- ).toBe(false)
- await waitFor(() => expect(editEditor).toHaveFocus())
-
- fireEvent.click(secondButton)
- fireEvent.click(
- screen.getByRole('button', { name: '放弃草稿并切换' })
- )
-
- expect(await screen.findByDisplayValue(second.title)).toBeInTheDocument()
- await waitFor(() => expect(secondButton).toHaveFocus())
- expect(get).not.toHaveBeenCalledWith(third.id)
- })
-
- it('guards keyboard tab selection and switches only after discard', async () => {
- render( )
-
- await screen.findByText('记录正文')
- fireEvent.click(screen.getByText('模拟输入并回车'))
- const notesTab = screen.getByRole('tab', { name: '笔记' })
- const todosTab = screen.getByRole('tab', { name: '待办' })
- notesTab.focus()
- fireEvent.keyDown(notesTab, { key: 'ArrowRight' })
-
- expect(todosTab).toHaveAttribute('aria-selected', 'false')
- expect(notesTab).toHaveAttribute('aria-selected', 'true')
- expect(
- screen.getByRole('alertdialog', {
- name: '放弃当前未保存草稿?'
- })
- ).toBeInTheDocument()
-
- fireEvent.click(
- screen.getByRole('button', { name: '放弃草稿并切换' })
- )
-
- expect(todosTab).toHaveAttribute('aria-selected', 'true')
- expect(screen.getByText('准备演示')).toBeInTheDocument()
- expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
- })
-
- it('does not create and select another note before the draft is discarded', async () => {
- const { container } = render(
-
- )
-
- await screen.findByText('记录正文')
- fireEvent.click(screen.getByText('模拟输入并回车'))
- fireEvent.click(screen.getByRole('button', { name: '新建笔记' }))
- fireEvent.change(
- container.querySelector(
- '.magic-notes-create input'
- )!,
- {
- target: { value: '新笔记' }
- }
- )
- fireEvent.click(screen.getByRole('button', { name: '创建笔记' }))
-
- expect(create).not.toHaveBeenCalled()
- expect(
- screen.getByRole('button', { name: /发布笔记/, pressed: true })
- ).toHaveAttribute('aria-pressed', 'true')
-
- fireEvent.click(
- screen.getByRole('button', { name: '放弃草稿并切换' })
- )
-
- await waitFor(() =>
- expect(create).toHaveBeenCalledWith({ title: '新笔记' })
- )
- expect(await screen.findByDisplayValue('新笔记')).toBeInTheDocument()
- })
-
- it('preserves the composer draft across save errors and unrelated rerenders', async () => {
- createEntry.mockRejectedValueOnce(new Error('暂时无法保存'))
- render( )
-
- await screen.findByText('记录正文')
- fireEvent.click(screen.getByText('模拟输入并回车'))
- await i18n.changeLanguage('en-US')
- fireEvent.click(screen.getByRole('button', { name: 'Save entry' }))
-
- await waitFor(() =>
- expect(onNotify).toHaveBeenCalledWith(
- expect.objectContaining({
- tone: 'error',
- message: '暂时无法保存'
- })
- )
- )
- fireEvent.click(screen.getByRole('button', { name: 'Save entry' }))
-
- await waitFor(() => expect(createEntry).toHaveBeenCalledTimes(2))
- expect(createEntry).toHaveBeenNthCalledWith(1, {
- noteId,
- content: {
- version: 1,
- ops: [{ insert: '新的句子\n' }]
- }
- })
- expect(createEntry).toHaveBeenNthCalledWith(2, {
- noteId,
- content: {
- version: 1,
- ops: [{ insert: '新的句子\n' }]
- }
- })
- await i18n.changeLanguage('zh-CN')
- })
-
- it('finalizes a created entry before a to-do refresh failure', async () => {
- render( )
-
- await screen.findByText('记录正文')
- listTodos.mockRejectedValueOnce(new Error('待办刷新失败'))
- fireEvent.click(screen.getByText('模拟输入并回车'))
- fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
-
- await waitFor(() => expect(createEntry).toHaveBeenCalledOnce())
- expect(onNotify).toHaveBeenCalledWith(
- expect.objectContaining({
- tone: 'success',
- message: '记录已保存'
- })
- )
- expect(onNotify).toHaveBeenCalledWith(
- expect.objectContaining({
- tone: 'error',
- message: '待办刷新失败'
- })
- )
- fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
-
- expect(createEntry).toHaveBeenCalledOnce()
- expect(screen.getByText('请先输入记录内容')).toBeInTheDocument()
- })
-
- it('finalizes an edited entry before a to-do refresh failure', async () => {
- const { container } = render(
-
- )
-
- await screen.findByText('记录正文')
- listTodos.mockRejectedValueOnce(new Error('待办刷新失败'))
- fireEvent.click(screen.getByRole('button', { name: '编辑' }))
- fireEvent.click(
- container.querySelector(
- '.magic-note-entry__editor [data-testid="magic-note-editor"]'
- )!
- )
- fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
-
- await waitFor(() => expect(updateEntry).toHaveBeenCalledOnce())
- expect(updateEntry).toHaveBeenCalledWith({
- entryId,
- content: {
- version: 1,
- ops: [{ insert: '新的句子\n' }]
- },
- expectedRevision: detail.entries[0]!.revision
- })
- expect(
- screen.queryByRole('button', { name: '保存修改' })
- ).not.toBeInTheDocument()
- expect(onNotify).toHaveBeenCalledWith(
- expect.objectContaining({
- tone: 'success',
- message: '记录已更新,原 AI 评论已清除'
- })
- )
- expect(onNotify).toHaveBeenCalledWith(
- expect.objectContaining({
- tone: 'error',
- message: '待办刷新失败'
- })
- )
- })
-
- it('does not override a note selected while retrying a refresh', async () => {
- const second = alternateDetail(secondNoteId, '第二篇笔记')
- list.mockResolvedValue({
- notes: [summaryFromDetail(detail), summaryFromDetail(second)]
- })
-
- render(
-
- )
-
- await screen.findByText('记录正文')
- list.mockRejectedValueOnce(new Error('刷新暂时不可用'))
- fireEvent.click(screen.getByRole('button', { name: `更多笔记操作 ${detail.title}` }))
- fireEvent.click(screen.getByRole('menuitem', { name: '删除笔记' }))
- fireEvent.click(screen.getByRole('button', { name: '删除笔记' }))
- const retry = await screen.findByRole('button', { name: '重试' })
-
- let resolveRefreshDetail:
- | ((value: MagicNoteDetail) => void)
- | undefined
- const delayedRefreshDetail = new Promise(
- (resolve) => {
- resolveRefreshDetail = resolve
- }
- )
- get.mockImplementation((requestedId) =>
- requestedId === second.id
- ? Promise.resolve(second)
- : delayedRefreshDetail
- )
-
- fireEvent.click(retry)
- await waitFor(() => expect(get).toHaveBeenCalledTimes(2))
- fireEvent.click(screen.getByText(second.title).closest('button')!)
- expect(await screen.findByDisplayValue(second.title)).toBeInTheDocument()
-
- resolveRefreshDetail?.(detail)
- await waitFor(() =>
- expect(screen.getByLabelText('笔记标题')).toHaveValue(second.title)
- )
- })
-
- it('only shows note-backed todos in a directory view', async () => {
- render(
-
- )
-
- await screen.findByText('记录正文')
- fireEvent.click(screen.getByRole('tab', { name: '待办' }))
- expect(
- screen.queryByRole('button', { name: '新建待办' })
- ).not.toBeInTheDocument()
- expect(
- screen.queryByRole('group', { name: '待办列表方式' })
- ).not.toBeInTheDocument()
- expect(screen.getAllByText('发布笔记')).not.toHaveLength(0)
- expect(screen.getByText('演示笔记')).toBeInTheDocument()
- expect(screen.getByText('准备演示')).toBeInTheDocument()
- })
-
- it('synchronizes todo completion with its source note immediately', async () => {
- render( )
-
- await screen.findByText('记录正文')
- fireEvent.click(screen.getByRole('tab', { name: '待办' }))
- fireEvent.click(
- screen.getAllByRole('button', {
- name: `标记为已完成:${noteTodo.title}`
- })[0]!
- )
-
- await waitFor(() =>
- expect(updateTodo).toHaveBeenCalledWith({
- todoId: noteTodo.id,
- completed: true,
- expectedRevision: noteTodo.revision
- })
- )
- expect(
- screen.getByRole('button', {
- name: `标记为未完成:${noteTodo.title}`
- })
- ).toHaveAttribute('aria-pressed', 'true')
- expect(onNotify).toHaveBeenCalledWith(
- expect.objectContaining({
- tone: 'success',
- message: '待办已完成'
- })
- )
- expect(
- within(
- screen.getByRole('region', { name: '对应的笔记记录' })
- ).getByTestId('magic-note-checklist-state')
- ).toHaveTextContent('checked')
-
- fireEvent.click(screen.getByRole('tab', { name: '笔记' }))
- expect(
- screen.getByTestId('magic-note-checklist-state')
- ).toHaveTextContent('checked')
-
- fireEvent.click(screen.getByRole('tab', { name: '待办' }))
- fireEvent.click(
- screen.getByRole('button', {
- name: `标记为未完成:${noteTodo.title}`
- })
- )
- await waitFor(() =>
- expect(updateTodo).toHaveBeenNthCalledWith(2, {
- todoId: noteTodo.id,
- completed: false,
- expectedRevision: noteTodo.revision + 1
- })
- )
-
- fireEvent.click(screen.getByRole('tab', { name: '笔记' }))
- expect(
- screen.getByTestId('magic-note-checklist-state')
- ).toHaveTextContent('unchecked')
- })
-
- it('reuses the AI comments pane for selected todos', async () => {
- getApplicationSettings.mockResolvedValue({
- checkUpdatesOnStartup: false,
- updateSource: 'github',
- modelDownloadSource: 'modelscope',
- localToolEnvironment: defaultLocalToolEnvironmentSettings,
- applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
- conversationHtmlRenderingEnabled: true,
- remoteProjectsEnabled: false,
- magicNotesEnabled: true,
- magicNotesShowIncompleteTodoCount: true,
- magicNoteCommentMode: 'after-save-manual',
- magicNoteCommentFormat: 'combined'
- })
- render(
-
- )
-
- await screen.findByText('记录正文')
- fireEvent.click(screen.getByRole('tab', { name: '待办' }))
- fireEvent.click(screen.getByRole('button', { name: 'AI 分析' }))
-
- await waitFor(() =>
- expect(analyzeTodo).toHaveBeenCalledWith(
- noteTodo.id,
- expect.objectContaining({
- requestId: expect.any(String),
- direction: 'general',
- format: 'combined'
- })
- )
- )
- expect(
- await screen.findByText('先补充明确的验收条件。')
- ).toBeInTheDocument()
- })
-
- it('streams with snapshotted sidebar options while later changes stay local', async () => {
- getApplicationSettings.mockResolvedValue({
- checkUpdatesOnStartup: false,
- updateSource: 'github',
- modelDownloadSource: 'modelscope',
- localToolEnvironment: defaultLocalToolEnvironmentSettings,
- applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
- conversationHtmlRenderingEnabled: true,
- remoteProjectsEnabled: false,
- magicNotesEnabled: true,
- magicNotesShowIncompleteTodoCount: true,
- magicNoteCommentMode: 'after-save-manual',
- magicNoteCommentFormat: 'narrative'
- })
- let finishAnalysis: (() => void) | undefined
- analyzeTodo.mockImplementationOnce(
- (_todoId, options) =>
- new Promise((resolve) => {
- analysisEventListener?.({
- requestId: options.requestId,
- type: 'text',
- delta: '正在扩展这一段内容。',
- direction: 'expand',
- format: 'narrative'
- })
- finishAnalysis = () =>
- resolve({
- ...noteTodo,
- comments: [
- {
- id: '00000000-0000-4000-8000-000000000620',
- kind: 'narrative',
- content: '扩展后的完整评论。',
- direction: 'expand',
- format: 'narrative'
- }
- ],
- analyzedAt: '2026-08-01T00:07:00.000Z',
- revision: 2
- })
- })
- )
-
- render( )
-
- await screen.findByText('记录正文')
- fireEvent.change(
- screen.getByRole('combobox', { name: 'AI 评论方向' }),
- { target: { value: 'expand' } }
- )
- fireEvent.click(screen.getByRole('tab', { name: '待办' }))
- fireEvent.click(screen.getByRole('button', { name: 'AI 分析' }))
-
- expect(
- await screen.findByText('正在扩展这一段内容。')
- ).toBeInTheDocument()
- expect(screen.getByText(/正在生成 ·/)).toHaveTextContent(
- '正在生成 · 扩展写作'
- )
- fireEvent.change(
- screen.getByRole('combobox', { name: 'AI 评论方向' }),
- { target: { value: 'polish' } }
- )
- expect(screen.getByText(/正在生成 ·/)).toHaveTextContent(
- '正在生成 · 扩展写作'
- )
-
- await act(async () => finishAnalysis?.())
-
- expect(await screen.findByText('扩展后的完整评论。')).toBeInTheDocument()
- expect(
- screen
- .getAllByText('扩展写作')
- .some((element) =>
- element.classList.contains('magic-note-comment__direction')
- )
- ).toBe(true)
- expect(
- screen.getByRole('combobox', { name: 'AI 评论方向' })
- ).toHaveValue('polish')
- expect(analyzeTodo).toHaveBeenCalledWith(
- noteTodo.id,
- expect.objectContaining({
- direction: 'expand',
- format: 'narrative'
- })
- )
- })
-
- it('clears note searches and todo status filters with no results', async () => {
- listTodos.mockResolvedValue({
- todos: [
- { ...noteTodo, completed: true },
- { ...manualTodo, completed: true }
- ]
- })
- render(
-
- )
-
- await screen.findByText('记录正文')
- fireEvent.change(screen.getByRole('searchbox', { name: '搜索当前范围的笔记' }), {
- target: { value: '不存在的笔记' }
- })
- expect(screen.getByText('没有符合条件的笔记')).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
- expect(screen.getByRole('searchbox', {
- name: '搜索当前范围的笔记'
- })).toHaveValue('')
- expect(screen.getByText('发布笔记')).toBeInTheDocument()
-
- fireEvent.click(screen.getByRole('tab', { name: '待办' }))
- expect(screen.getByText('没有符合条件的待办')).toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
- expect(
- screen.getByRole('button', { name: '全部' })
- ).toHaveAttribute('aria-pressed', 'true')
- expect(screen.getAllByText('核对发布材料')).not.toHaveLength(0)
- })
-
- it('automatically comments on a newly saved record in auto mode', async () => {
- getApplicationSettings.mockResolvedValue({
- checkUpdatesOnStartup: false,
- updateSource: 'github',
- modelDownloadSource: 'modelscope',
- localToolEnvironment: defaultLocalToolEnvironmentSettings,
- applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
- conversationHtmlRenderingEnabled: true,
- remoteProjectsEnabled: false,
- magicNotesEnabled: true,
- magicNotesShowIncompleteTodoCount: true,
- magicNoteCommentMode: 'after-save-auto',
- magicNoteCommentFormat: 'combined'
- })
- render( )
-
- await screen.findByText('记录正文')
- fireEvent.click(screen.getByText('模拟输入并回车'))
- fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
-
- await waitFor(() =>
- expect(createEntry).toHaveBeenCalledWith({
- noteId,
- content: {
- version: 1,
- ops: [{ insert: '新的句子\n' }]
- }
- })
- )
- await waitFor(() =>
- expect(analyze).toHaveBeenCalledWith(
- createdEntryId,
- expect.objectContaining({
- requestId: expect.any(String),
- direction: 'general',
- format: 'combined'
- })
- )
- )
- expect(
- await screen.findByText('保存后的自动评论。')
- ).toBeInTheDocument()
- })
-
- it('comments on an unsaved draft five seconds after Enter', async () => {
- render(
-
- )
-
- await screen.findByText('记录正文')
- vi.useFakeTimers()
- fireEvent.click(screen.getByText('模拟输入并回车'))
- await act(async () => {
- await vi.advanceTimersByTimeAsync(4_999)
- })
- expect(analyzeDraft).not.toHaveBeenCalled()
- await act(async () => {
- await vi.advanceTimersByTimeAsync(1)
- })
- expect(analyzeDraft).toHaveBeenCalledWith(
- {
- version: 1,
- ops: [{ insert: '新的句子\n' }]
- },
- expect.objectContaining({
- requestId: expect.any(String),
- direction: 'general',
- format: 'combined'
- })
- )
- expect(screen.getByText('这是最新的草稿评论。')).toBeInTheDocument()
- vi.useRealTimers()
- })
-
- it('switches to English without reloading notes', async () => {
- await i18n.changeLanguage('zh-CN')
- render( )
- await screen.findByRole('heading', { name: '魔法笔记' })
- expect(list).toHaveBeenCalledOnce()
-
- try {
- await i18n.changeLanguage('en-US')
-
- expect(
- await screen.findByRole('heading', { name: 'Magic Notes' })
- ).toBeInTheDocument()
- expect(
- screen.getByRole('tab', { name: 'Notes' })
- ).toHaveAttribute('aria-selected', 'true')
- expect(
- screen.getByRole('button', { name: 'New note' })
- ).toBeInTheDocument()
- expect(
- screen.getByRole('combobox', { name: 'AI comment direction' })
- ).toHaveValue('general')
- expect(screen.getByText(detail.title)).toBeInTheDocument()
- expect(screen.getByText('先核对发布材料。')).toBeInTheDocument()
- expect(list).toHaveBeenCalledOnce()
- } finally {
- await i18n.changeLanguage('zh-CN')
- }
- })
})
diff --git a/src/renderer/src/MagicNotesWorkspace.tsx b/src/renderer/src/MagicNotesWorkspace.tsx
index a639309e..054d245a 100644
--- a/src/renderer/src/MagicNotesWorkspace.tsx
+++ b/src/renderer/src/MagicNotesWorkspace.tsx
@@ -1,7 +1,9 @@
import {
+ ArrowLeft,
Bot,
BookOpen,
CheckCircle2,
+ ChevronRight,
CircleAlert,
Circle,
FileText,
@@ -9,8 +11,6 @@ import {
Lightbulb,
ListTodo,
MoreHorizontal,
- PanelLeftClose,
- PanelLeftOpen,
PanelRightClose,
PanelRightOpen,
Pin,
@@ -71,18 +71,15 @@ type ValidationTarget =
| 'new-entry'
| 'edit-entry'
type DraftSwitchTarget =
+ | { kind: 'overview' }
| { kind: 'library-view'; value: LibraryView }
| { kind: 'create-note'; title: string }
| { kind: 'edit-entry'; entry: MagicNoteEntry }
| { kind: 'note'; noteId: string; entryId?: string }
- | { kind: 'todo'; todoId: string }
const defaultAiPaneWidth = 280
const minimumAiPaneWidth = 240
const maximumAiPaneWidth = 520
-const defaultMagicNotesListPaneWidth = 220
-const minimumMagicNotesListPaneWidth = 180
-const maximumMagicNotesListPaneWidth = 360
const minimumMagicNotesEditorWidth = 300
const magicNotesResizeHandleWidth = 9
const magicNotesPaneKeyboardResizeStep = 16
@@ -90,16 +87,12 @@ const magicNotesLayoutStorageKey =
'goodbuddy.magic-notes-layout.v1'
type MagicNotesLayoutPreferences = {
- listPaneOpen: boolean
- listPaneWidth: number
aiPaneOpen: boolean
aiPaneWidth: number
}
function loadMagicNotesLayoutPreferences(): MagicNotesLayoutPreferences {
const defaults = {
- listPaneOpen: true,
- listPaneWidth: defaultMagicNotesListPaneWidth,
aiPaneOpen: true,
aiPaneWidth: defaultAiPaneWidth
}
@@ -112,18 +105,6 @@ function loadMagicNotesLayoutPreferences(): MagicNotesLayoutPreferences {
MagicNotesLayoutPreferences
>
return {
- listPaneOpen: parsed.listPaneOpen !== false,
- listPaneWidth:
- typeof parsed.listPaneWidth === 'number' &&
- Number.isFinite(parsed.listPaneWidth)
- ? Math.min(
- maximumMagicNotesListPaneWidth,
- Math.max(
- minimumMagicNotesListPaneWidth,
- parsed.listPaneWidth
- )
- )
- : defaults.listPaneWidth,
aiPaneOpen: parsed.aiPaneOpen !== false,
aiPaneWidth:
typeof parsed.aiPaneWidth === 'number' &&
@@ -157,30 +138,14 @@ type MagicNotesPaneWidthLimits = {
maximum: number
}
-function getMagicNotesPaneWidthLimits({
- adjacentPaneOpen,
- adjacentPaneWidth,
- layoutWidth,
- maximum,
- minimum
-}: {
- adjacentPaneOpen: boolean
- adjacentPaneWidth: number
- layoutWidth: number
- maximum: number
- minimum: number
-}): MagicNotesPaneWidthLimits {
- const reservedAdjacentWidth = adjacentPaneOpen
- ? adjacentPaneWidth + magicNotesResizeHandleWidth
- : 0
+function getAiPaneWidthLimits(layoutWidth: number): MagicNotesPaneWidthLimits {
return {
- minimum,
+ minimum: minimumAiPaneWidth,
maximum: Math.max(
- minimum,
+ minimumAiPaneWidth,
Math.min(
- maximum,
+ maximumAiPaneWidth,
layoutWidth -
- reservedAdjacentWidth -
minimumMagicNotesEditorWidth -
magicNotesResizeHandleWidth
)
@@ -197,63 +162,13 @@ function clampMagicNotesPaneWidth(
)
}
-function getAiPaneWidthLimits(
- layoutWidth: number,
- listPaneWidth: number,
- listPaneOpen: boolean
-): MagicNotesPaneWidthLimits {
- return getMagicNotesPaneWidthLimits({
- adjacentPaneOpen: listPaneOpen,
- adjacentPaneWidth: listPaneWidth,
- layoutWidth,
- maximum: maximumAiPaneWidth,
- minimum: minimumAiPaneWidth
- })
-}
-
function clampAiPaneWidth(
width: number,
- layoutWidth: number,
- listPaneWidth: number,
- listPaneOpen: boolean
+ layoutWidth: number
): number {
return clampMagicNotesPaneWidth(
width,
- getAiPaneWidthLimits(
- layoutWidth,
- listPaneWidth,
- listPaneOpen
- )
- )
-}
-
-function getListPaneWidthLimits(
- layoutWidth: number,
- aiPaneWidth: number,
- aiPaneOpen: boolean
-): MagicNotesPaneWidthLimits {
- return getMagicNotesPaneWidthLimits({
- adjacentPaneOpen: aiPaneOpen,
- adjacentPaneWidth: aiPaneWidth,
- layoutWidth,
- maximum: maximumMagicNotesListPaneWidth,
- minimum: minimumMagicNotesListPaneWidth
- })
-}
-
-function clampListPaneWidth(
- width: number,
- layoutWidth: number,
- aiPaneWidth: number,
- aiPaneOpen: boolean
-): number {
- return clampMagicNotesPaneWidth(
- width,
- getListPaneWidthLimits(
- layoutWidth,
- aiPaneWidth,
- aiPaneOpen
- )
+ getAiPaneWidthLimits(layoutWidth)
)
}
@@ -344,26 +259,22 @@ function AiComment({
function TodoListItem({
disabled,
+ expanded,
id,
onSelect,
onToggle,
- selected,
todo
}: {
disabled: boolean
+ expanded: boolean
id: string
onSelect: () => void
onToggle: () => void
- selected: boolean
todo: MagicTodoItem
}): React.JSX.Element {
const { t } = useTranslation('magicNotes')
return (
-
+
{todo.title}
{t('todos.sourceNote', { title: todo.noteTitle })}
+
)
@@ -426,9 +340,9 @@ export function MagicNotesWorkspace({
ReadonlyArray<{ value: TodoFilter; label: string }>
>(
() => [
+ { value: 'all', label: t('todos.filters.all') },
{ value: 'active', label: t('todos.filters.active') },
- { value: 'completed', label: t('todos.filters.completed') },
- { value: 'all', label: t('todos.filters.all') }
+ { value: 'completed', label: t('todos.filters.completed') }
],
[t]
)
@@ -479,6 +393,8 @@ export function MagicNotesWorkspace({
const [notes, setNotes] = useState
([])
const [todos, setTodos] = useState([])
const [libraryView, setLibraryView] = useState('notes')
+ const [detailView, setDetailView] = useState<'notes'>()
+ const overviewFocusRef = useRef('')
const [todoFilter, setTodoFilter] = useState('active')
const [loadedCommentMode, setCommentMode] =
useState('immediate')
@@ -493,6 +409,8 @@ export function MagicNotesWorkspace({
const [detail, setDetail] = useState()
const [todoSourceDetail, setTodoSourceDetail] =
useState()
+ const [todoSourceStatus, setTodoSourceStatus] = useState('loading')
+ const [todoSourceRetry, setTodoSourceRetry] = useState(0)
const [loadStatus, setLoadStatus] = useState('loading')
const [loadError, setLoadError] = useState('')
const [refreshError, setRefreshError] = useState('')
@@ -516,13 +434,6 @@ export function MagicNotesWorkspace({
() => loadMagicNotesLayoutPreferences(),
[]
)
- const [listPaneOpen, setListPaneOpen] = useState(
- initialLayoutPreferences.listPaneOpen
- )
- const [listPaneWidth, setListPaneWidth] = useState(
- initialLayoutPreferences.listPaneWidth
- )
- const [listPaneResizing, setListPaneResizing] = useState(false)
const [aiPaneOpen, setAiPaneOpen] = useState(
initialLayoutPreferences.aiPaneOpen
)
@@ -575,8 +486,6 @@ export function MagicNotesWorkspace({
const draftAnalysisContextRef = useRef(0)
const lastDraftAnalysisStartedAtRef = useRef(0)
const magicNotesLayoutRef = useRef(null)
- const liveListPaneWidthRef = useRef(listPaneWidth)
- const listResizePointerIdRef = useRef(undefined)
const liveAiPaneWidthRef = useRef(aiPaneWidth)
const aiResizePointerIdRef = useRef(undefined)
const composerRef = useRef(null)
@@ -629,9 +538,7 @@ export function MagicNotesWorkspace({
const bounds = getLayoutBounds()
const width = clampAiPaneWidth(
bounds.right - clientX,
- bounds.width,
- liveListPaneWidthRef.current,
- listPaneOpen
+ bounds.width
)
liveAiPaneWidthRef.current = width
if (commit) {
@@ -643,7 +550,7 @@ export function MagicNotesWorkspace({
`${width}px`
)
},
- [getLayoutBounds, listPaneOpen]
+ [getLayoutBounds]
)
const finishAiPaneResize = useCallback(
@@ -665,9 +572,7 @@ export function MagicNotesWorkspace({
(event: React.KeyboardEvent): void => {
const bounds = getLayoutBounds()
const limits = getAiPaneWidthLimits(
- bounds.width,
- listPaneWidth,
- listPaneOpen
+ bounds.width
)
const nextWidth =
event.key === 'Home'
@@ -685,85 +590,12 @@ export function MagicNotesWorkspace({
event.preventDefault()
const width = clampAiPaneWidth(
nextWidth,
- bounds.width,
- listPaneWidth,
- listPaneOpen
+ bounds.width
)
liveAiPaneWidthRef.current = width
setAiPaneWidth(width)
},
- [aiPaneWidth, getLayoutBounds, listPaneOpen, listPaneWidth]
- )
-
- const resizeListPaneFromClientX = useCallback(
- (clientX: number, commit: boolean): void => {
- const bounds = getLayoutBounds()
- const width = clampListPaneWidth(
- clientX - bounds.left,
- bounds.width,
- liveAiPaneWidthRef.current,
- aiPaneOpen
- )
- liveListPaneWidthRef.current = width
- if (commit) {
- setListPaneWidth(width)
- return
- }
- magicNotesLayoutRef.current?.style.setProperty(
- '--magic-notes-list-width',
- `${width}px`
- )
- },
- [aiPaneOpen, getLayoutBounds]
- )
-
- const finishListPaneResize = useCallback(
- (event: React.PointerEvent): void => {
- if (listResizePointerIdRef.current !== event.pointerId) {
- return
- }
- listResizePointerIdRef.current = undefined
- if (event.currentTarget.hasPointerCapture(event.pointerId)) {
- event.currentTarget.releasePointerCapture(event.pointerId)
- }
- setListPaneWidth(liveListPaneWidthRef.current)
- setListPaneResizing(false)
- },
- []
- )
-
- const resizeListPaneWithKeyboard = useCallback(
- (event: React.KeyboardEvent): void => {
- const bounds = getLayoutBounds()
- const limits = getListPaneWidthLimits(
- bounds.width,
- aiPaneWidth,
- aiPaneOpen
- )
- const nextWidth =
- event.key === 'Home'
- ? limits.minimum
- : event.key === 'End'
- ? limits.maximum
- : event.key === 'ArrowLeft'
- ? listPaneWidth - magicNotesPaneKeyboardResizeStep
- : event.key === 'ArrowRight'
- ? listPaneWidth + magicNotesPaneKeyboardResizeStep
- : undefined
- if (nextWidth === undefined) {
- return
- }
- event.preventDefault()
- const width = clampListPaneWidth(
- nextWidth,
- bounds.width,
- aiPaneWidth,
- aiPaneOpen
- )
- liveListPaneWidthRef.current = width
- setListPaneWidth(width)
- },
- [aiPaneOpen, aiPaneWidth, getLayoutBounds, listPaneWidth]
+ [aiPaneWidth, getLayoutBounds]
)
useEffect(
@@ -783,37 +615,21 @@ export function MagicNotesWorkspace({
useEffect(() => {
persistMagicNotesLayoutPreferences({
- listPaneOpen,
- listPaneWidth,
aiPaneOpen,
aiPaneWidth
})
- }, [aiPaneOpen, aiPaneWidth, listPaneOpen, listPaneWidth])
+ }, [aiPaneOpen, aiPaneWidth])
useEffect(() => {
const layout = magicNotesLayoutRef.current
const updateLayoutWidth = (): void => {
const width = layout?.getBoundingClientRect().width || window.innerWidth
setMagicNotesLayoutWidth(width)
- if (width > 720) {
- setListPaneWidth((current) => {
- const next = clampListPaneWidth(
- current,
- width,
- liveAiPaneWidthRef.current,
- aiPaneOpen
- )
- liveListPaneWidthRef.current = next
- return next
- })
- }
if (width > 800) {
setAiPaneWidth((current) => {
const next = clampAiPaneWidth(
current,
- width,
- liveListPaneWidthRef.current,
- listPaneOpen
+ width
)
liveAiPaneWidthRef.current = next
return next
@@ -836,7 +652,7 @@ export function MagicNotesWorkspace({
}
observer?.disconnect()
}
- }, [aiPaneOpen, listPaneOpen])
+ }, [aiPaneOpen, detailView, loadStatus])
const notifyError = useCallback(
(error: unknown): void =>
@@ -911,12 +727,14 @@ export function MagicNotesWorkspace({
lastDraftAnalysisStartedAtRef.current = Date.now()
setDraftAnalysisRunning(true)
const options = await createAnalysisOptions()
- setLiveAnalysis({
- requestId: options.requestId,
- content: '',
- direction: options.direction,
- format: options.format
- })
+ if (draftAnalysisContextRef.current === analysisContext) {
+ setLiveAnalysis({
+ requestId: options.requestId,
+ content: '',
+ direction: options.direction,
+ format: options.format
+ })
+ }
try {
const analysis =
await window.goodbuddy.magicNotes.analyzeDraft(content, options)
@@ -1026,7 +844,6 @@ export function MagicNotesWorkspace({
right.updatedAt.localeCompare(left.updatedAt)
)
)
- setSelectedTodoId(next.id)
}, [])
const loadDetail = useCallback(
@@ -1073,6 +890,7 @@ export function MagicNotesWorkspace({
draftAnalysisQueuedRef.current = false
draftAnalysisContextRef.current += 1
setDraftAnalyses([])
+ setLiveAnalysis(undefined)
if (draftAnalysisTimerRef.current !== undefined) {
window.clearTimeout(draftAnalysisTimerRef.current)
draftAnalysisTimerRef.current = undefined
@@ -1113,8 +931,10 @@ export function MagicNotesWorkspace({
discardEditingDraft()
}
applyDetail(created)
+ detailRequestRef.current += 1
requestedNoteIdRef.current = created.id
setSelectedNoteId(created.id)
+ setDetailView('notes')
setNewTitle('')
setCreating(false)
notifySuccess(t('notifications.noteCreated'))
@@ -1140,16 +960,14 @@ export function MagicNotesWorkspace({
(target: DraftSwitchTarget): void => {
requestAnimationFrame(() => {
const focusTarget =
- target.kind === 'library-view'
- ? document.getElementById(`magic-library-tab-${target.value}`)
- : target.kind === 'note'
- ? document.getElementById(
- `magic-note-select-${target.noteId}`
- )
- : target.kind === 'todo'
- ? document.getElementById(
- `magic-todo-select-${target.todoId}`
- )
+ target.kind === 'overview'
+ ? (document.getElementById('magic-todo-back')?.checkVisibility?.() &&
+ document.getElementById('magic-todo-back')) || document.getElementById(overviewFocusRef.current) ||
+ document.getElementById('magic-note-new')
+ : target.kind === 'library-view'
+ ? document.getElementById(`magic-library-tab-${target.value}`)
+ : target.kind === 'note'
+ ? document.getElementById('magic-notes-back')
: target.kind === 'edit-entry'
? document
.getElementById(
@@ -1161,7 +979,7 @@ export function MagicNotesWorkspace({
: composerRef.current?.querySelector(
'.ql-editor, [data-testid="magic-note-editor"]'
)
- focusTarget?.focus()
+ focusTarget?.focus({ preventScroll: true })
})
},
[]
@@ -1171,6 +989,21 @@ export function MagicNotesWorkspace({
(target: DraftSwitchTarget): void => {
setPendingDraftSwitch(undefined)
setValidation(undefined)
+ if (target.kind === 'overview') {
+ discardComposerDraft()
+ discardEditingDraft()
+ detailRequestRef.current += 1
+ requestedNoteIdRef.current = ''
+ setSelectedNoteId('')
+ setDetail(undefined)
+ setTitleDraft('')
+ setDetailLoadError(undefined)
+ setRefreshError('')
+ setDeletingEntryId('')
+ setDetailView(undefined)
+ focusSwitchTarget(target)
+ return
+ }
if (target.kind === 'library-view') {
if (target.value === 'todos') {
discardComposerDraft()
@@ -1182,11 +1015,6 @@ export function MagicNotesWorkspace({
focusSwitchTarget(target)
return
}
- if (target.kind === 'todo') {
- setSelectedTodoId(target.todoId)
- focusSwitchTarget(target)
- return
- }
if (target.kind === 'edit-entry') {
discardEditingDraft()
setDeletingEntryId('')
@@ -1196,15 +1024,17 @@ export function MagicNotesWorkspace({
return
}
if (target.kind === 'create-note') {
+ overviewFocusRef.current = 'magic-note-new'
void createNote(target.title, true)
.then(() => focusSwitchTarget(target))
return
}
setDeletingNote(false)
- setLibraryView('notes')
+ if (!detailView && libraryView === 'notes') overviewFocusRef.current = `magic-note-select-${target.noteId}`
+ setDetailView('notes')
+ focusSwitchTarget(target)
void loadDetail(target.noteId).then(() => {
- focusSwitchTarget(target)
- if (!target.entryId) {
+ if (!target.entryId || requestedNoteIdRef.current !== target.noteId) {
return
}
requestAnimationFrame(() =>
@@ -1216,10 +1046,12 @@ export function MagicNotesWorkspace({
},
[
createNote,
+ detailView,
discardComposerDraft,
discardEditingDraft,
focusSwitchTarget,
- loadDetail
+ loadDetail,
+ libraryView
]
)
@@ -1228,25 +1060,27 @@ export function MagicNotesWorkspace({
if (pendingDraftSwitch) {
return
}
+ if (busyRef.current) {
+ notifyInfo(tRef.current('notifications.waitForOperation'))
+ return
+ }
const changesContext =
target.kind === 'library-view'
? target.value !== libraryView
: target.kind === 'note'
- ? target.noteId !== selectedNoteId ||
+ ? !detailView || target.noteId !== selectedNoteId ||
target.entryId !== undefined
- : target.kind === 'todo'
- ? target.todoId !== selectedTodoId
- : target.kind === 'edit-entry'
+ : target.kind === 'edit-entry'
? target.entry.id !== editingEntry?.id
: true
if (!changesContext) {
return
}
const wouldClearComposer = target.kind !== 'edit-entry'
- const wouldClearEditing = target.kind !== 'todo'
if (
(wouldClearComposer && hasContent(composerContentRef.current)) ||
- (wouldClearEditing && hasDirtyEditingDraft())
+ hasDirtyEditingDraft() ||
+ (target.kind === 'overview' && detail && titleDraft !== detail.title)
) {
setPendingDraftSwitch(target)
return
@@ -1255,12 +1089,15 @@ export function MagicNotesWorkspace({
},
[
editingEntry?.id,
+ detail,
+ detailView,
+ titleDraft,
+ notifyInfo,
hasDirtyEditingDraft,
libraryView,
pendingDraftSwitch,
performDraftSwitch,
- selectedNoteId,
- selectedTodoId
+ selectedNoteId
]
)
@@ -1309,23 +1146,21 @@ export function MagicNotesWorkspace({
const nextId =
preferredId && snapshot.notes.some((note) => note.id === preferredId)
? preferredId
- : snapshot.notes[0]?.id ?? ''
+ : ''
const nextDetail = nextId
? await window.goodbuddy.magicNotes.get(nextId)
: undefined
if (refreshRequestRef.current !== requestId) {
return
}
- const requestedNoteId = requestedNoteIdRef.current
const preserveNewerSelection =
- detailRequestRef.current !== detailRequestAtStart &&
- snapshot.notes.some((note) => note.id === requestedNoteId)
+ detailRequestRef.current !== detailRequestAtStart
setNotes(snapshot.notes)
setTodos(todoSnapshot.todos)
setSelectedTodoId((current) =>
todoSnapshot.todos.some((todo) => todo.id === current)
? current
- : todoSnapshot.todos[0]?.id ?? ''
+ : ''
)
hasLoadedRef.current = true
setLoadStatus('ready')
@@ -1351,6 +1186,7 @@ export function MagicNotesWorkspace({
requestedNoteIdRef.current = nextId
setSelectedNoteId(nextId)
setDetail(nextDetail)
+ if (preferredId && !nextId) setDetailView(undefined)
if (!background || !titleDirty) {
setTitleDraft(nextDetail?.title ?? '')
}
@@ -1424,10 +1260,10 @@ export function MagicNotesWorkspace({
}, [notes, search])
const actionNote = useMemo(
- () => listPaneOpen && libraryView === 'notes' && !pendingDraftSwitch
+ () => !detailView && libraryView === 'notes' && !pendingDraftSwitch
? visibleNotes.find((note) => note.id === noteActionsId)
: undefined,
- [libraryView, listPaneOpen, noteActionsId, pendingDraftSwitch, visibleNotes]
+ [libraryView, detailView, noteActionsId, pendingDraftSwitch, visibleNotes]
)
if (noteActionsId && !actionNote) {
setNoteActionsId('')
@@ -1499,14 +1335,8 @@ export function MagicNotesWorkspace({
await window.goodbuddy.magicNotes.remove(note.id)
notifySuccess(t('notifications.noteDeleted'))
closeNoteActions()
- if (requestedNoteIdRef.current === note.id) {
- discardComposerDraft()
- discardEditingDraft()
- await refreshNotes()
- requestAnimationFrame(() => document.getElementById(`magic-note-select-${requestedNoteIdRef.current}`)?.focus())
- } else {
- await refreshNotes(requestedNoteIdRef.current, true)
- }
+ await refreshNotes()
+ requestAnimationFrame(() => document.getElementById('magic-note-new')?.focus({ preventScroll: true }))
} catch (error) {
notifyError(error)
} finally {
@@ -1553,33 +1383,39 @@ export function MagicNotesWorkspace({
}, [currentLocale, visibleTodos])
const selectedTodo = useMemo(
- () => todos.find((todo) => todo.id === selectedTodoId),
- [selectedTodoId, todos]
+ () => visibleTodos.find((todo) => todo.id === selectedTodoId),
+ [selectedTodoId, visibleTodos]
)
const selectedTodoNoteId = selectedTodo?.noteId
const selectedTodoEntryId = selectedTodo?.entryId
useEffect(() => {
- if (libraryView !== 'todos' || !selectedTodoNoteId) {
+ if (detailView || libraryView !== 'todos' || !selectedTodoNoteId) {
return
}
const requestId = ++todoSourceRequestRef.current
- void window.goodbuddy.magicNotes
- .get(selectedTodoNoteId)
+ void Promise.resolve().then(() => {
+ if (todoSourceRequestRef.current !== requestId) return undefined
+ setTodoSourceStatus('loading')
+ return window.goodbuddy.magicNotes.get(selectedTodoNoteId)
+ })
.then((sourceDetail) => {
if (todoSourceRequestRef.current === requestId) {
setTodoSourceDetail(sourceDetail)
+ setTodoSourceStatus('ready')
}
})
.catch((sourceError: unknown) => {
if (todoSourceRequestRef.current === requestId) {
+ setTodoSourceDetail(undefined)
+ setTodoSourceStatus('error')
notifyError(sourceError)
}
})
return () => {
todoSourceRequestRef.current += 1
}
- }, [libraryView, notifyError, selectedTodoNoteId, todos])
+ }, [detailView, libraryView, notifyError, selectedTodoNoteId, todos, todoSourceRetry])
const selectedTodoSourceEntry = useMemo(
() =>
@@ -1596,7 +1432,7 @@ export function MagicNotesWorkspace({
setSelectedTodoId((current) =>
snapshot.todos.some((todo) => todo.id === current)
? current
- : snapshot.todos[0]?.id ?? ''
+ : ''
)
}, [])
@@ -1614,20 +1450,9 @@ export function MagicNotesWorkspace({
? [...entries, editingEntry]
: entries
}, [detail, editingEntry])
- const listPaneWidthLimits = getListPaneWidthLimits(
- magicNotesLayoutWidth,
- aiPaneWidth,
- aiPaneOpen
- )
const aiPaneWidthLimits = getAiPaneWidthLimits(
- magicNotesLayoutWidth,
- listPaneWidth,
- listPaneOpen
+ magicNotesLayoutWidth
)
- const canResizeListPane =
- listPaneOpen &&
- magicNotesLayoutWidth > 720 &&
- listPaneWidthLimits.maximum > listPaneWidthLimits.minimum
const canResizeAiPane =
aiPaneOpen &&
magicNotesLayoutWidth > 800 &&
@@ -1691,6 +1516,13 @@ export function MagicNotesWorkspace({
expectedRevision: todo.revision
})
applyTodo(result.todo)
+ if (todoFilter !== 'all') {
+ requestAnimationFrame(() => {
+ if (document.activeElement === document.body) {
+ document.querySelector('#magic-library-panel-todos .magic-todo-list-item__check, #magic-library-panel-todos .segmented-control__option--active')?.focus()
+ }
+ })
+ }
if (selectedTodoId === todo.id) {
todoSourceRequestRef.current += 1
setTodoSourceDetail(result.note)
@@ -1916,24 +1748,19 @@ export function MagicNotesWorkspace({
+ {detailView && (
setListPaneOpen((current) => !current)}
+ onClick={() => requestDraftSwitch({ kind: 'overview' })}
+ disabled={Boolean(busy)}
type="button"
>
- {listPaneOpen ? (
-
- ) : (
-
- )}
- {t(
- listPaneOpen
- ? 'actions.hideListPane'
- : 'actions.showListPane'
- )}
+
+ {t(libraryView === 'todos' ? 'actions.backToTodos' : 'actions.backToOverview')}
+ )}
+ {detailView && (
- {libraryView === 'notes' && (
+ )}
+ {!detailView && (
+ <>
+ requestDraftSwitch({ kind: 'library-view', value })}
+ tabs={libraryTabs}
+ value={libraryView}
+ variant="segmented"
+ />
{
setValidation(undefined)
+ setLibraryView('notes')
setCreating(true)
}}
>
{t('actions.newNote')}
+ >
)}
>
}
@@ -2012,18 +1853,13 @@ export function MagicNotesWorkspace({
ref={magicNotesLayoutRef}
aria-busy={Boolean(busy)}
className={`magic-notes-layout${
- listPaneOpen ? '' : ' magic-notes-layout--list-hidden'
+ detailView ? '' : ' magic-notes-layout--overview'
}${
aiPaneOpen ? '' : ' magic-notes-layout--ai-hidden'
}${
- (listPaneResizing && canResizeListPane) ||
(aiPaneResizing && canResizeAiPane)
? ' magic-notes-layout--resizing'
: ''
- }${
- listPaneResizing && canResizeListPane
- ? ' magic-notes-layout--list-resizing'
- : ''
}${
aiPaneResizing && canResizeAiPane
? ' magic-notes-layout--ai-resizing'
@@ -2031,34 +1867,19 @@ export function MagicNotesWorkspace({
}`}
style={
{
- '--magic-notes-list-width': `${listPaneWidth}px`,
'--magic-notes-ai-width': `${aiPaneWidth}px`
} as React.CSSProperties
}
>
-
-
- requestDraftSwitch({
- kind: 'library-view',
- value
- })
- }
- tabs={libraryTabs}
- value={libraryView}
- variant="segmented"
- />
{libraryView === 'notes' ? (
)}
-
+
{loadStatus === 'loading' ? (
{t('status.loadingNotes')}
@@ -2162,12 +1983,7 @@ export function MagicNotesWorkspace({
requestDraftSwitch({
@@ -2274,8 +2090,9 @@ export function MagicNotesWorkspace({
>
{t('todos.heading')}
- {todos.length}
+ {t('todos.resultCount', { count: visibleTodos.length, total: todos.length })}
+
{t('todos.searchLabel')}
+
+
{loadStatus === 'loading' ? (
@@ -2333,102 +2152,126 @@ export function MagicNotesWorkspace({
{directory.todos.map((todo) => (
+
- requestDraftSwitch({
- kind: 'todo',
- todoId: todo.id
+ onSelect={() => {
+ overviewFocusRef.current = `magic-todo-select-${todo.id}`
+ setSelectedTodoId((current) => current === todo.id ? '' : todo.id)
+ requestAnimationFrame(() => {
+ const back = document.getElementById('magic-todo-back')
+ if (back?.checkVisibility?.()) back.focus({ preventScroll: true })
})
- }
+ }}
onToggle={() =>
void updateTodoCompletion(todo)
}
- selected={selectedTodoId === todo.id}
todo={todo}
/>
+
))}
))
)}
+
+ {selectedTodo ? (
+
+ {
+ setSelectedTodoId('')
+ requestAnimationFrame(() => document.getElementById(`magic-todo-select-${selectedTodo.id}`)?.focus({ preventScroll: true }))
+ }}>
+
+ {t('actions.backToTodoList')}
+
+ {selectedTodo.title}
+ {selectedTodo.instructions || t('todos.defaultInstructions')}
+ requestDraftSwitch({ kind: 'note', noteId: selectedTodo.noteId, entryId: selectedTodo.entryId })}
+ type="button"
+ >
+
+ {t('actions.openSourceNote')}
+
+
+ {t('todos.sourceEntryHeading')}
+ {selectedTodoSourceEntry ? (
+
+ ) : todoSourceStatus === 'error' ? (
+ setTodoSourceRetry((current) => current + 1)} type="button">{t('todos.retrySource')}
+ ) : (
+ {t(todoSourceStatus === 'loading' || todoSourceDetail?.id !== selectedTodo.noteId ? 'todos.loadingSource' : 'todos.sourceEntryMissing')}
+ )}
+
+
+ {t('comments.paneLabel')}
+
+
+ {t('comments.directionLabel')}
+ setCommentDirection(event.target.value as MagicNoteCommentDirection)}
+ value={commentDirection}
+ >
+ {commentDirections.map((direction) => {direction.label} )}
+
+
+ void analyzeTodo(selectedTodo.id)} type="button">
+
+ {t(busy === `analyze-todo-${selectedTodo.id}` ? 'actions.analyzing' : selectedTodo.analyzedAt ? 'actions.analyzeAgain' : 'actions.analyze')}
+
+ {t('comments.directionHelp')}
+
+ {liveAnalysis && busy === `analyze-todo-${selectedTodo.id}` && (
+
+
{t(liveAnalysis.format === 'structured' ? 'status.generatingPoints' : 'status.generatingDirection', { direction: commentDirectionLabels[liveAnalysis.direction] })}
+ {liveAnalysis.format !== 'structured' &&
{liveAnalysis.content || t('status.preparingComment')}
}
+
+ )}
+ {selectedTodo.comments.map((comment) => )}
+ {!liveAnalysis && selectedTodo.comments.length === 0 && {t('comments.analyzeTodoHint')}
}
+
+
+ ) : (
+
+ } title={t('todos.emptySelectionTitle')} description={t('todos.emptySelectionDescription')} />
+
+ )}
)}
-
-
- {listPaneOpen && (
-
{
- if (
- listResizePointerIdRef.current === event.pointerId
- ) {
- listResizePointerIdRef.current = undefined
- setListPaneWidth(liveListPaneWidthRef.current)
- setListPaneResizing(false)
- }
- }}
- onPointerCancel={finishListPaneResize}
- onPointerDown={(event) => {
- if (event.button !== 0 || !canResizeListPane) {
- return
- }
- event.preventDefault()
- listResizePointerIdRef.current = event.pointerId
- event.currentTarget.setPointerCapture(event.pointerId)
- resizeListPaneFromClientX(event.clientX, true)
- setListPaneResizing(true)
- }}
- onPointerMove={(event) => {
- if (
- listResizePointerIdRef.current !== event.pointerId
- ) {
- return
- }
- if (!canResizeListPane) {
- finishListPaneResize(event)
- return
- }
- event.preventDefault()
- resizeListPaneFromClientX(event.clientX, false)
- }}
- onPointerUp={finishListPaneResize}
- role="separator"
- tabIndex={canResizeListPane ? 0 : -1}
- />
- )}
+
+ {detailView && (
+ <>
- {libraryView === 'notes' ? (
- !detail ? (
+ {!detail ? (
void loadDetail(detailLoadError.noteId)}
+ type="button"
+ >
+ {t('actions.retry')}
+
+ ) : undefined}
+ description={detailLoadError ? detailLoadError.message : t('status.loadingNotes')}
icon={ }
title={
- loadStatus === 'loading'
- ? t('status.loading')
- : t('notes.emptySelectionTitle')
+ detailLoadError ? t('errors.initialLoadTitle') : t('status.loading')
}
/>
) : (
@@ -2776,114 +2619,6 @@ export function MagicNotesWorkspace({
)}
>
- )
- ) : !selectedTodo ? (
-
}
- title={
- loadStatus === 'loading'
- ? t('status.loading')
- : t('todos.emptySelectionTitle')
- }
- />
- ) : (
-
-
-
- void updateTodoCompletion(selectedTodo)
- }
- type="button"
- >
- {selectedTodo.completed ? (
-
- ) : (
-
- )}
-
-
-
{selectedTodo.title}
-
- {t('todos.sourceNote', {
- title: selectedTodo.noteTitle
- })}
-
-
-
- void analyzeTodo(selectedTodo.id)}
- type="button"
- >
-
- {busy === `analyze-todo-${selectedTodo.id}`
- ? t('actions.analyzing')
- : selectedTodo.analyzedAt
- ? t('actions.analyzeAgain')
- : t('actions.analyze')}
-
-
-
-
-
-
- {selectedTodo.instructions ||
- t('todos.defaultInstructions')}
-
-
- requestDraftSwitch({
- kind: 'note',
- noteId: selectedTodo.noteId,
- entryId: selectedTodo.entryId
- })
- }
- type="button"
- >
-
- {t('actions.openSourceNote')}
-
-
-
-
- {selectedTodoSourceEntry ? (
-
- ) : (
-
- {t('todos.sourceEntryMissing')}
-
- )}
-
-
)}
@@ -3016,46 +2751,11 @@ export function MagicNotesWorkspace({
) : null}
- {libraryView === 'todos' ? (
- !selectedTodo ? (
-
}
- title={t('comments.selectTodoTitle')}
- />
- ) : selectedTodo.comments.length === 0 ? (
- !liveAnalysis && (
-
}
- title={t('comments.analyzeTodoTitle')}
- />
- )
- ) : (
-
-
-
- {selectedTodo.title}
-
- {selectedTodo.comments.map((comment) => (
-
- ))}
-
-
- )
- ) : !detail ? (
+ {!detail ? (
}
- title={
- loadStatus === 'loading'
- ? t('status.loading')
- : t('comments.selectNoteTitle')
- }
+ title={t('status.loading')}
/>
) : aiEntries.length === 0 &&
draftAnalyses.length === 0 &&
@@ -3116,6 +2816,8 @@ export function MagicNotesWorkspace({
)}
+ >
+ )}
>
)}
diff --git a/src/renderer/src/i18n/locales/en-US/magicNotes.ts b/src/renderer/src/i18n/locales/en-US/magicNotes.ts
index ecb0c0cf..f98b201f 100644
--- a/src/renderer/src/i18n/locales/en-US/magicNotes.ts
+++ b/src/renderer/src/i18n/locales/en-US/magicNotes.ts
@@ -28,8 +28,9 @@ export const magicNotes = {
analyzeAgain: 'Analyze again',
analyzing: 'Analyzing…',
openSourceNote: 'Open source note',
- hideListPane: 'Hide left list',
- showListPane: 'Show left list',
+ backToOverview: 'Back to overview',
+ backToTodos: 'Back to to-dos',
+ backToTodoList: 'Back to to-do list',
hideAiComments: 'Hide AI comments',
showAiComments: 'Show AI comments',
pinNote: 'Pin note',
@@ -104,8 +105,11 @@ export const magicNotes = {
listLabel: 'To-do list',
detailLabel: 'To-do details',
heading: 'All to-dos',
+ resultCount: '{{count}} / {{total}} items',
+ loadingSource: 'Loading source entry…',
+ retrySource: 'Retry loading source',
searchLabel: 'Search to-dos in the current scope',
- searchPlaceholder: 'Search to-dos',
+ searchPlaceholder: 'Search tasks, instructions, or source notes',
filterLabel: 'Filter to-dos',
filters: {
active: 'Incomplete',
@@ -169,8 +173,6 @@ export const magicNotes = {
'Choose “Analyze with AI” on an entry to show comments here.'
},
accessibility: {
- resizeListPane: 'Resize the note list and editor panes',
- listPaneWidth: 'Left list, {{width}} pixels',
resizeAiPane: 'Resize the editor and AI comments panes',
aiPaneWidth: 'AI comments pane, {{width}} pixels'
},
diff --git a/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts b/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts
index 6c2bb264..de388659 100644
--- a/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts
+++ b/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts
@@ -24,8 +24,9 @@ export const magicNotes = {
analyzeAgain: '重新分析',
analyzing: '分析中…',
openSourceNote: '打开原笔记修改',
- hideListPane: '隐藏左侧列表',
- showListPane: '显示左侧列表',
+ backToOverview: '返回总览',
+ backToTodos: '返回待办',
+ backToTodoList: '返回待办列表',
hideAiComments: '隐藏 AI 评论',
showAiComments: '显示 AI 评论',
pinNote: '置顶笔记',
@@ -98,8 +99,11 @@ export const magicNotes = {
listLabel: '待办列表',
detailLabel: '待办详情',
heading: '全部待办',
+ resultCount: '{{count}} / {{total}} 项',
+ loadingSource: '正在加载来源记录…',
+ retrySource: '重试加载来源',
searchLabel: '搜索当前范围的待办',
- searchPlaceholder: '搜索待办',
+ searchPlaceholder: '搜索任务、说明或来源笔记',
filterLabel: '筛选待办',
filters: {
active: '未完成',
@@ -157,8 +161,6 @@ export const magicNotes = {
manualHint: '在记录上点击“AI 分析”,评论会显示在这里。'
},
accessibility: {
- resizeListPane: '调整笔记列表与编辑区宽度',
- listPaneWidth: '左侧列表 {{width}} 像素',
resizeAiPane: '调整编辑区与 AI 评论宽度',
aiPaneWidth: 'AI 评论栏 {{width}} 像素'
},
diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css
index 848bbd6d..f4fba4ea 100644
--- a/src/renderer/src/styles.css
+++ b/src/renderer/src/styles.css
@@ -1260,31 +1260,19 @@
display: grid;
flex: 1;
min-width: 0;
- min-height: 520px;
+ min-height: 0;
overflow: hidden;
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
grid-template-columns:
- var(--magic-notes-list-width, 220px) 9px
minmax(300px, 1fr) 9px
var(--magic-notes-ai-width, 280px);
}
-.magic-notes-layout--ai-hidden {
- grid-template-columns:
- var(--magic-notes-list-width, 220px) 9px
- minmax(300px, 1fr);
-}
-
-.magic-notes-layout--list-hidden {
- grid-template-columns:
- minmax(300px, 1fr) 9px
- var(--magic-notes-ai-width, 280px);
-}
-
-.magic-notes-layout--list-hidden.magic-notes-layout--ai-hidden {
- grid-template-columns: minmax(300px, 1fr);
+.magic-notes-layout--ai-hidden,
+.magic-notes-layout--overview {
+ grid-template-columns: minmax(0, 1fr);
}
.magic-notes-layout--resizing {
@@ -1292,7 +1280,6 @@
user-select: none;
}
-.magic-notes-list-resize-handle,
.magic-notes-ai-resize-handle {
position: relative;
z-index: 2;
@@ -1303,14 +1290,6 @@
touch-action: none;
}
-.magic-notes-list-resize-handle {
- background: linear-gradient(
- to right,
- var(--surface-subtle) 0 4px,
- var(--surface-raised) 4px 100%
- );
-}
-
.magic-notes-ai-resize-handle {
background: linear-gradient(
to right,
@@ -1319,7 +1298,6 @@
);
}
-.magic-notes-list-resize-handle::after,
.magic-notes-ai-resize-handle::after {
position: absolute;
top: 0;
@@ -1333,25 +1311,20 @@
background var(--motion-fast) ease-out;
}
-.magic-notes-list-resize-handle:hover::after,
-.magic-notes-list-resize-handle:focus-visible::after,
.magic-notes-ai-resize-handle:hover::after,
.magic-notes-ai-resize-handle:focus-visible::after,
-.magic-notes-layout--list-resizing
- > .magic-notes-list-resize-handle::after,
.magic-notes-layout--ai-resizing
> .magic-notes-ai-resize-handle::after {
width: 2px;
background: var(--accent);
}
-.magic-notes-list-resize-handle:focus-visible,
.magic-notes-ai-resize-handle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
-.magic-notes-list-pane,
+.magic-notes-overview,
.magic-notes-stream-pane,
.magic-notes-ai-pane {
min-width: 0;
@@ -1359,37 +1332,32 @@
overflow: auto;
}
-.magic-notes-list-pane,
.magic-notes-ai-pane {
background: var(--surface-subtle);
}
-.magic-notes-list-pane[hidden],
+.magic-notes-overview[hidden],
.magic-notes-ai-pane[hidden] {
display: none;
}
-.magic-notes-list-pane {
+.magic-notes-overview {
display: flex;
padding: var(--space-4);
flex-direction: column;
gap: var(--space-3);
}
-.magic-notes-list-pane > .page-tabs--segmented {
- width: 100%;
-}
-
-.magic-notes-list-pane > .page-tabs--segmented .page-tabs__tab {
- flex: 1;
- justify-content: center;
- padding-inline: var(--space-2);
+.magic-notes-page > .page-header .page-tabs {
+ flex: 0 0 auto;
+ align-self: flex-start;
+ width: fit-content;
}
.magic-notes-library-panel {
display: flex;
min-height: 0;
- flex: 1;
+ flex: 0 0 auto;
flex-direction: column;
gap: var(--space-3);
}
@@ -1571,11 +1539,6 @@
background: var(--surface-muted);
}
-.magic-todo-list-item--active {
- border-color: var(--accent-selected);
- background: var(--accent-subtle);
-}
-
.magic-todo-list-item__check {
display: grid;
width: 28px;
@@ -1603,6 +1566,130 @@
cursor: pointer;
gap: var(--space-1);
text-align: left;
+ grid-template-columns: minmax(0, 1fr) auto;
+}
+
+.magic-todo-list-item__content > svg {
+ grid-column: 2;
+ grid-row: 1 / 3;
+ align-self: center;
+ color: var(--text-muted);
+}
+
+.magic-todo-list-item--completed strong {
+ text-decoration: line-through;
+ color: var(--text-muted);
+}
+
+.magic-todo-toolbar {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-3);
+}
+
+.magic-notes-overview--todos {
+ overflow: hidden;
+}
+
+.magic-notes-overview--todos .magic-notes-library-panel {
+ flex: 1;
+}
+
+.magic-notes-overview--todos .magic-notes-pane-heading,
+.magic-todo-toolbar {
+ flex: 0 0 auto;
+}
+
+.magic-todo-toolbar .magic-notes-search {
+ flex: 1 1 240px;
+ min-width: 0;
+}
+
+.magic-todo-task {
+ min-width: 0;
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.magic-todo-task:has([aria-expanded='true']) {
+ border-radius: var(--radius-control);
+ background: var(--surface-subtle);
+}
+
+.magic-todo-workspace {
+ display: grid;
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
+ grid-template-columns: minmax(280px, 34%) minmax(0, 1fr);
+ overflow: hidden;
+}
+
+.magic-todo-workspace > .magic-notes-list {
+ padding-right: var(--space-3);
+}
+
+.magic-todo-detail {
+ display: grid;
+ min-width: 0;
+ min-height: 0;
+ overflow: auto;
+ align-content: start;
+ border-left: 1px solid var(--border-subtle);
+ padding: var(--space-3) var(--space-4) var(--space-4);
+ gap: var(--space-4);
+}
+
+#magic-todo-back {
+ display: none;
+}
+
+@container magic-notes-page (max-width: 700px) {
+ .magic-todo-workspace {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .magic-todo-workspace--selected > .magic-notes-list,
+ .magic-todo-detail--empty {
+ display: none;
+ }
+
+ .magic-todo-detail {
+ border-left: 0;
+ padding: var(--space-3) 0;
+ }
+
+ #magic-todo-back {
+ display: inline-flex;
+ }
+}
+
+.magic-todo-detail h3,
+.magic-todo-instructions {
+ margin: 0;
+ font-size: var(--font-body);
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+}
+
+.magic-todo-detail > button {
+ justify-self: start;
+}
+
+.magic-todo-comments {
+ display: grid;
+ min-width: 0;
+ gap: var(--space-2);
+}
+
+.magic-todo-comments .magic-notes-ai-controls {
+ display: flex;
+ align-items: end;
+ flex-wrap: wrap;
+}
+
+.magic-todo-comments .magic-notes-ai-controls > small {
+ flex-basis: 100%;
}
.magic-todo-list-item__content strong {
@@ -1687,25 +1774,30 @@
.magic-note-list-item {
display: grid;
width: 100%;
- padding: var(--space-3);
- border: 1px solid transparent;
- border-radius: var(--radius-control);
- background: transparent;
+ height: 100%;
+ min-height: 160px;
+ padding: var(--space-4);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-card);
+ background: var(--surface-raised);
color: var(--text-secondary);
cursor: pointer;
- gap: var(--space-1);
+ gap: var(--space-3);
+ grid-template-rows: auto 1fr auto;
text-align: left;
}
+.magic-notes-card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 240px), 1fr));
+ align-content: start;
+ gap: var(--space-4);
+}
+
.magic-note-list-item:hover {
background: var(--surface-muted);
}
-.magic-note-list-item--active {
- border-color: var(--accent-selected);
- background: var(--accent-subtle);
-}
-
.magic-note-list-item__title {
display: flex;
overflow: hidden;
@@ -1760,127 +1852,6 @@
gap: var(--space-3);
}
-.magic-todo-detail {
- display: flex;
- min-height: 100%;
- flex-direction: column;
- gap: var(--space-4);
-}
-
-.magic-todo-detail > header {
- display: grid;
- min-width: 0;
- align-items: start;
- padding-bottom: var(--space-3);
- border-bottom: 1px solid var(--border-subtle);
- gap: var(--space-3);
- grid-template-columns: auto minmax(0, 1fr) auto;
-}
-
-.magic-todo-detail__check {
- display: grid;
- width: 36px;
- height: 36px;
- padding: 0;
- border: 0;
- border-radius: var(--radius-control);
- background: var(--accent-subtle);
- color: var(--accent);
- cursor: pointer;
- place-items: center;
-}
-
-.magic-todo-detail__check:hover {
- background: var(--accent-selected);
-}
-
-.magic-todo-detail > header > div:nth-child(2) {
- display: grid;
- min-width: 0;
- gap: var(--space-1);
-}
-
-.magic-todo-detail > header span {
- color: var(--text-muted);
- font-size: var(--font-caption);
-}
-
-.magic-todo-detail h2 {
- margin: 0;
- color: var(--text-primary);
- font-size: 18px;
- line-height: 1.35;
- overflow-wrap: anywhere;
-}
-
-.magic-todo-detail__actions {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- flex-wrap: wrap;
- gap: var(--space-2);
-}
-
-.magic-todo-detail__actions button,
-.magic-todo-detail__content .secondary-button {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: var(--space-1);
-}
-
-.magic-todo-detail__content {
- display: grid;
- align-content: start;
- padding: var(--space-4);
- border: 1px solid var(--border-default);
- border-radius: var(--radius-card);
- background: var(--surface-subtle);
- gap: var(--space-4);
-}
-
-.magic-todo-detail__content p {
- margin: 0;
- color: var(--text-primary);
- font-size: 14px;
- line-height: 1.7;
- overflow-wrap: anywhere;
- white-space: pre-wrap;
-}
-
-.magic-todo-detail__content .secondary-button {
- width: fit-content;
-}
-
-.magic-todo-detail__editor {
- display: grid;
- gap: var(--space-4);
-}
-
-.magic-todo-detail__editor label {
- display: grid;
- color: var(--text-secondary);
- font-size: var(--font-body);
- gap: var(--space-2);
-}
-
-.magic-todo-detail__editor input,
-.magic-todo-detail__editor textarea {
- width: 100%;
- padding: var(--space-2) var(--space-3);
- border: 1px solid var(--border-control);
- border-radius: var(--radius-control);
- background: var(--surface-raised);
- color: var(--text-primary);
- resize: vertical;
-}
-
-.magic-todo-detail__editor > div {
- display: flex;
- justify-content: flex-end;
- gap: var(--space-2);
-}
-
.magic-notes-ai-source {
color: var(--text-muted);
font-size: var(--font-caption);
@@ -2263,14 +2234,13 @@
@container magic-notes-page (max-width: 800px) {
.magic-notes-layout {
- grid-template-columns:
- var(--magic-notes-list-width, 210px) 9px
- minmax(320px, 1fr);
- grid-template-rows: minmax(0, 1fr) auto;
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: minmax(0, 1fr) minmax(0, min(40%, 280px));
}
- .magic-notes-layout--list-hidden {
- grid-template-columns: minmax(320px, 1fr);
+ .magic-notes-layout--overview,
+ .magic-notes-layout--ai-hidden {
+ grid-template-rows: minmax(0, 1fr);
}
.magic-notes-ai-resize-handle {
@@ -2286,28 +2256,6 @@
}
@container magic-notes-page (max-width: 720px) {
- .magic-notes-layout {
- min-height: 0;
- overflow: visible;
- grid-template-columns: 1fr;
- grid-template-rows: auto auto auto;
- }
-
- .magic-notes-list-resize-handle {
- display: none;
- }
-
- .magic-notes-list-pane {
- max-height: 240px;
- border-right: 0;
- border-bottom: 1px solid var(--border-subtle);
- }
-
- .magic-notes-ai-pane {
- max-height: none;
- grid-column: auto;
- }
-
.magic-note-composer > footer,
.magic-note-entry > header {
align-items: stretch;
@@ -2320,15 +2268,6 @@
flex-direction: column;
}
- .magic-todo-detail > header {
- grid-template-columns: auto minmax(0, 1fr);
- }
-
- .magic-todo-detail__actions {
- justify-content: flex-start;
- grid-column: 1 / -1;
- }
-
}
* {