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:
+1
-1
@@ -51,7 +51,7 @@
|
|||||||
- [x] **分块、维护与评估**:支持固定、结构化和父子分块,分块维护、可取消重建及双语检索评估。
|
- [x] **分块、维护与评估**:支持固定、结构化和父子分块,分块维护、可取消重建及双语检索评估。
|
||||||
- [x] **受控知识本体**:每个知识库可定义实体、关系、别名和端点约束,保留证据偏移、置信度和抽取来源,并显式提示图谱重建。
|
- [x] **受控知识本体**:每个知识库可定义实体、关系、别名和端点约束,保留证据偏移、置信度和抽取来源,并显式提示图谱重建。
|
||||||
- [x] **强制检索与引用上下文**:对话可按需或每次先检索,显示零结果、降级、失败与取消状态,并可查看引用上下文或安全打开来源。
|
- [x] **强制检索与引用上下文**:对话可按需或每次先检索,显示零结果、降级、失败与取消状态,并可查看引用上下文或安全打开来源。
|
||||||
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;创建、保存和评论结果使用统一应用通知。
|
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。
|
||||||
- [ ] **MCP Server Control Plane**(规划中):统一 MCP 生命周期、健康检查、重连、Schema 缓存、隔离、审批和审计。
|
- [ ] **MCP Server Control Plane**(规划中):统一 MCP 生命周期、健康检查、重连、Schema 缓存、隔离、审批和审计。
|
||||||
- [ ] **可追溯笔记摘录与 AI 编辑**(规划中):从对话、知识和网页收集带来源的摘录,并提供需确认的总结、改写和整理操作。
|
- [ ] **可追溯笔记摘录与 AI 编辑**(规划中):从对话、知识和网页收集带来源的摘录,并提供需确认的总结、改写和整理操作。
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -613,6 +613,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
|||||||
|
|
||||||
- “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs` 的 `segmented` 视觉变体,与模型设置的分段控件保持同一外观。
|
- “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs` 的 `segmented` 视觉变体,与模型设置的分段控件保持同一外观。
|
||||||
- 页签切换保留 `tablist`、`tab` 和 `tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。
|
- 页签切换保留 `tablist`、`tab` 和 `tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。
|
||||||
|
- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。
|
||||||
- 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。
|
- 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。
|
||||||
- 当前记录草稿非空时,切换笔记、待办或内容面板必须先在编辑器旁就地确认;继续编辑时保留草稿并恢复编辑焦点,只有明确选择放弃后才切换。
|
- 当前记录草稿非空时,切换笔记、待办或内容面板必须先在编辑器旁就地确认;继续编辑时保留草稿并恢复编辑焦点,只有明确选择放弃后才切换。
|
||||||
- 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。
|
- 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。
|
||||||
@@ -633,7 +634,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
|||||||
- “模型连接”的认证字段按“认证方式 → API Key → 凭据状态”排列;只有选择 API Key 时显示输入框。已保存的 API Key 属于该连接,修改模型服务地址或临时选择“无需认证”不得自动清除,也不得要求重新输入;仅由明确的“清除凭据”或“删除连接”操作移除。
|
- “模型连接”的认证字段按“认证方式 → API Key → 凭据状态”排列;只有选择 API Key 时显示输入框。已保存的 API Key 属于该连接,修改模型服务地址或临时选择“无需认证”不得自动清除,也不得要求重新输入;仅由明确的“清除凭据”或“删除连接”操作移除。
|
||||||
- “保存并测试模型”必须在保存后发送一次有界的真实模型请求。文本连接校验本次随机测试文本,图像连接校验实际返回的内联图片;仅收到成功 HTTP 状态、模型列表或健康检查响应不算测试成功。界面持续说明该操作可能产生少量服务商用量费用,成功反馈明确写为“真实生成测试通过”。
|
- “保存并测试模型”必须在保存后发送一次有界的真实模型请求。文本连接校验本次随机测试文本,图像连接校验实际返回的内联图片;仅收到成功 HTTP 状态、模型列表或健康检查响应不算测试成功。界面持续说明该操作可能产生少量服务商用量费用,成功反馈明确写为“真实生成测试通过”。
|
||||||
- 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。
|
- 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。
|
||||||
- “平台功能”使用共享 `PageTabs` 区分“通用设置”和“魔法笔记”,默认进入通用设置。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
|
- “平台功能”使用共享 `PageTabs` 区分“通用设置”和“魔法笔记”,默认进入通用设置。“魔法笔记”页提供默认开启的“显示未完成待办数量”持久 Switch,关闭后只隐藏左侧数量徽标,不改变待办数据或魔法笔记入口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
|
||||||
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后,下拉框置灰且不可操作。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查和下载页,不显示底层服务商名称。
|
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后,下拉框置灰且不可操作。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查和下载页,不显示底层服务商名称。
|
||||||
- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。
|
- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。
|
||||||
- Agent Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。
|
- Agent Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。
|
||||||
|
|||||||
@@ -58,13 +58,15 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
store.update({
|
store.update({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
magicNotesEnabled: false
|
magicNotesEnabled: false,
|
||||||
|
magicNotesShowIncompleteTodoCount: false
|
||||||
})
|
})
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
|
magicNotesShowIncompleteTodoCount: false,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
})
|
})
|
||||||
@@ -73,15 +75,17 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
|
magicNotesShowIncompleteTodoCount: false,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
})
|
})
|
||||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||||
version: 7,
|
version: 8,
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
|
magicNotesShowIncompleteTodoCount: false,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined',
|
magicNoteCommentFormat: 'combined',
|
||||||
lastSeenReleaseNotesVersion: null
|
lastSeenReleaseNotesVersion: null
|
||||||
@@ -107,6 +111,7 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
})
|
})
|
||||||
@@ -130,6 +135,7 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
})
|
})
|
||||||
@@ -153,6 +159,7 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
})
|
})
|
||||||
@@ -176,6 +183,7 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'after-save-manual',
|
magicNoteCommentMode: 'after-save-manual',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
})
|
})
|
||||||
@@ -201,11 +209,12 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
new ApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
|
new ApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
|
||||||
).resolves.toBe('0.8.18')
|
).resolves.toBe('0.8.18')
|
||||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||||
version: 7,
|
version: 8,
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'after-save-manual',
|
magicNoteCommentMode: 'after-save-manual',
|
||||||
magicNoteCommentFormat: 'narrative',
|
magicNoteCommentFormat: 'narrative',
|
||||||
lastSeenReleaseNotesVersion: '0.8.18'
|
lastSeenReleaseNotesVersion: '0.8.18'
|
||||||
@@ -232,6 +241,7 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'after-save-auto',
|
magicNoteCommentMode: 'after-save-auto',
|
||||||
magicNoteCommentFormat: 'structured'
|
magicNoteCommentFormat: 'structured'
|
||||||
})
|
})
|
||||||
@@ -258,6 +268,7 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
updateSource: 'mirror',
|
updateSource: 'mirror',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'after-save-auto',
|
magicNoteCommentMode: 'after-save-auto',
|
||||||
magicNoteCommentFormat: 'structured'
|
magicNoteCommentFormat: 'structured'
|
||||||
})
|
})
|
||||||
@@ -268,11 +279,40 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
await store.update({ modelDownloadSource: 'hugging-face' })
|
await store.update({ modelDownloadSource: 'hugging-face' })
|
||||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||||
...versionSix,
|
...versionSix,
|
||||||
version: 7,
|
version: 8,
|
||||||
modelDownloadSource: 'hugging-face'
|
modelDownloadSource: 'hugging-face',
|
||||||
|
magicNotesShowIncompleteTodoCount: true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('migrates version 7 to show incomplete todo counts', async () => {
|
||||||
|
const { filePath, store } = await createStore()
|
||||||
|
const versionSeven = {
|
||||||
|
version: 7,
|
||||||
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
|
modelDownloadSource: 'modelscope',
|
||||||
|
magicNotesEnabled: true,
|
||||||
|
magicNoteCommentMode: 'immediate',
|
||||||
|
magicNoteCommentFormat: 'combined',
|
||||||
|
lastSeenReleaseNotesVersion: null
|
||||||
|
}
|
||||||
|
await writeFile(filePath, JSON.stringify(versionSeven), 'utf8')
|
||||||
|
|
||||||
|
await expect(store.get()).resolves.toEqual({
|
||||||
|
checkUpdatesOnStartup: false,
|
||||||
|
updateSource: 'github',
|
||||||
|
modelDownloadSource: 'modelscope',
|
||||||
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
|
magicNoteCommentMode: 'immediate',
|
||||||
|
magicNoteCommentFormat: 'combined'
|
||||||
|
})
|
||||||
|
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual(
|
||||||
|
versionSeven
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('strictly rejects incomplete full settings', () => {
|
it('strictly rejects incomplete full settings', () => {
|
||||||
for (const input of [
|
for (const input of [
|
||||||
{},
|
{},
|
||||||
@@ -319,6 +359,7 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
})
|
})
|
||||||
@@ -433,15 +474,17 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
})
|
})
|
||||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||||
version: 7,
|
version: 8,
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined',
|
magicNoteCommentFormat: 'combined',
|
||||||
lastSeenReleaseNotesVersion: null
|
lastSeenReleaseNotesVersion: null
|
||||||
@@ -467,6 +510,7 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export {
|
|||||||
} from '../shared/application-settings-contracts'
|
} from '../shared/application-settings-contracts'
|
||||||
export type { ApplicationSettings } from '../shared/application-settings-contracts'
|
export type { ApplicationSettings } from '../shared/application-settings-contracts'
|
||||||
|
|
||||||
const CURRENT_SETTINGS_VERSION = 7
|
const CURRENT_SETTINGS_VERSION = 8
|
||||||
|
|
||||||
const legacyStoredApplicationSettingsSchema = z
|
const legacyStoredApplicationSettingsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -47,14 +47,22 @@ const versionThreeStoredApplicationSettingsSchema = z
|
|||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema
|
const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema
|
||||||
.omit({ updateSource: true, modelDownloadSource: true })
|
.omit({
|
||||||
|
updateSource: true,
|
||||||
|
modelDownloadSource: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true
|
||||||
|
})
|
||||||
.extend({
|
.extend({
|
||||||
version: z.literal(4)
|
version: z.literal(4)
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema
|
const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema
|
||||||
.omit({ updateSource: true, modelDownloadSource: true })
|
.omit({
|
||||||
|
updateSource: true,
|
||||||
|
modelDownloadSource: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true
|
||||||
|
})
|
||||||
.extend({
|
.extend({
|
||||||
version: z.literal(5),
|
version: z.literal(5),
|
||||||
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
|
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
|
||||||
@@ -62,13 +70,25 @@ const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema
|
|||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema
|
const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema
|
||||||
.omit({ modelDownloadSource: true })
|
.omit({
|
||||||
|
modelDownloadSource: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true
|
||||||
|
})
|
||||||
.extend({
|
.extend({
|
||||||
version: z.literal(6),
|
version: z.literal(6),
|
||||||
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
|
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
|
const versionSevenStoredApplicationSettingsSchema =
|
||||||
|
applicationSettingsSchema
|
||||||
|
.omit({ magicNotesShowIncompleteTodoCount: true })
|
||||||
|
.extend({
|
||||||
|
version: z.literal(7),
|
||||||
|
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
const storedApplicationSettingsSchema = applicationSettingsSchema
|
const storedApplicationSettingsSchema = applicationSettingsSchema
|
||||||
.extend({
|
.extend({
|
||||||
version: z.literal(CURRENT_SETTINGS_VERSION),
|
version: z.literal(CURRENT_SETTINGS_VERSION),
|
||||||
@@ -85,6 +105,7 @@ export const defaultApplicationSettings: ApplicationSettings = {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
}
|
}
|
||||||
@@ -140,13 +161,24 @@ export class ApplicationSettingsStore {
|
|||||||
)
|
)
|
||||||
const result = storedApplicationSettingsSchema.safeParse(parsed)
|
const result = storedApplicationSettingsSchema.safeParse(parsed)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
const versionSevenResult =
|
||||||
|
versionSevenStoredApplicationSettingsSchema.safeParse(parsed)
|
||||||
|
if (versionSevenResult.success) {
|
||||||
|
this.settings = {
|
||||||
|
...versionSevenResult.data,
|
||||||
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
|
magicNotesShowIncompleteTodoCount: true
|
||||||
|
}
|
||||||
|
return this.settings
|
||||||
|
}
|
||||||
const versionSixResult =
|
const versionSixResult =
|
||||||
versionSixStoredApplicationSettingsSchema.safeParse(parsed)
|
versionSixStoredApplicationSettingsSchema.safeParse(parsed)
|
||||||
if (versionSixResult.success) {
|
if (versionSixResult.success) {
|
||||||
this.settings = {
|
this.settings = {
|
||||||
...versionSixResult.data,
|
...versionSixResult.data,
|
||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
modelDownloadSource: 'modelscope'
|
modelDownloadSource: 'modelscope',
|
||||||
|
magicNotesShowIncompleteTodoCount: true
|
||||||
}
|
}
|
||||||
return this.settings
|
return this.settings
|
||||||
}
|
}
|
||||||
@@ -157,7 +189,8 @@ export class ApplicationSettingsStore {
|
|||||||
...versionFiveResult.data,
|
...versionFiveResult.data,
|
||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope'
|
modelDownloadSource: 'modelscope',
|
||||||
|
magicNotesShowIncompleteTodoCount: true
|
||||||
}
|
}
|
||||||
return this.settings
|
return this.settings
|
||||||
}
|
}
|
||||||
@@ -169,6 +202,7 @@ export class ApplicationSettingsStore {
|
|||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
lastSeenReleaseNotesVersion: null
|
lastSeenReleaseNotesVersion: null
|
||||||
}
|
}
|
||||||
return this.settings
|
return this.settings
|
||||||
@@ -181,6 +215,7 @@ export class ApplicationSettingsStore {
|
|||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentFormat: 'combined',
|
magicNoteCommentFormat: 'combined',
|
||||||
lastSeenReleaseNotesVersion: null
|
lastSeenReleaseNotesVersion: null
|
||||||
}
|
}
|
||||||
@@ -194,6 +229,7 @@ export class ApplicationSettingsStore {
|
|||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined',
|
magicNoteCommentFormat: 'combined',
|
||||||
lastSeenReleaseNotesVersion: null
|
lastSeenReleaseNotesVersion: null
|
||||||
@@ -210,6 +246,7 @@ export class ApplicationSettingsStore {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined',
|
magicNoteCommentFormat: 'combined',
|
||||||
lastSeenReleaseNotesVersion: null
|
lastSeenReleaseNotesVersion: null
|
||||||
@@ -251,6 +288,8 @@ export class ApplicationSettingsStore {
|
|||||||
updateSource: stored.updateSource,
|
updateSource: stored.updateSource,
|
||||||
modelDownloadSource: stored.modelDownloadSource,
|
modelDownloadSource: stored.modelDownloadSource,
|
||||||
magicNotesEnabled: stored.magicNotesEnabled,
|
magicNotesEnabled: stored.magicNotesEnabled,
|
||||||
|
magicNotesShowIncompleteTodoCount:
|
||||||
|
stored.magicNotesShowIncompleteTodoCount,
|
||||||
magicNoteCommentMode: stored.magicNoteCommentMode,
|
magicNoteCommentMode: stored.magicNoteCommentMode,
|
||||||
magicNoteCommentFormat: stored.magicNoteCommentFormat,
|
magicNoteCommentFormat: stored.magicNoteCommentFormat,
|
||||||
...(this.warnings.length > 0
|
...(this.warnings.length > 0
|
||||||
@@ -284,6 +323,8 @@ export class ApplicationSettingsStore {
|
|||||||
updateSource: next.updateSource,
|
updateSource: next.updateSource,
|
||||||
modelDownloadSource: next.modelDownloadSource,
|
modelDownloadSource: next.modelDownloadSource,
|
||||||
magicNotesEnabled: next.magicNotesEnabled,
|
magicNotesEnabled: next.magicNotesEnabled,
|
||||||
|
magicNotesShowIncompleteTodoCount:
|
||||||
|
next.magicNotesShowIncompleteTodoCount,
|
||||||
magicNoteCommentMode: next.magicNoteCommentMode,
|
magicNoteCommentMode: next.magicNoteCommentMode,
|
||||||
magicNoteCommentFormat: next.magicNoteCommentFormat
|
magicNoteCommentFormat: next.magicNoteCommentFormat
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-'))
|
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-assistant-'))
|
||||||
temporaryDirectories.push(directory)
|
temporaryDirectories.push(directory)
|
||||||
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
|
const database = new AssistantDatabase(
|
||||||
|
join(directory, 'assistant.sqlite'),
|
||||||
|
options
|
||||||
|
)
|
||||||
database.initialize('C:\\Workspace')
|
database.initialize('C:\\Workspace')
|
||||||
return database
|
return database
|
||||||
}
|
}
|
||||||
@@ -4064,6 +4071,60 @@ describe('AssistantDatabase', () => {
|
|||||||
database.close()
|
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 () => {
|
it('protects magic note records from stale revisions', async () => {
|
||||||
const database = await createDatabase()
|
const database = await createDatabase()
|
||||||
const note = database.createMagicNote({ title: '并发笔记' })
|
const note = database.createMagicNote({ title: '并发笔记' })
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import {
|
|||||||
type MagicNoteSearchResult,
|
type MagicNoteSearchResult,
|
||||||
type MagicNoteSummary,
|
type MagicNoteSummary,
|
||||||
type MagicTodoItem,
|
type MagicTodoItem,
|
||||||
|
type MagicTodoStatus,
|
||||||
type MagicTodoUpdateInput
|
type MagicTodoUpdateInput
|
||||||
} from '../../shared/magic-notes-contracts'
|
} from '../../shared/magic-notes-contracts'
|
||||||
import type { ComputerControlAuditEvent } from '../computer-control/audit'
|
import type { ComputerControlAuditEvent } from '../computer-control/audit'
|
||||||
@@ -967,7 +968,16 @@ export class AssistantDatabase {
|
|||||||
private channelEventWrites = 0
|
private channelEventWrites = 0
|
||||||
private channelOutboxWrites = 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 {
|
initialize(defaultRootPath: string): void {
|
||||||
if (this.database) {
|
if (this.database) {
|
||||||
@@ -2605,7 +2615,11 @@ export class AssistantDatabase {
|
|||||||
database.exec('ROLLBACK')
|
database.exec('ROLLBACK')
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
return this.getMagicNote(id)
|
const detail = this.getMagicNote(id)
|
||||||
|
if (input.content) {
|
||||||
|
this.notifyMagicTodosChanged()
|
||||||
|
}
|
||||||
|
return detail
|
||||||
}
|
}
|
||||||
|
|
||||||
updateMagicNote(input: {
|
updateMagicNote(input: {
|
||||||
@@ -2644,6 +2658,7 @@ export class AssistantDatabase {
|
|||||||
if (result.changes !== 1) {
|
if (result.changes !== 1) {
|
||||||
throw new Error('笔记不存在')
|
throw new Error('笔记不存在')
|
||||||
}
|
}
|
||||||
|
this.notifyMagicTodosChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
createMagicNoteEntry(input: {
|
createMagicNoteEntry(input: {
|
||||||
@@ -2696,7 +2711,9 @@ export class AssistantDatabase {
|
|||||||
database.exec('ROLLBACK')
|
database.exec('ROLLBACK')
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
return this.getMagicNote(input.noteId)
|
const detail = this.getMagicNote(input.noteId)
|
||||||
|
this.notifyMagicTodosChanged()
|
||||||
|
return detail
|
||||||
}
|
}
|
||||||
|
|
||||||
updateMagicNoteEntry(input: {
|
updateMagicNoteEntry(input: {
|
||||||
@@ -2758,7 +2775,9 @@ export class AssistantDatabase {
|
|||||||
database.exec('ROLLBACK')
|
database.exec('ROLLBACK')
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
return this.getMagicNote(existing.note_id)
|
const detail = this.getMagicNote(existing.note_id)
|
||||||
|
this.notifyMagicTodosChanged()
|
||||||
|
return detail
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteMagicNoteEntry(entryId: string): MagicNoteDetail {
|
deleteMagicNoteEntry(entryId: string): MagicNoteDetail {
|
||||||
@@ -2787,7 +2806,9 @@ export class AssistantDatabase {
|
|||||||
database.exec('ROLLBACK')
|
database.exec('ROLLBACK')
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
return this.getMagicNote(existing.note_id)
|
const detail = this.getMagicNote(existing.note_id)
|
||||||
|
this.notifyMagicTodosChanged()
|
||||||
|
return detail
|
||||||
}
|
}
|
||||||
|
|
||||||
getMagicNoteEntry(entryId: string): MagicNoteEntry {
|
getMagicNoteEntry(entryId: string): MagicNoteEntry {
|
||||||
@@ -2861,6 +2882,17 @@ export class AssistantDatabase {
|
|||||||
).map(toMagicTodo)
|
).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[] {
|
searchMagicNotes(query: string, limit: number): MagicNoteSearchResult[] {
|
||||||
const pattern = `%${query.replace(/[\\%_]/gu, '\\$&')}%`
|
const pattern = `%${query.replace(/[\\%_]/gu, '\\$&')}%`
|
||||||
return (
|
return (
|
||||||
@@ -2985,7 +3017,9 @@ export class AssistantDatabase {
|
|||||||
database.exec('ROLLBACK')
|
database.exec('ROLLBACK')
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
return this.getMagicTodo(input.todoId)
|
const todo = this.getMagicTodo(input.todoId)
|
||||||
|
this.notifyMagicTodosChanged()
|
||||||
|
return todo
|
||||||
}
|
}
|
||||||
|
|
||||||
saveMagicTodoAnalysis(input: {
|
saveMagicTodoAnalysis(input: {
|
||||||
|
|||||||
+16
-1
@@ -511,7 +511,22 @@ if (hasSingleInstanceLock) {
|
|||||||
| ReturnType<typeof createRerankProvider>
|
| ReturnType<typeof createRerankProvider>
|
||||||
| undefined
|
| undefined
|
||||||
const startupAssistantDatabase = new AssistantDatabase(
|
const startupAssistantDatabase = new AssistantDatabase(
|
||||||
join(app.getPath('userData'), 'assistant.sqlite')
|
join(app.getPath('userData'), 'assistant.sqlite'),
|
||||||
|
{
|
||||||
|
onMagicTodosChanged: () => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (
|
||||||
|
mainWindow &&
|
||||||
|
!mainWindow.isDestroyed() &&
|
||||||
|
!mainWindow.webContents.isDestroyed()
|
||||||
|
) {
|
||||||
|
mainWindow.webContents.send(
|
||||||
|
ipcChannels.magicTodosStatusChanged
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
assistantDatabase = startupAssistantDatabase
|
assistantDatabase = startupAssistantDatabase
|
||||||
const goodbuddyConfigService = new GoodBuddyConfigService(
|
const goodbuddyConfigService = new GoodBuddyConfigService(
|
||||||
|
|||||||
@@ -5682,6 +5682,14 @@ export function registerIpcHandlers(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
registerHandler(
|
||||||
|
ipcChannels.magicTodosStatus,
|
||||||
|
(event) => {
|
||||||
|
assertTrustedSender(event, window)
|
||||||
|
return assistantDatabase.getMagicTodoStatus()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
registerHandler(
|
registerHandler(
|
||||||
ipcChannels.magicTodosUpdate,
|
ipcChannels.magicTodosUpdate,
|
||||||
(event, input: unknown) => {
|
(event, input: unknown) => {
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ import type {
|
|||||||
MagicNoteDetail,
|
MagicNoteDetail,
|
||||||
MagicNotesSnapshot,
|
MagicNotesSnapshot,
|
||||||
MagicTodoItem,
|
MagicTodoItem,
|
||||||
|
MagicTodoStatus,
|
||||||
MagicTodoUpdateResult,
|
MagicTodoUpdateResult,
|
||||||
MagicTodosSnapshot
|
MagicTodosSnapshot
|
||||||
} from '../shared/magic-notes-contracts'
|
} from '../shared/magic-notes-contracts'
|
||||||
@@ -1110,6 +1111,10 @@ const desktopApi: DesktopApi = {
|
|||||||
ipcRenderer.invoke(
|
ipcRenderer.invoke(
|
||||||
ipcChannels.magicTodosList
|
ipcChannels.magicTodosList
|
||||||
) as Promise<MagicTodosSnapshot>,
|
) as Promise<MagicTodosSnapshot>,
|
||||||
|
getTodoStatus: () =>
|
||||||
|
ipcRenderer.invoke(
|
||||||
|
ipcChannels.magicTodosStatus
|
||||||
|
) as Promise<MagicTodoStatus>,
|
||||||
updateTodo: (input) =>
|
updateTodo: (input) =>
|
||||||
ipcRenderer.invoke(
|
ipcRenderer.invoke(
|
||||||
ipcChannels.magicTodosUpdate,
|
ipcChannels.magicTodosUpdate,
|
||||||
@@ -1131,6 +1136,15 @@ const desktopApi: DesktopApi = {
|
|||||||
ipcChannels.magicNotesAnalysisEvent,
|
ipcChannels.magicNotesAnalysisEvent,
|
||||||
handler
|
handler
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
onTodoStatusChanged: (listener) => {
|
||||||
|
const handler = (): void => listener()
|
||||||
|
ipcRenderer.on(ipcChannels.magicTodosStatusChanged, handler)
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener(
|
||||||
|
ipcChannels.magicTodosStatusChanged,
|
||||||
|
handler
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
knowledge: {
|
knowledge: {
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ let fileSelectionProgressListener:
|
|||||||
>[0]
|
>[0]
|
||||||
| undefined
|
| undefined
|
||||||
let newConversationListener: (() => void) | undefined
|
let newConversationListener: (() => void) | undefined
|
||||||
|
let magicTodoStatusChangedListener: (() => void) | undefined
|
||||||
let maximizedChangedListener: ((maximized: boolean) => void) | undefined
|
let maximizedChangedListener: ((maximized: boolean) => void) | undefined
|
||||||
let beforeQuitListener: (() => Promise<void>) | undefined
|
let beforeQuitListener: (() => Promise<void>) | undefined
|
||||||
let conversationQueueDispatchListener:
|
let conversationQueueDispatchListener:
|
||||||
@@ -682,13 +683,20 @@ const api: DesktopApi = {
|
|||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
}),
|
}),
|
||||||
listTodos: vi.fn(async () => ({ todos: [] })),
|
listTodos: vi.fn(async () => ({ todos: [] })),
|
||||||
|
getTodoStatus: vi.fn(async () => ({ incompleteCount: 0 })),
|
||||||
updateTodo: vi.fn(async () => {
|
updateTodo: vi.fn(async () => {
|
||||||
throw new Error('not used')
|
throw new Error('not used')
|
||||||
}),
|
}),
|
||||||
analyzeTodo: vi.fn(async () => {
|
analyzeTodo: vi.fn(async () => {
|
||||||
throw new Error('not used')
|
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: {
|
knowledge: {
|
||||||
getSnapshot: vi.fn(async () => ({
|
getSnapshot: vi.fn(async () => ({
|
||||||
@@ -870,6 +878,18 @@ describe('App', () => {
|
|||||||
delete document.documentElement.dataset.theme
|
delete document.documentElement.dataset.theme
|
||||||
document.documentElement.style.colorScheme = ''
|
document.documentElement.style.colorScheme = ''
|
||||||
vi.clearAllMocks()
|
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.list).mockReset().mockResolvedValue([])
|
||||||
vi.mocked(api.conversations.replace)
|
vi.mocked(api.conversations.replace)
|
||||||
.mockReset()
|
.mockReset()
|
||||||
@@ -1849,6 +1869,7 @@ describe('App', () => {
|
|||||||
updateSource: 'github' as const,
|
updateSource: 'github' as const,
|
||||||
modelDownloadSource: 'modelscope' as const,
|
modelDownloadSource: 'modelscope' as const,
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate' as const,
|
magicNoteCommentMode: 'immediate' as const,
|
||||||
magicNoteCommentFormat: 'combined' as const
|
magicNoteCommentFormat: 'combined' as const
|
||||||
})),
|
})),
|
||||||
@@ -1942,6 +1963,7 @@ describe('App', () => {
|
|||||||
updateSource: 'github' as const,
|
updateSource: 'github' as const,
|
||||||
modelDownloadSource: 'modelscope' as const,
|
modelDownloadSource: 'modelscope' as const,
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate' as const,
|
magicNoteCommentMode: 'immediate' as const,
|
||||||
magicNoteCommentFormat: 'combined' as const
|
magicNoteCommentFormat: 'combined' as const
|
||||||
})),
|
})),
|
||||||
@@ -1950,6 +1972,7 @@ describe('App', () => {
|
|||||||
updateSource: 'github' as const,
|
updateSource: 'github' as const,
|
||||||
modelDownloadSource: 'modelscope' as const,
|
modelDownloadSource: 'modelscope' as const,
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate' as const,
|
magicNoteCommentMode: 'immediate' as const,
|
||||||
magicNoteCommentFormat: 'combined' as const
|
magicNoteCommentFormat: 'combined' as const
|
||||||
})),
|
})),
|
||||||
@@ -2044,6 +2067,7 @@ describe('App', () => {
|
|||||||
updateSource: 'github' as const,
|
updateSource: 'github' as const,
|
||||||
modelDownloadSource: 'modelscope' as const,
|
modelDownloadSource: 'modelscope' as const,
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate' as const,
|
magicNoteCommentMode: 'immediate' as const,
|
||||||
magicNoteCommentFormat: 'combined' as const
|
magicNoteCommentFormat: 'combined' as const
|
||||||
})),
|
})),
|
||||||
@@ -2052,6 +2076,7 @@ describe('App', () => {
|
|||||||
updateSource: 'github' as const,
|
updateSource: 'github' as const,
|
||||||
modelDownloadSource: 'modelscope' as const,
|
modelDownloadSource: 'modelscope' as const,
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate' as const,
|
magicNoteCommentMode: 'immediate' as const,
|
||||||
magicNoteCommentFormat: 'combined' as const
|
magicNoteCommentFormat: 'combined' as const
|
||||||
})),
|
})),
|
||||||
@@ -3887,6 +3912,7 @@ describe('App', () => {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
}
|
}
|
||||||
@@ -8617,6 +8643,7 @@ describe('App', () => {
|
|||||||
updateSource: 'github' as const,
|
updateSource: 'github' as const,
|
||||||
modelDownloadSource: 'modelscope' as const,
|
modelDownloadSource: 'modelscope' as const,
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate' as const,
|
magicNoteCommentMode: 'immediate' as const,
|
||||||
magicNoteCommentFormat: 'combined' as const
|
magicNoteCommentFormat: 'combined' as const
|
||||||
})),
|
})),
|
||||||
@@ -8625,6 +8652,7 @@ describe('App', () => {
|
|||||||
updateSource: 'github' as const,
|
updateSource: 'github' as const,
|
||||||
modelDownloadSource: 'modelscope' as const,
|
modelDownloadSource: 'modelscope' as const,
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate' as const,
|
magicNoteCommentMode: 'immediate' as const,
|
||||||
magicNoteCommentFormat: 'combined' 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 () => {
|
it('hides Magic Notes when the platform feature is disabled', async () => {
|
||||||
api.updates = {
|
api.updates = {
|
||||||
getSettings: vi.fn(async () => ({
|
getSettings: vi.fn(async () => ({
|
||||||
@@ -8666,6 +8779,7 @@ describe('App', () => {
|
|||||||
updateSource: 'github' as const,
|
updateSource: 'github' as const,
|
||||||
modelDownloadSource: 'modelscope' as const,
|
modelDownloadSource: 'modelscope' as const,
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate' as const,
|
magicNoteCommentMode: 'immediate' as const,
|
||||||
magicNoteCommentFormat: 'combined' as const
|
magicNoteCommentFormat: 'combined' as const
|
||||||
})),
|
})),
|
||||||
@@ -8674,6 +8788,7 @@ describe('App', () => {
|
|||||||
updateSource: 'github' as const,
|
updateSource: 'github' as const,
|
||||||
modelDownloadSource: 'modelscope' as const,
|
modelDownloadSource: 'modelscope' as const,
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate' as const,
|
magicNoteCommentMode: 'immediate' as const,
|
||||||
magicNoteCommentFormat: 'combined' as const
|
magicNoteCommentFormat: 'combined' as const
|
||||||
})),
|
})),
|
||||||
@@ -8707,6 +8822,7 @@ describe('App', () => {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2313,6 +2313,12 @@ function App(): React.JSX.Element {
|
|||||||
const [settingsInitialChannel, setSettingsInitialChannel] =
|
const [settingsInitialChannel, setSettingsInitialChannel] =
|
||||||
useState<ProjectChannel>()
|
useState<ProjectChannel>()
|
||||||
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
|
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
|
||||||
|
const [
|
||||||
|
magicNotesShowIncompleteTodoCount,
|
||||||
|
setMagicNotesShowIncompleteTodoCount
|
||||||
|
] = useState(true)
|
||||||
|
const [incompleteMagicTodoCount, setIncompleteMagicTodoCount] =
|
||||||
|
useState(0)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const deferredSearchQuery = useDeferredValue(searchQuery)
|
const deferredSearchQuery = useDeferredValue(searchQuery)
|
||||||
const [searchConversationSnapshot, setSearchConversationSnapshot] =
|
const [searchConversationSnapshot, setSearchConversationSnapshot] =
|
||||||
@@ -2713,6 +2719,9 @@ function App(): React.JSX.Element {
|
|||||||
.getSettings()
|
.getSettings()
|
||||||
.then(async (settings) => {
|
.then(async (settings) => {
|
||||||
setMagicNotesEnabled(settings.magicNotesEnabled)
|
setMagicNotesEnabled(settings.magicNotesEnabled)
|
||||||
|
setMagicNotesShowIncompleteTodoCount(
|
||||||
|
settings.magicNotesShowIncompleteTodoCount
|
||||||
|
)
|
||||||
if (!settings.magicNotesEnabled) {
|
if (!settings.magicNotesEnabled) {
|
||||||
setView((current) =>
|
setView((current) =>
|
||||||
current === 'magic-notes' ? 'chat' : current
|
current === 'magic-notes' ? 'chat' : current
|
||||||
@@ -2736,6 +2745,42 @@ function App(): React.JSX.Element {
|
|||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}, [i18n, setView])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const releaseNotesApi = window.goodbuddy.releaseNotes
|
const releaseNotesApi = window.goodbuddy.releaseNotes
|
||||||
if (!releaseNotesApi || startupReleaseNotesStartedRef.current) {
|
if (!releaseNotesApi || startupReleaseNotesStartedRef.current) {
|
||||||
@@ -7489,6 +7534,19 @@ function App(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
<Sparkles aria-hidden="true" size={17} />
|
<Sparkles aria-hidden="true" size={17} />
|
||||||
<span>{t('navigation.magicNotes')}</span>
|
<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>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -9619,7 +9677,18 @@ function App(): React.JSX.Element {
|
|||||||
}}
|
}}
|
||||||
onMagicNotesEnabledChange={(enabled) => {
|
onMagicNotesEnabledChange={(enabled) => {
|
||||||
setMagicNotesEnabled(enabled)
|
setMagicNotesEnabled(enabled)
|
||||||
|
if (!enabled) {
|
||||||
|
setIncompleteMagicTodoCount(0)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
|
onMagicNotesShowIncompleteTodoCountChange={
|
||||||
|
(enabled) => {
|
||||||
|
setMagicNotesShowIncompleteTodoCount(enabled)
|
||||||
|
if (!enabled) {
|
||||||
|
setIncompleteMagicTodoCount(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
onNotify={notify}
|
onNotify={notify}
|
||||||
onLeaveRequestReady={registerSettingsLeaveRequester}
|
onLeaveRequestReady={registerSettingsLeaveRequester}
|
||||||
onSaved={(settings) => {
|
onSaved={(settings) => {
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async (
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
}))
|
}))
|
||||||
@@ -202,6 +203,7 @@ beforeEach(() => {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
})
|
})
|
||||||
@@ -1065,6 +1067,7 @@ describe('MagicNotesWorkspace', () => {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'after-save-manual',
|
magicNoteCommentMode: 'after-save-manual',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
})
|
})
|
||||||
@@ -1097,6 +1100,7 @@ describe('MagicNotesWorkspace', () => {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'after-save-manual',
|
magicNoteCommentMode: 'after-save-manual',
|
||||||
magicNoteCommentFormat: 'narrative'
|
magicNoteCommentFormat: 'narrative'
|
||||||
})
|
})
|
||||||
@@ -1212,6 +1216,7 @@ describe('MagicNotesWorkspace', () => {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'after-save-auto',
|
magicNoteCommentMode: 'after-save-auto',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import {
|
|||||||
|
|
||||||
type PlatformFeaturesSettingsSectionProps = {
|
type PlatformFeaturesSettingsSectionProps = {
|
||||||
onMagicNotesEnabledChange: (enabled: boolean) => void
|
onMagicNotesEnabledChange: (enabled: boolean) => void
|
||||||
|
onMagicNotesShowIncompleteTodoCountChange: (
|
||||||
|
enabled: boolean
|
||||||
|
) => void
|
||||||
onNotify?: (notification: AppNotificationInput) => void
|
onNotify?: (notification: AppNotificationInput) => void
|
||||||
onDirtyChange?: (dirty: boolean) => void
|
onDirtyChange?: (dirty: boolean) => void
|
||||||
onShortcutSettingsChanged?: (
|
onShortcutSettingsChanged?: (
|
||||||
@@ -48,6 +51,7 @@ const shortcutErrorTranslationKeys: Record<
|
|||||||
|
|
||||||
export function PlatformFeaturesSettingsSection({
|
export function PlatformFeaturesSettingsSection({
|
||||||
onMagicNotesEnabledChange,
|
onMagicNotesEnabledChange,
|
||||||
|
onMagicNotesShowIncompleteTodoCountChange,
|
||||||
onNotify,
|
onNotify,
|
||||||
onDirtyChange,
|
onDirtyChange,
|
||||||
onShortcutSettingsChanged
|
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 (
|
const changeCommentFormat = async (
|
||||||
magicNoteCommentFormat: MagicNoteCommentFormat
|
magicNoteCommentFormat: MagicNoteCommentFormat
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
@@ -580,6 +610,31 @@ export function PlatformFeaturesSettingsSection({
|
|||||||
/>
|
/>
|
||||||
<span>{t('platformFeatures.magicNotes.showEntry')}</span>
|
<span>{t('platformFeatures.magicNotes.showEntry')}</span>
|
||||||
</label>
|
</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">
|
<div className="platform-feature-option">
|
||||||
<span>
|
<span>
|
||||||
{t('platformFeatures.magicNotes.commentMode')}
|
{t('platformFeatures.magicNotes.commentMode')}
|
||||||
|
|||||||
@@ -404,6 +404,7 @@ let applicationSettings: ApplicationSettings = {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
}
|
}
|
||||||
@@ -625,6 +626,7 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: false,
|
magicNotesEnabled: false,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
}
|
}
|
||||||
@@ -1116,12 +1118,16 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toggles the Magic Notes platform entry setting', async () => {
|
it('toggles Magic Notes navigation settings', async () => {
|
||||||
const onMagicNotesEnabledChange = vi.fn()
|
const onMagicNotesEnabledChange = vi.fn()
|
||||||
|
const onMagicNotesShowIncompleteTodoCountChange = vi.fn()
|
||||||
render(
|
render(
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
{...heartbeatSettingsProps}
|
{...heartbeatSettingsProps}
|
||||||
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
|
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
|
||||||
|
onMagicNotesShowIncompleteTodoCountChange={
|
||||||
|
onMagicNotesShowIncompleteTodoCountChange
|
||||||
|
}
|
||||||
open
|
open
|
||||||
onClearLocalData={vi.fn(async () => {})}
|
onClearLocalData={vi.fn(async () => {})}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
@@ -1146,6 +1152,19 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
expect(onMagicNotesEnabledChange).toHaveBeenCalledWith(true)
|
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(
|
fireEvent.click(
|
||||||
screen.getByRole('button', { name: '保存后自动' })
|
screen.getByRole('button', { name: '保存后自动' })
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -185,6 +185,9 @@ type SettingsPanelProps = {
|
|||||||
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
|
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
|
||||||
magicNotesEnabled?: boolean
|
magicNotesEnabled?: boolean
|
||||||
onMagicNotesEnabledChange?: (enabled: boolean) => void
|
onMagicNotesEnabledChange?: (enabled: boolean) => void
|
||||||
|
onMagicNotesShowIncompleteTodoCountChange?: (
|
||||||
|
enabled: boolean
|
||||||
|
) => void
|
||||||
onShortcutSettingsChanged?: (
|
onShortcutSettingsChanged?: (
|
||||||
snapshot: GlobalShortcutSettingsSnapshot
|
snapshot: GlobalShortcutSettingsSnapshot
|
||||||
) => void
|
) => void
|
||||||
@@ -542,6 +545,7 @@ export function SettingsPanel({
|
|||||||
onAppearanceThemeChange = () => {},
|
onAppearanceThemeChange = () => {},
|
||||||
magicNotesEnabled = false,
|
magicNotesEnabled = false,
|
||||||
onMagicNotesEnabledChange = () => {},
|
onMagicNotesEnabledChange = () => {},
|
||||||
|
onMagicNotesShowIncompleteTodoCountChange = () => {},
|
||||||
onShortcutSettingsChanged = () => {},
|
onShortcutSettingsChanged = () => {},
|
||||||
onLeaveRequestReady = () => {}
|
onLeaveRequestReady = () => {}
|
||||||
}: SettingsPanelProps): React.JSX.Element | null {
|
}: SettingsPanelProps): React.JSX.Element | null {
|
||||||
@@ -1857,6 +1861,9 @@ export function SettingsPanel({
|
|||||||
<PlatformFeaturesSettingsSection
|
<PlatformFeaturesSettingsSection
|
||||||
onDirtyChange={setPlatformFeaturesDirty}
|
onDirtyChange={setPlatformFeaturesDirty}
|
||||||
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
|
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
|
||||||
|
onMagicNotesShowIncompleteTodoCountChange={
|
||||||
|
onMagicNotesShowIncompleteTodoCountChange
|
||||||
|
}
|
||||||
onNotify={onNotify}
|
onNotify={onNotify}
|
||||||
onShortcutSettingsChanged={onShortcutSettingsChanged}
|
onShortcutSettingsChanged={onShortcutSettingsChanged}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ describe('UpdateSettingsSection', () => {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate' as const,
|
magicNoteCommentMode: 'immediate' as const,
|
||||||
magicNoteCommentFormat: 'combined' as const
|
magicNoteCommentFormat: 'combined' as const
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const app = {
|
|||||||
knowledge: 'Knowledge',
|
knowledge: 'Knowledge',
|
||||||
heartbeat: 'Smart Heartbeat',
|
heartbeat: 'Smart Heartbeat',
|
||||||
activity: 'Run history',
|
activity: 'Run history',
|
||||||
|
incompleteTodos: '{{count}} incomplete to-dos',
|
||||||
pendingSuggestions: '{{count}} pending suggestions'
|
pendingSuggestions: '{{count}} pending suggestions'
|
||||||
},
|
},
|
||||||
route: {
|
route: {
|
||||||
|
|||||||
@@ -226,6 +226,8 @@ export const settingsSections = {
|
|||||||
'Application settings are not available in this version',
|
'Application settings are not available in this version',
|
||||||
readFailed: 'Could not load platform feature settings',
|
readFailed: 'Could not load platform feature settings',
|
||||||
saveMagicNotesFailed: 'Could not save Magic Notes settings. Try again.',
|
saveMagicNotesFailed: 'Could not save Magic Notes settings. Try again.',
|
||||||
|
saveIncompleteTodoCountFailed:
|
||||||
|
'Could not save the incomplete to-do count setting. Try again.',
|
||||||
saveCommentModeFailed:
|
saveCommentModeFailed:
|
||||||
'Could not save the AI comment mode. Try again.',
|
'Could not save the AI comment mode. Try again.',
|
||||||
saveCommentFormatFailed:
|
saveCommentFormatFailed:
|
||||||
@@ -297,6 +299,9 @@ export const settingsSections = {
|
|||||||
description:
|
description:
|
||||||
'Off by default. Enable it to capture notes and to-dos and analyze content with AI.',
|
'Off by default. Enable it to capture notes and to-dos and analyze content with AI.',
|
||||||
showEntry: 'Show Magic Notes',
|
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',
|
commentMode: 'AI comment mode',
|
||||||
commentModeAria: 'Magic Notes AI comment mode',
|
commentModeAria: 'Magic Notes AI comment mode',
|
||||||
modes: {
|
modes: {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export const app = {
|
|||||||
knowledge: '知识库',
|
knowledge: '知识库',
|
||||||
heartbeat: '智能心跳',
|
heartbeat: '智能心跳',
|
||||||
activity: '运行记录',
|
activity: '运行记录',
|
||||||
|
incompleteTodos: '{{count}} 个未完成待办',
|
||||||
pendingSuggestions: '{{count}} 条待处理建议'
|
pendingSuggestions: '{{count}} 条待处理建议'
|
||||||
},
|
},
|
||||||
route: {
|
route: {
|
||||||
|
|||||||
@@ -211,6 +211,8 @@ export const settingsSections = {
|
|||||||
serviceUnavailable: '当前版本未提供应用设置服务',
|
serviceUnavailable: '当前版本未提供应用设置服务',
|
||||||
readFailed: '读取平台功能设置失败',
|
readFailed: '读取平台功能设置失败',
|
||||||
saveMagicNotesFailed: '保存魔法笔记设置失败,请重试',
|
saveMagicNotesFailed: '保存魔法笔记设置失败,请重试',
|
||||||
|
saveIncompleteTodoCountFailed:
|
||||||
|
'保存未完成待办数量设置失败,请重试',
|
||||||
saveCommentModeFailed: '保存 AI 评论方式失败,请重试',
|
saveCommentModeFailed: '保存 AI 评论方式失败,请重试',
|
||||||
saveCommentFormatFailed: '保存 AI 评论形式失败,请重试',
|
saveCommentFormatFailed: '保存 AI 评论形式失败,请重试',
|
||||||
saveModelDownloadSourceFailed: '保存模型下载源失败,请重试'
|
saveModelDownloadSourceFailed: '保存模型下载源失败,请重试'
|
||||||
@@ -267,6 +269,9 @@ export const settingsSections = {
|
|||||||
title: '魔法笔记',
|
title: '魔法笔记',
|
||||||
description: '默认关闭;开启后可记录笔记与待办,并使用 AI 分析内容',
|
description: '默认关闭;开启后可记录笔记与待办,并使用 AI 分析内容',
|
||||||
showEntry: '显示魔法笔记入口',
|
showEntry: '显示魔法笔记入口',
|
||||||
|
showIncompleteTodoCount: '显示未完成待办数量',
|
||||||
|
showIncompleteTodoCountHelp:
|
||||||
|
'开启后,左侧魔法笔记入口显示未完成待办数量;超过 99 项显示 99+。',
|
||||||
commentMode: 'AI 评论方式',
|
commentMode: 'AI 评论方式',
|
||||||
commentModeAria: '魔法笔记 AI 评论方式',
|
commentModeAria: '魔法笔记 AI 评论方式',
|
||||||
modes: {
|
modes: {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const applicationPreferencesSchema = z
|
|||||||
updateSource: updateSourceSchema,
|
updateSource: updateSourceSchema,
|
||||||
modelDownloadSource: modelDownloadSourceSchema,
|
modelDownloadSource: modelDownloadSourceSchema,
|
||||||
magicNotesEnabled: z.boolean(),
|
magicNotesEnabled: z.boolean(),
|
||||||
|
magicNotesShowIncompleteTodoCount: z.boolean(),
|
||||||
magicNoteCommentMode: magicNoteCommentModeSchema,
|
magicNoteCommentMode: magicNoteCommentModeSchema,
|
||||||
magicNoteCommentFormat: magicNoteCommentFormatSchema
|
magicNoteCommentFormat: magicNoteCommentFormatSchema
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import type {
|
|||||||
MagicNotesSnapshot,
|
MagicNotesSnapshot,
|
||||||
MagicNoteUpdateInput,
|
MagicNoteUpdateInput,
|
||||||
MagicTodoItem,
|
MagicTodoItem,
|
||||||
|
MagicTodoStatus,
|
||||||
MagicTodoUpdateInput,
|
MagicTodoUpdateInput,
|
||||||
MagicTodoUpdateResult,
|
MagicTodoUpdateResult,
|
||||||
MagicTodosSnapshot
|
MagicTodosSnapshot
|
||||||
@@ -1703,6 +1704,7 @@ export type DesktopApi = {
|
|||||||
options: MagicNoteAnalysisOptions
|
options: MagicNoteAnalysisOptions
|
||||||
) => Promise<MagicNoteDraftAnalysis>
|
) => Promise<MagicNoteDraftAnalysis>
|
||||||
listTodos: () => Promise<MagicTodosSnapshot>
|
listTodos: () => Promise<MagicTodosSnapshot>
|
||||||
|
getTodoStatus: () => Promise<MagicTodoStatus>
|
||||||
updateTodo: (
|
updateTodo: (
|
||||||
input: MagicTodoUpdateInput
|
input: MagicTodoUpdateInput
|
||||||
) => Promise<MagicTodoUpdateResult>
|
) => Promise<MagicTodoUpdateResult>
|
||||||
@@ -1713,6 +1715,7 @@ export type DesktopApi = {
|
|||||||
onAnalysisEvent: (
|
onAnalysisEvent: (
|
||||||
listener: (event: MagicNoteAnalysisStreamEvent) => void
|
listener: (event: MagicNoteAnalysisStreamEvent) => void
|
||||||
) => () => void
|
) => () => void
|
||||||
|
onTodoStatusChanged: (listener: () => void) => () => void
|
||||||
}
|
}
|
||||||
knowledge: {
|
knowledge: {
|
||||||
getSnapshot: (libraryId?: string) => Promise<KnowledgeSnapshot>
|
getSnapshot: (libraryId?: string) => Promise<KnowledgeSnapshot>
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ describe('GoodBuddy configuration contracts', () => {
|
|||||||
updateSource: 'github',
|
updateSource: 'github',
|
||||||
modelDownloadSource: 'modelscope',
|
modelDownloadSource: 'modelscope',
|
||||||
magicNotesEnabled: true,
|
magicNotesEnabled: true,
|
||||||
|
magicNotesShowIncompleteTodoCount: true,
|
||||||
magicNoteCommentMode: 'immediate',
|
magicNoteCommentMode: 'immediate',
|
||||||
magicNoteCommentFormat: 'combined'
|
magicNoteCommentFormat: 'combined'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -178,6 +178,8 @@ export const ipcChannels = {
|
|||||||
magicNotesAnalyzeDraft: 'magic-notes:analyze-draft',
|
magicNotesAnalyzeDraft: 'magic-notes:analyze-draft',
|
||||||
magicNotesAnalysisEvent: 'magic-notes:analysis-event',
|
magicNotesAnalysisEvent: 'magic-notes:analysis-event',
|
||||||
magicTodosList: 'magic-todos:list',
|
magicTodosList: 'magic-todos:list',
|
||||||
|
magicTodosStatus: 'magic-todos:status',
|
||||||
|
magicTodosStatusChanged: 'magic-todos:status-changed',
|
||||||
magicTodosUpdate: 'magic-todos:update',
|
magicTodosUpdate: 'magic-todos:update',
|
||||||
magicTodosAnalyze: 'magic-todos:analyze',
|
magicTodosAnalyze: 'magic-todos:analyze',
|
||||||
knowledgeSnapshot: 'knowledge:snapshot',
|
knowledgeSnapshot: 'knowledge:snapshot',
|
||||||
|
|||||||
@@ -438,6 +438,10 @@ export type MagicTodoUpdateResult = {
|
|||||||
note: MagicNoteDetail
|
note: MagicNoteDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MagicTodoStatus = {
|
||||||
|
incompleteCount: number
|
||||||
|
}
|
||||||
|
|
||||||
export type MagicTodosSnapshot = {
|
export type MagicTodosSnapshot = {
|
||||||
todos: MagicTodoItem[]
|
todos: MagicTodoItem[]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user