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.
This commit is contained in:
mesalogo
2026-09-15 00:31:21 +08:00
parent fbba2e3abf
commit e1fe1cf10a
19 changed files with 571 additions and 49 deletions
+2 -1
View File
@@ -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
+1 -1
View File
@@ -84,7 +84,7 @@
- [x] **分块、维护与评估**:支持固定、结构化和父子分块,分块维护、可取消重建及双语检索评估。
- [x] **受控知识本体**:每个知识库可定义实体、关系、别名和端点约束,保留证据偏移、置信度和抽取来源,并显式提示图谱重建。
- [x] **强制检索与引用上下文**:对话可按需或每次先检索,显示零结果、降级、失败与取消状态,并可查看引用上下文或安全打开来源。
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;来源记录直接显示在所选待办的详情中,切换事项不会混入上一项的来源内容,读取失败使用应用通知,来源缺失有明确提示。左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;来源记录直接显示在所选待办的详情中,切换事项不会混入上一项的来源内容,读取失败使用应用通知,来源缺失有明确提示。左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。Agent / MCP 写入后,已打开的工作台自动更新,并保留当前选择与未保存草稿。
- [ ] **MCP Server Control Plane**(规划中):统一 MCP 生命周期、健康检查、重连、Schema 缓存、隔离、审批和审计。
- [ ] **可追溯笔记摘录与 AI 编辑**(规划中):从对话、知识和网页收集带来源的摘录,并提供需确认的总结、改写和整理操作。
+2
View File
@@ -715,6 +715,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。
- 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。
- 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。
- Agent / MCP 或其他入口成功写入笔记后,已打开的工作台自动更新笔记列表、当前详情、待办及来源记录,不增加刷新按钮。保留仍存在的笔记与待办选择、搜索和筛选,以及未保存的标题、输入区和记录编辑草稿;普通后台刷新不重建编辑器。
- 外部删除当前笔记时,无草稿则选择剩余笔记;有草稿则保留编辑区并提示先复制草稿。外部删除正在编辑的记录时,同样保留该编辑器并明确提示记录已删除;保存失败不得清空草稿。
- 当前记录草稿非空时,切换笔记、待办或内容面板必须先在编辑器旁就地确认;继续编辑时保留草稿并恢复编辑焦点,只有明确选择放弃后才切换。
- 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。
- 标题或正文校验、删除确认、同步进度和可就地恢复的错误仍靠近对应编辑器或操作呈现。
+18
View File
@@ -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.
+26
View File
@@ -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.
@@ -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.
+13 -1
View File
@@ -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('笔记不存在')
+65 -4
View File
@@ -31,6 +31,7 @@ afterEach(async () => {
async function createDatabase(
options: {
onMagicNotesChanged?: () => void
onMagicTodosChanged?: () => void
} = {}
): Promise<AssistantDatabase> {
@@ -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 })
+74 -14
View File
@@ -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
+18 -1
View File
@@ -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<void>) | 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()],
+6
View File
@@ -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)
+25
View File
@@ -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)
})
})
+1
View File
@@ -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 () => {
@@ -201,10 +201,17 @@ const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async (
magicNoteCommentFormat: 'combined'
}))
const onNotify = vi.fn()
let changeListener: (() => void) | undefined
const unsubscribeChanges = vi.fn()
const onChanged = vi.fn<DesktopApi['magicNotes']['onChanged']>((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(<MagicNotesWorkspace onNotify={onNotify} />)
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(<MagicNotesWorkspace onNotify={onNotify} />)
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(<MagicNotesWorkspace onNotify={onNotify} />)
await screen.findByText('记录正文')
fireEvent.click(screen.getByRole('button', { name: '编辑' }))
const editor = container.querySelector<HTMLButtonElement>('.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(<MagicNotesWorkspace onNotify={onNotify} />)
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(<MagicNotesWorkspace onNotify={onNotify} />)
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(<MagicNotesWorkspace onNotify={onNotify} />)
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(<MagicNotesWorkspace onNotify={onNotify} />)
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(<MagicNotesWorkspace onNotify={onNotify} />)
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(<MagicNotesWorkspace onNotify={onNotify} />)
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('详情暂时不可用'))
+70 -11
View File
@@ -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<MagicNoteRichContent | undefined>(
undefined
)
@@ -1273,7 +1277,7 @@ export function MagicNotesWorkspace({
}, [pendingDraftSwitch])
const refreshNotes = useCallback(
async (preferredId?: string): Promise<void> => {
async (preferredId?: string, background = false): Promise<void> => {
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({
</span>
<button
className="secondary-button"
onClick={() => void refreshNotes(selectedNoteId)}
onClick={() => void refreshNotes(selectedNoteId, true)}
type="button"
>
{t('actions.retry')}
@@ -2466,12 +2525,12 @@ export function MagicNotesWorkspace({
</div>
<div className="magic-note-entry-stream">
{detail.entries.length === 0 ? (
{displayedEntries.length === 0 ? (
<p className="magic-notes-muted">
{t('notes.emptyEntries')}
</p>
) : (
[...detail.entries].reverse().map((entry) => (
[...displayedEntries].reverse().map((entry) => (
<article
key={entry.id}
id={`magic-note-entry-${entry.id}`}
@@ -2566,7 +2625,7 @@ export function MagicNotesWorkspace({
{editingEntry?.id === entry.id ? (
<div className="magic-note-entry__editor">
<MagicNoteEditor
key={`${entry.id}-${entry.revision}`}
key={`${editingEntry.id}-${editingEntry.revision}`}
ariaDescribedBy={
validation?.target === 'edit-entry'
? 'magic-note-entry-edit-error'
@@ -2576,7 +2635,7 @@ export function MagicNotesWorkspace({
validation?.target === 'edit-entry'
}
ariaLabel={t('notes.editEntryLabel')}
initialContent={entry.content}
initialContent={editingEntry.content}
onChange={(content) => {
editingContentRef.current = content
clearValidation('edit-entry')
@@ -59,6 +59,8 @@ export const magicNotes = {
todoReopened: 'To-do marked incomplete'
},
errors: {
noteDeletedExternally: 'This note was deleted elsewhere. Your unsaved drafts are still here; copy them before switching notes.',
entryDeletedExternally: 'The entry being edited was deleted elsewhere. Your draft is still here; copy it before leaving the editor.',
operationFailed: 'The operation failed. Try again.',
initialLoadTitle: 'Could not load Magic Notes',
initialLoadDescription: 'Could not load Magic Notes: {{error}}',
@@ -55,6 +55,8 @@ export const magicNotes = {
todoReopened: '待办已恢复为未完成'
},
errors: {
noteDeletedExternally: '这篇笔记已在其他入口删除。未保存的草稿仍保留在此,切换笔记前请复制草稿。',
entryDeletedExternally: '正在编辑的记录已在其他入口删除。草稿仍保留在此,退出编辑前请复制草稿。',
operationFailed: '操作失败,请重试',
initialLoadTitle: '魔法笔记加载失败',
initialLoadDescription: '无法加载魔法笔记:{{error}}',
+3 -16
View File
@@ -43,6 +43,7 @@ import {
type ActivityRecord,
type TokenUsageSummary,
type ConversationSnapshot,
type LiveAgentQuestion,
type ConversationAttachment,
type ConversationBranchInput,
type ConversationQueueItem,
@@ -1304,22 +1305,7 @@ export type AgentEvent =
argumentSummary?: string
allowPermanent?: boolean
}
| {
requestId: string
type: 'question'
questionId: string
childTaskId?: string
questions: Array<{
header: string
question: string
options: Array<{
label: string
description: string
}>
multiple: boolean
custom: boolean
}>
}
| LiveAgentQuestion
| {
requestId: string
type: 'question-resolved'
@@ -2286,6 +2272,7 @@ export type DesktopApi = {
listener: (event: MagicNoteAnalysisStreamEvent) => void
) => () => void
onTodoStatusChanged: (listener: () => void) => () => void
onChanged: (listener: () => void) => () => void
}
knowledge: {
externalInstancesList: () => Promise<import('./external-knowledge-contracts').ExternalKnowledgeInstanceSummary[]>
+1
View File
@@ -276,6 +276,7 @@ export const ipcChannels = {
magicTodosList: 'magic-todos:list',
magicTodosStatus: 'magic-todos:status',
magicTodosStatusChanged: 'magic-todos:status-changed',
magicNotesChanged: 'magic-notes:changed',
magicTodosUpdate: 'magic-todos:update',
magicTodosAnalyze: 'magic-todos:analyze',
knowledgeSnapshot: 'knowledge:snapshot',