feat: add configurable Magic Notes and polish UI
This commit is contained in:
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<ApplicationSettings> {
|
||||
const stored = await this.loadStored()
|
||||
return {
|
||||
checkUpdatesOnStartup: stored.checkUpdatesOnStartup
|
||||
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
|
||||
magicNotesEnabled: stored.magicNotesEnabled
|
||||
}
|
||||
}
|
||||
|
||||
update(input: unknown): Promise<ApplicationSettings> {
|
||||
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(
|
||||
|
||||
+14
-2
@@ -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)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
+2
-2
@@ -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 } : {}),
|
||||
|
||||
@@ -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<ApplicationSettings>,
|
||||
updateSettings: (input: ApplicationSettings) =>
|
||||
updateSettings: (input: ApplicationSettingsUpdate) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.applicationSettingsUpdate,
|
||||
input
|
||||
|
||||
@@ -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(
|
||||
<ActivityPanel
|
||||
onClear={vi.fn()}
|
||||
onOpenConversation={vi.fn()}
|
||||
records={[makeRecord(1)]}
|
||||
tokenUsage={makeTokenUsage()}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<ActivityPanel
|
||||
@@ -208,6 +229,32 @@ describe('ActivityPanel', () => {
|
||||
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(
|
||||
<ActivityPanel
|
||||
onClear={vi.fn()}
|
||||
onOpenConversation={vi.fn()}
|
||||
records={[projectRecord, unavailableRecord]}
|
||||
tokenUsage={makeTokenUsage()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getAllByText('项目:项目甲')).toHaveLength(2)
|
||||
expect(screen.getAllByText('范围不可用')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('uses the shared page hierarchy and explicit global scope', () => {
|
||||
render(
|
||||
<ActivityPanel
|
||||
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
DestructiveConfirmActions,
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
SegmentedControl
|
||||
ScopeBadge,
|
||||
SegmentedControl,
|
||||
type WorkspaceScope
|
||||
} from './WorkspacePrimitives'
|
||||
|
||||
type ActivityFilter = 'all' | 'active' | 'failed'
|
||||
@@ -136,10 +138,26 @@ type ActivityGroup = {
|
||||
conversationId: string
|
||||
title: string
|
||||
records: ActivityRecord[]
|
||||
scope: ActivityRecord['scope']
|
||||
latestAt: number
|
||||
status: ActivityRecord['status']
|
||||
}
|
||||
|
||||
function activityWorkspaceScope(
|
||||
scope: ActivityRecord['scope']
|
||||
): WorkspaceScope {
|
||||
if (scope.kind === 'project') {
|
||||
return { kind: 'project', projectName: scope.projectName }
|
||||
}
|
||||
if (scope.kind === 'global') {
|
||||
return { kind: 'global' }
|
||||
}
|
||||
return {
|
||||
kind: 'unavailable',
|
||||
explanation: '创建此活动记录时未能确定其归属范围。'
|
||||
}
|
||||
}
|
||||
|
||||
function groupActivityRecords(
|
||||
records: readonly ActivityRecord[],
|
||||
allRecords: readonly ActivityRecord[]
|
||||
@@ -171,6 +189,7 @@ function groupActivityRecords(
|
||||
request?.title ??
|
||||
items[0]!.title,
|
||||
records: items,
|
||||
scope: items[0]!.scope,
|
||||
latestAt: Math.max(...items.map((record) => record.createdAt)),
|
||||
status
|
||||
}
|
||||
@@ -361,6 +380,17 @@ export function ActivityPanel({
|
||||
|
||||
{filteredRecords.length === 0 ? (
|
||||
<EmptyState
|
||||
action={
|
||||
filter === 'all' ? undefined : (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setFilter('all')}
|
||||
type="button"
|
||||
>
|
||||
清除筛选
|
||||
</button>
|
||||
)
|
||||
}
|
||||
description={emptyMessage(filter)}
|
||||
icon={<Activity size={24} />}
|
||||
level="section"
|
||||
@@ -379,6 +409,9 @@ export function ActivityPanel({
|
||||
<span>
|
||||
<strong>对话:{group.title}</strong>
|
||||
<small>{group.records.length} 条活动</small>
|
||||
<ScopeBadge
|
||||
scope={activityWorkspaceScope(group.scope)}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={`status-badge activity-item__status activity-item__status--${group.status}`}
|
||||
@@ -413,6 +446,9 @@ export function ActivityPanel({
|
||||
{time.display}
|
||||
</time>
|
||||
</header>
|
||||
<ScopeBadge
|
||||
scope={activityWorkspaceScope(record.scope)}
|
||||
/>
|
||||
<h3>{record.title}</h3>
|
||||
{record.detail.length > 0 && <p>{record.detail}</p>}
|
||||
<button
|
||||
|
||||
+511
-17
@@ -26,6 +26,7 @@ vi.mock('./speech-recognition', async (importOriginal) => ({
|
||||
}))
|
||||
|
||||
import App from './App'
|
||||
import { loadActivityRecords } from './activity-store'
|
||||
|
||||
let agentListener: ((event: AgentEvent) => void) | undefined
|
||||
let browserListener: ((state: BrowserLiveState) => void) | undefined
|
||||
@@ -610,9 +611,13 @@ describe('App', () => {
|
||||
}))
|
||||
api.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,
|
||||
openReleasePage: vi.fn(async () => {}),
|
||||
onResult: vi.fn(() => () => {})
|
||||
@@ -651,9 +656,13 @@ describe('App', () => {
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
api.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,
|
||||
openReleasePage: vi.fn(async () => {}),
|
||||
onResult: vi.fn(() => () => {})
|
||||
@@ -847,6 +856,114 @@ describe('App', () => {
|
||||
expect(await screen.findByRole('status')).toBeVisible()
|
||||
})
|
||||
|
||||
it('requires an accessible confirmation before permanently deleting a conversation', async () => {
|
||||
render(<App />)
|
||||
const menuTrigger = screen.getByLabelText(
|
||||
'更多会话操作 新对话'
|
||||
)
|
||||
fireEvent.click(menuTrigger)
|
||||
const deleteTrigger = screen.getByRole('button', {
|
||||
name: '删除对话 新对话'
|
||||
})
|
||||
fireEvent.click(deleteTrigger)
|
||||
|
||||
const dialog = screen.getByRole('alertdialog', {
|
||||
name: '确认永久删除对话 新对话'
|
||||
})
|
||||
expect(dialog).toHaveTextContent('将永久删除此会话的全部内容')
|
||||
expect(dialog).toHaveTextContent(
|
||||
'如果此会话有正在运行的任务,也会同时停止'
|
||||
)
|
||||
expect(dialog).toHaveTextContent('此操作不可恢复')
|
||||
const cancel = screen.getByRole('button', {
|
||||
name: '取消删除对话 新对话'
|
||||
})
|
||||
const confirm = screen.getByRole('button', {
|
||||
name: '确认永久删除对话 新对话'
|
||||
})
|
||||
expect(cancel).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'Tab' })
|
||||
expect(confirm).toHaveFocus()
|
||||
fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true })
|
||||
expect(cancel).toHaveFocus()
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: '删除对话 新对话' })
|
||||
).toHaveFocus()
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole('alertdialog', {
|
||||
name: '确认永久删除对话 新对话'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '删除对话 新对话' })
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '确认永久删除对话 新对话'
|
||||
})
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('alertdialog', {
|
||||
name: '确认永久删除对话 新对话'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a conversation when cancelling its active task fails', async () => {
|
||||
vi.mocked(api.agent.cancel).mockRejectedValueOnce(
|
||||
new Error('cancel failed')
|
||||
)
|
||||
render(<App />)
|
||||
const title = '取消失败时保留会话'
|
||||
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: title }
|
||||
})
|
||||
fireEvent.click(await screen.findByLabelText('发送'))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByLabelText(`更多会话操作 ${title}`)
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: `删除对话 ${title}` })
|
||||
)
|
||||
const confirm = screen.getByRole('button', {
|
||||
name: `确认永久删除对话 ${title}`
|
||||
})
|
||||
fireEvent.click(confirm)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.agent.cancel).toHaveBeenCalledOnce()
|
||||
)
|
||||
expect(
|
||||
await screen.findByText('停止会话中的运行任务失败,尚未删除对话')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('alertdialog', {
|
||||
name: `确认永久删除对话 ${title}`
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
await waitFor(() => expect(confirm).toBeEnabled())
|
||||
|
||||
vi.mocked(api.agent.cancel).mockResolvedValueOnce()
|
||||
fireEvent.click(confirm)
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('alertdialog', {
|
||||
name: `确认永久删除对话 ${title}`
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
)
|
||||
})
|
||||
|
||||
it('renders streamed reasoning, text, and tools in event order', async () => {
|
||||
render(<App />)
|
||||
|
||||
@@ -1426,7 +1543,9 @@ describe('App', () => {
|
||||
|
||||
expect(composer).toHaveValue('尚未发送的草稿')
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: '删除对话 新对话' })
|
||||
screen.getAllByRole('button', {
|
||||
name: '更多会话操作 新对话'
|
||||
})
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -1448,7 +1567,9 @@ describe('App', () => {
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: /^删除对话/u })
|
||||
screen.getAllByRole('button', {
|
||||
name: /^更多会话操作/u
|
||||
})
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
@@ -3478,7 +3599,7 @@ describe('App', () => {
|
||||
await screen.findByRole('heading', { name: '智能心跳' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '成长概览' })
|
||||
await screen.findByRole('tab', { name: '成长概览' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '配置智能心跳' })
|
||||
@@ -3488,27 +3609,400 @@ describe('App', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens Magic Notes as a scoped first-class workspace', async () => {
|
||||
it('shows retryable page-local knowledge errors without an empty-state flash', async () => {
|
||||
vi.mocked(api.knowledge.getSnapshot).mockRejectedValueOnce(
|
||||
new Error('知识数据库暂时不可用')
|
||||
)
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '知识库' }))
|
||||
expect(
|
||||
await screen.findByText('知识库加载失败')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getAllByText('知识数据库暂时不可用')).toHaveLength(1)
|
||||
expect(screen.queryByText('建立第一个知识库')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
expect(
|
||||
await screen.findByText('建立第一个知识库')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('retries the knowledge library whose selection failed', async () => {
|
||||
const firstLibraryId = '11111111-1111-4111-8111-111111111111'
|
||||
const secondLibraryId = '22222222-2222-4222-8222-222222222222'
|
||||
const libraries = [
|
||||
{
|
||||
id: firstLibraryId,
|
||||
name: '产品知识',
|
||||
description: '',
|
||||
storageMode: 'managed' as const,
|
||||
graphEnabled: false,
|
||||
graphStrategy: 'rules' as const,
|
||||
sourceCount: 0,
|
||||
documentCount: 0,
|
||||
indexedDocumentCount: 0
|
||||
},
|
||||
{
|
||||
id: secondLibraryId,
|
||||
name: '工程知识',
|
||||
description: '',
|
||||
storageMode: 'managed' as const,
|
||||
graphEnabled: false,
|
||||
graphStrategy: 'rules' as const,
|
||||
sourceCount: 0,
|
||||
documentCount: 0,
|
||||
indexedDocumentCount: 0
|
||||
}
|
||||
]
|
||||
const snapshot = {
|
||||
libraries,
|
||||
sources: [],
|
||||
documents: [],
|
||||
graphNodes: [],
|
||||
graphRelations: [],
|
||||
evidence: []
|
||||
}
|
||||
vi.mocked(api.knowledge.getSnapshot)
|
||||
.mockResolvedValueOnce({
|
||||
...snapshot,
|
||||
selectedLibraryId: firstLibraryId
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('工程知识暂时不可用'))
|
||||
.mockResolvedValueOnce({
|
||||
...snapshot,
|
||||
selectedLibraryId: secondLibraryId
|
||||
})
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '知识库' }))
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
name: /^工程知识 0 个文档/u
|
||||
})
|
||||
)
|
||||
expect(
|
||||
await screen.findByText('知识库刷新失败')
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await waitFor(() =>
|
||||
expect(api.knowledge.getSnapshot).toHaveBeenLastCalledWith(
|
||||
secondLibraryId
|
||||
)
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /^工程知识 0 个文档/u
|
||||
})
|
||||
).toHaveAttribute('aria-current', 'page')
|
||||
})
|
||||
|
||||
it('shows retryable page-local heartbeat errors without first-time guidance', async () => {
|
||||
vi.mocked(api.heartbeats.list).mockRejectedValue(
|
||||
new Error('心跳数据库暂时不可用')
|
||||
)
|
||||
render(<App />)
|
||||
await screen.findByText('项目:默认项目')
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '魔法笔记' })
|
||||
screen.getByRole('button', { name: '智能心跳' })
|
||||
)
|
||||
expect(
|
||||
await screen.findByText('智能心跳加载失败')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '配置智能心跳' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
const retry = await screen.findByRole('button', { name: '重试' })
|
||||
vi.mocked(api.heartbeats.list).mockResolvedValue([])
|
||||
fireEvent.click(retry)
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '配置智能心跳' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not expose the previous project heartbeat after a switch fails', async () => {
|
||||
const secondProject = {
|
||||
...project,
|
||||
id: '00000000-0000-4000-8000-000000000102',
|
||||
name: '第二项目',
|
||||
rootPath: 'C:\\Second'
|
||||
}
|
||||
vi.mocked(api.projects.list).mockResolvedValueOnce([
|
||||
project,
|
||||
secondProject
|
||||
])
|
||||
vi.mocked(api.heartbeats.list).mockResolvedValue([
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000701',
|
||||
projectId,
|
||||
name: '旧项目心跳',
|
||||
timezone: 'Asia/Shanghai',
|
||||
recurrence: { type: 'daily', localTime: '09:00' },
|
||||
enabled: true,
|
||||
lookbackHours: 24,
|
||||
retentionDays: 30,
|
||||
nextRunAt: '2026-08-05T01:00:00.000Z',
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z'
|
||||
}
|
||||
])
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '智能心跳' })
|
||||
)
|
||||
fireEvent.click(
|
||||
await screen.findByRole('tab', { name: '心跳计划' })
|
||||
)
|
||||
expect(await screen.findAllByText('旧项目心跳')).not.toHaveLength(0)
|
||||
vi.mocked(api.heartbeats.list).mockRejectedValue(
|
||||
new Error('第二项目心跳读取失败')
|
||||
)
|
||||
fireEvent.change(screen.getByLabelText('当前项目'), {
|
||||
target: { value: secondProject.id }
|
||||
})
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '智能心跳' })
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: '魔法笔记' })
|
||||
await screen.findByText('智能心跳加载失败')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('项目:默认项目')).toBeInTheDocument()
|
||||
expect(screen.queryAllByText('旧项目心跳')).toHaveLength(0)
|
||||
expect(
|
||||
screen.getByRole('button', { name: '新建笔记' })
|
||||
).toBeInTheDocument()
|
||||
expect(api.magicNotes.list).toHaveBeenCalled()
|
||||
expect(
|
||||
screen.queryByLabelText('切换助手工作栏')
|
||||
screen.queryByRole('button', { name: '立即运行旧项目心跳' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('automatically snapshots the conversation project on new activity', async () => {
|
||||
render(<App />)
|
||||
await screen.findByText('项目:默认项目')
|
||||
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
target: { value: '记录项目范围' }
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('发送'))
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
loadActivityRecords().find(
|
||||
(record) =>
|
||||
record.kind === 'request' &&
|
||||
record.title === '记录项目范围'
|
||||
)?.scope
|
||||
).toEqual({
|
||||
kind: 'project',
|
||||
projectId,
|
||||
projectName: project.name
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('marks the current primary navigation page and hides decorative icons', async () => {
|
||||
render(<App />)
|
||||
|
||||
const navigation = screen.getByRole('navigation', {
|
||||
name: '主导航'
|
||||
})
|
||||
const chat = within(navigation).getByRole('button', { name: '对话' })
|
||||
const knowledge = within(navigation).getByRole('button', {
|
||||
name: '知识库'
|
||||
})
|
||||
|
||||
expect(chat).toHaveAttribute('aria-current', 'page')
|
||||
expect(knowledge).not.toHaveAttribute('aria-current')
|
||||
for (const button of within(navigation).getAllByRole('button')) {
|
||||
expect(button.querySelector('svg')).toHaveAttribute(
|
||||
'aria-hidden',
|
||||
'true'
|
||||
)
|
||||
}
|
||||
|
||||
fireEvent.click(knowledge)
|
||||
expect(knowledge).toHaveAttribute('aria-current', 'page')
|
||||
expect(chat).not.toHaveAttribute('aria-current')
|
||||
})
|
||||
|
||||
it('collapses the primary sidebar into an inert narrow-window overlay', async () => {
|
||||
const originalWidth = window.innerWidth
|
||||
const { container } = render(<App />)
|
||||
const sidebar = container.querySelector<HTMLElement>('.sidebar')
|
||||
const workspace = container.querySelector<HTMLElement>('.workspace')
|
||||
expect(sidebar).not.toBeNull()
|
||||
expect(workspace).not.toBeNull()
|
||||
if (!sidebar || !workspace) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 680
|
||||
})
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(sidebar).toHaveClass('sidebar--closed')
|
||||
)
|
||||
expect(sidebar).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(sidebar).toHaveAttribute('inert')
|
||||
|
||||
const toggle = screen.getByRole('button', { name: '切换侧栏' })
|
||||
fireEvent.click(toggle)
|
||||
expect(sidebar).not.toHaveClass('sidebar--closed')
|
||||
expect(sidebar).toHaveAttribute('aria-hidden', 'false')
|
||||
expect(sidebar).toHaveAttribute('aria-modal', 'true')
|
||||
expect(sidebar).not.toHaveAttribute('inert')
|
||||
expect(workspace).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(workspace).toHaveAttribute('inert')
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(sidebar).getByRole('button', { name: '对话' })
|
||||
).toHaveFocus()
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '关闭侧栏' })
|
||||
)
|
||||
await waitFor(() => expect(toggle).toHaveFocus())
|
||||
expect(sidebar).toHaveClass('sidebar--closed')
|
||||
expect(workspace).not.toHaveAttribute('aria-hidden')
|
||||
expect(workspace).not.toHaveAttribute('inert')
|
||||
} finally {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: originalWidth
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('opens Magic Notes as a scoped first-class workspace', async () => {
|
||||
api.updates = {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: true
|
||||
})),
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: true
|
||||
})),
|
||||
check: vi.fn(),
|
||||
openReleasePage: vi.fn(async () => {}),
|
||||
onResult: vi.fn(() => () => {})
|
||||
}
|
||||
try {
|
||||
render(<App />)
|
||||
await screen.findByText('项目:默认项目')
|
||||
const magicNotesEntry = await screen.findByRole('button', {
|
||||
name: '魔法笔记'
|
||||
})
|
||||
|
||||
fireEvent.click(magicNotesEntry)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: '魔法笔记' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('项目:默认项目')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '新建笔记' })
|
||||
).toBeInTheDocument()
|
||||
expect(api.magicNotes.list).toHaveBeenCalled()
|
||||
expect(
|
||||
screen.queryByLabelText('切换助手工作栏')
|
||||
).not.toBeInTheDocument()
|
||||
} finally {
|
||||
delete api.updates
|
||||
}
|
||||
})
|
||||
|
||||
it('hides Magic Notes when the platform feature is disabled', async () => {
|
||||
api.updates = {
|
||||
getSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: false
|
||||
})),
|
||||
updateSettings: vi.fn(async () => ({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: false
|
||||
})),
|
||||
check: vi.fn(),
|
||||
openReleasePage: vi.fn(async () => {}),
|
||||
onResult: vi.fn(() => () => {})
|
||||
}
|
||||
try {
|
||||
render(<App />)
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '魔法笔记' })
|
||||
).not.toBeInTheDocument()
|
||||
)
|
||||
} finally {
|
||||
delete api.updates
|
||||
}
|
||||
})
|
||||
|
||||
it('hides Magic Notes by default without an explicit setting', () => {
|
||||
render(<App />)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '魔法笔记' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps platform-feature switches in Settings without navigating', async () => {
|
||||
let applicationSettings = {
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: false
|
||||
}
|
||||
api.updates = {
|
||||
getSettings: vi.fn(async () => ({ ...applicationSettings })),
|
||||
updateSettings: vi.fn(async (input) => {
|
||||
applicationSettings = {
|
||||
...applicationSettings,
|
||||
...input
|
||||
}
|
||||
return { ...applicationSettings }
|
||||
}),
|
||||
check: vi.fn(),
|
||||
openReleasePage: vi.fn(async () => {}),
|
||||
onResult: vi.fn(() => () => {})
|
||||
}
|
||||
try {
|
||||
const { container } = render(<App />)
|
||||
fireEvent.click(await screen.findByText('本地工作区'))
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: '平台功能' })
|
||||
)
|
||||
const toggle = await screen.findByRole('switch', {
|
||||
name: '显示魔法笔记入口'
|
||||
})
|
||||
|
||||
fireEvent.click(toggle)
|
||||
await waitFor(() => expect(toggle).toBeChecked())
|
||||
expect(
|
||||
screen.getByRole('heading', { name: '设置中心' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '魔法笔记' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
container.querySelector('.magic-notes-page')
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(toggle)
|
||||
await waitFor(() => expect(toggle).not.toBeChecked())
|
||||
expect(
|
||||
screen.getByRole('heading', { name: '设置中心' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '魔法笔记' })
|
||||
).not.toBeInTheDocument()
|
||||
} finally {
|
||||
delete api.updates
|
||||
}
|
||||
})
|
||||
|
||||
it('gives the knowledge workspace the full content width', async () => {
|
||||
render(<App />)
|
||||
|
||||
|
||||
+335
-84
@@ -104,6 +104,7 @@ import { HeartbeatCenter } from './HeartbeatCenter'
|
||||
import { MagicNotesWorkspace } from './MagicNotesWorkspace'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
import {
|
||||
DestructiveConfirmActions,
|
||||
EmptyState,
|
||||
PageShell,
|
||||
ScopeBadge
|
||||
@@ -609,6 +610,19 @@ function createConversation(
|
||||
}
|
||||
}
|
||||
|
||||
function displayErrorMessage(reason: unknown, fallback: string): string {
|
||||
if (
|
||||
typeof reason === 'object' &&
|
||||
reason !== null &&
|
||||
'message' in reason &&
|
||||
typeof reason.message === 'string' &&
|
||||
reason.message
|
||||
) {
|
||||
return reason.message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function isUnusedConversation(conversation: Conversation): boolean {
|
||||
return (
|
||||
conversation.title === '新对话' &&
|
||||
@@ -1030,6 +1044,7 @@ function App(): React.JSX.Element {
|
||||
useState(false)
|
||||
const migrationConversations = useRef(conversations)
|
||||
const [projects, setProjects] = useState<AssistantProject[]>([])
|
||||
const projectsRef = useRef(projects)
|
||||
const [assistantTasks, setAssistantTasks] = useState<AssistantTask[]>([])
|
||||
const [tokenUsage, setTokenUsage] =
|
||||
useState<TokenUsageSummary>(emptyTokenUsage)
|
||||
@@ -1060,6 +1075,8 @@ function App(): React.JSX.Element {
|
||||
const [heartbeatRuns, setHeartbeatRuns] = useState<
|
||||
AssistantHeartbeatRun[]
|
||||
>([])
|
||||
const [heartbeatLoading, setHeartbeatLoading] = useState(true)
|
||||
const [heartbeatLoadError, setHeartbeatLoadError] = useState<string>()
|
||||
const [assistantExperts, setAssistantExperts] = useState<
|
||||
AssistantExpert[]
|
||||
>([])
|
||||
@@ -1110,7 +1127,12 @@ function App(): React.JSX.Element {
|
||||
? 'ask'
|
||||
: workMode
|
||||
const [appInfo, setAppInfo] = useState<AppInfo>()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [narrowWindow, setNarrowWindow] = useState(
|
||||
() => window.innerWidth < 900
|
||||
)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(
|
||||
() => window.innerWidth >= 900
|
||||
)
|
||||
const [assistantSidebarOpen, setAssistantSidebarOpen] = useState(
|
||||
() => window.innerWidth >= 1280
|
||||
)
|
||||
@@ -1120,8 +1142,13 @@ function App(): React.JSX.Element {
|
||||
Record<string, BrowserLiveState>
|
||||
>({})
|
||||
const [view, setView] = useState<WorkspaceView>('chat')
|
||||
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [conversationActionsId, setConversationActionsId] = useState('')
|
||||
const [confirmingConversationId, setConfirmingConversationId] =
|
||||
useState('')
|
||||
const [deletingConversationId, setDeletingConversationId] =
|
||||
useState('')
|
||||
const [renamingConversationId, setRenamingConversationId] = useState('')
|
||||
const [notifications, notify] = useReducer(
|
||||
appNotificationReducer,
|
||||
@@ -1169,6 +1196,11 @@ function App(): React.JSX.Element {
|
||||
evidence: []
|
||||
})
|
||||
const [knowledgeLoading, setKnowledgeLoading] = useState(true)
|
||||
const [knowledgeLoadError, setKnowledgeLoadError] = useState<string>()
|
||||
const knowledgeLoadRequestRef = useRef(0)
|
||||
const failedKnowledgeLibraryIdRef = useRef<string | undefined>(
|
||||
undefined
|
||||
)
|
||||
const [enabledKnowledgeLibraryIds, setEnabledKnowledgeLibraryIds] = useState<
|
||||
string[]
|
||||
>([])
|
||||
@@ -1182,20 +1214,67 @@ function App(): React.JSX.Element {
|
||||
const knowledgeScopeInitialized = useRef(false)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
const sidebarToggleRef = useRef<HTMLButtonElement>(null)
|
||||
const topbarMenuRef = useRef<HTMLDivElement>(null)
|
||||
const topbarMenuTriggerRef = useRef<HTMLButtonElement>(null)
|
||||
const conversationActionTriggerRefs = useRef(
|
||||
new Map<string, HTMLButtonElement>()
|
||||
)
|
||||
const closeNarrowSidebar = useCallback((): void => {
|
||||
setSidebarOpen(false)
|
||||
requestAnimationFrame(() => sidebarToggleRef.current?.focus())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
activeConversationIdRef.current = activeId
|
||||
}, [activeId])
|
||||
|
||||
useEffect(() => {
|
||||
const collapseSidebarAtNarrowWidth = (): void => {
|
||||
const narrow = window.innerWidth < 900
|
||||
setNarrowWindow(narrow)
|
||||
if (narrow) {
|
||||
setSidebarOpen(false)
|
||||
}
|
||||
}
|
||||
window.addEventListener('resize', collapseSidebarAtNarrowWidth)
|
||||
return () =>
|
||||
window.removeEventListener('resize', collapseSidebarAtNarrowWidth)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!narrowWindow || !sidebarOpen) {
|
||||
return
|
||||
}
|
||||
const focusFrame = requestAnimationFrame(() => {
|
||||
sidebarRef.current
|
||||
?.querySelector<HTMLButtonElement>(
|
||||
'.primary-nav button[aria-current="page"], .primary-nav button'
|
||||
)
|
||||
?.focus()
|
||||
})
|
||||
const closeOnEscape = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
closeNarrowSidebar()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', closeOnEscape)
|
||||
return () => {
|
||||
cancelAnimationFrame(focusFrame)
|
||||
document.removeEventListener('keydown', closeOnEscape)
|
||||
}
|
||||
}, [closeNarrowSidebar, narrowWindow, sidebarOpen])
|
||||
|
||||
useEffect(() => {
|
||||
conversationsRef.current = conversations
|
||||
}, [conversations])
|
||||
|
||||
useEffect(() => {
|
||||
projectsRef.current = projects
|
||||
}, [projects])
|
||||
|
||||
useEffect(() => {
|
||||
resizeComposerTextarea(inputRef.current)
|
||||
}, [input])
|
||||
@@ -1272,6 +1351,10 @@ function App(): React.JSX.Element {
|
||||
void updates
|
||||
.getSettings()
|
||||
.then(async (settings) => {
|
||||
setMagicNotesEnabled(settings.magicNotesEnabled)
|
||||
if (!settings.magicNotesEnabled) {
|
||||
setView('chat')
|
||||
}
|
||||
if (!settings.checkUpdatesOnStartup) {
|
||||
return
|
||||
}
|
||||
@@ -1712,10 +1795,35 @@ function App(): React.JSX.Element {
|
||||
)
|
||||
|
||||
const recordActivity = useCallback(
|
||||
(record: Omit<ActivityRecord, 'id' | 'createdAt'>): void => {
|
||||
(
|
||||
record: Omit<ActivityRecord, 'id' | 'createdAt' | 'scope'>,
|
||||
scopeOverride?: ActivityRecord['scope']
|
||||
): void => {
|
||||
const conversation = conversationsRef.current.find(
|
||||
(candidate) => candidate.id === record.conversationId
|
||||
)
|
||||
const project = conversation?.projectId
|
||||
? projectsRef.current.find(
|
||||
(candidate) => candidate.id === conversation.projectId
|
||||
)
|
||||
: undefined
|
||||
const scope: ActivityRecord['scope'] =
|
||||
scopeOverride ??
|
||||
(!conversation
|
||||
? { kind: 'unavailable' }
|
||||
: !conversation.projectId
|
||||
? { kind: 'global' }
|
||||
: project?.id && project.name
|
||||
? {
|
||||
kind: 'project',
|
||||
projectId: project.id.slice(0, 256),
|
||||
projectName: project.name.slice(0, 120)
|
||||
}
|
||||
: { kind: 'unavailable' })
|
||||
setActivityRecords((current) =>
|
||||
upsertActivityRecord(current, {
|
||||
...record,
|
||||
scope,
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: Date.now()
|
||||
})
|
||||
@@ -1758,39 +1866,76 @@ function App(): React.JSX.Element {
|
||||
activity.detail
|
||||
)
|
||||
}
|
||||
recordActivity({
|
||||
requestId: activity.requestId,
|
||||
conversationId: activity.conversationId,
|
||||
callId: activity.callId,
|
||||
kind: activity.kind,
|
||||
title: activity.title,
|
||||
detail: activity.detail,
|
||||
status: activity.status
|
||||
})
|
||||
recordActivity(
|
||||
{
|
||||
requestId: activity.requestId,
|
||||
conversationId: activity.conversationId,
|
||||
callId: activity.callId,
|
||||
kind: activity.kind,
|
||||
title: activity.title,
|
||||
detail: activity.detail,
|
||||
status: activity.status
|
||||
},
|
||||
{
|
||||
kind: 'project',
|
||||
projectId: activity.projectId.slice(0, 256),
|
||||
projectName: activity.projectName.slice(0, 120)
|
||||
}
|
||||
)
|
||||
})
|
||||
}, [recordActivity, updateRequestActivity])
|
||||
|
||||
const refreshKnowledge = useCallback(
|
||||
async (libraryId?: string): Promise<KnowledgeSnapshot> => {
|
||||
const snapshot = await window.goodbuddy.knowledge.getSnapshot(libraryId)
|
||||
setKnowledgeSnapshot(snapshot)
|
||||
if (!knowledgeScopeInitialized.current) {
|
||||
knowledgeScopeInitialized.current = true
|
||||
setEnabledKnowledgeLibraryIds(
|
||||
snapshot.libraries.map((library) => library.id)
|
||||
)
|
||||
} else {
|
||||
setEnabledKnowledgeLibraryIds((current) =>
|
||||
current.filter((id) =>
|
||||
snapshot.libraries.some((library) => library.id === id)
|
||||
const requestId = ++knowledgeLoadRequestRef.current
|
||||
try {
|
||||
const snapshot =
|
||||
await window.goodbuddy.knowledge.getSnapshot(libraryId)
|
||||
if (requestId !== knowledgeLoadRequestRef.current) {
|
||||
return snapshot
|
||||
}
|
||||
failedKnowledgeLibraryIdRef.current = undefined
|
||||
setKnowledgeSnapshot(snapshot)
|
||||
setKnowledgeLoadError(undefined)
|
||||
if (!knowledgeScopeInitialized.current) {
|
||||
knowledgeScopeInitialized.current = true
|
||||
setEnabledKnowledgeLibraryIds(
|
||||
snapshot.libraries.map((library) => library.id)
|
||||
)
|
||||
} else {
|
||||
setEnabledKnowledgeLibraryIds((current) =>
|
||||
current.filter((id) =>
|
||||
snapshot.libraries.some((library) => library.id === id)
|
||||
)
|
||||
)
|
||||
}
|
||||
return snapshot
|
||||
} catch (reason) {
|
||||
if (requestId !== knowledgeLoadRequestRef.current) {
|
||||
throw reason
|
||||
}
|
||||
failedKnowledgeLibraryIdRef.current = libraryId
|
||||
setKnowledgeLoadError(
|
||||
displayErrorMessage(reason, '本地知识库读取失败')
|
||||
)
|
||||
throw reason
|
||||
}
|
||||
return snapshot
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const retryKnowledgeLoad = useCallback(async (): Promise<void> => {
|
||||
setKnowledgeLoading(true)
|
||||
setKnowledgeLoadError(undefined)
|
||||
try {
|
||||
await refreshKnowledge(failedKnowledgeLibraryIdRef.current)
|
||||
} catch {
|
||||
// The recoverable page state is set by refreshKnowledge.
|
||||
} finally {
|
||||
setKnowledgeLoading(false)
|
||||
}
|
||||
}, [refreshKnowledge])
|
||||
|
||||
const switchRuntime = useCallback(
|
||||
async (selection: AgentRuntimeSelection): Promise<void> => {
|
||||
if (!runtimeSettings || !activeConversation || runtimeSwitching) {
|
||||
@@ -2610,19 +2755,41 @@ function App(): React.JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = ++heartbeatLoadRequestRef.current
|
||||
void loadHeartbeats()
|
||||
.then((result) => {
|
||||
if (requestId !== heartbeatLoadRequestRef.current) {
|
||||
return
|
||||
}
|
||||
setAssistantHeartbeats(result.configs)
|
||||
setHeartbeatRuns(result.runs)
|
||||
setHeartbeatEntries(result.entries)
|
||||
})
|
||||
.catch(() =>
|
||||
notify({ tone: 'error', message: '智能心跳读取失败' })
|
||||
)
|
||||
const timeout = setTimeout(() => {
|
||||
if (requestId !== heartbeatLoadRequestRef.current) {
|
||||
return
|
||||
}
|
||||
setHeartbeatLoading(true)
|
||||
setHeartbeatLoadError(undefined)
|
||||
setAssistantHeartbeats([])
|
||||
setHeartbeatRuns([])
|
||||
setHeartbeatEntries([])
|
||||
void loadHeartbeats()
|
||||
.then((result) => {
|
||||
if (requestId !== heartbeatLoadRequestRef.current) {
|
||||
return
|
||||
}
|
||||
setAssistantHeartbeats(result.configs)
|
||||
setHeartbeatRuns(result.runs)
|
||||
setHeartbeatEntries(result.entries)
|
||||
setHeartbeatLoadError(undefined)
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (requestId !== heartbeatLoadRequestRef.current) {
|
||||
return
|
||||
}
|
||||
setHeartbeatLoadError(
|
||||
displayErrorMessage(reason, '智能心跳读取失败')
|
||||
)
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestId === heartbeatLoadRequestRef.current) {
|
||||
setHeartbeatLoading(false)
|
||||
}
|
||||
})
|
||||
}, 0)
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
if (requestId === heartbeatLoadRequestRef.current) {
|
||||
heartbeatLoadRequestRef.current += 1
|
||||
}
|
||||
@@ -2647,6 +2814,21 @@ function App(): React.JSX.Element {
|
||||
)
|
||||
}, [activeProjectId, refreshHeartbeats])
|
||||
|
||||
const retryHeartbeatLoad = useCallback(async (): Promise<void> => {
|
||||
setHeartbeatLoading(true)
|
||||
setHeartbeatLoadError(undefined)
|
||||
try {
|
||||
await refreshHeartbeatCenter()
|
||||
setHeartbeatLoadError(undefined)
|
||||
} catch (reason) {
|
||||
setHeartbeatLoadError(
|
||||
displayErrorMessage(reason, '智能心跳读取失败')
|
||||
)
|
||||
} finally {
|
||||
setHeartbeatLoading(false)
|
||||
}
|
||||
}, [refreshHeartbeatCenter])
|
||||
|
||||
const createHeartbeat = useCallback(
|
||||
async (input: HeartbeatCreateInput): Promise<void> => {
|
||||
const projectId = activeProjectId
|
||||
@@ -2706,8 +2888,11 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
refreshing = true
|
||||
void refreshHeartbeatCenter()
|
||||
.catch(() =>
|
||||
notify({ tone: 'error', message: '智能心跳刷新失败' })
|
||||
.then(() => setHeartbeatLoadError(undefined))
|
||||
.catch((reason: unknown) =>
|
||||
setHeartbeatLoadError(
|
||||
displayErrorMessage(reason, '智能心跳刷新失败')
|
||||
)
|
||||
)
|
||||
.finally(() => {
|
||||
refreshing = false
|
||||
@@ -2806,14 +2991,8 @@ function App(): React.JSX.Element {
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
void refreshKnowledge()
|
||||
.catch((reason: unknown) => {
|
||||
notify({
|
||||
tone: 'error',
|
||||
message:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: '本地知识库读取失败'
|
||||
})
|
||||
.catch(() => {
|
||||
// refreshKnowledge exposes a recoverable page-local error.
|
||||
})
|
||||
.finally(() => setKnowledgeLoading(false))
|
||||
}, 0)
|
||||
@@ -3137,19 +3316,38 @@ function App(): React.JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
const deleteConversation = (conversationId: string): void => {
|
||||
const deleteConversation = async (
|
||||
conversationId: string
|
||||
): Promise<void> => {
|
||||
if (deletingConversationId) {
|
||||
return
|
||||
}
|
||||
setDeletingConversationId(conversationId)
|
||||
const activeRequests = [...activeRuns.current.entries()]
|
||||
.filter(([, run]) => run.conversationId === conversationId)
|
||||
.map(([requestId]) => requestId)
|
||||
try {
|
||||
await Promise.all(
|
||||
activeRequests.map((requestId) =>
|
||||
window.goodbuddy.agent.cancel(requestId)
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
notify({
|
||||
tone: 'error',
|
||||
message: '停止会话中的运行任务失败,尚未删除对话'
|
||||
})
|
||||
setDeletingConversationId('')
|
||||
return
|
||||
}
|
||||
setConfirmingConversationId('')
|
||||
setDeletingConversationId('')
|
||||
if (conversationActionsId === conversationId) {
|
||||
setConversationActionsId('')
|
||||
}
|
||||
if (renamingConversationId === conversationId) {
|
||||
setRenamingConversationId('')
|
||||
}
|
||||
const activeRequest = [...activeRuns.current.entries()].find(
|
||||
([, run]) => run.conversationId === conversationId
|
||||
)?.[0]
|
||||
if (activeRequest) {
|
||||
void window.goodbuddy.agent.cancel(activeRequest)
|
||||
}
|
||||
const browserStop = window.goodbuddy.browser?.stop(conversationId)
|
||||
if (browserStop) {
|
||||
void browserStop.catch(() => {
|
||||
@@ -3972,7 +4170,15 @@ function App(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'}>
|
||||
<aside
|
||||
aria-label={narrowWindow && sidebarOpen ? '主侧栏' : undefined}
|
||||
aria-hidden={!sidebarOpen}
|
||||
aria-modal={narrowWindow && sidebarOpen ? 'true' : undefined}
|
||||
className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'}
|
||||
inert={!sidebarOpen}
|
||||
ref={sidebarRef}
|
||||
role={narrowWindow && sidebarOpen ? 'dialog' : undefined}
|
||||
>
|
||||
<div className="brand">
|
||||
<div className="brand__mark">
|
||||
<img
|
||||
@@ -4028,28 +4234,33 @@ function App(): React.JSX.Element {
|
||||
|
||||
<nav className="primary-nav" aria-label="主导航">
|
||||
<button
|
||||
aria-current={view === 'chat' ? 'page' : undefined}
|
||||
className={
|
||||
view === 'chat' ? 'nav-item nav-item--active' : 'nav-item'
|
||||
}
|
||||
onClick={() => setView('chat')}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquare size={17} />
|
||||
<MessageSquare aria-hidden="true" size={17} />
|
||||
<span>对话</span>
|
||||
</button>
|
||||
{magicNotesEnabled && (
|
||||
<button
|
||||
aria-current={view === 'magic-notes' ? 'page' : undefined}
|
||||
className={
|
||||
view === 'magic-notes'
|
||||
? 'nav-item nav-item--active'
|
||||
: 'nav-item'
|
||||
}
|
||||
onClick={() => setView('magic-notes')}
|
||||
type="button"
|
||||
>
|
||||
<Sparkles aria-hidden="true" size={17} />
|
||||
<span>魔法笔记</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={
|
||||
view === 'magic-notes'
|
||||
? 'nav-item nav-item--active'
|
||||
: 'nav-item'
|
||||
}
|
||||
onClick={() => setView('magic-notes')}
|
||||
type="button"
|
||||
>
|
||||
<Sparkles size={17} />
|
||||
<span>魔法笔记</span>
|
||||
</button>
|
||||
<button
|
||||
aria-current={view === 'knowledge' ? 'page' : undefined}
|
||||
className={
|
||||
view === 'knowledge'
|
||||
? 'nav-item nav-item--active'
|
||||
@@ -4058,10 +4269,11 @@ function App(): React.JSX.Element {
|
||||
onClick={() => setView('knowledge')}
|
||||
type="button"
|
||||
>
|
||||
<Library size={17} />
|
||||
<Library aria-hidden="true" size={17} />
|
||||
<span>知识库</span>
|
||||
</button>
|
||||
<button
|
||||
aria-current={view === 'heartbeat' ? 'page' : undefined}
|
||||
className={
|
||||
view === 'heartbeat'
|
||||
? 'nav-item nav-item--active'
|
||||
@@ -4070,7 +4282,7 @@ function App(): React.JSX.Element {
|
||||
onClick={() => setView('heartbeat')}
|
||||
type="button"
|
||||
>
|
||||
<HeartPulse size={17} />
|
||||
<HeartPulse aria-hidden="true" size={17} />
|
||||
<span>智能心跳</span>
|
||||
{pendingHeartbeatSuggestionCount > 0 && (
|
||||
<span
|
||||
@@ -4082,6 +4294,7 @@ function App(): React.JSX.Element {
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
aria-current={view === 'activity' ? 'page' : undefined}
|
||||
className={
|
||||
view === 'activity'
|
||||
? 'nav-item nav-item--active'
|
||||
@@ -4090,7 +4303,7 @@ function App(): React.JSX.Element {
|
||||
onClick={() => setView('activity')}
|
||||
type="button"
|
||||
>
|
||||
<TerminalSquare size={17} />
|
||||
<TerminalSquare aria-hidden="true" size={17} />
|
||||
<span>任务与活动</span>
|
||||
</button>
|
||||
</nav>
|
||||
@@ -4157,6 +4370,7 @@ function App(): React.JSX.Element {
|
||||
className="conversation-more"
|
||||
onClick={() => {
|
||||
setRenamingConversationId('')
|
||||
setConfirmingConversationId('')
|
||||
setConversationActionsId((current) =>
|
||||
current === conversation.id ? '' : conversation.id
|
||||
)
|
||||
@@ -4177,16 +4391,6 @@ function App(): React.JSX.Element {
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
{!conversation.remote && (
|
||||
<button
|
||||
aria-label={`删除对话 ${conversation.title}`}
|
||||
className="conversation-delete"
|
||||
onClick={() => deleteConversation(conversation.id)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{conversationActionsId === conversation.id && (
|
||||
<div
|
||||
@@ -4229,6 +4433,30 @@ function App(): React.JSX.Element {
|
||||
<Download size={14} />
|
||||
导出 Markdown
|
||||
</button>
|
||||
{!conversation.remote && (
|
||||
<DestructiveConfirmActions
|
||||
cancelAriaLabel={`取消删除对话 ${conversation.title}`}
|
||||
confirmAriaLabel={`确认永久删除对话 ${conversation.title}`}
|
||||
confirmLabel="永久删除对话"
|
||||
confirming={
|
||||
confirmingConversationId === conversation.id
|
||||
}
|
||||
disabled={
|
||||
deletingConversationId === conversation.id
|
||||
}
|
||||
icon={<Trash2 aria-hidden="true" size={14} />}
|
||||
message="将永久删除此会话的全部内容;如果此会话有正在运行的任务,也会同时停止。此操作不可恢复。"
|
||||
onCancel={() => setConfirmingConversationId('')}
|
||||
onConfirm={() =>
|
||||
void deleteConversation(conversation.id)
|
||||
}
|
||||
onRequestConfirm={() =>
|
||||
setConfirmingConversationId(conversation.id)
|
||||
}
|
||||
triggerAriaLabel={`删除对话 ${conversation.title}`}
|
||||
triggerLabel="删除对话"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!conversation.remote &&
|
||||
@@ -4304,16 +4532,29 @@ function App(): React.JSX.Element {
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
{sidebarOpen && (
|
||||
<button
|
||||
aria-label="关闭侧栏"
|
||||
className="sidebar-backdrop"
|
||||
onClick={closeNarrowSidebar}
|
||||
type="button"
|
||||
/>
|
||||
)}
|
||||
|
||||
<main className="workspace">
|
||||
<main
|
||||
aria-hidden={narrowWindow && sidebarOpen ? 'true' : undefined}
|
||||
className="workspace"
|
||||
inert={narrowWindow && sidebarOpen}
|
||||
>
|
||||
<header className="topbar">
|
||||
<button
|
||||
className="icon-button sidebar-toggle"
|
||||
type="button"
|
||||
aria-label="切换侧栏"
|
||||
onClick={() => setSidebarOpen((open) => !open)}
|
||||
ref={sidebarToggleRef}
|
||||
>
|
||||
<PanelLeft size={18} />
|
||||
<PanelLeft aria-hidden="true" size={18} />
|
||||
</button>
|
||||
{view === 'chat' && (
|
||||
<>
|
||||
@@ -5496,7 +5737,7 @@ function App(): React.JSX.Element {
|
||||
)}
|
||||
</footer>
|
||||
</PageShell>
|
||||
) : view === 'magic-notes' ? (
|
||||
) : view === 'magic-notes' && magicNotesEnabled ? (
|
||||
<PageShell variant="master-detail">
|
||||
<MagicNotesWorkspace
|
||||
key={activeProject?.id ?? 'global'}
|
||||
@@ -5513,6 +5754,7 @@ function App(): React.JSX.Element {
|
||||
graphNodes={knowledgeSnapshot.graphNodes}
|
||||
graphRelations={knowledgeSnapshot.graphRelations}
|
||||
libraries={knowledgeSnapshot.libraries}
|
||||
loadError={knowledgeLoadError}
|
||||
loading={knowledgeLoading}
|
||||
onCreateLibrary={createKnowledgeLibrary}
|
||||
onCreateEntity={async (input) => {
|
||||
@@ -5629,8 +5871,11 @@ function App(): React.JSX.Element {
|
||||
window.goodbuddy.knowledge.retrySource(sourceId)
|
||||
)
|
||||
}
|
||||
onRetryLoad={retryKnowledgeLoad}
|
||||
onSelectLibrary={(libraryId) => {
|
||||
void refreshKnowledge(libraryId)
|
||||
void refreshKnowledge(libraryId).catch(() => {
|
||||
// KnowledgeWorkspace renders the recoverable load error.
|
||||
})
|
||||
}}
|
||||
onSyncSource={(sourceId) =>
|
||||
runKnowledgeSourceAction(() =>
|
||||
@@ -5660,9 +5905,12 @@ function App(): React.JSX.Element {
|
||||
configs={assistantHeartbeats}
|
||||
currentProjectName={activeProject?.name}
|
||||
entries={heartbeatEntries}
|
||||
loadError={heartbeatLoadError}
|
||||
loading={heartbeatLoading}
|
||||
memories={assistantMemories}
|
||||
onCreate={createHeartbeat}
|
||||
onRefresh={refreshHeartbeatCenter}
|
||||
onRefresh={retryHeartbeatLoad}
|
||||
onRetryLoad={retryHeartbeatLoad}
|
||||
onRemove={removeHeartbeat}
|
||||
onRunNow={runHeartbeat}
|
||||
onSetMemoryStatus={setMemoryStatus}
|
||||
@@ -5694,6 +5942,9 @@ function App(): React.JSX.Element {
|
||||
setSelectedExpertId('')
|
||||
}
|
||||
}}
|
||||
onMagicNotesEnabledChange={(enabled) => {
|
||||
setMagicNotesEnabled(enabled)
|
||||
}}
|
||||
onRemoveHeartbeat={removeHeartbeat}
|
||||
onRunHeartbeat={runHeartbeat}
|
||||
onNotify={notify}
|
||||
|
||||
@@ -110,6 +110,7 @@ function createProps(
|
||||
onRemove: vi.fn(async () => {}),
|
||||
onRunNow: vi.fn(async () => {}),
|
||||
onRefresh: vi.fn(async () => {}),
|
||||
onRetryLoad: vi.fn(async () => {}),
|
||||
onSetMemoryStatus: vi.fn(async () => {}),
|
||||
onSetTaskStatus: vi.fn(async () => {}),
|
||||
onUseFollowUpTask: vi.fn(),
|
||||
@@ -264,4 +265,56 @@ describe('HeartbeatCenter', () => {
|
||||
screen.getByRole('button', { name: '启用智能心跳' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps loading and load failure distinct from first-time empty state', () => {
|
||||
const emptyProps = {
|
||||
configs: [],
|
||||
runs: [],
|
||||
entries: [],
|
||||
memories: [],
|
||||
tasks: []
|
||||
}
|
||||
const { rerender } = render(
|
||||
<HeartbeatCenter
|
||||
{...createProps({
|
||||
...emptyProps,
|
||||
loading: true
|
||||
})}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('正在加载智能心跳')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '配置智能心跳' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<HeartbeatCenter
|
||||
{...createProps({
|
||||
...emptyProps,
|
||||
loadError: '数据库暂时不可用'
|
||||
})}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('智能心跳加载失败')).toBeInTheDocument()
|
||||
expect(screen.getByText('数据库暂时不可用')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '配置智能心跳' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '重试' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps existing heartbeat data visible when refresh fails', () => {
|
||||
render(
|
||||
<HeartbeatCenter
|
||||
{...createProps({ loadError: '刷新连接失败' })}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('智能心跳刷新失败')).toBeInTheDocument()
|
||||
expect(screen.getByText(config.name)).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(entry.summary)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,6 +54,9 @@ export type HeartbeatCenterProps = {
|
||||
) => Promise<void>
|
||||
onUseFollowUpTask: (task: AssistantTask) => void
|
||||
currentProjectName?: string
|
||||
loading?: boolean
|
||||
loadError?: string
|
||||
onRetryLoad: () => void | Promise<void>
|
||||
}
|
||||
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
@@ -143,7 +146,10 @@ export function HeartbeatCenter({
|
||||
onSetMemoryStatus,
|
||||
onSetTaskStatus,
|
||||
onUseFollowUpTask,
|
||||
currentProjectName = '当前项目'
|
||||
currentProjectName = '当前项目',
|
||||
loading = false,
|
||||
loadError,
|
||||
onRetryLoad
|
||||
}: HeartbeatCenterProps): React.JSX.Element {
|
||||
const [tab, setTab] = useState<HeartbeatCenterTab>('overview')
|
||||
const [pendingAction, setPendingAction] = useState<string>()
|
||||
@@ -226,6 +232,10 @@ export function HeartbeatCenter({
|
||||
entry.followUpTaskIds.length
|
||||
)
|
||||
)
|
||||
const hasHeartbeatData =
|
||||
configs.length > 0 || runs.length > 0 || entries.length > 0
|
||||
const initialLoadBlocked =
|
||||
!hasHeartbeatData && (loading || loadError !== undefined)
|
||||
|
||||
const runAction = async (
|
||||
actionId: string,
|
||||
@@ -269,11 +279,12 @@ export function HeartbeatCenter({
|
||||
>
|
||||
<PageHeader
|
||||
actions={
|
||||
<>
|
||||
initialLoadBlocked ? undefined : (
|
||||
<>
|
||||
<button
|
||||
aria-label="刷新智能心跳"
|
||||
className="secondary-button"
|
||||
disabled={pendingAction !== undefined}
|
||||
disabled={loading || pendingAction !== undefined}
|
||||
onClick={() => void runAction('refresh', onRefresh)}
|
||||
type="button"
|
||||
>
|
||||
@@ -283,7 +294,7 @@ export function HeartbeatCenter({
|
||||
{primaryConfig ? (
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={pendingAction !== undefined}
|
||||
disabled={loading || pendingAction !== undefined}
|
||||
onClick={() =>
|
||||
void runAction(`run:${primaryConfig.id}`, () =>
|
||||
onRunNow(primaryConfig.id)
|
||||
@@ -299,13 +310,15 @@ export function HeartbeatCenter({
|
||||
) : (
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={loading}
|
||||
onClick={() => setTab('plans')}
|
||||
type="button"
|
||||
>
|
||||
配置智能心跳
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
)
|
||||
}
|
||||
description="定期回顾经历、沉淀记忆、发现问题,并把每次变化转化为可处理的成长建议。"
|
||||
eyebrow="SMART HEARTBEAT"
|
||||
@@ -321,6 +334,49 @@ export function HeartbeatCenter({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && !hasHeartbeatData ? (
|
||||
<EmptyState
|
||||
description="正在读取心跳计划、运行记录和成长报告。"
|
||||
icon={<RefreshCw size={24} />}
|
||||
level="page"
|
||||
title="正在加载智能心跳"
|
||||
/>
|
||||
) : loadError && !hasHeartbeatData ? (
|
||||
<EmptyState
|
||||
action={
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => void onRetryLoad()}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={14} />
|
||||
重试
|
||||
</button>
|
||||
}
|
||||
description={loadError}
|
||||
icon={<XCircle size={24} />}
|
||||
level="page"
|
||||
title="智能心跳加载失败"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{loadError && hasHeartbeatData && (
|
||||
<div className="heartbeat-center__error" role="alert">
|
||||
<strong>智能心跳刷新失败</strong>
|
||||
<p>{loadError}</p>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => void onRetryLoad()}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={14} />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!initialLoadBlocked && (
|
||||
<>
|
||||
<PageTabs
|
||||
ariaLabel="智能心跳视图"
|
||||
idPrefix="heartbeat"
|
||||
@@ -1034,6 +1090,8 @@ export function HeartbeatCenter({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<KnowledgeWorkspace
|
||||
{...createProps({
|
||||
libraries: [],
|
||||
loadError: '数据库暂时不可用',
|
||||
onRetryLoad,
|
||||
selectedLibraryId: undefined
|
||||
})}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<KnowledgeWorkspace
|
||||
{...createProps({ loadError: '刷新连接失败' })}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
|
||||
@@ -159,6 +159,8 @@ export type KnowledgeWorkspaceProps = {
|
||||
graphRelations: readonly KnowledgeGraphRelation[]
|
||||
evidence: readonly KnowledgeEvidence[]
|
||||
loading?: boolean
|
||||
loadError?: string
|
||||
onRetryLoad: () => void | Promise<void>
|
||||
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({
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main
|
||||
<section
|
||||
aria-label="知识库详情"
|
||||
className="knowledge-workspace__main"
|
||||
style={{ minWidth: 0, background: 'var(--surface-raised)' }}
|
||||
>
|
||||
{loadError && libraries.length > 0 && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
...styles.surface,
|
||||
margin: 'var(--space-4)',
|
||||
padding: 'var(--space-3)',
|
||||
color: 'var(--danger)'
|
||||
}}
|
||||
>
|
||||
<strong>知识库刷新失败</strong>
|
||||
<p style={{ margin: 'var(--space-2) 0' }}>{loadError}</p>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => void onRetryLoad()}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={14} />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{selectedLibrary && !creating && !loading && (
|
||||
<button
|
||||
className="knowledge-workspace__mobile-back secondary-button"
|
||||
@@ -2337,13 +2363,31 @@ export function KnowledgeWorkspace({
|
||||
返回知识库列表
|
||||
</button>
|
||||
)}
|
||||
{loading ? (
|
||||
{loading && libraries.length === 0 ? (
|
||||
<EmptyState
|
||||
description="正在读取知识库、来源和索引状态。"
|
||||
icon={<LoaderCircle size={28} />}
|
||||
level="page"
|
||||
title="正在加载知识库"
|
||||
/>
|
||||
) : loadError && libraries.length === 0 ? (
|
||||
<EmptyState
|
||||
action={
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => void onRetryLoad()}
|
||||
style={styles.button}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={14} />
|
||||
重试
|
||||
</button>
|
||||
}
|
||||
description={loadError}
|
||||
icon={<AlertCircle size={28} />}
|
||||
level="page"
|
||||
title="知识库加载失败"
|
||||
/>
|
||||
) : creating ? (
|
||||
<CreateLibraryWizard
|
||||
onCancel={() => setCreating(false)}
|
||||
@@ -2502,7 +2546,7 @@ export function KnowledgeWorkspace({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</section>
|
||||
{deletingLibrary && (
|
||||
<DeleteLibraryDialog
|
||||
library={deletingLibrary}
|
||||
|
||||
@@ -123,6 +123,7 @@ const summaryFromDetail = (
|
||||
const list = vi.fn<() => Promise<MagicNotesSnapshot>>()
|
||||
const get = vi.fn<(noteId: string) => Promise<MagicNoteDetail>>()
|
||||
const listTodos = vi.fn<() => Promise<MagicTodosSnapshot>>()
|
||||
const remove = vi.fn<DesktopApi['magicNotes']['remove']>()
|
||||
const createTodo = vi.fn<DesktopApi['magicNotes']['createTodo']>()
|
||||
const updateTodo = vi.fn<DesktopApi['magicNotes']['updateTodo']>()
|
||||
const removeTodo = vi.fn<DesktopApi['magicNotes']['removeTodo']>()
|
||||
@@ -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(
|
||||
<MagicNotesWorkspace
|
||||
onNotify={onNotify}
|
||||
projectId={detail.projectId}
|
||||
projectName="默认项目"
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<MagicNotesWorkspace
|
||||
onNotify={onNotify}
|
||||
projectId={detail.projectId}
|
||||
projectName="默认项目"
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<MagicNotesWorkspace
|
||||
@@ -276,6 +341,53 @@ describe('MagicNotesWorkspace', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not override a note selected while retrying a refresh', async () => {
|
||||
const second = alternateDetail(secondNoteId, '第二篇笔记')
|
||||
list.mockResolvedValue({
|
||||
notes: [summaryFromDetail(detail), summaryFromDetail(second)]
|
||||
})
|
||||
|
||||
render(
|
||||
<MagicNotesWorkspace
|
||||
onNotify={onNotify}
|
||||
projectId={detail.projectId}
|
||||
projectName="默认项目"
|
||||
/>
|
||||
)
|
||||
|
||||
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<MagicNoteDetail>(
|
||||
(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(
|
||||
<MagicNotesWorkspace
|
||||
@@ -289,7 +401,7 @@ describe('MagicNotesWorkspace', () => {
|
||||
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(
|
||||
<MagicNotesWorkspace
|
||||
onNotify={onNotify}
|
||||
projectId={detail.projectId}
|
||||
projectName="默认项目"
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<MagicNotesWorkspace
|
||||
|
||||
@@ -49,6 +49,7 @@ export type MagicNotesWorkspaceProps = {
|
||||
|
||||
type LibraryView = 'notes' | 'todos'
|
||||
type TodoFilter = 'active' | 'completed' | 'all'
|
||||
type LoadStatus = 'loading' | 'ready' | 'error'
|
||||
type ValidationTarget =
|
||||
| 'create-note'
|
||||
| 'create-todo'
|
||||
@@ -150,7 +151,13 @@ export function MagicNotesWorkspace({
|
||||
const [selectedNoteId, setSelectedNoteId] = useState('')
|
||||
const [selectedTodoId, setSelectedTodoId] = useState('')
|
||||
const [detail, setDetail] = useState<MagicNoteDetail>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadStatus, setLoadStatus] = useState<LoadStatus>('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<MagicNoteRichContent | undefined>(
|
||||
undefined
|
||||
@@ -258,28 +267,40 @@ export function MagicNotesWorkspace({
|
||||
const loadDetail = useCallback(
|
||||
async (noteId: string): Promise<void> => {
|
||||
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<void> => {
|
||||
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)
|
||||
}}
|
||||
>
|
||||
<Plus size={15} />
|
||||
<Plus aria-hidden="true" size={15} />
|
||||
{libraryView === 'notes' ? '新建笔记' : '新建待办'}
|
||||
</button>
|
||||
</>
|
||||
@@ -666,6 +703,36 @@ export function MagicNotesWorkspace({
|
||||
title="魔法笔记"
|
||||
/>
|
||||
|
||||
{loadStatus === 'error' ? (
|
||||
<EmptyState
|
||||
action={
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => void refreshNotes()}
|
||||
type="button"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
}
|
||||
description={`无法加载魔法笔记:${loadError}`}
|
||||
icon={<CircleAlert size={24} />}
|
||||
level="page"
|
||||
title="魔法笔记加载失败"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{refreshError && (
|
||||
<div className="magic-note-delete-confirmation" role="alert">
|
||||
<span>刷新失败,已保留当前内容:{refreshError}</span>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => void refreshNotes(selectedNoteId)}
|
||||
type="button"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
aria-busy={Boolean(busy)}
|
||||
className={`magic-notes-layout${
|
||||
@@ -760,18 +827,31 @@ export function MagicNotesWorkspace({
|
||||
disabled={busy === 'create-note'}
|
||||
type="submit"
|
||||
>
|
||||
创建
|
||||
创建笔记
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
<div className="magic-notes-list">
|
||||
{loading ? (
|
||||
{loadStatus === 'loading' ? (
|
||||
<p className="magic-notes-muted">正在加载笔记…</p>
|
||||
) : visibleNotes.length === 0 ? (
|
||||
<p className="magic-notes-muted">
|
||||
{search ? '没有符合条件的笔记' : '还没有笔记'}
|
||||
</p>
|
||||
<>
|
||||
<p className="magic-notes-muted">
|
||||
{search.trim()
|
||||
? '没有符合条件的笔记'
|
||||
: '还没有笔记'}
|
||||
</p>
|
||||
{search.trim() && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setSearch('')}
|
||||
type="button"
|
||||
>
|
||||
清除筛选
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
visibleNotes.map((note) => (
|
||||
<button
|
||||
@@ -785,7 +865,6 @@ export function MagicNotesWorkspace({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
创建
|
||||
创建待办
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
<div className="magic-notes-list">
|
||||
{loading ? (
|
||||
{loadStatus === 'loading' ? (
|
||||
<p className="magic-notes-muted">正在加载待办…</p>
|
||||
) : visibleTodos.length === 0 ? (
|
||||
<p className="magic-notes-muted">
|
||||
{todos.length === 0
|
||||
? '还没有待办'
|
||||
: search || todoFilter !== 'all'
|
||||
? '没有符合条件的待办'
|
||||
: '还没有待办'}
|
||||
</p>
|
||||
<>
|
||||
<p className="magic-notes-muted">
|
||||
{todos.length === 0
|
||||
? '还没有待办'
|
||||
: search.trim() || todoFilter !== 'all'
|
||||
? '没有符合条件的待办'
|
||||
: '还没有待办'}
|
||||
</p>
|
||||
{todos.length > 0 &&
|
||||
(Boolean(search.trim()) ||
|
||||
todoFilter !== 'all') && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => {
|
||||
setSearch('')
|
||||
setTodoFilter('all')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
清除筛选
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
visibleTodos.map((todo) => (
|
||||
<button
|
||||
@@ -955,7 +1050,7 @@ export function MagicNotesWorkspace({
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main
|
||||
<section
|
||||
aria-label={libraryView === 'notes' ? '笔记记录' : '待办详情'}
|
||||
className="magic-notes-stream-pane"
|
||||
>
|
||||
@@ -964,10 +1059,27 @@ export function MagicNotesWorkspace({
|
||||
<EmptyState
|
||||
description="从左侧选择笔记,或新建一篇笔记开始记录。"
|
||||
icon={<FileText size={24} />}
|
||||
title={loading ? '正在加载' : '还没有选择笔记'}
|
||||
title={
|
||||
loadStatus === 'loading' ? '正在加载' : '还没有选择笔记'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{detailLoadError && (
|
||||
<div className="magic-note-delete-confirmation" role="alert">
|
||||
<span>
|
||||
笔记加载失败,已保留当前内容:
|
||||
{detailLoadError.message}
|
||||
</span>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => void loadDetail(detailLoadError.noteId)}
|
||||
type="button"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<header className="magic-note-detail-header">
|
||||
<input
|
||||
aria-describedby={
|
||||
@@ -1278,7 +1390,9 @@ export function MagicNotesWorkspace({
|
||||
<EmptyState
|
||||
description="从左侧选择待办,或新建一个手动待办。"
|
||||
icon={<ListTodo size={24} />}
|
||||
title={loading ? '正在加载' : '还没有选择待办'}
|
||||
title={
|
||||
loadStatus === 'loading' ? '正在加载' : '还没有选择待办'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<section className="magic-todo-detail">
|
||||
@@ -1451,7 +1565,6 @@ export function MagicNotesWorkspace({
|
||||
className="secondary-button"
|
||||
onClick={() => {
|
||||
setLibraryView('notes')
|
||||
setSelectedNoteId(selectedTodo.noteId!)
|
||||
void loadDetail(selectedTodo.noteId!).then(() => {
|
||||
requestAnimationFrame(() =>
|
||||
document
|
||||
@@ -1472,7 +1585,7 @@ export function MagicNotesWorkspace({
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</section>
|
||||
|
||||
<aside
|
||||
aria-label="AI 评论"
|
||||
@@ -1533,6 +1646,8 @@ export function MagicNotesWorkspace({
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<ApplicationSettings>()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | undefined>(() =>
|
||||
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<void> => {
|
||||
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 (
|
||||
<section
|
||||
aria-labelledby="platform-features-heading"
|
||||
className="settings-section"
|
||||
>
|
||||
<div className="settings-section__title">
|
||||
<Sparkles aria-hidden="true" size={17} />
|
||||
<div>
|
||||
<strong id="platform-features-heading">平台功能</strong>
|
||||
<small>控制 GoodBuddy 工作区中显示的功能入口</small>
|
||||
</div>
|
||||
</div>
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>魔法笔记</strong>
|
||||
<small>
|
||||
默认关闭;开启后可记录笔记与待办,并使用 AI 分析内容
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
checked={settings?.magicNotesEnabled ?? false}
|
||||
disabled={!settings || saving}
|
||||
onChange={(event) =>
|
||||
void changeMagicNotes(event.target.checked)
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>显示魔法笔记入口</span>
|
||||
</label>
|
||||
</article>
|
||||
{error && (
|
||||
<p className="settings-warning" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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<DesktopApi['updates']>['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(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
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(
|
||||
<SettingsPanel
|
||||
@@ -429,6 +485,63 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(content).toHaveClass('settings-panel__content')
|
||||
})
|
||||
|
||||
it('uses one first-level heading for the settings page', () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
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<void>((resolve) => {
|
||||
finishClear = resolve
|
||||
})
|
||||
)
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={onClearLocalData}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<SettingsPanel
|
||||
|
||||
@@ -34,9 +34,10 @@ import { SkillsSettingsSection } from './SkillsSettingsSection'
|
||||
import { HeartbeatSettings } from './HeartbeatSettings'
|
||||
import { ChannelSettingsSection } from './ChannelSettingsSection'
|
||||
import { UpdateSettingsSection } from './UpdateSettingsSection'
|
||||
import { PlatformFeaturesSettingsSection } from './PlatformFeaturesSettingsSection'
|
||||
import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
|
||||
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
|
||||
import type { AppearanceTheme } from './theme'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import type {
|
||||
@@ -46,6 +47,7 @@ import type {
|
||||
|
||||
type SettingsTab =
|
||||
| 'appearance'
|
||||
| 'platform-features'
|
||||
| 'model'
|
||||
| 'runtime'
|
||||
| 'security'
|
||||
@@ -64,6 +66,7 @@ type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & {
|
||||
|
||||
const settingsTabs: readonly SettingsTab[] = [
|
||||
'appearance',
|
||||
'platform-features',
|
||||
'model',
|
||||
'runtime',
|
||||
'security',
|
||||
@@ -93,6 +96,7 @@ type SettingsPanelProps = {
|
||||
onRunHeartbeat: (heartbeatId: string) => Promise<void>
|
||||
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<RuntimeSettings>()
|
||||
const [provider, setProvider] =
|
||||
@@ -328,6 +333,7 @@ export function SettingsPanel({
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [connectionResult, setConnectionResult] = useState<string>()
|
||||
const [confirmingClear, setConfirmingClear] = useState(false)
|
||||
const [clearingLocalData, setClearingLocalData] = useState(false)
|
||||
const [detection, setDetection] = useState<AgentRuntimeDetection>()
|
||||
const [detecting, setDetecting] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('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'}
|
||||
>
|
||||
<header className="settings-panel__header">
|
||||
<div className="settings-panel__header">
|
||||
<div>
|
||||
<p className="eyebrow">SETTINGS</p>
|
||||
<h2 id="settings-title">设置中心</h2>
|
||||
<p className="settings-panel__description">
|
||||
管理模型连接、Agent Runtime、自动化、扩展能力和本地数据。
|
||||
</p>
|
||||
<PageHeader
|
||||
actions={
|
||||
<button
|
||||
aria-label="关闭设置"
|
||||
className="icon-button"
|
||||
onClick={close}
|
||||
type="button"
|
||||
>
|
||||
<X aria-hidden="true" size={19} />
|
||||
</button>
|
||||
}
|
||||
description="管理模型连接、Agent Runtime、自动化、扩展能力和本地数据。"
|
||||
eyebrow="SETTINGS"
|
||||
headingId="settings-title"
|
||||
title="设置中心"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
aria-label="关闭设置"
|
||||
className="icon-button"
|
||||
onClick={close}
|
||||
type="button"
|
||||
>
|
||||
<X size={19} />
|
||||
</button>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div className="settings-panel__body">
|
||||
<nav
|
||||
@@ -992,6 +1002,22 @@ export function SettingsPanel({
|
||||
<strong>外观</strong>
|
||||
<small>亮色、暗色与系统主题</small>
|
||||
</button>
|
||||
<button
|
||||
aria-controls="settings-panel-platform-features"
|
||||
aria-label="平台功能"
|
||||
aria-selected={activeTab === 'platform-features'}
|
||||
id="settings-tab-platform-features"
|
||||
onClick={() => setActiveTab('platform-features')}
|
||||
onKeyDown={(event) =>
|
||||
handleTabKeyDown(event, 'platform-features')
|
||||
}
|
||||
role="tab"
|
||||
tabIndex={activeTab === 'platform-features' ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<strong>平台功能</strong>
|
||||
<small>功能入口与工作区能力</small>
|
||||
</button>
|
||||
<button
|
||||
aria-controls="settings-panel-model"
|
||||
aria-label="模型连接"
|
||||
@@ -1189,6 +1215,11 @@ export function SettingsPanel({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'platform-features' && (
|
||||
<PlatformFeaturesSettingsSection
|
||||
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'runtime' && (
|
||||
<>
|
||||
{settings?.warning && (
|
||||
@@ -2165,6 +2196,7 @@ export function SettingsPanel({
|
||||
<div className="danger-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={clearingLocalData}
|
||||
onClick={() => setConfirmingClear(false)}
|
||||
type="button"
|
||||
>
|
||||
@@ -2172,12 +2204,15 @@ export function SettingsPanel({
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
disabled={clearingLocalData}
|
||||
onClick={() => {
|
||||
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
|
||||
? '正在清除…'
|
||||
: '清除本地数据'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -18,7 +18,11 @@ describe('UpdateSettingsSection', () => {
|
||||
it('checks the official release manifest and updates the startup preference', async () => {
|
||||
const updateSettings = vi.fn<
|
||||
NonNullable<DesktopApi['updates']>['updateSettings']
|
||||
>(async (input) => input)
|
||||
>(async (input) => ({
|
||||
checkUpdatesOnStartup:
|
||||
input.checkUpdatesOnStartup ?? true,
|
||||
magicNotesEnabled: input.magicNotesEnabled ?? true
|
||||
}))
|
||||
const check = vi.fn<
|
||||
NonNullable<DesktopApi['updates']>['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"
|
||||
|
||||
@@ -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(
|
||||
<PageShell variant="dashboard">
|
||||
@@ -188,8 +216,10 @@ describe('WorkspacePrimitives', () => {
|
||||
const onCancel = vi.fn()
|
||||
const { rerender } = render(
|
||||
<DestructiveConfirmActions
|
||||
confirmAriaLabel="永久删除对象"
|
||||
confirmLabel="确认删除"
|
||||
confirming={false}
|
||||
icon={<span data-testid="delete-icon">×</span>}
|
||||
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(
|
||||
<DestructiveConfirmActions
|
||||
confirmAriaLabel="永久删除对象"
|
||||
confirmLabel="确认删除"
|
||||
confirming
|
||||
message="删除此对象?"
|
||||
@@ -211,11 +246,74 @@ describe('WorkspacePrimitives', () => {
|
||||
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(
|
||||
<DestructiveConfirmActions
|
||||
confirmAriaLabel="永久删除对象"
|
||||
confirmLabel="确认删除"
|
||||
confirming={false}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
onRequestConfirm={onRequestConfirm}
|
||||
triggerLabel="删除"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: '删除' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('keeps focus on the dialog while destructive actions are disabled', () => {
|
||||
const onCancel = vi.fn()
|
||||
const { rerender } = render(
|
||||
<DestructiveConfirmActions
|
||||
confirmLabel="确认删除"
|
||||
confirming={false}
|
||||
onCancel={onCancel}
|
||||
onConfirm={vi.fn()}
|
||||
onRequestConfirm={vi.fn()}
|
||||
triggerLabel="删除"
|
||||
/>
|
||||
)
|
||||
|
||||
rerender(
|
||||
<DestructiveConfirmActions
|
||||
confirmLabel="正在删除"
|
||||
confirming
|
||||
disabled
|
||||
onCancel={onCancel}
|
||||
onConfirm={vi.fn()}
|
||||
onRequestConfirm={vi.fn()}
|
||||
triggerLabel="删除"
|
||||
/>
|
||||
)
|
||||
|
||||
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([
|
||||
|
||||
@@ -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<HTMLButtonElement>(null)
|
||||
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(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 ? (
|
||||
<div
|
||||
aria-label={confirmAriaLabel}
|
||||
aria-describedby={descriptionId}
|
||||
aria-labelledby={titleId}
|
||||
aria-live="assertive"
|
||||
aria-modal="true"
|
||||
className="danger-confirm"
|
||||
onKeyDown={(event) => {
|
||||
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 && <span>{message}</span>}
|
||||
<span className="sr-only" id={titleId}>
|
||||
{confirmAriaLabel ?? confirmLabel}
|
||||
</span>
|
||||
<span className={message ? undefined : 'sr-only'} id={descriptionId}>
|
||||
{message ?? `确认${triggerLabel}操作。`}
|
||||
</span>
|
||||
<button
|
||||
aria-label={cancelAriaLabel}
|
||||
className="secondary-button"
|
||||
@@ -365,6 +433,7 @@ export function DestructiveConfirmActions({
|
||||
className="danger-button"
|
||||
disabled={disabled}
|
||||
onClick={onConfirm}
|
||||
ref={confirmRef}
|
||||
type="button"
|
||||
>
|
||||
{confirmLabel}
|
||||
@@ -379,7 +448,7 @@ export function DestructiveConfirmActions({
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
{icon && <span aria-hidden="true">{icon}</span>}
|
||||
{triggerLabel}
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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<string, unknown>
|
||||
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
|
||||
|
||||
+205
-49
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ApplicationSettings>
|
||||
updateSettings: (
|
||||
input: ApplicationSettings
|
||||
input: ApplicationSettingsUpdate
|
||||
) => Promise<ApplicationSettings>
|
||||
check: () => Promise<VersionCheckResult>
|
||||
openReleasePage: () => Promise<void>
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user