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
+117 -1
View File
@@ -107,6 +107,7 @@ let fileSelectionProgressListener:
>[0]
| undefined
let newConversationListener: (() => void) | undefined
let magicTodoStatusChangedListener: (() => void) | undefined
let maximizedChangedListener: ((maximized: boolean) => void) | undefined
let beforeQuitListener: (() => Promise<void>) | undefined
let conversationQueueDispatchListener:
@@ -682,13 +683,20 @@ const api: DesktopApi = {
throw new Error('not used')
}),
listTodos: vi.fn(async () => ({ todos: [] })),
getTodoStatus: vi.fn(async () => ({ incompleteCount: 0 })),
updateTodo: vi.fn(async () => {
throw new Error('not used')
}),
analyzeTodo: vi.fn(async () => {
throw new Error('not used')
}),
onAnalysisEvent: vi.fn(() => vi.fn())
onAnalysisEvent: vi.fn(() => vi.fn()),
onTodoStatusChanged: vi.fn((listener) => {
magicTodoStatusChangedListener = listener
return () => {
magicTodoStatusChangedListener = undefined
}
})
},
knowledge: {
getSnapshot: vi.fn(async () => ({
@@ -870,6 +878,18 @@ describe('App', () => {
delete document.documentElement.dataset.theme
document.documentElement.style.colorScheme = ''
vi.clearAllMocks()
magicTodoStatusChangedListener = undefined
vi.mocked(api.magicNotes.getTodoStatus)
.mockReset()
.mockResolvedValue({ incompleteCount: 0 })
vi.mocked(api.magicNotes.onTodoStatusChanged)
.mockReset()
.mockImplementation((listener) => {
magicTodoStatusChangedListener = listener
return () => {
magicTodoStatusChangedListener = undefined
}
})
vi.mocked(api.conversations.list).mockReset().mockResolvedValue([])
vi.mocked(api.conversations.replace)
.mockReset()
@@ -1849,6 +1869,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
})),
@@ -1942,6 +1963,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
})),
@@ -1950,6 +1972,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
})),
@@ -2044,6 +2067,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
})),
@@ -2052,6 +2076,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
})),
@@ -3887,6 +3912,7 @@ describe('App', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
}
@@ -8617,6 +8643,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
})),
@@ -8625,6 +8652,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
})),
@@ -8659,6 +8687,91 @@ describe('App', () => {
}
})
it('shows and refreshes the incomplete Magic Notes todo count', async () => {
vi.mocked(api.magicNotes.getTodoStatus).mockResolvedValueOnce({
incompleteCount: 4
})
api.updates = {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
})),
updateSettings: vi.fn(),
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {})
}
try {
render(<App />)
expect(
await screen.findByLabelText('4 个未完成待办')
).toHaveTextContent('4')
vi.mocked(api.magicNotes.getTodoStatus).mockResolvedValueOnce({
incompleteCount: 0
})
await act(async () => {
magicTodoStatusChangedListener?.()
})
await waitFor(() =>
expect(
screen.queryByLabelText('4 个未完成待办')
).not.toBeInTheDocument()
)
vi.mocked(api.magicNotes.getTodoStatus).mockResolvedValueOnce({
incompleteCount: 125
})
await act(async () => {
magicTodoStatusChangedListener?.()
})
expect(
await screen.findByLabelText('125 个未完成待办')
).toHaveTextContent('99+')
} finally {
delete api.updates
}
})
it('hides the todo count when its Magic Notes setting is off', async () => {
vi.mocked(api.magicNotes.getTodoStatus).mockResolvedValue({
incompleteCount: 4
})
api.updates = {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
})),
updateSettings: vi.fn(),
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {})
}
try {
render(<App />)
expect(
await screen.findByRole('button', { name: '魔法笔记' })
).toBeInTheDocument()
expect(
screen.queryByLabelText('4 个未完成待办')
).not.toBeInTheDocument()
expect(api.magicNotes.getTodoStatus).not.toHaveBeenCalled()
} finally {
delete api.updates
}
})
it('hides Magic Notes when the platform feature is disabled', async () => {
api.updates = {
getSettings: vi.fn(async () => ({
@@ -8666,6 +8779,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
})),
@@ -8674,6 +8788,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
})),
@@ -8707,6 +8822,7 @@ describe('App', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
}
+69
View File
@@ -2313,6 +2313,12 @@ function App(): React.JSX.Element {
const [settingsInitialChannel, setSettingsInitialChannel] =
useState<ProjectChannel>()
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
const [
magicNotesShowIncompleteTodoCount,
setMagicNotesShowIncompleteTodoCount
] = useState(true)
const [incompleteMagicTodoCount, setIncompleteMagicTodoCount] =
useState(0)
const [searchQuery, setSearchQuery] = useState('')
const deferredSearchQuery = useDeferredValue(searchQuery)
const [searchConversationSnapshot, setSearchConversationSnapshot] =
@@ -2713,6 +2719,9 @@ function App(): React.JSX.Element {
.getSettings()
.then(async (settings) => {
setMagicNotesEnabled(settings.magicNotesEnabled)
setMagicNotesShowIncompleteTodoCount(
settings.magicNotesShowIncompleteTodoCount
)
if (!settings.magicNotesEnabled) {
setView((current) =>
current === 'magic-notes' ? 'chat' : current
@@ -2736,6 +2745,42 @@ function App(): React.JSX.Element {
.catch(() => undefined)
}, [i18n, setView])
useEffect(() => {
const magicNotes = window.goodbuddy.magicNotes
if (
!magicNotesEnabled ||
!magicNotesShowIncompleteTodoCount
) {
return
}
let active = true
let requestId = 0
const refresh = async (): Promise<void> => {
const currentRequestId = ++requestId
try {
const status = await magicNotes.getTodoStatus()
if (active && requestId === currentRequestId) {
setIncompleteMagicTodoCount(status.incompleteCount)
}
} catch {
if (active && requestId === currentRequestId) {
setIncompleteMagicTodoCount(0)
}
}
}
const removeListener = magicNotes.onTodoStatusChanged(() => {
void refresh()
})
void refresh()
return () => {
active = false
removeListener()
}
}, [
magicNotesEnabled,
magicNotesShowIncompleteTodoCount
])
useEffect(() => {
const releaseNotesApi = window.goodbuddy.releaseNotes
if (!releaseNotesApi || startupReleaseNotesStartedRef.current) {
@@ -7489,6 +7534,19 @@ function App(): React.JSX.Element {
>
<Sparkles aria-hidden="true" size={17} />
<span>{t('navigation.magicNotes')}</span>
{magicNotesShowIncompleteTodoCount &&
incompleteMagicTodoCount > 0 && (
<span
aria-label={t('navigation.incompleteTodos', {
count: incompleteMagicTodoCount
})}
className="nav-item__badge"
>
{incompleteMagicTodoCount > 99
? '99+'
: incompleteMagicTodoCount}
</span>
)}
</button>
)}
<button
@@ -9619,7 +9677,18 @@ function App(): React.JSX.Element {
}}
onMagicNotesEnabledChange={(enabled) => {
setMagicNotesEnabled(enabled)
if (!enabled) {
setIncompleteMagicTodoCount(0)
}
}}
onMagicNotesShowIncompleteTodoCountChange={
(enabled) => {
setMagicNotesShowIncompleteTodoCount(enabled)
if (!enabled) {
setIncompleteMagicTodoCount(0)
}
}
}
onNotify={notify}
onLeaveRequestReady={registerSettingsLeaveRequester}
onSaved={(settings) => {
@@ -190,6 +190,7 @@ const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async (
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
}))
@@ -202,6 +203,7 @@ beforeEach(() => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
})
@@ -1065,6 +1067,7 @@ describe('MagicNotesWorkspace', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'combined'
})
@@ -1097,6 +1100,7 @@ describe('MagicNotesWorkspace', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative'
})
@@ -1212,6 +1216,7 @@ describe('MagicNotesWorkspace', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'after-save-auto',
magicNoteCommentFormat: 'combined'
})
@@ -25,6 +25,9 @@ import {
type PlatformFeaturesSettingsSectionProps = {
onMagicNotesEnabledChange: (enabled: boolean) => void
onMagicNotesShowIncompleteTodoCountChange: (
enabled: boolean
) => void
onNotify?: (notification: AppNotificationInput) => void
onDirtyChange?: (dirty: boolean) => void
onShortcutSettingsChanged?: (
@@ -48,6 +51,7 @@ const shortcutErrorTranslationKeys: Record<
export function PlatformFeaturesSettingsSection({
onMagicNotesEnabledChange,
onMagicNotesShowIncompleteTodoCountChange,
onNotify,
onDirtyChange,
onShortcutSettingsChanged
@@ -312,6 +316,32 @@ export function PlatformFeaturesSettingsSection({
}
}
const changeIncompleteTodoCount = async (
magicNotesShowIncompleteTodoCount: boolean
): Promise<void> => {
const updates = window.goodbuddy.updates
if (!updates || !settings) {
return
}
setSaving(true)
setError(undefined)
try {
const nextSettings = await updates.updateSettings({
magicNotesShowIncompleteTodoCount
})
setSettings(nextSettings)
onMagicNotesShowIncompleteTodoCountChange(
nextSettings.magicNotesShowIncompleteTodoCount
)
} catch {
setError(
t('platformFeatures.errors.saveIncompleteTodoCountFailed')
)
} finally {
setSaving(false)
}
}
const changeCommentFormat = async (
magicNoteCommentFormat: MagicNoteCommentFormat
): Promise<void> => {
@@ -580,6 +610,31 @@ export function PlatformFeaturesSettingsSection({
/>
<span>{t('platformFeatures.magicNotes.showEntry')}</span>
</label>
<label className="toggle-row">
<input
checked={
settings.magicNotesShowIncompleteTodoCount
}
disabled={saving}
onChange={(event) =>
void changeIncompleteTodoCount(
event.target.checked
)
}
role="switch"
type="checkbox"
/>
<span>
{t(
'platformFeatures.magicNotes.showIncompleteTodoCount'
)}
</span>
</label>
<p className="settings-notice">
{t(
'platformFeatures.magicNotes.showIncompleteTodoCountHelp'
)}
</p>
<div className="platform-feature-option">
<span>
{t('platformFeatures.magicNotes.commentMode')}
+20 -1
View File
@@ -404,6 +404,7 @@ let applicationSettings: ApplicationSettings = {
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
}
@@ -625,6 +626,7 @@ describe('SettingsPanel runtime files', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
}
@@ -1116,12 +1118,16 @@ describe('SettingsPanel runtime files', () => {
).toBeInTheDocument()
})
it('toggles the Magic Notes platform entry setting', async () => {
it('toggles Magic Notes navigation settings', async () => {
const onMagicNotesEnabledChange = vi.fn()
const onMagicNotesShowIncompleteTodoCountChange = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
onMagicNotesShowIncompleteTodoCountChange={
onMagicNotesShowIncompleteTodoCountChange
}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
@@ -1146,6 +1152,19 @@ describe('SettingsPanel runtime files', () => {
})
)
expect(onMagicNotesEnabledChange).toHaveBeenCalledWith(true)
const countToggle = screen.getByRole('switch', {
name: '显示未完成待办数量'
})
expect(countToggle).toBeChecked()
fireEvent.click(countToggle)
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
magicNotesShowIncompleteTodoCount: false
})
)
expect(
onMagicNotesShowIncompleteTodoCountChange
).toHaveBeenCalledWith(false)
fireEvent.click(
screen.getByRole('button', { name: '保存后自动' })
)
+7
View File
@@ -185,6 +185,9 @@ type SettingsPanelProps = {
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
magicNotesEnabled?: boolean
onMagicNotesEnabledChange?: (enabled: boolean) => void
onMagicNotesShowIncompleteTodoCountChange?: (
enabled: boolean
) => void
onShortcutSettingsChanged?: (
snapshot: GlobalShortcutSettingsSnapshot
) => void
@@ -542,6 +545,7 @@ export function SettingsPanel({
onAppearanceThemeChange = () => {},
magicNotesEnabled = false,
onMagicNotesEnabledChange = () => {},
onMagicNotesShowIncompleteTodoCountChange = () => {},
onShortcutSettingsChanged = () => {},
onLeaveRequestReady = () => {}
}: SettingsPanelProps): React.JSX.Element | null {
@@ -1857,6 +1861,9 @@ export function SettingsPanel({
<PlatformFeaturesSettingsSection
onDirtyChange={setPlatformFeaturesDirty}
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
onMagicNotesShowIncompleteTodoCountChange={
onMagicNotesShowIncompleteTodoCountChange
}
onNotify={onNotify}
onShortcutSettingsChanged={onShortcutSettingsChanged}
/>
@@ -22,6 +22,7 @@ describe('UpdateSettingsSection', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate' as const,
magicNoteCommentFormat: 'combined' as const
}
@@ -48,6 +48,7 @@ export const app = {
knowledge: 'Knowledge',
heartbeat: 'Smart Heartbeat',
activity: 'Run history',
incompleteTodos: '{{count}} incomplete to-dos',
pendingSuggestions: '{{count}} pending suggestions'
},
route: {
@@ -226,6 +226,8 @@ export const settingsSections = {
'Application settings are not available in this version',
readFailed: 'Could not load platform feature settings',
saveMagicNotesFailed: 'Could not save Magic Notes settings. Try again.',
saveIncompleteTodoCountFailed:
'Could not save the incomplete to-do count setting. Try again.',
saveCommentModeFailed:
'Could not save the AI comment mode. Try again.',
saveCommentFormatFailed:
@@ -297,6 +299,9 @@ export const settingsSections = {
description:
'Off by default. Enable it to capture notes and to-dos and analyze content with AI.',
showEntry: 'Show Magic Notes',
showIncompleteTodoCount: 'Show incomplete to-do count',
showIncompleteTodoCountHelp:
'Shows the number of incomplete to-dos beside Magic Notes in the primary navigation. Counts above 99 appear as 99+.',
commentMode: 'AI comment mode',
commentModeAria: 'Magic Notes AI comment mode',
modes: {
@@ -45,6 +45,7 @@ export const app = {
knowledge: '知识库',
heartbeat: '智能心跳',
activity: '运行记录',
incompleteTodos: '{{count}} 个未完成待办',
pendingSuggestions: '{{count}} 条待处理建议'
},
route: {
@@ -211,6 +211,8 @@ export const settingsSections = {
serviceUnavailable: '当前版本未提供应用设置服务',
readFailed: '读取平台功能设置失败',
saveMagicNotesFailed: '保存魔法笔记设置失败,请重试',
saveIncompleteTodoCountFailed:
'保存未完成待办数量设置失败,请重试',
saveCommentModeFailed: '保存 AI 评论方式失败,请重试',
saveCommentFormatFailed: '保存 AI 评论形式失败,请重试',
saveModelDownloadSourceFailed: '保存模型下载源失败,请重试'
@@ -267,6 +269,9 @@ export const settingsSections = {
title: '魔法笔记',
description: '默认关闭;开启后可记录笔记与待办,并使用 AI 分析内容',
showEntry: '显示魔法笔记入口',
showIncompleteTodoCount: '显示未完成待办数量',
showIncompleteTodoCountHelp:
'开启后,左侧魔法笔记入口显示未完成待办数量;超过 99 项显示 99+。',
commentMode: 'AI 评论方式',
commentModeAria: '魔法笔记 AI 评论方式',
modes: {