diff --git a/package-lock.json b/package-lock.json
index 66b8e03..ce6292b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "goodbuddy",
- "version": "0.8.6",
+ "version": "0.8.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodbuddy",
- "version": "0.8.6",
+ "version": "0.8.8",
"license": "UNLICENSED",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
diff --git a/package.json b/package.json
index 2daacc3..c41f33e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "goodbuddy",
- "version": "0.8.6",
+ "version": "0.8.8",
"private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy",
diff --git a/src/main/application-settings-store.test.ts b/src/main/application-settings-store.test.ts
index 08b4b1a..6746919 100644
--- a/src/main/application-settings-store.test.ts
+++ b/src/main/application-settings-store.test.ts
@@ -11,6 +11,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import {
ApplicationSettingsStore,
applicationSettingsSchema,
+ applicationSettingsUpdateSchema,
defaultApplicationSettings
} from './application-settings-store'
@@ -51,18 +52,26 @@ describe('ApplicationSettingsStore', () => {
await expect(readdir(directory)).resolves.toEqual([])
})
- it('persists only the versioned startup update preference', async () => {
+ it('persists versioned application preferences', async () => {
const { directory, filePath, store } = await createStore()
await expect(
- store.update({ checkUpdatesOnStartup: false })
- ).resolves.toEqual({ checkUpdatesOnStartup: false })
+ store.update({
+ checkUpdatesOnStartup: false,
+ magicNotesEnabled: false
+ })
+ ).resolves.toEqual({
+ checkUpdatesOnStartup: false,
+ magicNotesEnabled: false
+ })
await expect(store.get()).resolves.toEqual({
- checkUpdatesOnStartup: false
+ checkUpdatesOnStartup: false,
+ magicNotesEnabled: false
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
- version: 1,
- checkUpdatesOnStartup: false
+ version: 2,
+ checkUpdatesOnStartup: false,
+ magicNotesEnabled: false
})
expect(
(await readdir(directory)).filter((name) => name.endsWith('.tmp'))
@@ -73,38 +82,103 @@ describe('ApplicationSettingsStore', () => {
const { directory } = await createStore()
const filePath = join(directory, 'nested', 'application-settings.json')
const store = new ApplicationSettingsStore(filePath)
- await store.update({ checkUpdatesOnStartup: false })
+ await store.update({
+ checkUpdatesOnStartup: false,
+ magicNotesEnabled: false
+ })
await expect(
new ApplicationSettingsStore(filePath).get()
- ).resolves.toEqual({ checkUpdatesOnStartup: false })
+ ).resolves.toEqual({
+ checkUpdatesOnStartup: false,
+ magicNotesEnabled: false
+ })
})
- it('strictly rejects unknown, missing, and mistyped input', async () => {
- const { directory, store } = await createStore()
+ it.each([1, 2])(
+ 'loads version %s settings missing the field with Magic Notes disabled',
+ async (version) => {
+ const { filePath, store } = await createStore()
+ await writeFile(
+ filePath,
+ JSON.stringify({
+ version,
+ checkUpdatesOnStartup: false
+ }),
+ 'utf8'
+ )
+
+ await expect(store.get()).resolves.toEqual({
+ checkUpdatesOnStartup: false,
+ magicNotesEnabled: false
+ })
+ }
+ )
+
+ it('strictly rejects incomplete full settings', () => {
for (const input of [
{},
{ checkUpdatesOnStartup: 'true' },
- { checkUpdatesOnStartup: true, anotherSetting: true },
+ {
+ checkUpdatesOnStartup: true,
+ magicNotesEnabled: true,
+ anotherSetting: true
+ },
+ { checkUpdatesOnStartup: true },
null
]) {
expect(applicationSettingsSchema.safeParse(input).success).toBe(
false
)
+ }
+ })
+
+ it('strictly rejects empty, unknown, and mistyped updates', async () => {
+ const { directory, store } = await createStore()
+ for (const input of [
+ {},
+ { checkUpdatesOnStartup: 'true' },
+ { anotherSetting: true },
+ null
+ ]) {
+ expect(
+ applicationSettingsUpdateSchema.safeParse(input).success
+ ).toBe(false)
await expect(store.update(input)).rejects.toThrow()
}
await expect(readdir(directory)).resolves.toEqual([])
})
+ it('merges partial updates without overwriting other settings', async () => {
+ const { store } = await createStore()
+
+ await store.update({ magicNotesEnabled: true })
+ await expect(
+ store.update({ checkUpdatesOnStartup: false })
+ ).resolves.toEqual({
+ checkUpdatesOnStartup: false,
+ magicNotesEnabled: true
+ })
+ })
+
it.each([
'{not-json',
- JSON.stringify({ version: 2, checkUpdatesOnStartup: false }),
JSON.stringify({
- version: 1,
+ version: 3,
checkUpdatesOnStartup: false,
+ magicNotesEnabled: false
+ }),
+ JSON.stringify({
+ version: 2,
+ checkUpdatesOnStartup: false,
+ magicNotesEnabled: true,
injected: true
}),
- JSON.stringify({ version: 1, checkUpdatesOnStartup: 'false' })
+ JSON.stringify({
+ version: 2,
+ checkUpdatesOnStartup: 'false',
+ magicNotesEnabled: true
+ })
])('isolates corrupt persisted data and restores defaults', async (data) => {
const { directory, filePath, store } = await createStore()
await writeFile(filePath, data, 'utf8')
@@ -142,28 +216,48 @@ describe('ApplicationSettingsStore', () => {
const { filePath, store } = await createStore()
await Promise.all([
- store.update({ checkUpdatesOnStartup: false }),
- store.update({ checkUpdatesOnStartup: true }),
- store.update({ checkUpdatesOnStartup: false })
+ store.update({
+ checkUpdatesOnStartup: false,
+ magicNotesEnabled: true
+ }),
+ store.update({
+ checkUpdatesOnStartup: true,
+ magicNotesEnabled: false
+ }),
+ store.update({
+ checkUpdatesOnStartup: false,
+ magicNotesEnabled: false
+ })
])
await expect(store.get()).resolves.toEqual({
- checkUpdatesOnStartup: false
+ checkUpdatesOnStartup: false,
+ magicNotesEnabled: false
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
- version: 1,
- checkUpdatesOnStartup: false
+ version: 2,
+ checkUpdatesOnStartup: false,
+ magicNotesEnabled: false
})
})
it('continues accepting updates after a validation failure', async () => {
const { store } = await createStore()
await expect(
- store.update({ checkUpdatesOnStartup: 'invalid' })
+ store.update({
+ checkUpdatesOnStartup: 'invalid',
+ magicNotesEnabled: true
+ })
).rejects.toThrow()
await expect(
- store.update({ checkUpdatesOnStartup: false })
- ).resolves.toEqual({ checkUpdatesOnStartup: false })
+ store.update({
+ checkUpdatesOnStartup: false,
+ magicNotesEnabled: true
+ })
+ ).resolves.toEqual({
+ checkUpdatesOnStartup: false,
+ magicNotesEnabled: true
+ })
})
})
diff --git a/src/main/application-settings-store.ts b/src/main/application-settings-store.ts
index 1f97c30..928079c 100644
--- a/src/main/application-settings-store.ts
+++ b/src/main/application-settings-store.ts
@@ -10,12 +10,23 @@ import { dirname } from 'node:path'
import { z } from 'zod'
import {
applicationSettingsSchema,
+ applicationSettingsUpdateSchema,
type ApplicationSettings
} from '../shared/application-settings-contracts'
-export { applicationSettingsSchema } from '../shared/application-settings-contracts'
+export {
+ applicationSettingsSchema,
+ applicationSettingsUpdateSchema
+} from '../shared/application-settings-contracts'
export type { ApplicationSettings } from '../shared/application-settings-contracts'
-const CURRENT_SETTINGS_VERSION = 1
+const CURRENT_SETTINGS_VERSION = 2
+
+const legacyStoredApplicationSettingsSchema = z
+ .object({
+ version: z.union([z.literal(1), z.literal(2)]),
+ checkUpdatesOnStartup: z.boolean()
+ })
+ .strict()
const storedApplicationSettingsSchema = applicationSettingsSchema
.extend({
@@ -28,7 +39,8 @@ type StoredApplicationSettings = z.infer<
>
export const defaultApplicationSettings: ApplicationSettings = {
- checkUpdatesOnStartup: true
+ checkUpdatesOnStartup: true,
+ magicNotesEnabled: false
}
function isMissingFile(error: unknown): boolean {
@@ -81,6 +93,17 @@ export class ApplicationSettingsStore {
}
const result = storedApplicationSettingsSchema.safeParse(parsed)
if (!result.success) {
+ const legacyResult =
+ legacyStoredApplicationSettingsSchema.safeParse(parsed)
+ if (legacyResult.success) {
+ this.settings = {
+ version: CURRENT_SETTINGS_VERSION,
+ checkUpdatesOnStartup:
+ legacyResult.data.checkUpdatesOnStartup,
+ magicNotesEnabled: false
+ }
+ return this.settings
+ }
await this.isolateCorruptFile()
this.settings = {
version: CURRENT_SETTINGS_VERSION,
@@ -106,16 +129,19 @@ export class ApplicationSettingsStore {
async get(): Promise {
const stored = await this.loadStored()
return {
- checkUpdatesOnStartup: stored.checkUpdatesOnStartup
+ checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
+ magicNotesEnabled: stored.magicNotesEnabled
}
}
update(input: unknown): Promise {
const operation = this.updateQueue.then(async () => {
- const settings = applicationSettingsSchema.parse(input)
+ const updates = applicationSettingsUpdateSchema.parse(input)
+ const current = await this.loadStored()
const next: StoredApplicationSettings = {
- version: CURRENT_SETTINGS_VERSION,
- ...settings
+ ...current,
+ ...updates,
+ version: CURRENT_SETTINGS_VERSION
}
await mkdir(dirname(this.filePath), { recursive: true })
const temporaryPath =
@@ -137,7 +163,8 @@ export class ApplicationSettingsStore {
}
this.settings = next
return {
- checkUpdatesOnStartup: next.checkUpdatesOnStartup
+ checkUpdatesOnStartup: next.checkUpdatesOnStartup,
+ magicNotesEnabled: next.magicNotesEnabled
}
})
this.updateQueue = operation.then(
diff --git a/src/main/ipc.ts b/src/main/ipc.ts
index 5244579..c907f95 100644
--- a/src/main/ipc.ts
+++ b/src/main/ipc.ts
@@ -63,7 +63,7 @@ import {
dingTalkChannelSettingsInputSchema,
weComChannelSettingsInputSchema
} from '../shared/channel-settings-contracts'
-import { applicationSettingsSchema } from '../shared/application-settings-contracts'
+import { applicationSettingsUpdateSchema } from '../shared/application-settings-contracts'
import {
speechModelActionInputSchema,
speechModelSelectionInputSchema
@@ -834,6 +834,7 @@ export function registerIpcHandlers(
channel: keyof typeof projectChannelLabels
channelLabel: string
senderDisplay: string
+ projectId: string
projectName: string
rootPath: string
conversationId: string
@@ -1027,6 +1028,8 @@ export function registerIpcHandlers(
publishRemoteActivity({
requestId,
conversationId: remoteContext.conversationId,
+ projectId: remoteContext.projectId,
+ projectName: remoteContext.projectName,
channel: remoteContext.channel,
kind: 'tool',
callId: taskEvent.callId,
@@ -1450,6 +1453,8 @@ export function registerIpcHandlers(
publishRemoteActivity({
requestId: remoteTaskId,
conversationId: remoteConversation.id,
+ projectId: project.id,
+ projectName: project.name,
channel,
kind: 'request',
title: `${channelLabel} · ${senderDisplay}`,
@@ -1490,6 +1495,8 @@ export function registerIpcHandlers(
publishRemoteActivity({
requestId: remoteTaskId,
conversationId: remoteConversation.id,
+ projectId: project.id,
+ projectName: project.name,
channel,
kind: 'result',
title: `${channelLabel}远程执行不可用`,
@@ -1522,6 +1529,8 @@ export function registerIpcHandlers(
publishRemoteActivity({
requestId: remoteTaskId,
conversationId: remoteConversation.id,
+ projectId: project.id,
+ projectName: project.name,
channel,
kind: 'result',
title: `${channelLabel}远程执行不可用`,
@@ -1554,6 +1563,7 @@ export function registerIpcHandlers(
channel,
channelLabel,
senderDisplay,
+ projectId: project.id,
projectName: project.name,
rootPath: project.rootPath,
conversationId: remoteConversation.id,
@@ -1604,6 +1614,8 @@ export function registerIpcHandlers(
publishRemoteActivity({
requestId: remoteTaskId,
conversationId: remoteConversation.id,
+ projectId: project.id,
+ projectName: project.name,
channel,
kind: 'result',
title:
@@ -2409,7 +2421,7 @@ export function registerIpcHandlers(
throw new Error('应用设置服务不可用')
}
return applicationSettingsStore.update(
- applicationSettingsSchema.parse(input)
+ applicationSettingsUpdateSchema.parse(input)
)
}
)
diff --git a/src/main/window.ts b/src/main/window.ts
index 58058b2..6a77485 100644
--- a/src/main/window.ts
+++ b/src/main/window.ts
@@ -60,8 +60,8 @@ export function createMainWindow(shouldQuit: () => boolean): BrowserWindow {
const window = new BrowserWindow({
width: 1180,
height: 760,
- minWidth: 920,
- minHeight: 620,
+ minWidth: 680,
+ minHeight: 560,
show: false,
frame: false,
...(usableIcon ? { icon: usableIcon } : {}),
diff --git a/src/preload/index.ts b/src/preload/index.ts
index eb936a2..8da1379 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -61,6 +61,7 @@ import type {
} from '../shared/channel-settings-contracts'
import type {
ApplicationSettings,
+ ApplicationSettingsUpdate,
VersionCheckResult
} from '../shared/application-settings-contracts'
import type {
@@ -294,7 +295,7 @@ const desktopApi: DesktopApi = {
ipcRenderer.invoke(
ipcChannels.applicationSettingsGet
) as Promise,
- updateSettings: (input: ApplicationSettings) =>
+ updateSettings: (input: ApplicationSettingsUpdate) =>
ipcRenderer.invoke(
ipcChannels.applicationSettingsUpdate,
input
diff --git a/src/renderer/src/ActivityPanel.test.tsx b/src/renderer/src/ActivityPanel.test.tsx
index 624e9c7..14fd9b8 100644
--- a/src/renderer/src/ActivityPanel.test.tsx
+++ b/src/renderer/src/ActivityPanel.test.tsx
@@ -21,6 +21,7 @@ function makeRecord(
id: `activity-${index}`,
conversationId: `conversation-${index}`,
requestId: `request-${index}`,
+ scope: { kind: 'global' },
kind: 'tool',
title: `活动 ${index}`,
detail: `详情 ${index}`,
@@ -150,6 +151,26 @@ describe('ActivityPanel', () => {
).toBeDisabled()
})
+ it('clears a filter that has no matching activity', () => {
+ render(
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: '进行中' }))
+ expect(screen.getByText('没有匹配的活动')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
+
+ expect(
+ screen.getByRole('button', { name: '全部' })
+ ).toHaveAttribute('aria-pressed', 'true')
+ expect(screen.getByText('对话:活动 1')).toBeInTheDocument()
+ })
+
it('labels Subagent activity as child expert work', () => {
render(
{
expect(groups[0]!.querySelectorAll('article')).toHaveLength(2)
})
+ it('shows immutable scope snapshots on groups and records', () => {
+ const projectRecord: ActivityRecord = {
+ ...makeRecord(1),
+ scope: {
+ kind: 'project',
+ projectId: 'project-1',
+ projectName: '项目甲'
+ }
+ }
+ const unavailableRecord: ActivityRecord = {
+ ...makeRecord(2),
+ scope: { kind: 'unavailable' }
+ }
+ render(
+
+ )
+
+ expect(screen.getAllByText('项目:项目甲')).toHaveLength(2)
+ expect(screen.getAllByText('范围不可用')).toHaveLength(2)
+ })
+
it('uses the shared page hierarchy and explicit global scope', () => {
render(
record.createdAt)),
status
}
@@ -361,6 +380,17 @@ export function ActivityPanel({
{filteredRecords.length === 0 ? (
setFilter('all')}
+ type="button"
+ >
+ 清除筛选
+
+ )
+ }
description={emptyMessage(filter)}
icon={}
level="section"
@@ -379,6 +409,9 @@ export function ActivityPanel({
对话:{group.title}
{group.records.length} 条活动
+
+
{record.title}
{record.detail.length > 0 && {record.detail}
}
)}
+ {loading && !hasHeartbeatData ? (
+ }
+ level="page"
+ title="正在加载智能心跳"
+ />
+ ) : loadError && !hasHeartbeatData ? (
+ void onRetryLoad()}
+ type="button"
+ >
+
+ 重试
+
+ }
+ description={loadError}
+ icon={}
+ level="page"
+ title="智能心跳加载失败"
+ />
+ ) : null}
+
+ {loadError && hasHeartbeatData && (
+
+
智能心跳刷新失败
+
{loadError}
+
void onRetryLoad()}
+ type="button"
+ >
+
+ 重试
+
+
+ )}
+
+ {!initialLoadBlocked && (
+ <>
)}
+ >
+ )}
)
}
diff --git a/src/renderer/src/KnowledgeWorkspace.test.tsx b/src/renderer/src/KnowledgeWorkspace.test.tsx
index 6a6952e..f9e5e80 100644
--- a/src/renderer/src/KnowledgeWorkspace.test.tsx
+++ b/src/renderer/src/KnowledgeWorkspace.test.tsx
@@ -4,7 +4,8 @@ import {
fireEvent,
render,
screen,
- waitFor
+ waitFor,
+ within
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
@@ -143,6 +144,7 @@ function createProps(
onCreateRelation: vi.fn(),
onUpdateRelation: vi.fn(),
onDeleteRelation: vi.fn(),
+ onRetryLoad: vi.fn(),
...overrides
}
}
@@ -283,10 +285,11 @@ describe('KnowledgeWorkspace', () => {
'knowledge-workspace__sidebar'
)
expect(workspace.querySelector('aside')).not.toHaveAttribute('style')
- expect(workspace.querySelector('main')).toHaveClass(
- 'knowledge-workspace__main'
- )
- expect(workspace.querySelector('main')).toHaveStyle({
+ const detailRegion = within(workspace).getByRole('region', {
+ name: '知识库详情'
+ })
+ expect(detailRegion).toHaveClass('knowledge-workspace__main')
+ expect(detailRegion).toHaveStyle({
background: 'var(--surface-raised)'
})
expect(screen.getByText('全局')).toHaveClass('scope-badge')
@@ -308,6 +311,9 @@ describe('KnowledgeWorkspace', () => {
expect(screen.getByLabelText('搜索文档').closest('label')).toHaveClass(
'knowledge-documents__search'
)
+ expect(screen.getByLabelText('搜索文档')).not.toHaveStyle({
+ outline: 'none'
+ })
expect(screen.getByText('本地文件 · 架构说明.md')).toBeInTheDocument()
expect(screen.queryByText('D:\\Private\\架构说明.md')).not
.toBeInTheDocument()
@@ -609,6 +615,44 @@ describe('KnowledgeWorkspace', () => {
).toBeDisabled()
})
+ it('shows a retryable load error instead of the first-library empty state', () => {
+ const onRetryLoad = vi.fn()
+ render(
+
+ )
+
+ expect(screen.getByText('知识库加载失败')).toBeInTheDocument()
+ expect(screen.getByText('数据库暂时不可用')).toBeInTheDocument()
+ expect(
+ screen.queryByText('建立第一个知识库')
+ ).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: '重试' }))
+ expect(onRetryLoad).toHaveBeenCalledOnce()
+ })
+
+ it('keeps existing data and selection visible when refresh fails', () => {
+ render(
+
+ )
+
+ expect(screen.getByText('知识库刷新失败')).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: library.name }))
+ .toBeInTheDocument()
+ expect(screen.getByText('架构说明.md')).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', { name: /^产品知识 1 个文档/u })
+ ).toHaveAttribute('aria-current', 'page')
+ })
+
it('confirms that deleting a managed library removes managed copies', async () => {
const onDeleteLibrary = vi.fn()
render(
diff --git a/src/renderer/src/KnowledgeWorkspace.tsx b/src/renderer/src/KnowledgeWorkspace.tsx
index 0a2777a..73625a1 100644
--- a/src/renderer/src/KnowledgeWorkspace.tsx
+++ b/src/renderer/src/KnowledgeWorkspace.tsx
@@ -159,6 +159,8 @@ export type KnowledgeWorkspaceProps = {
graphRelations: readonly KnowledgeGraphRelation[]
evidence: readonly KnowledgeEvidence[]
loading?: boolean
+ loadError?: string
+ onRetryLoad: () => void | Promise
onSelectLibrary: (libraryId: string) => void
onCreateLibrary: (
input: CreateKnowledgeLibraryInput
@@ -274,7 +276,6 @@ const styles = {
padding: 'var(--space-2) var(--space-3)',
border: '1px solid var(--border-control)',
borderRadius: 'var(--radius-control)',
- outline: 'none',
background: 'var(--surface-raised)',
color: 'var(--text-primary)',
font: 'inherit'
@@ -2115,6 +2116,8 @@ export function KnowledgeWorkspace({
graphRelations,
evidence,
loading = false,
+ loadError,
+ onRetryLoad,
onSelectLibrary,
onCreateLibrary,
onDeleteLibrary,
@@ -2323,10 +2326,33 @@ export function KnowledgeWorkspace({
-
+ {loadError && libraries.length > 0 && (
+
+
知识库刷新失败
+
{loadError}
+
void onRetryLoad()}
+ type="button"
+ >
+
+ 重试
+
+
+ )}
{selectedLibrary && !creating && !loading && (
)}
- {loading ? (
+ {loading && libraries.length === 0 ? (
}
level="page"
title="正在加载知识库"
/>
+ ) : loadError && libraries.length === 0 ? (
+ void onRetryLoad()}
+ style={styles.button}
+ type="button"
+ >
+
+ 重试
+
+ }
+ description={loadError}
+ icon={}
+ level="page"
+ title="知识库加载失败"
+ />
) : creating ? (
setCreating(false)}
@@ -2502,7 +2546,7 @@ export function KnowledgeWorkspace({
>
)}
-
+
{deletingLibrary && (
Promise>()
const get = vi.fn<(noteId: string) => Promise>()
const listTodos = vi.fn<() => Promise>()
+const remove = vi.fn()
const createTodo = vi.fn()
const updateTodo = vi.fn()
const removeTodo = vi.fn()
@@ -133,6 +134,7 @@ beforeEach(() => {
list.mockResolvedValue({ notes: [detail] })
get.mockResolvedValue(detail)
listTodos.mockResolvedValue({ todos: [noteTodo, manualTodo] })
+ remove.mockResolvedValue()
createTodo.mockResolvedValue({
...manualTodo,
id: '00000000-0000-4000-8000-000000000606',
@@ -166,6 +168,7 @@ beforeEach(() => {
list,
get,
listTodos,
+ remove,
createTodo,
updateTodo,
removeTodo,
@@ -181,6 +184,68 @@ afterEach(() => {
})
describe('MagicNotesWorkspace', () => {
+ it('shows a retryable EmptyState when the initial load fails', async () => {
+ get.mockRejectedValueOnce(new Error('详情暂时不可用'))
+
+ render(
+
+ )
+
+ expect(
+ await screen.findByText('魔法笔记加载失败')
+ ).toBeInTheDocument()
+ expect(screen.getByText(/详情暂时不可用/)).toBeInTheDocument()
+ expect(screen.queryByText('还没有笔记')).not.toBeInTheDocument()
+ expect(screen.queryByText('还没有待办')).not.toBeInTheDocument()
+ expect(screen.queryByText('还没有选择笔记')).not.toBeInTheDocument()
+ expect(onNotify).not.toHaveBeenCalled()
+
+ fireEvent.click(screen.getByRole('button', { name: '重试' }))
+
+ expect(await screen.findByText('记录正文')).toBeInTheDocument()
+ expect(screen.queryByText('魔法笔记加载失败')).not.toBeInTheDocument()
+ })
+
+ it('keeps successful data and selection when a refresh fails', async () => {
+ render(
+
+ )
+
+ await screen.findByText('记录正文')
+ list.mockRejectedValueOnce(new Error('刷新暂时不可用'))
+ fireEvent.click(screen.getByRole('button', { name: '删除笔记' }))
+ fireEvent.click(
+ screen.getAllByRole('button', { name: '删除笔记' })[1]!
+ )
+
+ expect(
+ await screen.findByText(/刷新失败,已保留当前内容:刷新暂时不可用/)
+ ).toBeInTheDocument()
+ expect(screen.getByLabelText('笔记标题')).toHaveValue(detail.title)
+ expect(
+ screen.getByRole('button', { name: /发布笔记/ })
+ ).toHaveAttribute('aria-pressed', 'true')
+ fireEvent.click(screen.getByRole('tab', { name: '待办' }))
+ expect(screen.getByText('准备演示')).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', { name: /核对发布材料/ })
+ ).toHaveAttribute('aria-pressed', 'true')
+ expect(onNotify).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ tone: 'error',
+ message: '刷新暂时不可用'
+ })
+ )
+ })
+
it('aggregates note and manual todos without AI-created todo actions', async () => {
render(
{
)
})
+ it('does not override a note selected while retrying a refresh', async () => {
+ const second = alternateDetail(secondNoteId, '第二篇笔记')
+ list.mockResolvedValue({
+ notes: [summaryFromDetail(detail), summaryFromDetail(second)]
+ })
+
+ render(
+
+ )
+
+ await screen.findByText('记录正文')
+ list.mockRejectedValueOnce(new Error('刷新暂时不可用'))
+ fireEvent.click(screen.getByRole('button', { name: '删除笔记' }))
+ fireEvent.click(
+ screen.getAllByRole('button', { name: '删除笔记' })[1]!
+ )
+ const retry = await screen.findByRole('button', { name: '重试' })
+
+ let resolveRefreshDetail:
+ | ((value: MagicNoteDetail) => void)
+ | undefined
+ const delayedRefreshDetail = new Promise(
+ (resolve) => {
+ resolveRefreshDetail = resolve
+ }
+ )
+ get.mockImplementation((requestedId) =>
+ requestedId === second.id
+ ? Promise.resolve(second)
+ : delayedRefreshDetail
+ )
+
+ fireEvent.click(retry)
+ await waitFor(() => expect(get).toHaveBeenCalledTimes(2))
+ fireEvent.click(screen.getByText(second.title).closest('button')!)
+ expect(await screen.findByDisplayValue(second.title)).toBeInTheDocument()
+
+ resolveRefreshDetail?.(detail)
+ await waitFor(() =>
+ expect(screen.getByLabelText('笔记标题')).toHaveValue(second.title)
+ )
+ })
+
it('creates a manual todo with a dedicated title and details form', async () => {
render(
{
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
fireEvent.click(screen.getByRole('button', { name: '新建待办' }))
expect(createTodo).not.toHaveBeenCalled()
- fireEvent.click(screen.getByRole('button', { name: '创建' }))
+ fireEvent.click(screen.getByRole('button', { name: '创建待办' }))
expect(screen.getByRole('alert')).toHaveTextContent('请输入待办标题')
expect(onNotify).not.toHaveBeenCalled()
@@ -300,7 +412,7 @@ describe('MagicNotesWorkspace', () => {
fireEvent.change(screen.getByLabelText('说明'), {
target: { value: '新增说明' }
})
- fireEvent.click(screen.getByRole('button', { name: '创建' }))
+ fireEvent.click(screen.getByRole('button', { name: '创建待办' }))
await waitFor(() =>
expect(createTodo).toHaveBeenCalledWith({
@@ -335,6 +447,41 @@ describe('MagicNotesWorkspace', () => {
).toBeInTheDocument()
})
+ it('clears note searches and todo status filters with no results', async () => {
+ listTodos.mockResolvedValue({
+ todos: [
+ { ...noteTodo, completed: true },
+ { ...manualTodo, completed: true }
+ ]
+ })
+ render(
+
+ )
+
+ await screen.findByText('记录正文')
+ fireEvent.change(screen.getByRole('searchbox', { name: '搜索当前范围的笔记' }), {
+ target: { value: '不存在的笔记' }
+ })
+ expect(screen.getByText('没有符合条件的笔记')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
+ expect(screen.getByRole('searchbox', {
+ name: '搜索当前范围的笔记'
+ })).toHaveValue('')
+ expect(screen.getByText('发布笔记')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('tab', { name: '待办' }))
+ expect(screen.getByText('没有符合条件的待办')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
+ expect(
+ screen.getByRole('button', { name: '全部' })
+ ).toHaveAttribute('aria-pressed', 'true')
+ expect(screen.getAllByText('核对发布材料')).not.toHaveLength(0)
+ })
+
it('clears delete confirmation before selecting the next todo', async () => {
render(
()
- const [loading, setLoading] = useState(true)
+ const [loadStatus, setLoadStatus] = useState('loading')
+ const [loadError, setLoadError] = useState('')
+ const [refreshError, setRefreshError] = useState('')
+ const [detailLoadError, setDetailLoadError] = useState<{
+ message: string
+ noteId: string
+ }>()
const [busy, setBusy] = useState('')
const [search, setSearch] = useState('')
const [creating, setCreating] = useState(false)
@@ -172,7 +179,9 @@ export function MagicNotesWorkspace({
message: string
}>()
const detailRequestRef = useRef(0)
+ const requestedNoteIdRef = useRef('')
const refreshRequestRef = useRef(0)
+ const hasLoadedRef = useRef(false)
const busyRef = useRef('')
const composerContentRef = useRef(
undefined
@@ -258,28 +267,40 @@ export function MagicNotesWorkspace({
const loadDetail = useCallback(
async (noteId: string): Promise => {
const requestId = ++detailRequestRef.current
+ requestedNoteIdRef.current = noteId
+ setDetailLoadError(undefined)
try {
const nextDetail = await window.goodbuddy.magicNotes.get(noteId)
if (detailRequestRef.current === requestId) {
+ setSelectedNoteId(noteId)
applyDetail(nextDetail)
}
} catch (loadError) {
if (detailRequestRef.current === requestId) {
- notifyError(loadError)
+ setDetailLoadError({
+ message: errorMessage(loadError),
+ noteId
+ })
}
}
},
- [applyDetail, notifyError]
+ [applyDetail]
)
const refreshNotes = useCallback(
async (preferredId?: string): Promise => {
const requestId = ++refreshRequestRef.current
+ const detailRequestAtStart = detailRequestRef.current
await Promise.resolve()
if (refreshRequestRef.current !== requestId) {
return
}
- setLoading(true)
+ const isInitialLoad = !hasLoadedRef.current
+ if (isInitialLoad) {
+ setLoadStatus('loading')
+ setLoadError('')
+ }
+ setRefreshError('')
try {
const [snapshot, todoSnapshot] = await Promise.all([
window.goodbuddy.magicNotes.list(projectId),
@@ -295,23 +316,37 @@ export function MagicNotesWorkspace({
if (refreshRequestRef.current !== requestId) {
return
}
+ const requestedNoteId = requestedNoteIdRef.current
+ const preserveNewerSelection =
+ detailRequestRef.current !== detailRequestAtStart &&
+ snapshot.notes.some((note) => note.id === requestedNoteId)
setNotes(snapshot.notes)
setTodos(todoSnapshot.todos)
- setSelectedNoteId(nextId)
setSelectedTodoId(todoSnapshot.todos[0]?.id ?? '')
+ hasLoadedRef.current = true
+ setLoadStatus('ready')
+ if (preserveNewerSelection) {
+ return
+ }
+ detailRequestRef.current += 1
+ requestedNoteIdRef.current = nextId
+ setSelectedNoteId(nextId)
setDetail(nextDetail)
setTitleDraft(nextDetail?.title ?? '')
+ setDetailLoadError(undefined)
} catch (loadError) {
if (refreshRequestRef.current === requestId) {
- notifyError(loadError)
- }
- } finally {
- if (refreshRequestRef.current === requestId) {
- setLoading(false)
+ const message = errorMessage(loadError)
+ if (hasLoadedRef.current) {
+ setRefreshError(message)
+ } else {
+ setLoadError(message)
+ setLoadStatus('error')
+ }
}
}
},
- [notifyError, projectId]
+ [projectId]
)
useEffect(() => {
@@ -321,6 +356,7 @@ export function MagicNotesWorkspace({
return () => {
window.clearTimeout(timeout)
refreshRequestRef.current += 1
+ detailRequestRef.current += 1
}
}, [refreshNotes])
@@ -391,6 +427,7 @@ export function MagicNotesWorkspace({
title
})
applyDetail(created)
+ requestedNoteIdRef.current = created.id
setSelectedNoteId(created.id)
setNewTitle('')
setCreating(false)
@@ -649,7 +686,7 @@ export function MagicNotesWorkspace({
setCreating(true)
}}
>
-
+
{libraryView === 'notes' ? '新建笔记' : '新建待办'}
>
@@ -666,6 +703,36 @@ export function MagicNotesWorkspace({
title="魔法笔记"
/>
+ {loadStatus === 'error' ? (
+ void refreshNotes()}
+ type="button"
+ >
+ 重试
+
+ }
+ description={`无法加载魔法笔记:${loadError}`}
+ icon={}
+ level="page"
+ title="魔法笔记加载失败"
+ />
+ ) : (
+ <>
+ {refreshError && (
+
+ 刷新失败,已保留当前内容:{refreshError}
+ void refreshNotes(selectedNoteId)}
+ type="button"
+ >
+ 重试
+
+
+ )}
- 创建
+ 创建笔记
)}
- {loading ? (
+ {loadStatus === 'loading' ? (
正在加载笔记…
) : visibleNotes.length === 0 ? (
-
- {search ? '没有符合条件的笔记' : '还没有笔记'}
-
+ <>
+
+ {search.trim()
+ ? '没有符合条件的笔记'
+ : '还没有笔记'}
+
+ {search.trim() && (
+
setSearch('')}
+ type="button"
+ >
+ 清除筛选
+
+ )}
+ >
) : (
visibleNotes.map((note) => (
{
setValidation(undefined)
- setSelectedNoteId(note.id)
setDeletingNote(false)
setEditingEntry(undefined)
editingContentRef.current = undefined
@@ -895,22 +974,38 @@ export function MagicNotesWorkspace({
disabled={busy === 'create-todo'}
type="submit"
>
- 创建
+ 创建待办
)}
- {loading ? (
+ {loadStatus === 'loading' ? (
正在加载待办…
) : visibleTodos.length === 0 ? (
-
- {todos.length === 0
- ? '还没有待办'
- : search || todoFilter !== 'all'
- ? '没有符合条件的待办'
- : '还没有待办'}
-
+ <>
+
+ {todos.length === 0
+ ? '还没有待办'
+ : search.trim() || todoFilter !== 'all'
+ ? '没有符合条件的待办'
+ : '还没有待办'}
+
+ {todos.length > 0 &&
+ (Boolean(search.trim()) ||
+ todoFilter !== 'all') && (
+
{
+ setSearch('')
+ setTodoFilter('all')
+ }}
+ type="button"
+ >
+ 清除筛选
+
+ )}
+ >
) : (
visibleTodos.map((todo) => (
-
@@ -964,10 +1059,27 @@ export function MagicNotesWorkspace({
}
- title={loading ? '正在加载' : '还没有选择笔记'}
+ title={
+ loadStatus === 'loading' ? '正在加载' : '还没有选择笔记'
+ }
/>
) : (
<>
+ {detailLoadError && (
+
+
+ 笔记加载失败,已保留当前内容:
+ {detailLoadError.message}
+
+ void loadDetail(detailLoadError.noteId)}
+ type="button"
+ >
+ 重试
+
+
+ )}
+
+ >
+ )}
)
}
diff --git a/src/renderer/src/PlatformFeaturesSettingsSection.tsx b/src/renderer/src/PlatformFeaturesSettingsSection.tsx
new file mode 100644
index 0000000..7d31e32
--- /dev/null
+++ b/src/renderer/src/PlatformFeaturesSettingsSection.tsx
@@ -0,0 +1,106 @@
+import { Sparkles } from 'lucide-react'
+import { useEffect, useState } from 'react'
+import type { ApplicationSettings } from '../../shared/application-settings-contracts'
+
+type PlatformFeaturesSettingsSectionProps = {
+ onMagicNotesEnabledChange: (enabled: boolean) => void
+}
+
+export function PlatformFeaturesSettingsSection({
+ onMagicNotesEnabledChange
+}: PlatformFeaturesSettingsSectionProps): React.JSX.Element {
+ const [settings, setSettings] = useState()
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState(() =>
+ window.goodbuddy.updates
+ ? undefined
+ : '当前版本未提供应用设置服务'
+ )
+
+ useEffect(() => {
+ const updates = window.goodbuddy.updates
+ let active = true
+ if (!updates) {
+ return () => {
+ active = false
+ }
+ }
+ void updates
+ .getSettings()
+ .then((nextSettings) => {
+ if (active) {
+ setSettings(nextSettings)
+ }
+ })
+ .catch(() => {
+ if (active) {
+ setError('读取平台功能设置失败')
+ }
+ })
+ return () => {
+ active = false
+ }
+ }, [])
+
+ const changeMagicNotes = async (enabled: boolean): Promise => {
+ const updates = window.goodbuddy.updates
+ if (!updates || !settings) {
+ return
+ }
+ setSaving(true)
+ setError(undefined)
+ try {
+ const nextSettings = await updates.updateSettings({
+ magicNotesEnabled: enabled
+ })
+ setSettings(nextSettings)
+ onMagicNotesEnabledChange(nextSettings.magicNotesEnabled)
+ } catch {
+ setError('保存魔法笔记设置失败,请重试')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+
+
+ 平台功能
+ 控制 GoodBuddy 工作区中显示的功能入口
+
+
+
+
+
+ 魔法笔记
+
+ 默认关闭;开启后可记录笔记与待办,并使用 AI 分析内容
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ )
+}
diff --git a/src/renderer/src/SettingsPanel.test.tsx b/src/renderer/src/SettingsPanel.test.tsx
index 520dfa3..e0bc1b0 100644
--- a/src/renderer/src/SettingsPanel.test.tsx
+++ b/src/renderer/src/SettingsPanel.test.tsx
@@ -325,10 +325,30 @@ const onEmbeddingStatus = vi.fn(
}
}
)
+let applicationSettings = {
+ checkUpdatesOnStartup: true,
+ magicNotesEnabled: false
+}
+const getApplicationSettings = vi.fn(async () => ({
+ ...applicationSettings
+}))
+const updateApplicationSettings = vi.fn<
+ NonNullable['updateSettings']
+>(async (input) => {
+ applicationSettings = {
+ ...applicationSettings,
+ ...input
+ }
+ return { ...applicationSettings }
+})
describe('SettingsPanel runtime files', () => {
beforeEach(() => {
vi.clearAllMocks()
+ applicationSettings = {
+ checkUpdatesOnStartup: true,
+ magicNotesEnabled: false
+ }
embeddingStatusListeners.splice(0)
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
@@ -377,6 +397,13 @@ describe('SettingsPanel runtime files', () => {
rebuild: rebuildEmbeddingIndex,
cancel: cancelEmbeddingIndex,
onStatus: onEmbeddingStatus
+ },
+ updates: {
+ getSettings: getApplicationSettings,
+ updateSettings: updateApplicationSettings,
+ check: vi.fn(),
+ openReleasePage: vi.fn(),
+ onResult: vi.fn(() => () => {})
}
} as unknown as DesktopApi
})
@@ -408,6 +435,35 @@ describe('SettingsPanel runtime files', () => {
expect(onAppearanceThemeChange).toHaveBeenCalledWith('dark')
})
+ it('toggles the Magic Notes platform entry setting', async () => {
+ const onMagicNotesEnabledChange = vi.fn()
+ render(
+ {})}
+ onClose={vi.fn()}
+ onSaved={vi.fn()}
+ />
+ )
+
+ fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
+ const toggle = await screen.findByRole('switch', {
+ name: '显示魔法笔记入口'
+ })
+ expect(toggle).not.toBeChecked()
+ expect(screen.getByText(/默认关闭/)).toBeInTheDocument()
+ fireEvent.click(toggle)
+
+ await waitFor(() =>
+ expect(updateApplicationSettings).toHaveBeenCalledWith({
+ magicNotesEnabled: true
+ })
+ )
+ expect(onMagicNotesEnabledChange).toHaveBeenCalledWith(true)
+ })
+
it('keeps page navigation beside an independently scrollable panel', () => {
render(
{
expect(content).toHaveClass('settings-panel__content')
})
+ it('uses one first-level heading for the settings page', () => {
+ render(
+ {})}
+ onClose={vi.fn()}
+ onSaved={vi.fn()}
+ presentation="page"
+ />
+ )
+
+ expect(
+ screen.getAllByRole('heading', { level: 1 })
+ ).toHaveLength(1)
+ expect(
+ screen.getByRole('heading', { level: 1, name: '设置中心' })
+ ).toBeInTheDocument()
+ })
+
+ it('keeps local progress but does not duplicate clear-data success', async () => {
+ let finishClear: (() => void) | undefined
+ const onClearLocalData = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ finishClear = resolve
+ })
+ )
+ render(
+
+ )
+
+ fireEvent.click(screen.getByRole('tab', { name: '安全与数据' }))
+ fireEvent.click(
+ screen.getByRole('button', { name: '清除本地数据' })
+ )
+ fireEvent.click(
+ screen.getByRole('button', { name: '清除本地数据' })
+ )
+
+ expect(
+ screen.getByRole('button', { name: '正在清除…' })
+ ).toBeDisabled()
+ await act(async () => finishClear?.())
+ await waitFor(() =>
+ expect(
+ screen.queryByText('本地数据已清除')
+ ).not.toBeInTheDocument()
+ )
+ })
+
it('supports keyboard navigation between settings tabs', () => {
render(
Promise
appearanceTheme?: AppearanceTheme
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
+ onMagicNotesEnabledChange?: (enabled: boolean) => void
}
const credentialLabels: Record<
@@ -255,7 +259,8 @@ export function SettingsPanel({
onRunHeartbeat,
onExpertsChanged = () => {},
appearanceTheme = 'system',
- onAppearanceThemeChange = () => {}
+ onAppearanceThemeChange = () => {},
+ onMagicNotesEnabledChange = () => {}
}: SettingsPanelProps): React.JSX.Element | null {
const [settings, setSettings] = useState()
const [provider, setProvider] =
@@ -328,6 +333,7 @@ export function SettingsPanel({
const [saved, setSaved] = useState(false)
const [connectionResult, setConnectionResult] = useState()
const [confirmingClear, setConfirmingClear] = useState(false)
+ const [clearingLocalData, setClearingLocalData] = useState(false)
const [detection, setDetection] = useState()
const [detecting, setDetecting] = useState(false)
const [activeTab, setActiveTab] = useState('runtime')
@@ -382,6 +388,7 @@ export function SettingsPanel({
setSaved(false)
setConnectionResult(undefined)
setConfirmingClear(false)
+ setClearingLocalData(false)
setModelType('llm')
setAgentRuntimeType('opencode')
setSettings(value)
@@ -951,23 +958,26 @@ export function SettingsPanel({
className="settings-panel"
role={presentation === 'modal' ? 'dialog' : 'region'}
>
-
+
-
SETTINGS
-
设置中心
-
- 管理模型连接、Agent Runtime、自动化、扩展能力和本地数据。
-
+
+
+
+ }
+ description="管理模型连接、Agent Runtime、自动化、扩展能力和本地数据。"
+ eyebrow="SETTINGS"
+ headingId="settings-title"
+ title="设置中心"
+ />
-
-
-
-
+
)}
+ {activeTab === 'platform-features' && (
+
+ )}
{activeTab === 'runtime' && (
<>
{settings?.warning && (
@@ -2165,6 +2196,7 @@ export function SettingsPanel({
setConfirmingClear(false)}
type="button"
>
@@ -2172,12 +2204,15 @@ export function SettingsPanel({
{
+ setClearingLocalData(true)
+ setError(undefined)
+ setSaved(false)
+ setConnectionResult(undefined)
void onClearLocalData()
.then(() => {
setConfirmingClear(false)
- setConnectionResult('本地数据已清除')
- setSaved(true)
})
.catch((reason: unknown) => {
setError(
@@ -2187,10 +2222,13 @@ export function SettingsPanel({
)
)
})
+ .finally(() => setClearingLocalData(false))
}}
type="button"
>
- 确认清除
+ {clearingLocalData
+ ? '正在清除…'
+ : '清除本地数据'}
) : (
diff --git a/src/renderer/src/UpdateSettingsSection.test.tsx b/src/renderer/src/UpdateSettingsSection.test.tsx
index 86e65ba..37d4d40 100644
--- a/src/renderer/src/UpdateSettingsSection.test.tsx
+++ b/src/renderer/src/UpdateSettingsSection.test.tsx
@@ -18,7 +18,11 @@ describe('UpdateSettingsSection', () => {
it('checks the official release manifest and updates the startup preference', async () => {
const updateSettings = vi.fn<
NonNullable['updateSettings']
- >(async (input) => input)
+ >(async (input) => ({
+ checkUpdatesOnStartup:
+ input.checkUpdatesOnStartup ?? true,
+ magicNotesEnabled: input.magicNotesEnabled ?? true
+ }))
const check = vi.fn<
NonNullable['check']
>(async () => ({
@@ -54,7 +58,8 @@ describe('UpdateSettingsSection', () => {
},
updates: {
getSettings: vi.fn(async () => ({
- checkUpdatesOnStartup: true
+ checkUpdatesOnStartup: true,
+ magicNotesEnabled: true
})),
updateSettings,
check,
@@ -101,9 +106,13 @@ describe('UpdateSettingsSection', () => {
},
updates: {
getSettings: vi.fn(async () => ({
- checkUpdatesOnStartup: true
+ checkUpdatesOnStartup: true,
+ magicNotesEnabled: true
+ })),
+ updateSettings: vi.fn(async () => ({
+ checkUpdatesOnStartup: true,
+ magicNotesEnabled: true
})),
- updateSettings: vi.fn(async (input) => input),
check: vi.fn(async () => {
throw new Error(
"Error invoking remote method 'application:update:check': TypeError: fetch failed"
diff --git a/src/renderer/src/WorkspacePrimitives.test.tsx b/src/renderer/src/WorkspacePrimitives.test.tsx
index ba3bd58..57f4751 100644
--- a/src/renderer/src/WorkspacePrimitives.test.tsx
+++ b/src/renderer/src/WorkspacePrimitives.test.tsx
@@ -98,6 +98,34 @@ describe('WorkspacePrimitives', () => {
)
})
+ it('keeps shared controls keyboard and pointer accessible at narrow widths', () => {
+ expect(stylesheet).toMatch(
+ /\.window-control\s*>\s*svg,\s*\.icon-button\s*>\s*svg\s*\{[^}]*pointer-events:\s*none;/u
+ )
+ expect(stylesheet).toMatch(
+ /button:focus-visible,\s*input:focus-visible,\s*select:focus-visible,\s*textarea:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--accent\);/u
+ )
+ expect(stylesheet).toMatch(
+ /\.page-tabs\s*\{[^}]*overflow-x:\s*auto;[^}]*flex-wrap:\s*nowrap;/u
+ )
+ expect(stylesheet).not.toContain(
+ '.heartbeat-center > .page-tabs {\n display: grid;'
+ )
+ })
+
+ it('uses the design-system page gutters at each window width', () => {
+ expect(stylesheet).toMatch(/--page-gutter:\s*32px;/u)
+ expect(stylesheet).toMatch(
+ /@media \(max-width: 1199px\)\s*\{\s*:root\s*\{\s*--page-gutter:\s*24px;/u
+ )
+ expect(stylesheet).toMatch(
+ /@media \(max-width: 959px\)\s*\{\s*:root\s*\{\s*--page-gutter:\s*16px;/u
+ )
+ expect(stylesheet).toMatch(
+ /\.page-shell--master-detail\s*\{[^}]*padding:\s*var\(--page-gutter\);/u
+ )
+ })
+
it('renders a consistent page shell and scoped header', () => {
render(
@@ -188,8 +216,10 @@ describe('WorkspacePrimitives', () => {
const onCancel = vi.fn()
const { rerender } = render(
×}
onCancel={onCancel}
onConfirm={onConfirm}
onRequestConfirm={onRequestConfirm}
@@ -197,11 +227,16 @@ describe('WorkspacePrimitives', () => {
/>
)
+ expect(screen.getByTestId('delete-icon').parentElement).toHaveAttribute(
+ 'aria-hidden',
+ 'true'
+ )
fireEvent.click(screen.getByRole('button', { name: '删除' }))
expect(onRequestConfirm).toHaveBeenCalledOnce()
rerender(
{
triggerLabel="删除"
/>
)
- expect(screen.getByRole('button', { name: '取消' })).toHaveFocus()
- fireEvent.click(screen.getByRole('button', { name: '确认删除' }))
- fireEvent.click(screen.getByRole('button', { name: '取消' }))
- expect(onConfirm).toHaveBeenCalledOnce()
+ const dialog = screen.getByRole('alertdialog', {
+ name: '永久删除对象'
+ })
+ const cancelButton = screen.getByRole('button', { name: '取消' })
+ const confirmButton = screen.getByRole('button', {
+ name: '永久删除对象'
+ })
+ expect(dialog).toHaveAttribute('aria-modal', 'true')
+ expect(dialog).toHaveAccessibleDescription('删除此对象?')
+ expect(cancelButton).toHaveFocus()
+
+ fireEvent.keyDown(cancelButton, { key: 'Tab', shiftKey: true })
+ expect(confirmButton).toHaveFocus()
+ fireEvent.keyDown(confirmButton, { key: 'Tab' })
+ expect(cancelButton).toHaveFocus()
+
+ fireEvent.keyDown(cancelButton, { key: 'Escape' })
expect(onCancel).toHaveBeenCalledOnce()
+ fireEvent.click(confirmButton)
+ expect(onConfirm).toHaveBeenCalledOnce()
+
+ rerender(
+
+ )
+ expect(screen.getByRole('button', { name: '删除' })).toHaveFocus()
+ })
+
+ it('keeps focus on the dialog while destructive actions are disabled', () => {
+ const onCancel = vi.fn()
+ const { rerender } = render(
+
+ )
+
+ rerender(
+
+ )
+
+ const dialog = screen.getByRole('alertdialog', {
+ name: '正在删除'
+ })
+ expect(dialog).toHaveFocus()
+ fireEvent.keyDown(dialog, { key: 'Tab' })
+ expect(dialog).toHaveFocus()
+ fireEvent.keyDown(dialog, { key: 'Escape' })
+ expect(onCancel).not.toHaveBeenCalled()
})
it.each([
diff --git a/src/renderer/src/WorkspacePrimitives.tsx b/src/renderer/src/WorkspacePrimitives.tsx
index ae1f2e5..e791243 100644
--- a/src/renderer/src/WorkspacePrimitives.tsx
+++ b/src/renderer/src/WorkspacePrimitives.tsx
@@ -6,6 +6,7 @@ import {
} from 'lucide-react'
import {
useEffect,
+ useId,
useRef,
type KeyboardEvent,
type ReactNode
@@ -326,30 +327,97 @@ export function DestructiveConfirmActions({
triggerLabel: string
}): React.JSX.Element {
const cancelRef = useRef(null)
+ const confirmRef = useRef(null)
+ const dialogRef = useRef(null)
const triggerRef = useRef(null)
const wasConfirming = useRef(confirming)
+ const shouldRestoreTrigger = useRef(false)
+ const titleId = useId()
+ const descriptionId = useId()
useEffect(() => {
if (confirming && !wasConfirming.current) {
- cancelRef.current?.focus()
+ if (disabled) {
+ dialogRef.current?.focus()
+ } else {
+ cancelRef.current?.focus()
+ }
+ } else if (confirming && disabled) {
+ dialogRef.current?.focus()
} else if (
+ confirming &&
+ !disabled &&
+ document.activeElement === dialogRef.current
+ ) {
+ cancelRef.current?.focus()
+ } else if (!confirming && wasConfirming.current) {
+ shouldRestoreTrigger.current = true
+ }
+
+ if (
!confirming &&
- wasConfirming.current &&
+ shouldRestoreTrigger.current &&
!triggerRef.current?.disabled
) {
triggerRef.current?.focus()
+ shouldRestoreTrigger.current = false
}
wasConfirming.current = confirming
- }, [confirming])
+ }, [confirming, disabled])
return confirming ? (
{
+ if (event.key === 'Escape' && !disabled) {
+ event.preventDefault()
+ onCancel()
+ return
+ }
+ if (event.key !== 'Tab') {
+ return
+ }
+ if (disabled) {
+ event.preventDefault()
+ dialogRef.current?.focus()
+ return
+ }
+
+ const cancelButton = cancelRef.current
+ const confirmButton = confirmRef.current
+ if (
+ !cancelButton ||
+ !confirmButton ||
+ cancelButton.disabled ||
+ confirmButton.disabled
+ ) {
+ return
+ }
+
+ event.preventDefault()
+ const nextButton = event.shiftKey
+ ? document.activeElement === cancelButton
+ ? confirmButton
+ : cancelButton
+ : document.activeElement === confirmButton
+ ? cancelButton
+ : confirmButton
+ nextButton.focus()
+ }}
+ ref={dialogRef}
role="alertdialog"
+ tabIndex={-1}
>
- {message &&
{message}}
+
+ {confirmAriaLabel ?? confirmLabel}
+
+
+ {message ?? `确认${triggerLabel}操作。`}
+
{confirmLabel}
@@ -379,7 +448,7 @@ export function DestructiveConfirmActions({
ref={triggerRef}
type="button"
>
- {icon}
+ {icon && {icon}}
{triggerLabel}
)
diff --git a/src/renderer/src/activity-store.test.ts b/src/renderer/src/activity-store.test.ts
index 4e609c5..edb4bbe 100644
--- a/src/renderer/src/activity-store.test.ts
+++ b/src/renderer/src/activity-store.test.ts
@@ -15,6 +15,7 @@ function makeRecord(index: number): ActivityRecord {
id: `activity-${index}`,
conversationId: 'conversation-1',
requestId: 'request-1',
+ scope: { kind: 'global' },
kind: 'tool',
title: `工具调用 ${index}`,
detail: '读取文件',
@@ -55,6 +56,41 @@ describe('activity-store', () => {
expect(loadActivityRecords()).toEqual([validRecord])
})
+ it('loads legacy records with an explicit unavailable scope', () => {
+ const { scope, ...legacyRecord } = makeRecord(1)
+ void scope
+ localStorage.setItem(
+ ACTIVITY_STORAGE_KEY,
+ JSON.stringify([legacyRecord])
+ )
+
+ expect(loadActivityRecords()).toEqual([
+ expect.objectContaining({
+ id: legacyRecord.id,
+ scope: { kind: 'unavailable' }
+ })
+ ])
+ })
+
+ it('rejects malformed project snapshots from untrusted storage', () => {
+ const record = makeRecord(1)
+ localStorage.setItem(
+ ACTIVITY_STORAGE_KEY,
+ JSON.stringify([
+ {
+ ...record,
+ scope: {
+ kind: 'project',
+ projectId: 'project-1',
+ projectName: 'x'.repeat(121)
+ }
+ }
+ ])
+ )
+
+ expect(loadActivityRecords()).toEqual([])
+ })
+
it('persists no more than the record limit', () => {
const records = Array.from(
{ length: MAX_ACTIVITY_RECORDS + 1 },
@@ -102,10 +138,32 @@ describe('activity-store', () => {
.toMatchObject({
id: first.id,
createdAt: first.createdAt,
+ scope: first.scope,
status: 'failed'
})
})
+ it('keeps the original scope snapshot when a call is updated', () => {
+ const first: ActivityRecord = {
+ ...makeRecord(1),
+ callId: 'call-1',
+ scope: {
+ kind: 'project',
+ projectId: 'project-1',
+ projectName: '原项目名称'
+ },
+ status: 'running'
+ }
+
+ expect(
+ upsertActivityRecord([first], {
+ ...first,
+ scope: { kind: 'global' },
+ status: 'completed'
+ })[0]?.scope
+ ).toEqual(first.scope)
+ })
+
it('persists and upserts Subagent state transitions', () => {
const queued: ActivityRecord = {
...makeRecord(1),
diff --git a/src/renderer/src/activity-store.ts b/src/renderer/src/activity-store.ts
index 7d7fc96..ef4900b 100644
--- a/src/renderer/src/activity-store.ts
+++ b/src/renderer/src/activity-store.ts
@@ -7,6 +7,7 @@ export const MAX_ACTIVITY_DETAIL_LENGTH = 4_000
const MAX_STORED_JSON_LENGTH = 2_000_000
const MAX_ID_LENGTH = 256
const MAX_TITLE_LENGTH = 240
+const MAX_PROJECT_NAME_LENGTH = 120
const activityKinds = [
'request',
@@ -30,6 +31,10 @@ export type ActivityRecord = {
conversationId: string
requestId: string
callId?: string
+ scope:
+ | { kind: 'global' }
+ | { kind: 'project'; projectId: string; projectName: string }
+ | { kind: 'unavailable' }
kind: (typeof activityKinds)[number]
title: string
detail: string
@@ -57,30 +62,82 @@ function isBoundedString(
)
}
-function isActivityRecord(value: unknown): value is ActivityRecord {
+function parseActivityScope(
+ value: unknown
+): ActivityRecord['scope'] | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
- return false
+ return undefined
+ }
+ const candidate = value as Record
+ if (candidate.kind === 'global') {
+ return { kind: 'global' }
+ }
+ if (candidate.kind === 'unavailable') {
+ return { kind: 'unavailable' }
+ }
+ if (
+ candidate.kind === 'project' &&
+ isBoundedString(candidate.projectId, MAX_ID_LENGTH) &&
+ isBoundedString(candidate.projectName, MAX_PROJECT_NAME_LENGTH)
+ ) {
+ return {
+ kind: 'project',
+ projectId: candidate.projectId,
+ projectName: candidate.projectName
+ }
+ }
+ return undefined
+}
+
+function parseActivityRecord(value: unknown): ActivityRecord | undefined {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ return undefined
}
const candidate = value as Record
- return (
- isBoundedString(candidate.id, MAX_ID_LENGTH) &&
- isBoundedString(candidate.conversationId, MAX_ID_LENGTH) &&
- isBoundedString(candidate.requestId, MAX_ID_LENGTH) &&
- (candidate.callId === undefined ||
- isBoundedString(candidate.callId, MAX_ID_LENGTH)) &&
- activityKinds.some((kind) => kind === candidate.kind) &&
- isBoundedString(candidate.title, MAX_TITLE_LENGTH) &&
- isBoundedString(
+ if (
+ !isBoundedString(candidate.id, MAX_ID_LENGTH) ||
+ !isBoundedString(candidate.conversationId, MAX_ID_LENGTH) ||
+ !isBoundedString(candidate.requestId, MAX_ID_LENGTH) ||
+ (candidate.callId !== undefined &&
+ !isBoundedString(candidate.callId, MAX_ID_LENGTH)) ||
+ !activityKinds.some((kind) => kind === candidate.kind) ||
+ !isBoundedString(candidate.title, MAX_TITLE_LENGTH) ||
+ !isBoundedString(
candidate.detail,
MAX_ACTIVITY_DETAIL_LENGTH,
true
- ) &&
- activityStatuses.some((status) => status === candidate.status) &&
- typeof candidate.createdAt === 'number' &&
- Number.isFinite(candidate.createdAt) &&
- candidate.createdAt >= 0
- )
+ ) ||
+ !activityStatuses.some((status) => status === candidate.status) ||
+ typeof candidate.createdAt !== 'number' ||
+ !Number.isFinite(candidate.createdAt) ||
+ candidate.createdAt < 0
+ ) {
+ return undefined
+ }
+
+ const scope =
+ candidate.scope === undefined
+ ? { kind: 'unavailable' as const }
+ : parseActivityScope(candidate.scope)
+ if (!scope) {
+ return undefined
+ }
+
+ return {
+ id: candidate.id,
+ conversationId: candidate.conversationId,
+ requestId: candidate.requestId,
+ ...(candidate.callId === undefined
+ ? {}
+ : { callId: candidate.callId }),
+ scope,
+ kind: candidate.kind as ActivityRecord['kind'],
+ title: candidate.title,
+ detail: candidate.detail,
+ status: candidate.status as ActivityRecord['status'],
+ createdAt: candidate.createdAt
+ }
}
export function upsertActivityRecord(
@@ -109,7 +166,8 @@ export function upsertActivityRecord(
{
...incoming,
id: existing.id,
- createdAt: existing.createdAt
+ createdAt: existing.createdAt,
+ scope: existing.scope
},
...records.filter((_, index) => index !== existingIndex)
].slice(0, MAX_ACTIVITY_RECORDS)
@@ -200,8 +258,9 @@ export function loadActivityRecords(
const records: ActivityRecord[] = []
for (const candidate of parsed) {
- if (isActivityRecord(candidate)) {
- records.push(candidate)
+ const record = parseActivityRecord(candidate)
+ if (record) {
+ records.push(record)
}
if (records.length === MAX_ACTIVITY_RECORDS) {
break
@@ -227,8 +286,9 @@ export function saveActivityRecords(
const safeRecords: ActivityRecord[] = []
for (const record of records) {
- if (isActivityRecord(record)) {
- safeRecords.push(record)
+ const safeRecord = parseActivityRecord(record)
+ if (safeRecord) {
+ safeRecords.push(safeRecord)
}
if (safeRecords.length === MAX_ACTIVITY_RECORDS) {
break
diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css
index 42f664e..ce27641 100644
--- a/src/renderer/src/styles.css
+++ b/src/renderer/src/styles.css
@@ -34,7 +34,7 @@
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
- --page-gutter: clamp(16px, 2vw, 28px);
+ --page-gutter: 32px;
--content-reading: 820px;
--content-standard: 960px;
--content-dashboard: 1040px;
@@ -60,6 +60,18 @@
text-rendering: optimizeLegibility;
}
+@media (max-width: 1199px) {
+ :root {
+ --page-gutter: 24px;
+ }
+}
+
+@media (max-width: 959px) {
+ :root {
+ --page-gutter: 16px;
+ }
+}
+
.magic-notes-page {
display: flex;
min-width: 0;
@@ -785,8 +797,9 @@ button {
button:focus-visible,
input:focus-visible,
+select:focus-visible,
textarea:focus-visible {
- outline: 2px solid #1677ff;
+ outline: 2px solid var(--accent);
outline-offset: 2px;
}
@@ -819,6 +832,10 @@ textarea:focus-visible {
padding-left: 0;
}
+.sidebar-backdrop {
+ display: none;
+}
+
.brand {
display: flex;
align-items: center;
@@ -1330,6 +1347,11 @@ textarea:focus-visible {
outline-offset: -2px;
}
+.window-control > svg,
+.icon-button > svg {
+ pointer-events: none;
+}
+
.icon-button {
display: grid;
width: 34px;
@@ -1337,18 +1359,18 @@ textarea:focus-visible {
place-items: center;
border-radius: 9px;
background: transparent;
- color: #595959;
+ color: var(--text-secondary);
cursor: pointer;
}
.icon-button:hover {
- background: #e6f4ff;
- color: #1677ff;
+ background: var(--accent-subtle);
+ color: var(--accent);
}
.icon-button--active {
- background: #e6f4ff;
- color: #1677ff;
+ background: var(--accent-subtle);
+ color: var(--accent);
}
.assistant-sidebar {
@@ -3858,6 +3880,7 @@ textarea:focus-visible {
}
.settings-panel__header {
+ position: relative;
display: flex;
align-items: center;
padding: 22px 24px 18px;
@@ -3868,6 +3891,25 @@ textarea:focus-visible {
flex: 1;
}
+.settings-panel__header .page-header {
+ position: relative;
+ width: 100%;
+}
+
+.settings-panel__header .page-header__content {
+ padding-right: 44px;
+}
+
+.settings-panel__header
+ .page-header:not(.page-header--compact)
+ .page-header__actions {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: auto;
+ justify-content: flex-end;
+}
+
.settings-panel__header .eyebrow {
margin-bottom: 5px;
}
@@ -5050,17 +5092,17 @@ details.settings-section > :not(summary) + :not(summary) {
.capability-card {
display: flex;
flex-direction: column;
- padding: 12px;
- border: 1px solid #e8e8e8;
- border-radius: 8px;
- background: #fff;
- gap: 8px;
+ padding: var(--space-3);
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-card);
+ background: var(--surface-raised);
+ gap: var(--space-2);
}
.capability-card__header {
display: flex;
align-items: flex-start;
- gap: 12px;
+ gap: var(--space-3);
}
.capability-card__header > div:first-child {
@@ -5071,30 +5113,30 @@ details.settings-section > :not(summary) + :not(summary) {
}
.capability-card strong {
- color: #1f1f1f;
- font-size: 11px;
+ color: var(--text-primary);
+ font-size: var(--font-body);
}
.capability-card small,
.runtime-assignments small {
- color: #8c8c8c;
- font-size: 9px;
+ color: var(--text-muted);
+ font-size: var(--font-caption);
}
.capability-card p {
margin: 0;
- color: #595959;
- font-size: 10px;
+ color: var(--text-secondary);
+ font-size: var(--font-caption);
line-height: 1.55;
}
.capability-card code {
- padding: 7px 8px;
- border-radius: 6px;
+ padding: var(--space-2);
+ border-radius: var(--radius-control);
overflow: hidden;
- background: #f5f5f5;
- color: #595959;
- font-size: 9px;
+ background: var(--surface-subtle);
+ color: var(--text-secondary);
+ font-size: var(--font-caption);
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -5111,9 +5153,69 @@ details.settings-section > :not(summary) + :not(summary) {
.capability-switch,
.runtime-assignments label {
- color: #595959;
- font-size: 9px;
- gap: 4px;
+ color: var(--text-secondary);
+ font-size: var(--font-caption);
+ gap: var(--space-1);
+}
+
+.toggle-row {
+ display: flex;
+ min-height: 36px;
+ align-items: center;
+ justify-content: space-between;
+ padding-top: var(--space-2);
+ border-top: 1px solid var(--border-subtle);
+ color: var(--text-secondary);
+ cursor: pointer;
+ font-size: var(--font-body);
+ gap: var(--space-3);
+}
+
+.toggle-row input {
+ position: relative;
+ width: 38px;
+ height: 22px;
+ padding: 0;
+ border: 1px solid var(--border-control);
+ border-radius: 999px;
+ appearance: none;
+ background: var(--surface-muted);
+ cursor: pointer;
+ flex: 0 0 auto;
+ transition:
+ border-color var(--motion-fast) ease-out,
+ background-color var(--motion-fast) ease-out;
+}
+
+.toggle-row input::after {
+ position: absolute;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: var(--surface-raised);
+ box-shadow: 0 1px 2px rgb(0 0 0 / 24%);
+ content: '';
+ left: 2px;
+ top: 2px;
+ transition: transform var(--motion-fast) ease-out;
+}
+
+.toggle-row input:checked {
+ border-color: var(--accent-solid);
+ background: var(--accent-solid);
+}
+
+.toggle-row input:checked::after {
+ transform: translateX(16px);
+}
+
+.toggle-row input:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+.toggle-row:has(input:disabled) {
+ cursor: not-allowed;
}
.runtime-assignments {
@@ -5770,7 +5872,7 @@ details.settings-section > :not(summary) + :not(summary) {
}
.page-shell--master-detail {
- padding: clamp(14px, 2vw, 28px);
+ padding: var(--page-gutter);
overflow-x: hidden;
container-type: inline-size;
}
@@ -5886,7 +5988,9 @@ details.settings-section > :not(summary) + :not(summary) {
.page-tabs {
display: flex;
- flex-wrap: wrap;
+ max-width: 100%;
+ overflow-x: auto;
+ flex-wrap: nowrap;
gap: var(--space-1);
}
@@ -5900,9 +6004,11 @@ details.settings-section > :not(summary) + :not(summary) {
background: transparent;
color: var(--text-secondary);
cursor: pointer;
+ flex: 0 0 auto;
font-size: 11px;
font-weight: 650;
gap: 6px;
+ white-space: nowrap;
}
.page-tabs__tab:hover {
@@ -7876,15 +7982,6 @@ details.settings-section > :not(summary) + :not(summary) {
grid-template-columns: 1fr;
}
- .heartbeat-center > .page-tabs {
- display: grid;
- grid-template-columns: 1fr 1fr;
- }
-
- .heartbeat-center > .page-tabs .page-tabs__tab {
- justify-content: center;
- }
-
.heartbeat-center__trend-row {
grid-template-columns: 70px minmax(0, 1fr) 18px;
}
@@ -7999,6 +8096,32 @@ details.settings-section > :not(summary) + :not(summary) {
}
}
+@media (max-width: 899px) {
+ .sidebar {
+ position: absolute;
+ z-index: 40;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: min(278px, calc(100vw - 52px));
+ box-shadow: 12px 0 30px rgb(0 0 0 / 14%);
+ }
+
+ .sidebar--closed {
+ width: 0;
+ box-shadow: none;
+ }
+
+ .sidebar-backdrop {
+ position: absolute;
+ z-index: 35;
+ display: block;
+ border: 0;
+ background: var(--overlay-backdrop);
+ inset: 0;
+ }
+}
+
@media (max-width: 719px) {
.assistant-sidebar__resize-handle {
display: none;
@@ -8007,7 +8130,49 @@ details.settings-section > :not(summary) + :not(summary) {
@media (max-width: 720px) {
.workspace-panel-scroll {
- padding: 20px;
+ padding: var(--page-gutter);
+ }
+
+ .settings-page .settings-panel__body {
+ display: flex;
+ padding: var(--space-4);
+ overflow: hidden;
+ flex-direction: column;
+ gap: var(--space-3);
+ }
+
+ .settings-page .settings-tabs {
+ display: flex;
+ width: 100%;
+ min-height: auto;
+ padding: var(--space-1);
+ overflow-x: auto;
+ overflow-y: hidden;
+ flex: 0 0 auto;
+ flex-direction: row;
+ scrollbar-gutter: auto;
+ scrollbar-color: var(--border-control) transparent;
+ scrollbar-width: thin;
+ }
+
+ .settings-page .settings-tabs button {
+ min-width: max-content;
+ min-height: 40px;
+ padding: var(--space-2) var(--space-3);
+ align-items: center;
+ text-align: center;
+ }
+
+ .settings-page .settings-tabs button small {
+ display: none;
+ }
+
+ .settings-page .settings-panel__content {
+ width: 100%;
+ min-height: 0;
+ padding-right: 0;
+ overflow-y: auto;
+ flex: 1;
}
.agent-runtime-navigation {
@@ -8072,7 +8237,7 @@ details.settings-section > :not(summary) + :not(summary) {
@media (max-width: 520px) {
.workspace-panel-scroll {
- padding: 16px;
+ padding: var(--page-gutter);
}
.model-connection-add {
@@ -8094,15 +8259,6 @@ details.settings-section > :not(summary) + :not(summary) {
grid-template-columns: 1fr;
}
- .heartbeat-center > .page-tabs {
- display: grid;
- grid-template-columns: 1fr 1fr;
- }
-
- .heartbeat-center > .page-tabs .page-tabs__tab {
- justify-content: center;
- }
-
.heartbeat-center__trend-row {
grid-template-columns: 70px minmax(0, 1fr) 18px;
}
diff --git a/src/shared/application-settings-contracts.ts b/src/shared/application-settings-contracts.ts
index af4ecb0..865dd36 100644
--- a/src/shared/application-settings-contracts.ts
+++ b/src/shared/application-settings-contracts.ts
@@ -2,14 +2,25 @@ import { z } from 'zod'
export const applicationSettingsSchema = z
.object({
- checkUpdatesOnStartup: z.boolean()
+ checkUpdatesOnStartup: z.boolean(),
+ magicNotesEnabled: z.boolean()
})
.strict()
+export const applicationSettingsUpdateSchema = applicationSettingsSchema
+ .partial()
+ .refine((input) => Object.keys(input).length > 0, {
+ message: 'At least one application setting is required'
+ })
+
export type ApplicationSettings = z.infer<
typeof applicationSettingsSchema
>
+export type ApplicationSettingsUpdate = z.infer<
+ typeof applicationSettingsUpdateSchema
+>
+
export type VersionCheckFile = {
name: string
size: number
diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts
index 0319db7..bcfed62 100644
--- a/src/shared/contracts.ts
+++ b/src/shared/contracts.ts
@@ -58,6 +58,7 @@ import type {
} from './channel-settings-contracts'
import type {
ApplicationSettings,
+ ApplicationSettingsUpdate,
VersionCheckResult
} from './application-settings-contracts'
import type {
@@ -981,7 +982,7 @@ export type DesktopApi = {
updates?: {
getSettings: () => Promise
updateSettings: (
- input: ApplicationSettings
+ input: ApplicationSettingsUpdate
) => Promise
check: () => Promise
openReleasePage: () => Promise
diff --git a/src/shared/remote-channel-contracts.ts b/src/shared/remote-channel-contracts.ts
index e7daafc..423556d 100644
--- a/src/shared/remote-channel-contracts.ts
+++ b/src/shared/remote-channel-contracts.ts
@@ -5,6 +5,8 @@ export const remoteChannelActivitySchema = z
.object({
requestId: z.string().uuid(),
conversationId: z.string().uuid(),
+ projectId: z.string().uuid(),
+ projectName: z.string().trim().min(1).max(120),
channel: projectChannelSchema,
kind: z.enum(['request', 'tool', 'result']),
title: z.string().trim().min(1).max(240),