feat: show incomplete Magic Notes todo counts

Magic Notes can now show a default-on incomplete todo badge in the primary
navigation. The badge hides at zero, caps its visual label at 99+ while
retaining the exact accessible count, and refreshes after user or Agent/MCP
note changes.

Platform Features > Magic Notes includes a persistent switch that hides only
the count without changing notes or the Magic Notes entry. Existing settings
migrate with the count enabled.

Release note: 左侧“魔法笔记”入口现在可显示未完成待办数量;可在“平台功能 → 魔法笔记”中关闭,超过 99 项显示“99+”。
This commit is contained in:
mesalogo
2026-08-22 08:32:57 +08:00
parent fee4686750
commit fb51793595
25 changed files with 538 additions and 25 deletions
+63 -2
View File
@@ -24,10 +24,17 @@ afterEach(async () => {
)
})
async function createDatabase(): Promise<AssistantDatabase> {
async function createDatabase(
options: {
onMagicTodosChanged?: () => void
} = {}
): Promise<AssistantDatabase> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-assistant-'))
temporaryDirectories.push(directory)
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
const database = new AssistantDatabase(
join(directory, 'assistant.sqlite'),
options
)
database.initialize('C:\\Workspace')
return database
}
@@ -4064,6 +4071,60 @@ describe('AssistantDatabase', () => {
database.close()
})
it('counts incomplete todos and publishes todo-changing writes', async () => {
const onMagicTodosChanged = vi.fn()
const database = await createDatabase({ onMagicTodosChanged })
const note = database.createMagicNote({ title: '发布笔记' })
expect(database.getMagicTodoStatus()).toEqual({
incompleteCount: 0
})
expect(onMagicTodosChanged).not.toHaveBeenCalled()
database.createMagicNoteEntry({
noteId: note.id,
content: {
version: 1,
ops: [
{ insert: '核对发布材料' },
{ insert: '\n', attributes: { list: 'unchecked' } },
{ insert: '上传构建产物' },
{ insert: '\n', attributes: { list: 'unchecked' } },
{ insert: '通知负责人' },
{ insert: '\n', attributes: { list: 'checked' } }
]
},
plainText: '核对发布材料\n上传构建产物\n通知负责人'
})
expect(database.getMagicTodoStatus()).toEqual({
incompleteCount: 2
})
expect(onMagicTodosChanged).toHaveBeenCalledTimes(1)
const todo = database
.listMagicTodos()
.find((candidate) => !candidate.completed)!
database.updateMagicTodo({
todoId: todo.id,
completed: true,
expectedRevision: todo.revision
})
expect(database.getMagicTodoStatus()).toEqual({
incompleteCount: 1
})
expect(onMagicTodosChanged).toHaveBeenCalledTimes(2)
database.deleteMagicNote(note.id)
expect(database.getMagicTodoStatus()).toEqual({
incompleteCount: 0
})
expect(onMagicTodosChanged).toHaveBeenCalledTimes(3)
database.close()
})
it('protects magic note records from stale revisions', async () => {
const database = await createDatabase()
const note = database.createMagicNote({ title: '并发笔记' })
+40 -6
View File
@@ -63,6 +63,7 @@ import {
type MagicNoteSearchResult,
type MagicNoteSummary,
type MagicTodoItem,
type MagicTodoStatus,
type MagicTodoUpdateInput
} from '../../shared/magic-notes-contracts'
import type { ComputerControlAuditEvent } from '../computer-control/audit'
@@ -967,7 +968,16 @@ export class AssistantDatabase {
private channelEventWrites = 0
private channelOutboxWrites = 0
constructor(private readonly databasePath: string) {}
constructor(
private readonly databasePath: string,
private readonly options: {
onMagicTodosChanged?: () => void
} = {}
) {}
private notifyMagicTodosChanged(): void {
this.options.onMagicTodosChanged?.()
}
initialize(defaultRootPath: string): void {
if (this.database) {
@@ -2605,7 +2615,11 @@ export class AssistantDatabase {
database.exec('ROLLBACK')
throw error
}
return this.getMagicNote(id)
const detail = this.getMagicNote(id)
if (input.content) {
this.notifyMagicTodosChanged()
}
return detail
}
updateMagicNote(input: {
@@ -2644,6 +2658,7 @@ export class AssistantDatabase {
if (result.changes !== 1) {
throw new Error('笔记不存在')
}
this.notifyMagicTodosChanged()
}
createMagicNoteEntry(input: {
@@ -2696,7 +2711,9 @@ export class AssistantDatabase {
database.exec('ROLLBACK')
throw error
}
return this.getMagicNote(input.noteId)
const detail = this.getMagicNote(input.noteId)
this.notifyMagicTodosChanged()
return detail
}
updateMagicNoteEntry(input: {
@@ -2758,7 +2775,9 @@ export class AssistantDatabase {
database.exec('ROLLBACK')
throw error
}
return this.getMagicNote(existing.note_id)
const detail = this.getMagicNote(existing.note_id)
this.notifyMagicTodosChanged()
return detail
}
deleteMagicNoteEntry(entryId: string): MagicNoteDetail {
@@ -2787,7 +2806,9 @@ export class AssistantDatabase {
database.exec('ROLLBACK')
throw error
}
return this.getMagicNote(existing.note_id)
const detail = this.getMagicNote(existing.note_id)
this.notifyMagicTodosChanged()
return detail
}
getMagicNoteEntry(entryId: string): MagicNoteEntry {
@@ -2861,6 +2882,17 @@ export class AssistantDatabase {
).map(toMagicTodo)
}
getMagicTodoStatus(): MagicTodoStatus {
const row = this.requireDatabase()
.prepare(
`SELECT COUNT(*) AS incomplete_count
FROM magic_todos
WHERE source = 'note' AND completed = 0`
)
.get() as { incomplete_count: number }
return { incompleteCount: row.incomplete_count }
}
searchMagicNotes(query: string, limit: number): MagicNoteSearchResult[] {
const pattern = `%${query.replace(/[\\%_]/gu, '\\$&')}%`
return (
@@ -2985,7 +3017,9 @@ export class AssistantDatabase {
database.exec('ROLLBACK')
throw error
}
return this.getMagicTodo(input.todoId)
const todo = this.getMagicTodo(input.todoId)
this.notifyMagicTodosChanged()
return todo
}
saveMagicTodoAnalysis(input: {