From e1fe1cf10aff711ca3d21c63017165ed003d64d3 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Tue, 15 Sep 2026 00:31:21 +0800 Subject: [PATCH] fix: refresh magic notes after external updates Subscribe the open Magic Notes workspace to committed database changes from desktop and Agent/MCP writes. Preserve selections and unsaved drafts during refresh and external deletion. File-level commit includes existing runtime edits in shared files; their remaining dependencies are still in the working tree. Release note: Magic Notes now updates automatically after Agent/MCP edits while preserving your selection and unsaved drafts. --- FEATURES.md | 3 +- FEATURES.zh-CN.md | 2 +- UI-DESIGN.md | 2 + docs/features/magic-notes/README.md | 18 ++ docs/features/magic-notes/progress.md | 26 +++ docs/features/magic-notes/technical-design.md | 60 ++++++ src/main/agent/knowledge-mcp-gateway.test.ts | 14 +- src/main/assistant/assistant-database.test.ts | 69 ++++++- src/main/assistant/assistant-database.ts | 88 +++++++-- src/main/index.ts | 19 +- src/preload/index.ts | 6 + src/preload/magic-notes-events.test.ts | 25 +++ src/renderer/src/App.test.tsx | 1 + src/renderer/src/MagicNotesWorkspace.test.tsx | 182 ++++++++++++++++++ src/renderer/src/MagicNotesWorkspace.tsx | 81 ++++++-- .../src/i18n/locales/en-US/magicNotes.ts | 2 + .../src/i18n/locales/zh-CN/magicNotes.ts | 2 + src/shared/contracts.ts | 19 +- src/shared/ipc-channels.ts | 1 + 19 files changed, 571 insertions(+), 49 deletions(-) create mode 100644 docs/features/magic-notes/README.md create mode 100644 docs/features/magic-notes/progress.md create mode 100644 docs/features/magic-notes/technical-design.md create mode 100644 src/preload/magic-notes-events.test.ts diff --git a/FEATURES.md b/FEATURES.md index a3983ee5..4bc1aa31 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -542,7 +542,8 @@ otherwise. showing the previous item's source after a selection change. Failed reads use application notifications, 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. + results use application-wide notifications. Agent/MCP note writes automatically + refresh the open workbench while preserving selection and unsaved drafts. - [ ] **MCP Server Control Plane** (planned): Unified MCP lifecycle, health checks, reconnection, schema cache, isolation, approval, and audit. - [ ] **Traceable note excerpts and AI editing** (planned): Collects sourced diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md index fdad20ba..db33116d 100644 --- a/FEATURES.zh-CN.md +++ b/FEATURES.zh-CN.md @@ -84,7 +84,7 @@ - [x] **分块、维护与评估**:支持固定、结构化和父子分块,分块维护、可取消重建及双语检索评估。 - [x] **受控知识本体**:每个知识库可定义实体、关系、别名和端点约束,保留证据偏移、置信度和抽取来源,并显式提示图谱重建。 - [x] **强制检索与引用上下文**:对话可按需或每次先检索,显示零结果、降级、失败与取消状态,并可查看引用上下文或安全打开来源。 -- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;来源记录直接显示在所选待办的详情中,切换事项不会混入上一项的来源内容,读取失败使用应用通知,来源缺失有明确提示。左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。 +- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;来源记录直接显示在所选待办的详情中,切换事项不会混入上一项的来源内容,读取失败使用应用通知,来源缺失有明确提示。左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。Agent / MCP 写入后,已打开的工作台自动更新,并保留当前选择与未保存草稿。 - [ ] **MCP Server Control Plane**(规划中):统一 MCP 生命周期、健康检查、重连、Schema 缓存、隔离、审批和审计。 - [ ] **可追溯笔记摘录与 AI 编辑**(规划中):从对话、知识和网页收集带来源的摘录,并提供需确认的总结、改写和整理操作。 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 21a0618f..d105f254 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -715,6 +715,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 左侧“魔法笔记”入口可显示未完成待办数量。数量为 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 new file mode 100644 index 00000000..97694d20 --- /dev/null +++ b/docs/features/magic-notes/README.md @@ -0,0 +1,18 @@ +# Magic Notes + +Magic Notes is the local notes, checklist and AI-comment workbench. Desktop +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. + +## Documents + +- [Technical design](./technical-design.md): database notifications, process + boundaries, refresh lifecycle and regression coverage. +- [Progress](./progress.md): implementation and validation evidence. +- [UI design system](../../../UI-DESIGN.md#137-魔法笔记): authoritative workbench + interaction rules, including selection and draft preservation. + +An entry is a saved rich-text record within a note. A todo is a checklist item +derived from an entry. An unsaved draft belongs to the open editor; it is not +written to storage by a background refresh. diff --git a/docs/features/magic-notes/progress.md b/docs/features/magic-notes/progress.md new file mode 100644 index 00000000..b2bd850e --- /dev/null +++ b/docs/features/magic-notes/progress.md @@ -0,0 +1,26 @@ +# Magic Notes Progress + +## 2026-09-15: Automatic External Refresh + +- Implemented the shared-database notification and Main/preload subscription + described in [technical design](./technical-design.md). +- The open workbench reloads external changes while retaining current selection + and unsaved drafts. Deleted notes and edited entries retain drafts with an + explicit explanation. The cancelled manual-refresh proposal is not implemented. +- An initial completed `npm test` run passed 357 files and 4,201 tests, with + 9 files and 67 tests skipped (680.45 seconds). The first invocation had exceeded + the command tool's 120-second limit. +- After three additional renderer race/retry tests, the final `npm test` run + reported 355 files and 4,202 tests passed, 9 files and 67 tests skipped, and two + unrelated timeout failures: the large offline dependency inventory test in + `tests/agent-package.test.ts` (60 seconds), and the two-project OpenCode case in + `src/main/agent/local-runtime-reuse.test.ts` (40-second request timeout). +- Rerunning those two files together passed 24 tests with one skipped in + 103.71 seconds, without source or timeout changes. The final renderer/preload + focused run also passed all 38 tests. No Magic Notes regression failed. +- `npm run typecheck` and `npm run lint`: passed. The new required subscription + method is also present in the application test API fixture. +- Validation uses production SQLite and Agent/MCP gateway code, the production + preload with mocked Electron transport, and renderer interaction tests. An + interactive Electron window was not exercised. No deployed Agent code or + desktop-to-Agent protocol changed, so no separate Linux Host scenario applies. diff --git a/docs/features/magic-notes/technical-design.md b/docs/features/magic-notes/technical-design.md new file mode 100644 index 00000000..0b4c4319 --- /dev/null +++ b/docs/features/magic-notes/technical-design.md @@ -0,0 +1,60 @@ +# Magic Notes Technical Design + +## Change Notifications + +`AssistantDatabase` accepts `onMagicNotesChanged: () => void`. Every successful +note/entry create, update or delete emits once after the write commits, including +empty-note creation, initial content, title/pin updates and cascading deletion. +Saved note analysis, todo completion, saved todo analysis and assistant-data +clearing also emit. Revision conflicts, missing rows, rolled-back writes and +unchanged todo completion do not emit. Startup schema migrations run before the +workbench loads and do not publish notifications. + +Desktop IPC and `KnowledgeMcpGateway` use this same database instance. The latter +also serves Agent tools; no tool-specific notification or daemon protocol change +is needed. Raw SQL writes from an unrelated process are outside this callback +contract. + +Main queues notification delivery to the live window on `magic-notes:changed`. +The typed preload method `magicNotes.onChanged(listener)` exposes no Electron +objects and returns a function removing that listener. The existing +`onTodoStatusChanged` event remains the separate sidebar-count signal. + +## Refresh Lifecycle + +`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 +refresh results. This does not introduce periodic database polling. + +The existing list/detail loader reloads notes and todos, then the preferred note. +Request counters reject superseded results and preserve newer user selections. +Todo selection survives list reordering while the item exists; replacing the todo +snapshot also reloads the selected todo's source entry. Search, filters and layout +state are not reset. + +Background application of a detail reads the current draft state after the +asynchronous fetch. A dirty title is retained, and the composer is not remounted. +An entry editor uses its original editing content and revision for both its React +key and save request; external revisions cannot remount it or bypass optimistic +concurrency checks. Deleted entries remain displayed only while their editor is +open. If an externally deleted note has a draft, its editor remains available +with a deletion explanation instead of moving the draft to another note. + +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-魔法笔记). + +## Validation + +- `assistant-database.test.ts` checks committed writes with a second SQLite + connection, successful mutation coverage, failures, no-op completion and reset. +- `knowledge-mcp-gateway.test.ts` exercises real database CRUD through Agent/MCP + access, including revision failure without an extra notification. +- `magic-notes-events.test.ts` executes the production preload, checking the + 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. + +No schema migration, dependency, refresh control or network service is added. diff --git a/src/main/agent/knowledge-mcp-gateway.test.ts b/src/main/agent/knowledge-mcp-gateway.test.ts index 5ae7b79b..741f7179 100644 --- a/src/main/agent/knowledge-mcp-gateway.test.ts +++ b/src/main/agent/knowledge-mcp-gateway.test.ts @@ -783,8 +783,10 @@ describe('KnowledgeMcpGateway', () => { const { service } = createService() const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-note-mcp-')) temporaryDirectories.push(directory) + const onMagicNotesChanged = vi.fn() const database = new AssistantDatabase( - join(directory, 'assistant.sqlite') + join(directory, 'assistant.sqlite'), + { onMagicNotesChanged } ) databases.push(database) database.initialize('C:\\Workspace') @@ -838,12 +840,20 @@ describe('KnowledgeMcpGateway', () => { }) ]) expect(created.entries[0]?.content).toBe('核对构建产物') + expect(onMagicNotesChanged).toHaveBeenCalledTimes(1) + gateway.updateMagicNote(writeToken, { + noteId: created.id, + title: 'Updated plan', + expectedRevision: created.revision + }) + expect(onMagicNotesChanged).toHaveBeenCalledTimes(2) const withEntry = gateway.createMagicNoteEntry(writeToken, { noteId: created.id, content: '通知发布负责人' }) const entry = withEntry.entries[1]! expect(entry.content).toBe('通知发布负责人') + expect(onMagicNotesChanged).toHaveBeenCalledTimes(3) const updatedEntry = gateway.updateMagicNoteEntry(writeToken, { entryId: entry.id, @@ -859,6 +869,7 @@ describe('KnowledgeMcpGateway', () => { expectedRevision: entry.revision }) ).toThrow('已被更新') + expect(onMagicNotesChanged).toHaveBeenCalledTimes(4) const withoutEntry = gateway.deleteMagicNoteEntry(writeToken, { entryId: entry.id, @@ -873,6 +884,7 @@ describe('KnowledgeMcpGateway', () => { expectedRevision: withoutEntry.revision }) ).toEqual({ deleted: true, noteId: created.id }) + expect(onMagicNotesChanged).toHaveBeenCalledTimes(6) expect(() => gateway.getMagicNote(readToken, { noteId: created.id }) ).toThrow('笔记不存在') diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 957caa31..7124d376 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -31,6 +31,7 @@ afterEach(async () => { async function createDatabase( options: { + onMagicNotesChanged?: () => void onMagicTodosChanged?: () => void } = {} ): Promise { @@ -3438,7 +3439,7 @@ describe('AssistantDatabase', () => { raw.close() }) - it('creates and recovers a remote-authoritative conversation task', async () => { + it.each(['failed', 'cancelled'] as const)('creates and recovers a remote-authoritative conversation task, then ends it as %s', async (terminalStatus) => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-remote-conversation-recovery-') ) @@ -3731,14 +3732,15 @@ describe('AssistantDatabase', () => { content: recoveredText, state: 'complete' }) - reopened.failRecoverableRemoteTask( + reopened.endRecoverableRemoteTask( taskId, - '远端请求已不存在' + '远端请求已不存在', + terminalStatus ) expect( reopened.listTasks().find((task) => task.id === taskId) ).toMatchObject({ - status: 'failed', + status: terminalStatus, error: '远端请求已不存在' }) expect( @@ -6546,6 +6548,65 @@ describe('AssistantDatabase', () => { database.close() }) + it('publishes committed note, entry, todo and analysis writes but not failed writes', async () => { + const onMagicNotesChanged = vi.fn() + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-note-events-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const database = new AssistantDatabase(databasePath, { onMagicNotesChanged }) + database.initialize('C:\\Workspace') + const reader = new DatabaseSync(databasePath, { readOnly: true }) + // A separate connection must already see each emitted write. + const observed: string[][] = [] + onMagicNotesChanged.mockImplementation(() => { + observed.push((reader.prepare('SELECT title FROM magic_notes').all() as Array<{ title: string }>).map((note) => note.title)) + }) + try { + const note = database.createMagicNote({ title: 'Empty note' }) + expect(observed).toEqual([['Empty note']]) + database.updateMagicNote({ noteId: note.id, title: 'Renamed', pinned: true, expectedRevision: 0 }) + expect(observed.at(-1)).toEqual(['Renamed']) + expect(() => database.updateMagicNote({ noteId: note.id, title: 'Stale', expectedRevision: 0 })).toThrow() + expect(onMagicNotesChanged).toHaveBeenCalledTimes(2) + const content = { + version: 1 as const, + ops: [{ insert: 'Task' }, { insert: '\n', attributes: { list: 'unchecked' as const } }] + } + const withEntry = database.createMagicNoteEntry({ noteId: note.id, content, plainText: 'Task' }) + const entry = withEntry.entries[0]! + expect(onMagicNotesChanged).toHaveBeenCalledTimes(3) + database.updateMagicNoteEntry({ entryId: entry.id, content, plainText: 'Task', expectedRevision: entry.revision }) + expect(() => database.updateMagicNoteEntry({ entryId: entry.id, content, plainText: 'Stale', expectedRevision: entry.revision })).toThrow() + expect(() => database.createMagicNoteEntry({ noteId: randomUUID(), content, plainText: 'Missing' })).toThrow() + expect(onMagicNotesChanged).toHaveBeenCalledTimes(4) + const todo = database.listMagicTodos()[0]! + database.updateMagicTodo({ todoId: todo.id, completed: false, expectedRevision: todo.revision }) + expect(onMagicNotesChanged).toHaveBeenCalledTimes(4) + const completed = database.updateMagicTodo({ todoId: todo.id, completed: true, expectedRevision: todo.revision }) + expect(onMagicNotesChanged).toHaveBeenCalledTimes(5) + database.saveMagicTodoAnalysis({ todoId: todo.id, comments: [], expectedRevision: completed.revision }) + expect(onMagicNotesChanged).toHaveBeenCalledTimes(6) + database.saveMagicNoteAnalysis({ entryId: entry.id, comments: [], expectedRevision: database.getMagicNoteEntry(entry.id).revision }) + expect(() => database.saveMagicNoteAnalysis({ entryId: entry.id, comments: [], expectedRevision: 0 })).toThrow() + expect(onMagicNotesChanged).toHaveBeenCalledTimes(7) + database.deleteMagicNoteEntry(entry.id) + expect(onMagicNotesChanged).toHaveBeenCalledTimes(8) + database.deleteMagicNote(note.id) + expect(() => database.deleteMagicNote(note.id)).toThrow() + expect(() => database.deleteMagicNoteEntry(entry.id)).toThrow() + expect(onMagicNotesChanged).toHaveBeenCalledTimes(9) + expect(observed.at(-1)).toEqual([]) + database.createMagicNote({ title: 'With content', content }) + expect(onMagicNotesChanged).toHaveBeenCalledTimes(10) + database.clearAssistantData() + expect(onMagicNotesChanged).toHaveBeenCalledTimes(11) + expect(observed.at(-1)).toEqual([]) + } finally { + reader.close() + database.close() + } + }) + it('counts incomplete todos and publishes todo-changing writes', async () => { const onMagicTodosChanged = vi.fn() const database = await createDatabase({ onMagicTodosChanged }) diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 8c1df6d1..5d35342c 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -6,6 +6,7 @@ import { builtInDefaultProjectSeedName, activityHistorySnapshotSchema, assistantIdSchema, + conversationAnsweredQuestionSchema, conversationMessageSchema, conversationSnapshotSchema, expertCreateSchema, @@ -26,6 +27,7 @@ import type { AssistantTask, ActivityHistorySnapshot, ConversationQueueItem, + ConversationAnsweredQuestion, ConversationBranchInput, ConversationMessageBlock, ConversationSubagentActivity, @@ -1761,6 +1763,7 @@ export class AssistantDatabase { constructor( private readonly databasePath: string, private readonly options: { + onMagicNotesChanged?: () => void onMagicTodosChanged?: () => void } = {} ) {} @@ -2063,6 +2066,7 @@ export class AssistantDatabase { database.exec('ROLLBACK') throw error } + this.options.onMagicNotesChanged?.() } listProjects(includeArchived = false): AssistantProject[] { @@ -3675,6 +3679,7 @@ export class AssistantDatabase { throw error } const detail = this.getMagicNote(id) + this.options.onMagicNotesChanged?.() if (input.content) { this.notifyMagicTodosChanged() } @@ -3707,7 +3712,9 @@ export class AssistantDatabase { if (result.changes !== 1) { throw new Error('笔记已被更新,请刷新后重试') } - return this.getMagicNote(input.noteId) + const detail = this.getMagicNote(input.noteId) + this.options.onMagicNotesChanged?.() + return detail } deleteMagicNote(noteId: string): void { @@ -3717,6 +3724,7 @@ export class AssistantDatabase { if (result.changes !== 1) { throw new Error('笔记不存在') } + this.options.onMagicNotesChanged?.() this.notifyMagicTodosChanged() } @@ -3771,6 +3779,7 @@ export class AssistantDatabase { throw error } const detail = this.getMagicNote(input.noteId) + this.options.onMagicNotesChanged?.() this.notifyMagicTodosChanged() return detail } @@ -3835,6 +3844,7 @@ export class AssistantDatabase { throw error } const detail = this.getMagicNote(existing.note_id) + this.options.onMagicNotesChanged?.() this.notifyMagicTodosChanged() return detail } @@ -3866,6 +3876,7 @@ export class AssistantDatabase { throw error } const detail = this.getMagicNote(existing.note_id) + this.options.onMagicNotesChanged?.() this.notifyMagicTodosChanged() return detail } @@ -3923,7 +3934,9 @@ export class AssistantDatabase { if (result.changes !== 1) { throw new Error('记录已被更新,请重新分析') } - return this.getMagicNote(existing.note_id) + const detail = this.getMagicNote(existing.note_id) + this.options.onMagicNotesChanged?.() + return detail } listMagicTodos(): MagicTodoItem[] { @@ -4077,6 +4090,7 @@ export class AssistantDatabase { throw error } const todo = this.getMagicTodo(input.todoId) + this.options.onMagicNotesChanged?.() this.notifyMagicTodosChanged() return todo } @@ -4118,7 +4132,9 @@ export class AssistantDatabase { if (result.changes !== 1) { throw new Error('待办已被更新,请刷新后重试') } - return this.getMagicTodo(input.todoId) + const todo = this.getMagicTodo(input.todoId) + this.options.onMagicNotesChanged?.() + return todo } listPendingDelegationResults(): Array<{ @@ -4581,11 +4597,55 @@ export class AssistantDatabase { }) } - failRecoverableRemoteTask(taskIdInput: string, messageInput: string): void { + recordRemoteTaskQuestionAnswer( + taskIdInput: string, + answerInput: ConversationAnsweredQuestion + ): boolean { + const taskId = assistantIdSchema.parse(taskIdInput) + const answer = conversationAnsweredQuestionSchema.parse(answerInput) + const database = this.requireDatabase() + database.exec('BEGIN IMMEDIATE') + try { + const row = database.prepare( + `SELECT m.id, m.conversation_id, m.metadata_json + FROM tasks t JOIN messages m + ON m.id = t.current_assistant_message_id + AND m.conversation_id = t.conversation_id AND m.request_id = t.id + WHERE t.id = ? AND t.remote_recoverable = 1 AND m.role = 'assistant'` + ).get(taskId) as { + id: string; conversation_id: string; metadata_json: string + } | undefined + if (!row) { + database.exec('COMMIT') + return false + } + const metadata = JSON.parse(row.metadata_json) as MessageMetadata + metadata.answeredQuestions = [ + ...(metadata.answeredQuestions ?? []).filter(item => item.questionId !== answer.questionId), + answer + ] + database.prepare('UPDATE messages SET metadata_json = ? WHERE id = ?') + .run(JSON.stringify(metadata), row.id) + database.prepare( + 'UPDATE conversations SET updated_at = MAX(updated_at, ?) WHERE id = ?' + ).run(new Date().toISOString(), row.conversation_id) + database.exec('COMMIT') + return true + } catch (error) { + database.exec('ROLLBACK') + throw error + } + } + + endRecoverableRemoteTask( + taskIdInput: string, + messageInput: string, + status: 'failed' | 'cancelled' + ): void { const taskId = assistantIdSchema.parse(taskIdInput) const message = messageInput.trim().slice(0, 4_000) if (!message) { - throw new Error('远程恢复失败原因不能为空') + throw new Error('远程任务终止原因不能为空') } const database = this.requireDatabase() const now = new Date().toISOString() @@ -4657,10 +4717,10 @@ export class AssistantDatabase { attachments: metadata.attachments, answeredQuestions: metadata.answeredQuestions }) - const failed = reduceRecoveredAgentEvent(current, { + const ended = reduceRecoveredAgentEvent(current, { requestId: taskId, type: 'error', - status: 'failed', + status, message }) database @@ -4670,9 +4730,9 @@ export class AssistantDatabase { WHERE id = ? AND conversation_id = ? AND request_id = ?` ) .run( - failed.content, - failed.state, - serializeConversationMessageMetadata(failed), + ended.content, + ended.state, + serializeConversationMessageMetadata(ended), owner.current_assistant_message_id, owner.conversation_id, taskId @@ -4680,10 +4740,10 @@ export class AssistantDatabase { database .prepare( `UPDATE tasks - SET status = 'failed', error = ?, completed_at = ? + SET status = ?, error = ?, completed_at = ? WHERE id = ?` ) - .run(message, now, taskId) + .run(status, message, now, taskId) database .prepare( `INSERT INTO task_events @@ -4695,7 +4755,7 @@ export class AssistantDatabase { JSON.stringify({ requestId: taskId, type: 'error', - status: 'failed', + status, message }), now @@ -7747,7 +7807,7 @@ export class AssistantDatabase { return readProject(this.requireDatabase(), projectId) } - private getTask(taskId: string): AssistantTask { + getTask(taskId: string): AssistantTask { const row = this.requireDatabase() .prepare(`${taskSelect} WHERE tasks.id = ?`) .get(taskId) as TaskRow | undefined diff --git a/src/main/index.ts b/src/main/index.ts index 88bcda3f..240962f4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -22,6 +22,7 @@ import { import { AgentRuntimeController } from './agent/runtime-controller' import type { AgentRuntime } from './agent/runtime' import { SelectedRuntimeManager } from './agent/selected-runtime-manager' +import { LocalRuntimeRegistry } from './agent/local-runtime-registry' import { KnowledgeMcpGateway } from './agent/knowledge-mcp-gateway' import { applyRuntimeSelection, @@ -206,6 +207,7 @@ let removeIpcHandlers: (() => Promise) | undefined let removeFeedbackIpcHandler: (() => void) | undefined let runtime: AgentRuntimeController | undefined let selectedRuntimeManager: SelectedRuntimeManager | undefined +const localRuntimeRegistry = new LocalRuntimeRegistry() let knowledgeService: KnowledgeService | undefined let knowledgeGateway: KnowledgeMcpGateway | undefined let assistantDatabase: AssistantDatabase | undefined @@ -881,6 +883,17 @@ if (hasSingleInstanceLock) { const startupAssistantDatabase = new AssistantDatabase( join(app.getPath('userData'), 'assistant.sqlite'), { + onMagicNotesChanged: () => { + queueMicrotask(() => { + if ( + mainWindow && + !mainWindow.isDestroyed() && + !mainWindow.webContents.isDestroyed() + ) { + mainWindow.webContents.send(ipcChannels.magicNotesChanged) + } + }) + }, onMagicTodosChanged: () => { queueMicrotask(() => { if ( @@ -954,6 +967,7 @@ if (hasSingleInstanceLock) { : Promise.resolve([]) ]) return createAgentRuntime(defaultWorkspace, settings, { + localRuntimeRegistry, skillInstructions: skillContext.instructions, skillPackages: skillContext.packages, mcpServers, @@ -1148,7 +1162,8 @@ if (hasSingleInstanceLock) { undefined, undefined, observeDesktopFailure, - createSelectedStatusRuntime + createSelectedStatusRuntime, + (conversationId) => localRuntimeRegistry.releaseConversation(conversationId) ) const contextManager = new ContextManager({ parseDocument: documentParsingService.parse @@ -1229,6 +1244,7 @@ if (hasSingleInstanceLock) { nextSubagentProfileRuntimes ) await selectedRuntimeManager?.reset() + localRuntimeRegistry.reset(nextRuntime) const previousEmbeddingProvider = activeEmbeddingProvider activeEmbeddingProvider = nextEmbeddingProvider activeRerankProvider = nextRerankProvider @@ -1530,6 +1546,7 @@ app.on('before-quit', (event) => { () => documentOcrModelManager?.dispose(), () => documentOcrBroker?.dispose() ], + [() => localRuntimeRegistry.dispose()], [() => terminalSessionManager?.dispose()], [() => localToolEnvironmentService?.dispose()], [() => managedRemoteExecutionServices?.dispose()], diff --git a/src/preload/index.ts b/src/preload/index.ts index 03ab50b4..d910b867 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1604,6 +1604,12 @@ const desktopApi: DesktopApi = { handler ) }, + onChanged: (listener) => { + const handler = (): void => listener() + ipcRenderer.on(ipcChannels.magicNotesChanged, handler) + return () => + ipcRenderer.removeListener(ipcChannels.magicNotesChanged, handler) + }, onTodoStatusChanged: (listener) => { const handler = (): void => listener() ipcRenderer.on(ipcChannels.magicTodosStatusChanged, handler) diff --git a/src/preload/magic-notes-events.test.ts b/src/preload/magic-notes-events.test.ts new file mode 100644 index 00000000..1ea16bf9 --- /dev/null +++ b/src/preload/magic-notes-events.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest' +import type { DesktopApi } from '../shared/contracts' +import { ipcChannels } from '../shared/ipc-channels' + +const electron = vi.hoisted(() => ({ + contextBridge: { exposeInMainWorld: vi.fn() }, + ipcRenderer: { on: vi.fn(), removeListener: vi.fn() }, + webUtils: {} +})) +vi.mock('electron', () => electron) + +describe('Magic Notes preload events', () => { + it('forwards change notifications without exposing the Electron event and removes the same listener', async () => { + await import('./index') + const api = electron.contextBridge.exposeInMainWorld.mock.calls[0]![1] as DesktopApi + const listener = vi.fn() + const unsubscribe = api.magicNotes.onChanged(listener) + const [channel, handler] = electron.ipcRenderer.on.mock.calls[0]! + expect(channel).toBe(ipcChannels.magicNotesChanged) + handler({ sender: 'must stay in preload' }) + expect(listener).toHaveBeenCalledExactlyOnceWith() + unsubscribe() + expect(electron.ipcRenderer.removeListener).toHaveBeenCalledExactlyOnceWith(channel, handler) + }) +}) diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index f1a694f4..57984349 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -811,6 +811,7 @@ const api: DesktopApi = { throw new Error("not used"); }), onAnalysisEvent: vi.fn(() => vi.fn()), + onChanged: vi.fn(() => vi.fn()), onTodoStatusChanged: vi.fn((listener) => { magicTodoStatusChangedListener = listener; return () => { diff --git a/src/renderer/src/MagicNotesWorkspace.test.tsx b/src/renderer/src/MagicNotesWorkspace.test.tsx index b4085e15..8dafa0d8 100644 --- a/src/renderer/src/MagicNotesWorkspace.test.tsx +++ b/src/renderer/src/MagicNotesWorkspace.test.tsx @@ -201,10 +201,17 @@ const getApplicationSettings = vi.fn<() => Promise>(async ( magicNoteCommentFormat: 'combined' })) const onNotify = vi.fn() +let changeListener: (() => void) | undefined +const unsubscribeChanges = vi.fn() +const onChanged = vi.fn((listener) => { + changeListener = listener + return unsubscribeChanges +}) beforeEach(() => { localStorage.clear() analysisEventListener = undefined + changeListener = undefined getApplicationSettings.mockResolvedValue({ checkUpdatesOnStartup: false, updateSource: 'github', @@ -350,6 +357,7 @@ beforeEach(() => { updateTodo, analyzeTodo, analyzeDraft, + onChanged, onAnalysisEvent }, updates: { @@ -366,6 +374,180 @@ afterEach(() => { }) describe('MagicNotesWorkspace', () => { + it('coalesces external writes, keeps selection and refreshes the selected detail', async () => { + render() + 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(get).toHaveBeenLastCalledWith(noteId) + expect(list).toHaveBeenCalledTimes(2) + expect(screen.getByRole('button', { name: '取消置顶' })).toBeInTheDocument() + + list.mockResolvedValue({ notes: [added] }) + get.mockResolvedValue(added) + act(() => changeListener?.()) + await screen.findByDisplayValue(added.title) + expect(screen.queryByDisplayValue(updated.title)).not.toBeInTheDocument() + }) + + it('preserves title and composer drafts when external entries arrive', 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' }] } + })) + }) + + 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 () => { + 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) + list.mockResolvedValue({ notes: [] }) + listTodos.mockResolvedValue({ todos: [] }) + act(() => changeListener?.()) + await screen.findByText(/这篇笔记已在其他入口删除/) + expect(title).toHaveValue('Unsaved title') + expect(screen.getByTestId('magic-note-editor')).toBe(composer) + expect(screen.queryByRole('button', { name: /发布笔记.*整理发布清单/ })).not.toBeInTheDocument() + }) + + it('preserves the selected todo and reloads its source after external writes', async () => { + render() + await screen.findByText('记录正文') + fireEvent.click(screen.getByRole('tab', { name: '待办' })) + 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' }] }) + 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)) + }) + + it('unsubscribes and cancels queued refreshes on unmount', async () => { + const { unmount } = render() + await screen.findByDisplayValue(detail.title) + vi.useFakeTimers() + act(() => changeListener?.()) + unmount() + await act(() => vi.advanceTimersByTimeAsync(200)) + 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/ })) + 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('详情暂时不可用')) diff --git a/src/renderer/src/MagicNotesWorkspace.tsx b/src/renderer/src/MagicNotesWorkspace.tsx index 0460dc9f..fb105815 100644 --- a/src/renderer/src/MagicNotesWorkspace.tsx +++ b/src/renderer/src/MagicNotesWorkspace.tsx @@ -544,6 +544,10 @@ export function MagicNotesWorkspace({ const refreshRequestRef = useRef(0) const hasLoadedRef = useRef(false) const busyRef = useRef('') + const refreshContextRef = useRef({ detail, titleDraft, editingEntry }) + useEffect(() => { + refreshContextRef.current = { detail, titleDraft, editingEntry } + }, [detail, titleDraft, editingEntry]) const composerContentRef = useRef( undefined ) @@ -1273,7 +1277,7 @@ export function MagicNotesWorkspace({ }, [pendingDraftSwitch]) const refreshNotes = useCallback( - async (preferredId?: string): Promise => { + async (preferredId?: string, background = false): Promise => { const requestId = ++refreshRequestRef.current const detailRequestAtStart = detailRequestRef.current await Promise.resolve() @@ -1307,17 +1311,44 @@ export function MagicNotesWorkspace({ snapshot.notes.some((note) => note.id === requestedNoteId) setNotes(snapshot.notes) setTodos(todoSnapshot.todos) - setSelectedTodoId(todoSnapshot.todos[0]?.id ?? '') + setSelectedTodoId((current) => + todoSnapshot.todos.some((todo) => todo.id === current) + ? current + : todoSnapshot.todos[0]?.id ?? '' + ) hasLoadedRef.current = true setLoadStatus('ready') if (preserveNewerSelection) { return } + const current = refreshContextRef.current + const titleDirty = current.detail && + current.titleDraft !== current.detail.title + if (background && current.detail?.id !== nextId) { + if ( + titleDirty || + hasContent(composerContentRef.current) || + current.editingEntry + ) { + setRefreshError(tRef.current('errors.noteDeletedExternally')) + return + } + discardComposerDraft() + discardEditingDraft() + } detailRequestRef.current += 1 requestedNoteIdRef.current = nextId setSelectedNoteId(nextId) setDetail(nextDetail) - setTitleDraft(nextDetail?.title ?? '') + if (!background || !titleDirty) { + setTitleDraft(nextDetail?.title ?? '') + } + if ( + background && current.editingEntry && nextDetail && + !nextDetail.entries.some((entry) => entry.id === current.editingEntry?.id) + ) { + setRefreshError(tRef.current('errors.entryDeletedExternally')) + } setDetailLoadError(undefined) } catch (loadError) { if (refreshRequestRef.current === requestId) { @@ -1334,7 +1365,7 @@ export function MagicNotesWorkspace({ } } }, - [] + [discardComposerDraft, discardEditingDraft] ) useEffect(() => { @@ -1348,6 +1379,28 @@ export function MagicNotesWorkspace({ } }, [refreshNotes]) + useEffect(() => { + let timeout: number | undefined + const refresh = (): void => { + if (busyRef.current) { + timeout = window.setTimeout(refresh, 100) + return + } + timeout = undefined + void refreshNotes(requestedNoteIdRef.current, true) + } + const unsubscribe = window.goodbuddy.magicNotes.onChanged(() => { + refreshRequestRef.current += 1 + window.clearTimeout(timeout) + timeout = window.setTimeout(refresh, 100) + }) + return () => { + unsubscribe() + window.clearTimeout(timeout) + refreshRequestRef.current += 1 + } + }, [refreshNotes]) + const visibleNotes = useMemo(() => { const query = search.trim().toLocaleLowerCase() return query @@ -1424,7 +1477,7 @@ export function MagicNotesWorkspace({ return () => { todoSourceRequestRef.current += 1 } - }, [libraryView, notifyError, selectedTodoNoteId]) + }, [libraryView, notifyError, selectedTodoNoteId, todos]) const selectedTodoSourceEntry = useMemo( () => @@ -1452,6 +1505,13 @@ export function MagicNotesWorkspace({ .reverse(), [detail] ) + const displayedEntries = useMemo(() => { + const entries = detail?.entries ?? [] + return editingEntry && editingEntry.noteId === detail?.id && + !entries.some((entry) => entry.id === editingEntry.id) + ? [...entries, editingEntry] + : entries + }, [detail, editingEntry]) const listPaneWidthLimits = getListPaneWidthLimits( magicNotesLayoutWidth, aiPaneWidth, @@ -1578,7 +1638,6 @@ export function MagicNotesWorkspace({ applyDetail(updated) await reloadTodos() } catch (updateError) { - setTitleDraft(detail.title) notifyError(updateError) } finally { endBusy(operation) @@ -1840,7 +1899,7 @@ export function MagicNotesWorkspace({