diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 46385763..c0723252 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -315,9 +315,10 @@ 接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不 超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时 单行截断,不能撑宽或挤压侧栏。 -- 侧栏底部固定显示文件、终端、浏览器和资源监控四个系统工具图标,设置按钮独立靠右。工具 - 图标只负责打开或聚焦助手工作栏面板,不在侧栏复制工具内容。已有稳定设置入口时,不在顶栏 - 重复提供同一入口。 +- 侧栏底部提供应用中心入口,设置按钮独立靠右。中心与共同应用设置使用和全局设置相同的整窗 Modal;魔法笔记、智能心跳、本机推理可启用、常驻与排序, + 取消常驻不关闭功能。知识库固定显示在首个可选应用槽位之后,再过滤隐藏的可选项;不参与启用、常驻或排序,中心只提供“打开”,原工作区实际配置保留,没有实际设置时不添加通用设置按钮。 + 设置持久化后的事件同步各入口及工具配置写入;重开中心刷新期间锁定修改,新快照不被旧读取覆盖。文件、终端、浏览器和资源监控只通过右侧工作栏访问,不在左侧复制入口。 + 具体规则见[应用中心 UI 设计](./docs/features/application-tool-navigation/ui-design.md)。已有稳定设置入口时,不在顶栏重复提供同一入口。 ### 6.9 助手工作栏 @@ -335,8 +336,8 @@ - Tab 行只显示已打开实例,行末始终提供“+”普通按钮;点击后在内容区打开应用选择页。 “+”不使用 `tab` 语义,也不能因为终端或其他应用已经打开而消失。 - 应用注册表分别声明单实例或多实例、默认打开、必须存在、可关闭和排序策略。在工作栏“+”目录中, - 单实例重复选择时聚焦已有 Tab;浏览器和终端每次选择创建独立实例。主侧栏系统工具快捷 - 入口按当前上下文聚焦最近使用的匹配实例,额外实例仍从“+”目录创建。 + 单实例重复选择时聚焦已有 Tab;浏览器和终端每次选择创建独立实例。选择已有 Tab 只激活 + 该实例,保持其目标和历史;主侧栏不再提供按当前上下文匹配实例的重复工具入口。 - Task Center 是默认跟随当前项目的单实例索引,不使用多实例模式。用户可在同一 Tab 内切换 “当前项目”和“所有项目”;无项目任务在“所有项目”中标注“未绑定项目”。范围变化不得创建第二个 Task Center。每个 Task 只 关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容,也 @@ -714,6 +715,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 也可从页面标题区隐藏和恢复;宽度与显示状态均记住用户选择。分隔条保留 `separator` 语义及方向键、Home、End 操作,窄窗口堆叠布局不显示拖拽分隔条。 - 左侧笔记列表依次显示标题、最多两行内容预览,以及同一行的记录数和本地化更新时间。记录数靠左、更新时间靠右,窄宽度下均保持单行省略,不挤压标题或扩大列表栏。 +- 整篇笔记的置顶、取消置顶和删除放在列表项的“更多”菜单,标题编辑区不重复提供入口。按钮在悬停、键盘聚焦或菜单展开时显示,触屏设备常显;菜单沿用会话列表样式,通过 Portal 避免滚动区域裁切,支持方向键、Home、End 和 Escape。删除在菜单内二次确认并说明笔记标题及全部记录;打开菜单不切换笔记,置顶或删除其他笔记不丢弃当前草稿。 - 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。 - 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。 - 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。 @@ -750,6 +752,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - Agent Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。 - “能力与默认配置”只显示一个模块标题,刷新入口位于该标题右侧,能力状态压缩为一行并排在默认 Agent 或 Continue 预设编辑器之前;不得再复制“Runtime 原生能力”等同义标题、说明或状态结论。刷新只更新能力快照,不覆盖未保存的原生定制草稿。 - OpenCode 的默认 Agent 使用原生下拉选择;Continue 预设编辑器允许管理名称、说明、启用的 Rules 以及 Prompt 名称、说明和正文,并可展开查看原生 Rules 与启用预设 Rules 的最终合并顺序。持久启停仍使用共享 Switch,添加与删除使用明确按钮和可访问名称。 +- 应用中心交付时,上述“平台功能 → 魔法笔记”的设置迁入应用共同设置界面;原页签如保留则只提供定位入口,不维护第二份表单。笔记页与应用中心共用配置;旧入口开关绑定业务启用值,迁为“启用应用”并保留原值,常驻使用独立配置。默认开启仅适用于未配置布尔值,只有独立显隐偏好才映射到常驻。此迁移规则见[应用导航 PRD](./docs/features/application-tool-navigation/prd.md#fr-11-应用导航设置)。 - MCP 设置按“内置 MCP / 直连模型 / 自定义 MCP / 电脑控制”四个同级 `PageTabs` 组织。联网搜索保留在直连模型;内置浏览器归入内置 MCP,提供持久启停和 Runtime 分配,并明确说明其操作 GoodBuddy 隔离浏览器、不控制客户端已安装的浏览器,开启后可由已分配 Runtime 的 Execute 直接使用,不逐次询问。该开关只控制 Agent 工具访问,关闭时不得禁用浏览器工作栏中的用户手动导航、返回、刷新、停止加载、交互或关闭操作。尚未生效的命名浏览器配置不得显示在界面中,“电脑控制”只显示实际操作客户端电脑的能力。内置 MCP 卡片与 Skills 一样提供持久启停和 Runtime 分配;直连模型、GoodBuddy 管理的 OpenCode 与 Continue 默认选中且可调整,DeepSeek Harness 必须以置灰、未选择和“暂不支持”文案持续显示,不能呈现为可保存的分配。魔法笔记 MCP 的自身启停与平台功能依赖分别显示,依赖未开启时保留用户配置并说明当前不会加载。 - MCP Server 测试结果在同一展开卡片中分组显示 Tools、Prompts 和 Resources 的支持状态、数量与有界元数据;Prompt 参数标明必填项,Resource 只显示 URI、名称、类型和说明,不读取或渲染 Resource 内容。 - 内置 MCP 中的“图片生成与编辑”是自动管理例外:该行始终可见,标记“内置 · 自动管理”,不提供独立启停、分配保存、移除或凭据编辑。直连 Model、OpenCode、Continue、DeepSeek Harness 的分配只由已保存图片模型的“允许 AI 在会话中调用”设置推导;至少一个开启则全部勾选,没有开启模型则全部取消。使用禁用的原生 Checkbox、Runtime 标签及关联说明,保留可聚焦的“前往图片模型设置”导航;远程适用范围通过 GoodBuddy Agent 说明持续可见。Ask、离线、无工具能力或调用错误不改变勾选。图片模型默认关闭会话调用;默认生图模型独立于普通默认连接,关闭或删除后保留失效选择并提示改选,不自动回退。 diff --git a/docs/features/magic-notes/progress.md b/docs/features/magic-notes/progress.md index b2bd850e..0e56fdb8 100644 --- a/docs/features/magic-notes/progress.md +++ b/docs/features/magic-notes/progress.md @@ -1,5 +1,24 @@ # Magic Notes Progress +## 2026-09-17: Note List Action Menu + +- Moved pin/unpin and whole-note deletion from the editor header to each note's + action menu, following the [UI design system](../../../UI-DESIGN.md#137-魔法笔记). + The menu uses existing update/delete IPC and shared destructive confirmation. +- Added regression coverage for menu keyboard behavior, portal placement, + dismissal, pin ordering, revision failures, deletion and retained drafts. +- The focused Magic Notes suite passed 42 tests. The combined Magic Notes, + conversation and shared-primitives suites passed 332 tests. Typecheck and + targeted ESLint passed. +- Full `npm test`: 370 files and 4,440 tests passed, 2 files and 3 tests failed, + 9 files and 67 tests skipped. Two heartbeat migration fixtures relabel a current + database as an old version and fail on the existing `pinned` column. One + OpenCode runtime-reuse request timed out; its two tests passed on isolated rerun. +- Full lint reported 22 `document` no-undef errors in the unrelated, untracked + `docs/features/story-graph/demo.js`. No unrelated files were changed to resolve + these validation failures. Interactive Electron and touch verification remain + unperformed. + ## 2026-09-15: Automatic External Refresh - Implemented the shared-database notification and Main/preload subscription diff --git a/docs/features/magic-notes/technical-design.md b/docs/features/magic-notes/technical-design.md index 3a2205a5..10572a40 100644 --- a/docs/features/magic-notes/technical-design.md +++ b/docs/features/magic-notes/technical-design.md @@ -45,6 +45,22 @@ 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-魔法笔记). +## Note List Actions + +Each note row has a selection 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 +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 +are unchanged. + ## Agent Search Contract `note_search` accepts an integer `limit` from 1 to 100, defaulting to 8. @@ -71,6 +87,8 @@ daemon implementation update. channel, payload-free callback and listener removal. - `MagicNotesWorkspace.test.tsx` covers external updates and deletion, selection, source reload, editor identity and draft saves, coalescing, deferred writes, - asynchronous selection races, error retry and unmount cleanup. + asynchronous selection races, error retry and unmount cleanup. List-action + coverage includes portal placement, keyboard navigation, dismissal, delete + confirmation, target selection, revision failures and draft preservation. No schema migration, dependency, refresh control or network service is added. diff --git a/src/renderer/src/MagicNotesWorkspace.test.tsx b/src/renderer/src/MagicNotesWorkspace.test.tsx index 8dafa0d8..dd7cc89e 100644 --- a/src/renderer/src/MagicNotesWorkspace.test.tsx +++ b/src/renderer/src/MagicNotesWorkspace.test.tsx @@ -12,6 +12,7 @@ import i18n from './i18n' import type { DesktopApi } from '../../shared/contracts' import { defaultLocalToolEnvironmentSettings, + defaultApplicationNavigation, type ApplicationSettings } from '../../shared/application-settings-contracts' import type { @@ -173,6 +174,7 @@ const get = vi.fn<(noteId: string) => Promise>() const listTodos = vi.fn<() => Promise>() const create = vi.fn() const remove = vi.fn() +const update = vi.fn() const createEntry = vi.fn() const updateEntry = vi.fn() const analyze = vi.fn() @@ -193,6 +195,7 @@ const getApplicationSettings = vi.fn<() => Promise>(async ( updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, + applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, magicNotesEnabled: true, @@ -217,6 +220,7 @@ beforeEach(() => { updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, + applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, magicNotesEnabled: true, @@ -229,6 +233,7 @@ beforeEach(() => { listTodos.mockResolvedValue({ todos: [noteTodo, manualTodo] }) create.mockResolvedValue(alternateDetail(thirdNoteId, '新笔记')) remove.mockResolvedValue() + update.mockResolvedValue({ ...detail, pinned: true, revision: 2 }) const createdDetail: MagicNoteDetail = { ...detail, revision: detail.revision + 1, @@ -351,6 +356,7 @@ beforeEach(() => { listTodos, create, remove, + update, createEntry, updateEntry, analyze, @@ -374,6 +380,116 @@ afterEach(() => { }) describe('MagicNotesWorkspace', () => { + it('portals note actions with keyboard navigation, confirmation and outside dismissal', async () => { + render() + const trigger = await screen.findByRole('button', { name: `更多笔记操作 ${detail.title}` }) + vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ right: 5000, bottom: 5000 } as DOMRect) + fireEvent.click(trigger) + const menu = screen.getByRole('menu') + expect(menu.parentElement).toBe(document.body) + expect(parseFloat(menu.style.left)).toBeLessThanOrEqual(window.innerWidth - 8) + 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('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()) + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('button', { name: '隐藏左侧列表' })) + 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}` }) + 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') + fireEvent.click(trigger) + 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}` })) + 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() + 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()) + }) + it('coalesces external writes, keeps selection and refreshes the selected detail', async () => { render() await screen.findByDisplayValue(detail.title) @@ -390,7 +506,8 @@ describe('MagicNotesWorkspace', () => { expect(screen.getByText(added.title)).toBeInTheDocument() expect(get).toHaveBeenLastCalledWith(noteId) expect(list).toHaveBeenCalledTimes(2) - expect(screen.getByRole('button', { name: '取消置顶' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: `更多笔记操作 ${updated.title}` })) + expect(screen.getByRole('menuitem', { name: '取消置顶' })).toBeInTheDocument() list.mockResolvedValue({ notes: [added] }) get.mockResolvedValue(added) @@ -505,7 +622,7 @@ describe('MagicNotesWorkspace', () => { list.mockImplementationOnce(() => new Promise((resolve) => { resolveList = resolve })) act(() => changeListener?.()) await waitFor(() => expect(list).toHaveBeenCalledTimes(2)) - fireEvent.click(screen.getByRole('button', { name: /Second note/ })) + 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() @@ -577,20 +694,19 @@ describe('MagicNotesWorkspace', () => { 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: '删除笔记' })) - fireEvent.click( - screen.getAllByRole('button', { name: '删除笔记' })[1]! - ) expect( await screen.findByText(/刷新失败,已保留当前内容:刷新暂时不可用/) ).toBeInTheDocument() expect(screen.getByLabelText('笔记标题')).toHaveValue(detail.title) expect( - screen.getByRole('button', { name: /发布笔记/ }) + screen.getByRole('button', { name: /发布笔记/, pressed: true }) ).toHaveAttribute('aria-pressed', 'true') const updatedTime = screen - .getByRole('button', { name: /发布笔记/ }) + .getByRole('button', { name: /发布笔记/, pressed: true }) .querySelector('time') expect(updatedTime).toHaveAttribute('datetime', detail.updatedAt) expect(updatedTime).toHaveTextContent('更新于') @@ -662,17 +778,10 @@ describe('MagicNotesWorkspace', () => { ) await screen.findByText('记录正文') - const deleteNote = screen.getByRole('button', { - name: '删除笔记' - }) + expect(container.querySelector('.magic-note-detail-header button')).toBeNull() const deleteEntry = screen.getByRole('button', { name: '删除记录' }) - expect(deleteNote).toHaveClass( - 'icon-button', - 'magic-note-detail-header__delete' - ) - expect(deleteNote).toHaveTextContent('') expect(deleteEntry).toHaveClass('danger-button', 'danger-button--quiet') expect(deleteEntry).toHaveTextContent('删除记录') @@ -1043,7 +1152,7 @@ describe('MagicNotesWorkspace', () => { expect(continueEditing).toHaveFocus() expect(get).toHaveBeenCalledTimes(callsBeforeSwitch) expect( - screen.getByRole('button', { name: /发布笔记/ }) + screen.getByRole('button', { name: /发布笔记/, pressed: true }) ).toHaveAttribute('aria-pressed', 'true') discardAndSwitch.focus() @@ -1076,7 +1185,7 @@ describe('MagicNotesWorkspace', () => { expect(await screen.findByDisplayValue(second.title)).toBeInTheDocument() expect( - screen.getByRole('button', { name: /第二篇笔记/ }) + screen.getByRole('button', { name: /第二篇笔记/, pressed: true }) ).toHaveAttribute('aria-pressed', 'true') }) @@ -1186,7 +1295,7 @@ describe('MagicNotesWorkspace', () => { expect(create).not.toHaveBeenCalled() expect( - screen.getByRole('button', { name: /发布笔记/ }) + screen.getByRole('button', { name: /发布笔记/, pressed: true }) ).toHaveAttribute('aria-pressed', 'true') fireEvent.click( @@ -1316,10 +1425,9 @@ describe('MagicNotesWorkspace', () => { 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: '删除笔记' })) - fireEvent.click( - screen.getAllByRole('button', { name: '删除笔记' })[1]! - ) const retry = await screen.findByRole('button', { name: '重试' }) let resolveRefreshDetail: @@ -1431,6 +1539,7 @@ describe('MagicNotesWorkspace', () => { updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, + applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, magicNotesEnabled: true, @@ -1467,6 +1576,7 @@ describe('MagicNotesWorkspace', () => { updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, + applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, magicNotesEnabled: true, @@ -1586,6 +1696,7 @@ describe('MagicNotesWorkspace', () => { updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, + applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, magicNotesEnabled: true, diff --git a/src/renderer/src/MagicNotesWorkspace.tsx b/src/renderer/src/MagicNotesWorkspace.tsx index fb105815..a639309e 100644 --- a/src/renderer/src/MagicNotesWorkspace.tsx +++ b/src/renderer/src/MagicNotesWorkspace.tsx @@ -8,6 +8,7 @@ import { FolderTree, Lightbulb, ListTodo, + MoreHorizontal, PanelLeftClose, PanelLeftOpen, PanelRightClose, @@ -22,11 +23,13 @@ import { useCallback, useEffect, useId, + useLayoutEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { createPortal } from 'react-dom' import type { MagicNoteAnalysisOptions, MagicNoteCommentDirection, @@ -39,7 +42,7 @@ import type { MagicNoteSummary, MagicTodoItem } from '../../shared/magic-notes-contracts' -import type { MagicNoteCommentMode } from '../../shared/application-settings-contracts' +import type { ApplicationSettings, MagicNoteCommentMode } from '../../shared/application-settings-contracts' import { MagicNoteContent } from './MagicNoteContent' import { MagicNoteEditor } from './MagicNoteEditor' import { MarkdownRenderer } from './MarkdownRenderer' @@ -47,6 +50,7 @@ import { activateModalFocus, trapTabFocus } from './dialog-focus' import type { AppNotificationInput } from './notifications' import { EmptyState, + DestructiveConfirmActions, PageHeader, PageTabs, SegmentedControl, @@ -54,6 +58,7 @@ import { } from './WorkspacePrimitives' export type MagicNotesWorkspaceProps = { + applicationSettings?: ApplicationSettings onNotify: (notification: AppNotificationInput) => void } @@ -393,7 +398,8 @@ function TodoListItem({ } export function MagicNotesWorkspace({ - onNotify + onNotify, + applicationSettings }: MagicNotesWorkspaceProps): React.JSX.Element { const { i18n, t } = useTranslation('magicNotes') const tRef = useRef(t) @@ -474,12 +480,14 @@ export function MagicNotesWorkspace({ const [todos, setTodos] = useState([]) const [libraryView, setLibraryView] = useState('notes') const [todoFilter, setTodoFilter] = useState('active') - const [commentMode, setCommentMode] = + const [loadedCommentMode, setCommentMode] = useState('immediate') const [commentDirection, setCommentDirection] = useState('general') - const [commentFormat, setCommentFormat] = + const [loadedCommentFormat, setCommentFormat] = useState('combined') + const commentMode = applicationSettings?.magicNoteCommentMode ?? loadedCommentMode + const commentFormat = applicationSettings?.magicNoteCommentFormat ?? loadedCommentFormat const [selectedNoteId, setSelectedNoteId] = useState('') const [selectedTodoId, setSelectedTodoId] = useState('') const [detail, setDetail] = useState() @@ -498,6 +506,9 @@ export function MagicNotesWorkspace({ const [newTitle, setNewTitle] = useState('') const [titleDraft, setTitleDraft] = useState('') const [deletingNote, setDeletingNote] = useState(false) + const [noteActionsId, setNoteActionsId] = useState('') + const noteActionsRef = useRef(null) + const noteActionTriggerRef = useRef(null) const [composerKey, setComposerKey] = useState(0) const [editingEntry, setEditingEntry] = useState() const [deletingEntryId, setDeletingEntryId] = useState('') @@ -1412,6 +1423,97 @@ export function MagicNotesWorkspace({ : notes }, [notes, search]) + const actionNote = useMemo( + () => listPaneOpen && libraryView === 'notes' && !pendingDraftSwitch + ? visibleNotes.find((note) => note.id === noteActionsId) + : undefined, + [libraryView, listPaneOpen, noteActionsId, pendingDraftSwitch, visibleNotes] + ) + if (noteActionsId && !actionNote) { + setNoteActionsId('') + setDeletingNote(false) + } + + const closeNoteActions = (): void => { + setNoteActionsId('') + setDeletingNote(false) + noteActionTriggerRef.current?.focus({ preventScroll: true }) + } + + useLayoutEffect(() => { + const surface = noteActionsRef.current + const trigger = noteActionTriggerRef.current + if (!noteActionsId || !surface || !trigger) return + const position = (): void => { + const anchor = trigger.getBoundingClientRect() + const bounds = surface.getBoundingClientRect() + surface.style.left = `${Math.max(8, Math.min(anchor.right - bounds.width, window.innerWidth - bounds.width - 8))}px` + surface.style.top = `${Math.max(8, Math.min(anchor.bottom + 4, window.innerHeight - bounds.height - 8))}px` + } + position() + surface.querySelector('button:not(:disabled)')?.focus({ preventScroll: true }) + const dismiss = (event: Event): void => { + if (event.target instanceof Node && !surface.contains(event.target) && !trigger.contains(event.target)) { + setNoteActionsId('') + setDeletingNote(false) + } + } + const observer = new ResizeObserver(position) + observer.observe(surface) + document.addEventListener('pointerdown', dismiss) + document.addEventListener('focusin', dismiss) + window.addEventListener('resize', position) + window.addEventListener('scroll', position, true) + return () => { + observer.disconnect() + document.removeEventListener('pointerdown', dismiss) + document.removeEventListener('focusin', dismiss) + window.removeEventListener('resize', position) + window.removeEventListener('scroll', position, true) + } + }, [noteActionsId]) + + const pinNote = async (note: MagicNoteSummary): Promise => { + const operation = 'pin-note' + if (!beginBusy(operation)) return + closeNoteActions() + try { + const updated = await window.goodbuddy.magicNotes.update({ + noteId: note.id, + pinned: !note.pinned, + expectedRevision: note.revision + }) + applyNoteSummary(updated) + setDetail((current) => current?.id === updated.id ? updated : current) + } catch (error) { + notifyError(error) + } finally { + endBusy(operation) + } + } + + const deleteNote = async (note: MagicNoteSummary): Promise => { + const operation = 'delete-note' + if (!beginBusy(operation)) return + try { + 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) + } + } catch (error) { + notifyError(error) + } finally { + endBusy(operation) + } + } + const visibleTodos = useMemo(() => { const normalized = search.trim().toLocaleLowerCase() return todos.filter((todo) => { @@ -2057,8 +2159,8 @@ export function MagicNotesWorkspace({ ) : ( visibleNotes.map((note) => ( +
+ +
)) )} + {actionNote && createPortal( + , document.body + )} ) : ( @@ -2305,7 +2465,10 @@ export function MagicNotesWorkspace({ aria-label={t('notes.titleLabel')} maxLength={100} value={titleDraft} - onBlur={() => void updateTitle()} + onBlur={(event) => { + if (event.relatedTarget instanceof Element && event.relatedTarget.closest('.magic-note-more, .magic-note-actions')) return + void updateTitle() + }} onChange={(event) => { setTitleDraft(event.target.value) clearValidation('note-title') @@ -2320,54 +2483,6 @@ export function MagicNotesWorkspace({ } }} /> -
- - -
{validation?.target === 'note-title' && (

)} - {deletingNote && ( -

- - {t('confirmations.deleteNote', { - title: detail.title - })} - - - -
- )} -
div { - display: flex; - align-items: center; - gap: var(--space-1); -} - -.magic-note-detail-header__delete { - color: var(--text-muted); -} - -.magic-note-detail-header__delete:hover { - background: var(--danger-subtle); - color: var(--danger); -} - -.magic-note-delete-confirmation { - display: flex; - align-items: center; - padding: var(--space-2) var(--space-3); - border: 1px solid var(--border-default); - border-radius: var(--radius-control); - margin-bottom: var(--space-3); - background: var(--surface-raised); - color: var(--text-secondary); - gap: var(--space-2); -} - -.magic-note-delete-confirmation > span { - min-width: 0; - flex: 1; -} - .magic-notes-field-error { margin: 0; color: var(--danger-strong);