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:
+2
-1
@@ -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
@@ -84,7 +84,7 @@
|
||||
- [x] **分块、维护与评估**:支持固定、结构化和父子分块,分块维护、可取消重建及双语检索评估。
|
||||
- [x] **受控知识本体**:每个知识库可定义实体、关系、别名和端点约束,保留证据偏移、置信度和抽取来源,并显式提示图谱重建。
|
||||
- [x] **强制检索与引用上下文**:对话可按需或每次先检索,显示零结果、降级、失败与取消状态,并可查看引用上下文或安全打开来源。
|
||||
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;来源记录直接显示在所选待办的详情中,切换事项不会混入上一项的来源内容,读取失败使用应用通知,来源缺失有明确提示。左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。
|
||||
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;来源记录直接显示在所选待办的详情中,切换事项不会混入上一项的来源内容,读取失败使用应用通知,来源缺失有明确提示。左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。Agent / MCP 写入后,已打开的工作台自动更新,并保留当前选择与未保存草稿。
|
||||
- [ ] **MCP Server Control Plane**(规划中):统一 MCP 生命周期、健康检查、重连、Schema 缓存、隔离、审批和审计。
|
||||
- [ ] **可追溯笔记摘录与 AI 编辑**(规划中):从对话、知识和网页收集带来源的摘录,并提供需确认的总结、改写和整理操作。
|
||||
|
||||
|
||||
@@ -715,6 +715,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。
|
||||
- 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。
|
||||
- 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。
|
||||
- Agent / MCP 或其他入口成功写入笔记后,已打开的工作台自动更新笔记列表、当前详情、待办及来源记录,不增加刷新按钮。保留仍存在的笔记与待办选择、搜索和筛选,以及未保存的标题、输入区和记录编辑草稿;普通后台刷新不重建编辑器。
|
||||
- 外部删除当前笔记时,无草稿则选择剩余笔记;有草稿则保留编辑区并提示先复制草稿。外部删除正在编辑的记录时,同样保留该编辑器并明确提示记录已删除;保存失败不得清空草稿。
|
||||
- 当前记录草稿非空时,切换笔记、待办或内容面板必须先在编辑器旁就地确认;继续编辑时保留草稿并恢复编辑焦点,只有明确选择放弃后才切换。
|
||||
- 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。
|
||||
- 标题或正文校验、删除确认、同步进度和可就地恢复的错误仍靠近对应编辑器或操作呈现。
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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('笔记不存在')
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
@@ -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()],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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('详情暂时不可用'))
|
||||
|
||||
|
||||
@@ -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
@@ -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[]>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user