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
+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',