feat: add configurable Magic Notes and polish UI
Cross-platform packages / Validate source (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / Publish GitHub Release (push) Has been cancelled
Cross-platform packages / Validate source (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / Publish GitHub Release (push) Has been cancelled
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "goodbuddy",
|
"name": "goodbuddy",
|
||||||
"version": "0.8.6",
|
"version": "0.8.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "goodbuddy",
|
"name": "goodbuddy",
|
||||||
"version": "0.8.6",
|
"version": "0.8.8",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "goodbuddy",
|
"name": "goodbuddy",
|
||||||
"version": "0.8.6",
|
"version": "0.8.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||||
"desktopName": "GoodBuddy",
|
"desktopName": "GoodBuddy",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
ApplicationSettingsStore,
|
ApplicationSettingsStore,
|
||||||
applicationSettingsSchema,
|
applicationSettingsSchema,
|
||||||
|
applicationSettingsUpdateSchema,
|
||||||
defaultApplicationSettings
|
defaultApplicationSettings
|
||||||
} from './application-settings-store'
|
} from './application-settings-store'
|
||||||
|
|
||||||
@@ -51,18 +52,26 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
await expect(readdir(directory)).resolves.toEqual([])
|
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()
|
const { directory, filePath, store } = await createStore()
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
store.update({ checkUpdatesOnStartup: false })
|
store.update({
|
||||||
).resolves.toEqual({ checkUpdatesOnStartup: false })
|
checkUpdatesOnStartup: false,
|
||||||
|
magicNotesEnabled: false
|
||||||
|
})
|
||||||
|
).resolves.toEqual({
|
||||||
|
checkUpdatesOnStartup: false,
|
||||||
|
magicNotesEnabled: false
|
||||||
|
})
|
||||||
await expect(store.get()).resolves.toEqual({
|
await expect(store.get()).resolves.toEqual({
|
||||||
checkUpdatesOnStartup: false
|
checkUpdatesOnStartup: false,
|
||||||
|
magicNotesEnabled: false
|
||||||
})
|
})
|
||||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||||
version: 1,
|
version: 2,
|
||||||
checkUpdatesOnStartup: false
|
checkUpdatesOnStartup: false,
|
||||||
|
magicNotesEnabled: false
|
||||||
})
|
})
|
||||||
expect(
|
expect(
|
||||||
(await readdir(directory)).filter((name) => name.endsWith('.tmp'))
|
(await readdir(directory)).filter((name) => name.endsWith('.tmp'))
|
||||||
@@ -73,38 +82,103 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
const { directory } = await createStore()
|
const { directory } = await createStore()
|
||||||
const filePath = join(directory, 'nested', 'application-settings.json')
|
const filePath = join(directory, 'nested', 'application-settings.json')
|
||||||
const store = new ApplicationSettingsStore(filePath)
|
const store = new ApplicationSettingsStore(filePath)
|
||||||
await store.update({ checkUpdatesOnStartup: false })
|
await store.update({
|
||||||
|
checkUpdatesOnStartup: false,
|
||||||
|
magicNotesEnabled: false
|
||||||
|
})
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
new ApplicationSettingsStore(filePath).get()
|
new ApplicationSettingsStore(filePath).get()
|
||||||
).resolves.toEqual({ checkUpdatesOnStartup: false })
|
).resolves.toEqual({
|
||||||
|
checkUpdatesOnStartup: false,
|
||||||
|
magicNotesEnabled: false
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('strictly rejects unknown, missing, and mistyped input', async () => {
|
it.each([1, 2])(
|
||||||
const { directory, store } = await createStore()
|
'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 [
|
for (const input of [
|
||||||
{},
|
{},
|
||||||
{ checkUpdatesOnStartup: 'true' },
|
{ checkUpdatesOnStartup: 'true' },
|
||||||
{ checkUpdatesOnStartup: true, anotherSetting: true },
|
{
|
||||||
|
checkUpdatesOnStartup: true,
|
||||||
|
magicNotesEnabled: true,
|
||||||
|
anotherSetting: true
|
||||||
|
},
|
||||||
|
{ checkUpdatesOnStartup: true },
|
||||||
null
|
null
|
||||||
]) {
|
]) {
|
||||||
expect(applicationSettingsSchema.safeParse(input).success).toBe(
|
expect(applicationSettingsSchema.safeParse(input).success).toBe(
|
||||||
false
|
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(store.update(input)).rejects.toThrow()
|
||||||
}
|
}
|
||||||
await expect(readdir(directory)).resolves.toEqual([])
|
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([
|
it.each([
|
||||||
'{not-json',
|
'{not-json',
|
||||||
JSON.stringify({ version: 2, checkUpdatesOnStartup: false }),
|
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 1,
|
version: 3,
|
||||||
checkUpdatesOnStartup: false,
|
checkUpdatesOnStartup: false,
|
||||||
|
magicNotesEnabled: false
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
checkUpdatesOnStartup: false,
|
||||||
|
magicNotesEnabled: true,
|
||||||
injected: 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) => {
|
])('isolates corrupt persisted data and restores defaults', async (data) => {
|
||||||
const { directory, filePath, store } = await createStore()
|
const { directory, filePath, store } = await createStore()
|
||||||
await writeFile(filePath, data, 'utf8')
|
await writeFile(filePath, data, 'utf8')
|
||||||
@@ -142,28 +216,48 @@ describe('ApplicationSettingsStore', () => {
|
|||||||
const { filePath, store } = await createStore()
|
const { filePath, store } = await createStore()
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
store.update({ checkUpdatesOnStartup: false }),
|
store.update({
|
||||||
store.update({ checkUpdatesOnStartup: true }),
|
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({
|
await expect(store.get()).resolves.toEqual({
|
||||||
checkUpdatesOnStartup: false
|
checkUpdatesOnStartup: false,
|
||||||
|
magicNotesEnabled: false
|
||||||
})
|
})
|
||||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||||
version: 1,
|
version: 2,
|
||||||
checkUpdatesOnStartup: false
|
checkUpdatesOnStartup: false,
|
||||||
|
magicNotesEnabled: false
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('continues accepting updates after a validation failure', async () => {
|
it('continues accepting updates after a validation failure', async () => {
|
||||||
const { store } = await createStore()
|
const { store } = await createStore()
|
||||||
await expect(
|
await expect(
|
||||||
store.update({ checkUpdatesOnStartup: 'invalid' })
|
store.update({
|
||||||
|
checkUpdatesOnStartup: 'invalid',
|
||||||
|
magicNotesEnabled: true
|
||||||
|
})
|
||||||
).rejects.toThrow()
|
).rejects.toThrow()
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
store.update({ checkUpdatesOnStartup: false })
|
store.update({
|
||||||
).resolves.toEqual({ checkUpdatesOnStartup: false })
|
checkUpdatesOnStartup: false,
|
||||||
|
magicNotesEnabled: true
|
||||||
|
})
|
||||||
|
).resolves.toEqual({
|
||||||
|
checkUpdatesOnStartup: false,
|
||||||
|
magicNotesEnabled: true
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,12 +10,23 @@ import { dirname } from 'node:path'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import {
|
import {
|
||||||
applicationSettingsSchema,
|
applicationSettingsSchema,
|
||||||
|
applicationSettingsUpdateSchema,
|
||||||
type ApplicationSettings
|
type ApplicationSettings
|
||||||
} from '../shared/application-settings-contracts'
|
} 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'
|
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
|
const storedApplicationSettingsSchema = applicationSettingsSchema
|
||||||
.extend({
|
.extend({
|
||||||
@@ -28,7 +39,8 @@ type StoredApplicationSettings = z.infer<
|
|||||||
>
|
>
|
||||||
|
|
||||||
export const defaultApplicationSettings: ApplicationSettings = {
|
export const defaultApplicationSettings: ApplicationSettings = {
|
||||||
checkUpdatesOnStartup: true
|
checkUpdatesOnStartup: true,
|
||||||
|
magicNotesEnabled: false
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMissingFile(error: unknown): boolean {
|
function isMissingFile(error: unknown): boolean {
|
||||||
@@ -81,6 +93,17 @@ export class ApplicationSettingsStore {
|
|||||||
}
|
}
|
||||||
const result = storedApplicationSettingsSchema.safeParse(parsed)
|
const result = storedApplicationSettingsSchema.safeParse(parsed)
|
||||||
if (!result.success) {
|
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()
|
await this.isolateCorruptFile()
|
||||||
this.settings = {
|
this.settings = {
|
||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
@@ -106,16 +129,19 @@ export class ApplicationSettingsStore {
|
|||||||
async get(): Promise<ApplicationSettings> {
|
async get(): Promise<ApplicationSettings> {
|
||||||
const stored = await this.loadStored()
|
const stored = await this.loadStored()
|
||||||
return {
|
return {
|
||||||
checkUpdatesOnStartup: stored.checkUpdatesOnStartup
|
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
|
||||||
|
magicNotesEnabled: stored.magicNotesEnabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update(input: unknown): Promise<ApplicationSettings> {
|
update(input: unknown): Promise<ApplicationSettings> {
|
||||||
const operation = this.updateQueue.then(async () => {
|
const operation = this.updateQueue.then(async () => {
|
||||||
const settings = applicationSettingsSchema.parse(input)
|
const updates = applicationSettingsUpdateSchema.parse(input)
|
||||||
|
const current = await this.loadStored()
|
||||||
const next: StoredApplicationSettings = {
|
const next: StoredApplicationSettings = {
|
||||||
version: CURRENT_SETTINGS_VERSION,
|
...current,
|
||||||
...settings
|
...updates,
|
||||||
|
version: CURRENT_SETTINGS_VERSION
|
||||||
}
|
}
|
||||||
await mkdir(dirname(this.filePath), { recursive: true })
|
await mkdir(dirname(this.filePath), { recursive: true })
|
||||||
const temporaryPath =
|
const temporaryPath =
|
||||||
@@ -137,7 +163,8 @@ export class ApplicationSettingsStore {
|
|||||||
}
|
}
|
||||||
this.settings = next
|
this.settings = next
|
||||||
return {
|
return {
|
||||||
checkUpdatesOnStartup: next.checkUpdatesOnStartup
|
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
|
||||||
|
magicNotesEnabled: next.magicNotesEnabled
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
this.updateQueue = operation.then(
|
this.updateQueue = operation.then(
|
||||||
|
|||||||
+14
-2
@@ -63,7 +63,7 @@ import {
|
|||||||
dingTalkChannelSettingsInputSchema,
|
dingTalkChannelSettingsInputSchema,
|
||||||
weComChannelSettingsInputSchema
|
weComChannelSettingsInputSchema
|
||||||
} from '../shared/channel-settings-contracts'
|
} from '../shared/channel-settings-contracts'
|
||||||
import { applicationSettingsSchema } from '../shared/application-settings-contracts'
|
import { applicationSettingsUpdateSchema } from '../shared/application-settings-contracts'
|
||||||
import {
|
import {
|
||||||
speechModelActionInputSchema,
|
speechModelActionInputSchema,
|
||||||
speechModelSelectionInputSchema
|
speechModelSelectionInputSchema
|
||||||
@@ -834,6 +834,7 @@ export function registerIpcHandlers(
|
|||||||
channel: keyof typeof projectChannelLabels
|
channel: keyof typeof projectChannelLabels
|
||||||
channelLabel: string
|
channelLabel: string
|
||||||
senderDisplay: string
|
senderDisplay: string
|
||||||
|
projectId: string
|
||||||
projectName: string
|
projectName: string
|
||||||
rootPath: string
|
rootPath: string
|
||||||
conversationId: string
|
conversationId: string
|
||||||
@@ -1027,6 +1028,8 @@ export function registerIpcHandlers(
|
|||||||
publishRemoteActivity({
|
publishRemoteActivity({
|
||||||
requestId,
|
requestId,
|
||||||
conversationId: remoteContext.conversationId,
|
conversationId: remoteContext.conversationId,
|
||||||
|
projectId: remoteContext.projectId,
|
||||||
|
projectName: remoteContext.projectName,
|
||||||
channel: remoteContext.channel,
|
channel: remoteContext.channel,
|
||||||
kind: 'tool',
|
kind: 'tool',
|
||||||
callId: taskEvent.callId,
|
callId: taskEvent.callId,
|
||||||
@@ -1450,6 +1453,8 @@ export function registerIpcHandlers(
|
|||||||
publishRemoteActivity({
|
publishRemoteActivity({
|
||||||
requestId: remoteTaskId,
|
requestId: remoteTaskId,
|
||||||
conversationId: remoteConversation.id,
|
conversationId: remoteConversation.id,
|
||||||
|
projectId: project.id,
|
||||||
|
projectName: project.name,
|
||||||
channel,
|
channel,
|
||||||
kind: 'request',
|
kind: 'request',
|
||||||
title: `${channelLabel} · ${senderDisplay}`,
|
title: `${channelLabel} · ${senderDisplay}`,
|
||||||
@@ -1490,6 +1495,8 @@ export function registerIpcHandlers(
|
|||||||
publishRemoteActivity({
|
publishRemoteActivity({
|
||||||
requestId: remoteTaskId,
|
requestId: remoteTaskId,
|
||||||
conversationId: remoteConversation.id,
|
conversationId: remoteConversation.id,
|
||||||
|
projectId: project.id,
|
||||||
|
projectName: project.name,
|
||||||
channel,
|
channel,
|
||||||
kind: 'result',
|
kind: 'result',
|
||||||
title: `${channelLabel}远程执行不可用`,
|
title: `${channelLabel}远程执行不可用`,
|
||||||
@@ -1522,6 +1529,8 @@ export function registerIpcHandlers(
|
|||||||
publishRemoteActivity({
|
publishRemoteActivity({
|
||||||
requestId: remoteTaskId,
|
requestId: remoteTaskId,
|
||||||
conversationId: remoteConversation.id,
|
conversationId: remoteConversation.id,
|
||||||
|
projectId: project.id,
|
||||||
|
projectName: project.name,
|
||||||
channel,
|
channel,
|
||||||
kind: 'result',
|
kind: 'result',
|
||||||
title: `${channelLabel}远程执行不可用`,
|
title: `${channelLabel}远程执行不可用`,
|
||||||
@@ -1554,6 +1563,7 @@ export function registerIpcHandlers(
|
|||||||
channel,
|
channel,
|
||||||
channelLabel,
|
channelLabel,
|
||||||
senderDisplay,
|
senderDisplay,
|
||||||
|
projectId: project.id,
|
||||||
projectName: project.name,
|
projectName: project.name,
|
||||||
rootPath: project.rootPath,
|
rootPath: project.rootPath,
|
||||||
conversationId: remoteConversation.id,
|
conversationId: remoteConversation.id,
|
||||||
@@ -1604,6 +1614,8 @@ export function registerIpcHandlers(
|
|||||||
publishRemoteActivity({
|
publishRemoteActivity({
|
||||||
requestId: remoteTaskId,
|
requestId: remoteTaskId,
|
||||||
conversationId: remoteConversation.id,
|
conversationId: remoteConversation.id,
|
||||||
|
projectId: project.id,
|
||||||
|
projectName: project.name,
|
||||||
channel,
|
channel,
|
||||||
kind: 'result',
|
kind: 'result',
|
||||||
title:
|
title:
|
||||||
@@ -2409,7 +2421,7 @@ export function registerIpcHandlers(
|
|||||||
throw new Error('应用设置服务不可用')
|
throw new Error('应用设置服务不可用')
|
||||||
}
|
}
|
||||||
return applicationSettingsStore.update(
|
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({
|
const window = new BrowserWindow({
|
||||||
width: 1180,
|
width: 1180,
|
||||||
height: 760,
|
height: 760,
|
||||||
minWidth: 920,
|
minWidth: 680,
|
||||||
minHeight: 620,
|
minHeight: 560,
|
||||||
show: false,
|
show: false,
|
||||||
frame: false,
|
frame: false,
|
||||||
...(usableIcon ? { icon: usableIcon } : {}),
|
...(usableIcon ? { icon: usableIcon } : {}),
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import type {
|
|||||||
} from '../shared/channel-settings-contracts'
|
} from '../shared/channel-settings-contracts'
|
||||||
import type {
|
import type {
|
||||||
ApplicationSettings,
|
ApplicationSettings,
|
||||||
|
ApplicationSettingsUpdate,
|
||||||
VersionCheckResult
|
VersionCheckResult
|
||||||
} from '../shared/application-settings-contracts'
|
} from '../shared/application-settings-contracts'
|
||||||
import type {
|
import type {
|
||||||
@@ -294,7 +295,7 @@ const desktopApi: DesktopApi = {
|
|||||||
ipcRenderer.invoke(
|
ipcRenderer.invoke(
|
||||||
ipcChannels.applicationSettingsGet
|
ipcChannels.applicationSettingsGet
|
||||||
) as Promise<ApplicationSettings>,
|
) as Promise<ApplicationSettings>,
|
||||||
updateSettings: (input: ApplicationSettings) =>
|
updateSettings: (input: ApplicationSettingsUpdate) =>
|
||||||
ipcRenderer.invoke(
|
ipcRenderer.invoke(
|
||||||
ipcChannels.applicationSettingsUpdate,
|
ipcChannels.applicationSettingsUpdate,
|
||||||
input
|
input
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ function makeRecord(
|
|||||||
id: `activity-${index}`,
|
id: `activity-${index}`,
|
||||||
conversationId: `conversation-${index}`,
|
conversationId: `conversation-${index}`,
|
||||||
requestId: `request-${index}`,
|
requestId: `request-${index}`,
|
||||||
|
scope: { kind: 'global' },
|
||||||
kind: 'tool',
|
kind: 'tool',
|
||||||
title: `活动 ${index}`,
|
title: `活动 ${index}`,
|
||||||
detail: `详情 ${index}`,
|
detail: `详情 ${index}`,
|
||||||
@@ -150,6 +151,26 @@ describe('ActivityPanel', () => {
|
|||||||
).toBeDisabled()
|
).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', () => {
|
it('labels Subagent activity as child expert work', () => {
|
||||||
render(
|
render(
|
||||||
<ActivityPanel
|
<ActivityPanel
|
||||||
@@ -208,6 +229,32 @@ describe('ActivityPanel', () => {
|
|||||||
expect(groups[0]!.querySelectorAll('article')).toHaveLength(2)
|
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', () => {
|
it('uses the shared page hierarchy and explicit global scope', () => {
|
||||||
render(
|
render(
|
||||||
<ActivityPanel
|
<ActivityPanel
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import {
|
|||||||
DestructiveConfirmActions,
|
DestructiveConfirmActions,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
SegmentedControl
|
ScopeBadge,
|
||||||
|
SegmentedControl,
|
||||||
|
type WorkspaceScope
|
||||||
} from './WorkspacePrimitives'
|
} from './WorkspacePrimitives'
|
||||||
|
|
||||||
type ActivityFilter = 'all' | 'active' | 'failed'
|
type ActivityFilter = 'all' | 'active' | 'failed'
|
||||||
@@ -136,10 +138,26 @@ type ActivityGroup = {
|
|||||||
conversationId: string
|
conversationId: string
|
||||||
title: string
|
title: string
|
||||||
records: ActivityRecord[]
|
records: ActivityRecord[]
|
||||||
|
scope: ActivityRecord['scope']
|
||||||
latestAt: number
|
latestAt: number
|
||||||
status: ActivityRecord['status']
|
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(
|
function groupActivityRecords(
|
||||||
records: readonly ActivityRecord[],
|
records: readonly ActivityRecord[],
|
||||||
allRecords: readonly ActivityRecord[]
|
allRecords: readonly ActivityRecord[]
|
||||||
@@ -171,6 +189,7 @@ function groupActivityRecords(
|
|||||||
request?.title ??
|
request?.title ??
|
||||||
items[0]!.title,
|
items[0]!.title,
|
||||||
records: items,
|
records: items,
|
||||||
|
scope: items[0]!.scope,
|
||||||
latestAt: Math.max(...items.map((record) => record.createdAt)),
|
latestAt: Math.max(...items.map((record) => record.createdAt)),
|
||||||
status
|
status
|
||||||
}
|
}
|
||||||
@@ -361,6 +380,17 @@ export function ActivityPanel({
|
|||||||
|
|
||||||
{filteredRecords.length === 0 ? (
|
{filteredRecords.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
action={
|
||||||
|
filter === 'all' ? undefined : (
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => setFilter('all')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
清除筛选
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
description={emptyMessage(filter)}
|
description={emptyMessage(filter)}
|
||||||
icon={<Activity size={24} />}
|
icon={<Activity size={24} />}
|
||||||
level="section"
|
level="section"
|
||||||
@@ -379,6 +409,9 @@ export function ActivityPanel({
|
|||||||
<span>
|
<span>
|
||||||
<strong>对话:{group.title}</strong>
|
<strong>对话:{group.title}</strong>
|
||||||
<small>{group.records.length} 条活动</small>
|
<small>{group.records.length} 条活动</small>
|
||||||
|
<ScopeBadge
|
||||||
|
scope={activityWorkspaceScope(group.scope)}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`status-badge activity-item__status activity-item__status--${group.status}`}
|
className={`status-badge activity-item__status activity-item__status--${group.status}`}
|
||||||
@@ -413,6 +446,9 @@ export function ActivityPanel({
|
|||||||
{time.display}
|
{time.display}
|
||||||
</time>
|
</time>
|
||||||
</header>
|
</header>
|
||||||
|
<ScopeBadge
|
||||||
|
scope={activityWorkspaceScope(record.scope)}
|
||||||
|
/>
|
||||||
<h3>{record.title}</h3>
|
<h3>{record.title}</h3>
|
||||||
{record.detail.length > 0 && <p>{record.detail}</p>}
|
{record.detail.length > 0 && <p>{record.detail}</p>}
|
||||||
<button
|
<button
|
||||||
|
|||||||
+511
-17
@@ -26,6 +26,7 @@ vi.mock('./speech-recognition', async (importOriginal) => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
import { loadActivityRecords } from './activity-store'
|
||||||
|
|
||||||
let agentListener: ((event: AgentEvent) => void) | undefined
|
let agentListener: ((event: AgentEvent) => void) | undefined
|
||||||
let browserListener: ((state: BrowserLiveState) => void) | undefined
|
let browserListener: ((state: BrowserLiveState) => void) | undefined
|
||||||
@@ -610,9 +611,13 @@ describe('App', () => {
|
|||||||
}))
|
}))
|
||||||
api.updates = {
|
api.updates = {
|
||||||
getSettings: vi.fn(async () => ({
|
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,
|
check,
|
||||||
openReleasePage: vi.fn(async () => {}),
|
openReleasePage: vi.fn(async () => {}),
|
||||||
onResult: vi.fn(() => () => {})
|
onResult: vi.fn(() => () => {})
|
||||||
@@ -651,9 +656,13 @@ describe('App', () => {
|
|||||||
.mockRejectedValueOnce(new Error('offline'))
|
.mockRejectedValueOnce(new Error('offline'))
|
||||||
api.updates = {
|
api.updates = {
|
||||||
getSettings: vi.fn(async () => ({
|
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,
|
check,
|
||||||
openReleasePage: vi.fn(async () => {}),
|
openReleasePage: vi.fn(async () => {}),
|
||||||
onResult: vi.fn(() => () => {})
|
onResult: vi.fn(() => () => {})
|
||||||
@@ -847,6 +856,114 @@ describe('App', () => {
|
|||||||
expect(await screen.findByRole('status')).toBeVisible()
|
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 () => {
|
it('renders streamed reasoning, text, and tools in event order', async () => {
|
||||||
render(<App />)
|
render(<App />)
|
||||||
|
|
||||||
@@ -1426,7 +1543,9 @@ describe('App', () => {
|
|||||||
|
|
||||||
expect(composer).toHaveValue('尚未发送的草稿')
|
expect(composer).toHaveValue('尚未发送的草稿')
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByRole('button', { name: '删除对话 新对话' })
|
screen.getAllByRole('button', {
|
||||||
|
name: '更多会话操作 新对话'
|
||||||
|
})
|
||||||
).toHaveLength(1)
|
).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1448,7 +1567,9 @@ describe('App', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByRole('button', { name: /^删除对话/u })
|
screen.getAllByRole('button', {
|
||||||
|
name: /^更多会话操作/u
|
||||||
|
})
|
||||||
).toHaveLength(2)
|
).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -3478,7 +3599,7 @@ describe('App', () => {
|
|||||||
await screen.findByRole('heading', { name: '智能心跳' })
|
await screen.findByRole('heading', { name: '智能心跳' })
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('tab', { name: '成长概览' })
|
await screen.findByRole('tab', { name: '成长概览' })
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('button', { name: '配置智能心跳' })
|
screen.getByRole('button', { name: '配置智能心跳' })
|
||||||
@@ -3488,27 +3609,400 @@ describe('App', () => {
|
|||||||
).not.toBeInTheDocument()
|
).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 />)
|
render(<App />)
|
||||||
await screen.findByText('项目:默认项目')
|
|
||||||
|
|
||||||
fireEvent.click(
|
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(
|
expect(
|
||||||
await screen.findByRole('heading', { name: '魔法笔记' })
|
await screen.findByText('智能心跳加载失败')
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
expect(screen.getByText('项目:默认项目')).toBeInTheDocument()
|
expect(screen.queryAllByText('旧项目心跳')).toHaveLength(0)
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('button', { name: '新建笔记' })
|
screen.queryByRole('button', { name: '立即运行旧项目心跳' })
|
||||||
).toBeInTheDocument()
|
|
||||||
expect(api.magicNotes.list).toHaveBeenCalled()
|
|
||||||
expect(
|
|
||||||
screen.queryByLabelText('切换助手工作栏')
|
|
||||||
).not.toBeInTheDocument()
|
).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 () => {
|
it('gives the knowledge workspace the full content width', async () => {
|
||||||
render(<App />)
|
render(<App />)
|
||||||
|
|
||||||
|
|||||||
+335
-84
@@ -104,6 +104,7 @@ import { HeartbeatCenter } from './HeartbeatCenter'
|
|||||||
import { MagicNotesWorkspace } from './MagicNotesWorkspace'
|
import { MagicNotesWorkspace } from './MagicNotesWorkspace'
|
||||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||||
import {
|
import {
|
||||||
|
DestructiveConfirmActions,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
PageShell,
|
PageShell,
|
||||||
ScopeBadge
|
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 {
|
function isUnusedConversation(conversation: Conversation): boolean {
|
||||||
return (
|
return (
|
||||||
conversation.title === '新对话' &&
|
conversation.title === '新对话' &&
|
||||||
@@ -1030,6 +1044,7 @@ function App(): React.JSX.Element {
|
|||||||
useState(false)
|
useState(false)
|
||||||
const migrationConversations = useRef(conversations)
|
const migrationConversations = useRef(conversations)
|
||||||
const [projects, setProjects] = useState<AssistantProject[]>([])
|
const [projects, setProjects] = useState<AssistantProject[]>([])
|
||||||
|
const projectsRef = useRef(projects)
|
||||||
const [assistantTasks, setAssistantTasks] = useState<AssistantTask[]>([])
|
const [assistantTasks, setAssistantTasks] = useState<AssistantTask[]>([])
|
||||||
const [tokenUsage, setTokenUsage] =
|
const [tokenUsage, setTokenUsage] =
|
||||||
useState<TokenUsageSummary>(emptyTokenUsage)
|
useState<TokenUsageSummary>(emptyTokenUsage)
|
||||||
@@ -1060,6 +1075,8 @@ function App(): React.JSX.Element {
|
|||||||
const [heartbeatRuns, setHeartbeatRuns] = useState<
|
const [heartbeatRuns, setHeartbeatRuns] = useState<
|
||||||
AssistantHeartbeatRun[]
|
AssistantHeartbeatRun[]
|
||||||
>([])
|
>([])
|
||||||
|
const [heartbeatLoading, setHeartbeatLoading] = useState(true)
|
||||||
|
const [heartbeatLoadError, setHeartbeatLoadError] = useState<string>()
|
||||||
const [assistantExperts, setAssistantExperts] = useState<
|
const [assistantExperts, setAssistantExperts] = useState<
|
||||||
AssistantExpert[]
|
AssistantExpert[]
|
||||||
>([])
|
>([])
|
||||||
@@ -1110,7 +1127,12 @@ function App(): React.JSX.Element {
|
|||||||
? 'ask'
|
? 'ask'
|
||||||
: workMode
|
: workMode
|
||||||
const [appInfo, setAppInfo] = useState<AppInfo>()
|
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(
|
const [assistantSidebarOpen, setAssistantSidebarOpen] = useState(
|
||||||
() => window.innerWidth >= 1280
|
() => window.innerWidth >= 1280
|
||||||
)
|
)
|
||||||
@@ -1120,8 +1142,13 @@ function App(): React.JSX.Element {
|
|||||||
Record<string, BrowserLiveState>
|
Record<string, BrowserLiveState>
|
||||||
>({})
|
>({})
|
||||||
const [view, setView] = useState<WorkspaceView>('chat')
|
const [view, setView] = useState<WorkspaceView>('chat')
|
||||||
|
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [conversationActionsId, setConversationActionsId] = useState('')
|
const [conversationActionsId, setConversationActionsId] = useState('')
|
||||||
|
const [confirmingConversationId, setConfirmingConversationId] =
|
||||||
|
useState('')
|
||||||
|
const [deletingConversationId, setDeletingConversationId] =
|
||||||
|
useState('')
|
||||||
const [renamingConversationId, setRenamingConversationId] = useState('')
|
const [renamingConversationId, setRenamingConversationId] = useState('')
|
||||||
const [notifications, notify] = useReducer(
|
const [notifications, notify] = useReducer(
|
||||||
appNotificationReducer,
|
appNotificationReducer,
|
||||||
@@ -1169,6 +1196,11 @@ function App(): React.JSX.Element {
|
|||||||
evidence: []
|
evidence: []
|
||||||
})
|
})
|
||||||
const [knowledgeLoading, setKnowledgeLoading] = useState(true)
|
const [knowledgeLoading, setKnowledgeLoading] = useState(true)
|
||||||
|
const [knowledgeLoadError, setKnowledgeLoadError] = useState<string>()
|
||||||
|
const knowledgeLoadRequestRef = useRef(0)
|
||||||
|
const failedKnowledgeLibraryIdRef = useRef<string | undefined>(
|
||||||
|
undefined
|
||||||
|
)
|
||||||
const [enabledKnowledgeLibraryIds, setEnabledKnowledgeLibraryIds] = useState<
|
const [enabledKnowledgeLibraryIds, setEnabledKnowledgeLibraryIds] = useState<
|
||||||
string[]
|
string[]
|
||||||
>([])
|
>([])
|
||||||
@@ -1182,20 +1214,67 @@ function App(): React.JSX.Element {
|
|||||||
const knowledgeScopeInitialized = useRef(false)
|
const knowledgeScopeInitialized = useRef(false)
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const sidebarRef = useRef<HTMLElement>(null)
|
||||||
|
const sidebarToggleRef = useRef<HTMLButtonElement>(null)
|
||||||
const topbarMenuRef = useRef<HTMLDivElement>(null)
|
const topbarMenuRef = useRef<HTMLDivElement>(null)
|
||||||
const topbarMenuTriggerRef = useRef<HTMLButtonElement>(null)
|
const topbarMenuTriggerRef = useRef<HTMLButtonElement>(null)
|
||||||
const conversationActionTriggerRefs = useRef(
|
const conversationActionTriggerRefs = useRef(
|
||||||
new Map<string, HTMLButtonElement>()
|
new Map<string, HTMLButtonElement>()
|
||||||
)
|
)
|
||||||
|
const closeNarrowSidebar = useCallback((): void => {
|
||||||
|
setSidebarOpen(false)
|
||||||
|
requestAnimationFrame(() => sidebarToggleRef.current?.focus())
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeConversationIdRef.current = activeId
|
activeConversationIdRef.current = activeId
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
conversationsRef.current = conversations
|
conversationsRef.current = conversations
|
||||||
}, [conversations])
|
}, [conversations])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
projectsRef.current = projects
|
||||||
|
}, [projects])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resizeComposerTextarea(inputRef.current)
|
resizeComposerTextarea(inputRef.current)
|
||||||
}, [input])
|
}, [input])
|
||||||
@@ -1272,6 +1351,10 @@ function App(): React.JSX.Element {
|
|||||||
void updates
|
void updates
|
||||||
.getSettings()
|
.getSettings()
|
||||||
.then(async (settings) => {
|
.then(async (settings) => {
|
||||||
|
setMagicNotesEnabled(settings.magicNotesEnabled)
|
||||||
|
if (!settings.magicNotesEnabled) {
|
||||||
|
setView('chat')
|
||||||
|
}
|
||||||
if (!settings.checkUpdatesOnStartup) {
|
if (!settings.checkUpdatesOnStartup) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1712,10 +1795,35 @@ function App(): React.JSX.Element {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const recordActivity = useCallback(
|
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) =>
|
setActivityRecords((current) =>
|
||||||
upsertActivityRecord(current, {
|
upsertActivityRecord(current, {
|
||||||
...record,
|
...record,
|
||||||
|
scope,
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
createdAt: Date.now()
|
createdAt: Date.now()
|
||||||
})
|
})
|
||||||
@@ -1758,39 +1866,76 @@ function App(): React.JSX.Element {
|
|||||||
activity.detail
|
activity.detail
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
recordActivity({
|
recordActivity(
|
||||||
requestId: activity.requestId,
|
{
|
||||||
conversationId: activity.conversationId,
|
requestId: activity.requestId,
|
||||||
callId: activity.callId,
|
conversationId: activity.conversationId,
|
||||||
kind: activity.kind,
|
callId: activity.callId,
|
||||||
title: activity.title,
|
kind: activity.kind,
|
||||||
detail: activity.detail,
|
title: activity.title,
|
||||||
status: activity.status
|
detail: activity.detail,
|
||||||
})
|
status: activity.status
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'project',
|
||||||
|
projectId: activity.projectId.slice(0, 256),
|
||||||
|
projectName: activity.projectName.slice(0, 120)
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}, [recordActivity, updateRequestActivity])
|
}, [recordActivity, updateRequestActivity])
|
||||||
|
|
||||||
const refreshKnowledge = useCallback(
|
const refreshKnowledge = useCallback(
|
||||||
async (libraryId?: string): Promise<KnowledgeSnapshot> => {
|
async (libraryId?: string): Promise<KnowledgeSnapshot> => {
|
||||||
const snapshot = await window.goodbuddy.knowledge.getSnapshot(libraryId)
|
const requestId = ++knowledgeLoadRequestRef.current
|
||||||
setKnowledgeSnapshot(snapshot)
|
try {
|
||||||
if (!knowledgeScopeInitialized.current) {
|
const snapshot =
|
||||||
knowledgeScopeInitialized.current = true
|
await window.goodbuddy.knowledge.getSnapshot(libraryId)
|
||||||
setEnabledKnowledgeLibraryIds(
|
if (requestId !== knowledgeLoadRequestRef.current) {
|
||||||
snapshot.libraries.map((library) => library.id)
|
return snapshot
|
||||||
)
|
}
|
||||||
} else {
|
failedKnowledgeLibraryIdRef.current = undefined
|
||||||
setEnabledKnowledgeLibraryIds((current) =>
|
setKnowledgeSnapshot(snapshot)
|
||||||
current.filter((id) =>
|
setKnowledgeLoadError(undefined)
|
||||||
snapshot.libraries.some((library) => library.id === id)
|
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(
|
const switchRuntime = useCallback(
|
||||||
async (selection: AgentRuntimeSelection): Promise<void> => {
|
async (selection: AgentRuntimeSelection): Promise<void> => {
|
||||||
if (!runtimeSettings || !activeConversation || runtimeSwitching) {
|
if (!runtimeSettings || !activeConversation || runtimeSwitching) {
|
||||||
@@ -2610,19 +2755,41 @@ function App(): React.JSX.Element {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const requestId = ++heartbeatLoadRequestRef.current
|
const requestId = ++heartbeatLoadRequestRef.current
|
||||||
void loadHeartbeats()
|
const timeout = setTimeout(() => {
|
||||||
.then((result) => {
|
if (requestId !== heartbeatLoadRequestRef.current) {
|
||||||
if (requestId !== heartbeatLoadRequestRef.current) {
|
return
|
||||||
return
|
}
|
||||||
}
|
setHeartbeatLoading(true)
|
||||||
setAssistantHeartbeats(result.configs)
|
setHeartbeatLoadError(undefined)
|
||||||
setHeartbeatRuns(result.runs)
|
setAssistantHeartbeats([])
|
||||||
setHeartbeatEntries(result.entries)
|
setHeartbeatRuns([])
|
||||||
})
|
setHeartbeatEntries([])
|
||||||
.catch(() =>
|
void loadHeartbeats()
|
||||||
notify({ tone: 'error', message: '智能心跳读取失败' })
|
.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 () => {
|
return () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
if (requestId === heartbeatLoadRequestRef.current) {
|
if (requestId === heartbeatLoadRequestRef.current) {
|
||||||
heartbeatLoadRequestRef.current += 1
|
heartbeatLoadRequestRef.current += 1
|
||||||
}
|
}
|
||||||
@@ -2647,6 +2814,21 @@ function App(): React.JSX.Element {
|
|||||||
)
|
)
|
||||||
}, [activeProjectId, refreshHeartbeats])
|
}, [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(
|
const createHeartbeat = useCallback(
|
||||||
async (input: HeartbeatCreateInput): Promise<void> => {
|
async (input: HeartbeatCreateInput): Promise<void> => {
|
||||||
const projectId = activeProjectId
|
const projectId = activeProjectId
|
||||||
@@ -2706,8 +2888,11 @@ function App(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
refreshing = true
|
refreshing = true
|
||||||
void refreshHeartbeatCenter()
|
void refreshHeartbeatCenter()
|
||||||
.catch(() =>
|
.then(() => setHeartbeatLoadError(undefined))
|
||||||
notify({ tone: 'error', message: '智能心跳刷新失败' })
|
.catch((reason: unknown) =>
|
||||||
|
setHeartbeatLoadError(
|
||||||
|
displayErrorMessage(reason, '智能心跳刷新失败')
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
refreshing = false
|
refreshing = false
|
||||||
@@ -2806,14 +2991,8 @@ function App(): React.JSX.Element {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
void refreshKnowledge()
|
void refreshKnowledge()
|
||||||
.catch((reason: unknown) => {
|
.catch(() => {
|
||||||
notify({
|
// refreshKnowledge exposes a recoverable page-local error.
|
||||||
tone: 'error',
|
|
||||||
message:
|
|
||||||
reason instanceof Error
|
|
||||||
? reason.message
|
|
||||||
: '本地知识库读取失败'
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.finally(() => setKnowledgeLoading(false))
|
.finally(() => setKnowledgeLoading(false))
|
||||||
}, 0)
|
}, 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) {
|
if (conversationActionsId === conversationId) {
|
||||||
setConversationActionsId('')
|
setConversationActionsId('')
|
||||||
}
|
}
|
||||||
if (renamingConversationId === conversationId) {
|
if (renamingConversationId === conversationId) {
|
||||||
setRenamingConversationId('')
|
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)
|
const browserStop = window.goodbuddy.browser?.stop(conversationId)
|
||||||
if (browserStop) {
|
if (browserStop) {
|
||||||
void browserStop.catch(() => {
|
void browserStop.catch(() => {
|
||||||
@@ -3972,7 +4170,15 @@ function App(): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<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">
|
||||||
<div className="brand__mark">
|
<div className="brand__mark">
|
||||||
<img
|
<img
|
||||||
@@ -4028,28 +4234,33 @@ function App(): React.JSX.Element {
|
|||||||
|
|
||||||
<nav className="primary-nav" aria-label="主导航">
|
<nav className="primary-nav" aria-label="主导航">
|
||||||
<button
|
<button
|
||||||
|
aria-current={view === 'chat' ? 'page' : undefined}
|
||||||
className={
|
className={
|
||||||
view === 'chat' ? 'nav-item nav-item--active' : 'nav-item'
|
view === 'chat' ? 'nav-item nav-item--active' : 'nav-item'
|
||||||
}
|
}
|
||||||
onClick={() => setView('chat')}
|
onClick={() => setView('chat')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<MessageSquare size={17} />
|
<MessageSquare aria-hidden="true" size={17} />
|
||||||
<span>对话</span>
|
<span>对话</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
className={
|
aria-current={view === 'knowledge' ? 'page' : undefined}
|
||||||
view === 'magic-notes'
|
|
||||||
? 'nav-item nav-item--active'
|
|
||||||
: 'nav-item'
|
|
||||||
}
|
|
||||||
onClick={() => setView('magic-notes')}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Sparkles size={17} />
|
|
||||||
<span>魔法笔记</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={
|
className={
|
||||||
view === 'knowledge'
|
view === 'knowledge'
|
||||||
? 'nav-item nav-item--active'
|
? 'nav-item nav-item--active'
|
||||||
@@ -4058,10 +4269,11 @@ function App(): React.JSX.Element {
|
|||||||
onClick={() => setView('knowledge')}
|
onClick={() => setView('knowledge')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Library size={17} />
|
<Library aria-hidden="true" size={17} />
|
||||||
<span>知识库</span>
|
<span>知识库</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-current={view === 'heartbeat' ? 'page' : undefined}
|
||||||
className={
|
className={
|
||||||
view === 'heartbeat'
|
view === 'heartbeat'
|
||||||
? 'nav-item nav-item--active'
|
? 'nav-item nav-item--active'
|
||||||
@@ -4070,7 +4282,7 @@ function App(): React.JSX.Element {
|
|||||||
onClick={() => setView('heartbeat')}
|
onClick={() => setView('heartbeat')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<HeartPulse size={17} />
|
<HeartPulse aria-hidden="true" size={17} />
|
||||||
<span>智能心跳</span>
|
<span>智能心跳</span>
|
||||||
{pendingHeartbeatSuggestionCount > 0 && (
|
{pendingHeartbeatSuggestionCount > 0 && (
|
||||||
<span
|
<span
|
||||||
@@ -4082,6 +4294,7 @@ function App(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-current={view === 'activity' ? 'page' : undefined}
|
||||||
className={
|
className={
|
||||||
view === 'activity'
|
view === 'activity'
|
||||||
? 'nav-item nav-item--active'
|
? 'nav-item nav-item--active'
|
||||||
@@ -4090,7 +4303,7 @@ function App(): React.JSX.Element {
|
|||||||
onClick={() => setView('activity')}
|
onClick={() => setView('activity')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<TerminalSquare size={17} />
|
<TerminalSquare aria-hidden="true" size={17} />
|
||||||
<span>任务与活动</span>
|
<span>任务与活动</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -4157,6 +4370,7 @@ function App(): React.JSX.Element {
|
|||||||
className="conversation-more"
|
className="conversation-more"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRenamingConversationId('')
|
setRenamingConversationId('')
|
||||||
|
setConfirmingConversationId('')
|
||||||
setConversationActionsId((current) =>
|
setConversationActionsId((current) =>
|
||||||
current === conversation.id ? '' : conversation.id
|
current === conversation.id ? '' : conversation.id
|
||||||
)
|
)
|
||||||
@@ -4177,16 +4391,6 @@ function App(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
<MoreHorizontal size={14} />
|
<MoreHorizontal size={14} />
|
||||||
</button>
|
</button>
|
||||||
{!conversation.remote && (
|
|
||||||
<button
|
|
||||||
aria-label={`删除对话 ${conversation.title}`}
|
|
||||||
className="conversation-delete"
|
|
||||||
onClick={() => deleteConversation(conversation.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{conversationActionsId === conversation.id && (
|
{conversationActionsId === conversation.id && (
|
||||||
<div
|
<div
|
||||||
@@ -4229,6 +4433,30 @@ function App(): React.JSX.Element {
|
|||||||
<Download size={14} />
|
<Download size={14} />
|
||||||
导出 Markdown
|
导出 Markdown
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!conversation.remote &&
|
{!conversation.remote &&
|
||||||
@@ -4304,16 +4532,29 @@ function App(): React.JSX.Element {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</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">
|
<header className="topbar">
|
||||||
<button
|
<button
|
||||||
className="icon-button sidebar-toggle"
|
className="icon-button sidebar-toggle"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="切换侧栏"
|
aria-label="切换侧栏"
|
||||||
onClick={() => setSidebarOpen((open) => !open)}
|
onClick={() => setSidebarOpen((open) => !open)}
|
||||||
|
ref={sidebarToggleRef}
|
||||||
>
|
>
|
||||||
<PanelLeft size={18} />
|
<PanelLeft aria-hidden="true" size={18} />
|
||||||
</button>
|
</button>
|
||||||
{view === 'chat' && (
|
{view === 'chat' && (
|
||||||
<>
|
<>
|
||||||
@@ -5496,7 +5737,7 @@ function App(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
) : view === 'magic-notes' ? (
|
) : view === 'magic-notes' && magicNotesEnabled ? (
|
||||||
<PageShell variant="master-detail">
|
<PageShell variant="master-detail">
|
||||||
<MagicNotesWorkspace
|
<MagicNotesWorkspace
|
||||||
key={activeProject?.id ?? 'global'}
|
key={activeProject?.id ?? 'global'}
|
||||||
@@ -5513,6 +5754,7 @@ function App(): React.JSX.Element {
|
|||||||
graphNodes={knowledgeSnapshot.graphNodes}
|
graphNodes={knowledgeSnapshot.graphNodes}
|
||||||
graphRelations={knowledgeSnapshot.graphRelations}
|
graphRelations={knowledgeSnapshot.graphRelations}
|
||||||
libraries={knowledgeSnapshot.libraries}
|
libraries={knowledgeSnapshot.libraries}
|
||||||
|
loadError={knowledgeLoadError}
|
||||||
loading={knowledgeLoading}
|
loading={knowledgeLoading}
|
||||||
onCreateLibrary={createKnowledgeLibrary}
|
onCreateLibrary={createKnowledgeLibrary}
|
||||||
onCreateEntity={async (input) => {
|
onCreateEntity={async (input) => {
|
||||||
@@ -5629,8 +5871,11 @@ function App(): React.JSX.Element {
|
|||||||
window.goodbuddy.knowledge.retrySource(sourceId)
|
window.goodbuddy.knowledge.retrySource(sourceId)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
onRetryLoad={retryKnowledgeLoad}
|
||||||
onSelectLibrary={(libraryId) => {
|
onSelectLibrary={(libraryId) => {
|
||||||
void refreshKnowledge(libraryId)
|
void refreshKnowledge(libraryId).catch(() => {
|
||||||
|
// KnowledgeWorkspace renders the recoverable load error.
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
onSyncSource={(sourceId) =>
|
onSyncSource={(sourceId) =>
|
||||||
runKnowledgeSourceAction(() =>
|
runKnowledgeSourceAction(() =>
|
||||||
@@ -5660,9 +5905,12 @@ function App(): React.JSX.Element {
|
|||||||
configs={assistantHeartbeats}
|
configs={assistantHeartbeats}
|
||||||
currentProjectName={activeProject?.name}
|
currentProjectName={activeProject?.name}
|
||||||
entries={heartbeatEntries}
|
entries={heartbeatEntries}
|
||||||
|
loadError={heartbeatLoadError}
|
||||||
|
loading={heartbeatLoading}
|
||||||
memories={assistantMemories}
|
memories={assistantMemories}
|
||||||
onCreate={createHeartbeat}
|
onCreate={createHeartbeat}
|
||||||
onRefresh={refreshHeartbeatCenter}
|
onRefresh={retryHeartbeatLoad}
|
||||||
|
onRetryLoad={retryHeartbeatLoad}
|
||||||
onRemove={removeHeartbeat}
|
onRemove={removeHeartbeat}
|
||||||
onRunNow={runHeartbeat}
|
onRunNow={runHeartbeat}
|
||||||
onSetMemoryStatus={setMemoryStatus}
|
onSetMemoryStatus={setMemoryStatus}
|
||||||
@@ -5694,6 +5942,9 @@ function App(): React.JSX.Element {
|
|||||||
setSelectedExpertId('')
|
setSelectedExpertId('')
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onMagicNotesEnabledChange={(enabled) => {
|
||||||
|
setMagicNotesEnabled(enabled)
|
||||||
|
}}
|
||||||
onRemoveHeartbeat={removeHeartbeat}
|
onRemoveHeartbeat={removeHeartbeat}
|
||||||
onRunHeartbeat={runHeartbeat}
|
onRunHeartbeat={runHeartbeat}
|
||||||
onNotify={notify}
|
onNotify={notify}
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ function createProps(
|
|||||||
onRemove: vi.fn(async () => {}),
|
onRemove: vi.fn(async () => {}),
|
||||||
onRunNow: vi.fn(async () => {}),
|
onRunNow: vi.fn(async () => {}),
|
||||||
onRefresh: vi.fn(async () => {}),
|
onRefresh: vi.fn(async () => {}),
|
||||||
|
onRetryLoad: vi.fn(async () => {}),
|
||||||
onSetMemoryStatus: vi.fn(async () => {}),
|
onSetMemoryStatus: vi.fn(async () => {}),
|
||||||
onSetTaskStatus: vi.fn(async () => {}),
|
onSetTaskStatus: vi.fn(async () => {}),
|
||||||
onUseFollowUpTask: vi.fn(),
|
onUseFollowUpTask: vi.fn(),
|
||||||
@@ -264,4 +265,56 @@ describe('HeartbeatCenter', () => {
|
|||||||
screen.getByRole('button', { name: '启用智能心跳' })
|
screen.getByRole('button', { name: '启用智能心跳' })
|
||||||
).toBeInTheDocument()
|
).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>
|
) => Promise<void>
|
||||||
onUseFollowUpTask: (task: AssistantTask) => void
|
onUseFollowUpTask: (task: AssistantTask) => void
|
||||||
currentProjectName?: string
|
currentProjectName?: string
|
||||||
|
loading?: boolean
|
||||||
|
loadError?: string
|
||||||
|
onRetryLoad: () => void | Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
|
const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||||
@@ -143,7 +146,10 @@ export function HeartbeatCenter({
|
|||||||
onSetMemoryStatus,
|
onSetMemoryStatus,
|
||||||
onSetTaskStatus,
|
onSetTaskStatus,
|
||||||
onUseFollowUpTask,
|
onUseFollowUpTask,
|
||||||
currentProjectName = '当前项目'
|
currentProjectName = '当前项目',
|
||||||
|
loading = false,
|
||||||
|
loadError,
|
||||||
|
onRetryLoad
|
||||||
}: HeartbeatCenterProps): React.JSX.Element {
|
}: HeartbeatCenterProps): React.JSX.Element {
|
||||||
const [tab, setTab] = useState<HeartbeatCenterTab>('overview')
|
const [tab, setTab] = useState<HeartbeatCenterTab>('overview')
|
||||||
const [pendingAction, setPendingAction] = useState<string>()
|
const [pendingAction, setPendingAction] = useState<string>()
|
||||||
@@ -226,6 +232,10 @@ export function HeartbeatCenter({
|
|||||||
entry.followUpTaskIds.length
|
entry.followUpTaskIds.length
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
const hasHeartbeatData =
|
||||||
|
configs.length > 0 || runs.length > 0 || entries.length > 0
|
||||||
|
const initialLoadBlocked =
|
||||||
|
!hasHeartbeatData && (loading || loadError !== undefined)
|
||||||
|
|
||||||
const runAction = async (
|
const runAction = async (
|
||||||
actionId: string,
|
actionId: string,
|
||||||
@@ -269,11 +279,12 @@ export function HeartbeatCenter({
|
|||||||
>
|
>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
actions={
|
actions={
|
||||||
<>
|
initialLoadBlocked ? undefined : (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
aria-label="刷新智能心跳"
|
aria-label="刷新智能心跳"
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
disabled={pendingAction !== undefined}
|
disabled={loading || pendingAction !== undefined}
|
||||||
onClick={() => void runAction('refresh', onRefresh)}
|
onClick={() => void runAction('refresh', onRefresh)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -283,7 +294,7 @@ export function HeartbeatCenter({
|
|||||||
{primaryConfig ? (
|
{primaryConfig ? (
|
||||||
<button
|
<button
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
disabled={pendingAction !== undefined}
|
disabled={loading || pendingAction !== undefined}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void runAction(`run:${primaryConfig.id}`, () =>
|
void runAction(`run:${primaryConfig.id}`, () =>
|
||||||
onRunNow(primaryConfig.id)
|
onRunNow(primaryConfig.id)
|
||||||
@@ -299,13 +310,15 @@ export function HeartbeatCenter({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
|
disabled={loading}
|
||||||
onClick={() => setTab('plans')}
|
onClick={() => setTab('plans')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
配置智能心跳
|
配置智能心跳
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
description="定期回顾经历、沉淀记忆、发现问题,并把每次变化转化为可处理的成长建议。"
|
description="定期回顾经历、沉淀记忆、发现问题,并把每次变化转化为可处理的成长建议。"
|
||||||
eyebrow="SMART HEARTBEAT"
|
eyebrow="SMART HEARTBEAT"
|
||||||
@@ -321,6 +334,49 @@ export function HeartbeatCenter({
|
|||||||
</p>
|
</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
|
<PageTabs
|
||||||
ariaLabel="智能心跳视图"
|
ariaLabel="智能心跳视图"
|
||||||
idPrefix="heartbeat"
|
idPrefix="heartbeat"
|
||||||
@@ -1034,6 +1090,8 @@ export function HeartbeatCenter({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
fireEvent,
|
fireEvent,
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
waitFor
|
waitFor,
|
||||||
|
within
|
||||||
} from '@testing-library/react'
|
} from '@testing-library/react'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
@@ -143,6 +144,7 @@ function createProps(
|
|||||||
onCreateRelation: vi.fn(),
|
onCreateRelation: vi.fn(),
|
||||||
onUpdateRelation: vi.fn(),
|
onUpdateRelation: vi.fn(),
|
||||||
onDeleteRelation: vi.fn(),
|
onDeleteRelation: vi.fn(),
|
||||||
|
onRetryLoad: vi.fn(),
|
||||||
...overrides
|
...overrides
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,10 +285,11 @@ describe('KnowledgeWorkspace', () => {
|
|||||||
'knowledge-workspace__sidebar'
|
'knowledge-workspace__sidebar'
|
||||||
)
|
)
|
||||||
expect(workspace.querySelector('aside')).not.toHaveAttribute('style')
|
expect(workspace.querySelector('aside')).not.toHaveAttribute('style')
|
||||||
expect(workspace.querySelector('main')).toHaveClass(
|
const detailRegion = within(workspace).getByRole('region', {
|
||||||
'knowledge-workspace__main'
|
name: '知识库详情'
|
||||||
)
|
})
|
||||||
expect(workspace.querySelector('main')).toHaveStyle({
|
expect(detailRegion).toHaveClass('knowledge-workspace__main')
|
||||||
|
expect(detailRegion).toHaveStyle({
|
||||||
background: 'var(--surface-raised)'
|
background: 'var(--surface-raised)'
|
||||||
})
|
})
|
||||||
expect(screen.getByText('全局')).toHaveClass('scope-badge')
|
expect(screen.getByText('全局')).toHaveClass('scope-badge')
|
||||||
@@ -308,6 +311,9 @@ describe('KnowledgeWorkspace', () => {
|
|||||||
expect(screen.getByLabelText('搜索文档').closest('label')).toHaveClass(
|
expect(screen.getByLabelText('搜索文档').closest('label')).toHaveClass(
|
||||||
'knowledge-documents__search'
|
'knowledge-documents__search'
|
||||||
)
|
)
|
||||||
|
expect(screen.getByLabelText('搜索文档')).not.toHaveStyle({
|
||||||
|
outline: 'none'
|
||||||
|
})
|
||||||
expect(screen.getByText('本地文件 · 架构说明.md')).toBeInTheDocument()
|
expect(screen.getByText('本地文件 · 架构说明.md')).toBeInTheDocument()
|
||||||
expect(screen.queryByText('D:\\Private\\架构说明.md')).not
|
expect(screen.queryByText('D:\\Private\\架构说明.md')).not
|
||||||
.toBeInTheDocument()
|
.toBeInTheDocument()
|
||||||
@@ -609,6 +615,44 @@ describe('KnowledgeWorkspace', () => {
|
|||||||
).toBeDisabled()
|
).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 () => {
|
it('confirms that deleting a managed library removes managed copies', async () => {
|
||||||
const onDeleteLibrary = vi.fn()
|
const onDeleteLibrary = vi.fn()
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ export type KnowledgeWorkspaceProps = {
|
|||||||
graphRelations: readonly KnowledgeGraphRelation[]
|
graphRelations: readonly KnowledgeGraphRelation[]
|
||||||
evidence: readonly KnowledgeEvidence[]
|
evidence: readonly KnowledgeEvidence[]
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
loadError?: string
|
||||||
|
onRetryLoad: () => void | Promise<void>
|
||||||
onSelectLibrary: (libraryId: string) => void
|
onSelectLibrary: (libraryId: string) => void
|
||||||
onCreateLibrary: (
|
onCreateLibrary: (
|
||||||
input: CreateKnowledgeLibraryInput
|
input: CreateKnowledgeLibraryInput
|
||||||
@@ -274,7 +276,6 @@ const styles = {
|
|||||||
padding: 'var(--space-2) var(--space-3)',
|
padding: 'var(--space-2) var(--space-3)',
|
||||||
border: '1px solid var(--border-control)',
|
border: '1px solid var(--border-control)',
|
||||||
borderRadius: 'var(--radius-control)',
|
borderRadius: 'var(--radius-control)',
|
||||||
outline: 'none',
|
|
||||||
background: 'var(--surface-raised)',
|
background: 'var(--surface-raised)',
|
||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
font: 'inherit'
|
font: 'inherit'
|
||||||
@@ -2115,6 +2116,8 @@ export function KnowledgeWorkspace({
|
|||||||
graphRelations,
|
graphRelations,
|
||||||
evidence,
|
evidence,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
loadError,
|
||||||
|
onRetryLoad,
|
||||||
onSelectLibrary,
|
onSelectLibrary,
|
||||||
onCreateLibrary,
|
onCreateLibrary,
|
||||||
onDeleteLibrary,
|
onDeleteLibrary,
|
||||||
@@ -2323,10 +2326,33 @@ export function KnowledgeWorkspace({
|
|||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main
|
<section
|
||||||
|
aria-label="知识库详情"
|
||||||
className="knowledge-workspace__main"
|
className="knowledge-workspace__main"
|
||||||
style={{ minWidth: 0, background: 'var(--surface-raised)' }}
|
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 && (
|
{selectedLibrary && !creating && !loading && (
|
||||||
<button
|
<button
|
||||||
className="knowledge-workspace__mobile-back secondary-button"
|
className="knowledge-workspace__mobile-back secondary-button"
|
||||||
@@ -2337,13 +2363,31 @@ export function KnowledgeWorkspace({
|
|||||||
返回知识库列表
|
返回知识库列表
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{loading ? (
|
{loading && libraries.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
description="正在读取知识库、来源和索引状态。"
|
description="正在读取知识库、来源和索引状态。"
|
||||||
icon={<LoaderCircle size={28} />}
|
icon={<LoaderCircle size={28} />}
|
||||||
level="page"
|
level="page"
|
||||||
title="正在加载知识库"
|
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 ? (
|
) : creating ? (
|
||||||
<CreateLibraryWizard
|
<CreateLibraryWizard
|
||||||
onCancel={() => setCreating(false)}
|
onCancel={() => setCreating(false)}
|
||||||
@@ -2502,7 +2546,7 @@ export function KnowledgeWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</section>
|
||||||
{deletingLibrary && (
|
{deletingLibrary && (
|
||||||
<DeleteLibraryDialog
|
<DeleteLibraryDialog
|
||||||
library={deletingLibrary}
|
library={deletingLibrary}
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ const summaryFromDetail = (
|
|||||||
const list = vi.fn<() => Promise<MagicNotesSnapshot>>()
|
const list = vi.fn<() => Promise<MagicNotesSnapshot>>()
|
||||||
const get = vi.fn<(noteId: string) => Promise<MagicNoteDetail>>()
|
const get = vi.fn<(noteId: string) => Promise<MagicNoteDetail>>()
|
||||||
const listTodos = vi.fn<() => Promise<MagicTodosSnapshot>>()
|
const listTodos = vi.fn<() => Promise<MagicTodosSnapshot>>()
|
||||||
|
const remove = vi.fn<DesktopApi['magicNotes']['remove']>()
|
||||||
const createTodo = vi.fn<DesktopApi['magicNotes']['createTodo']>()
|
const createTodo = vi.fn<DesktopApi['magicNotes']['createTodo']>()
|
||||||
const updateTodo = vi.fn<DesktopApi['magicNotes']['updateTodo']>()
|
const updateTodo = vi.fn<DesktopApi['magicNotes']['updateTodo']>()
|
||||||
const removeTodo = vi.fn<DesktopApi['magicNotes']['removeTodo']>()
|
const removeTodo = vi.fn<DesktopApi['magicNotes']['removeTodo']>()
|
||||||
@@ -133,6 +134,7 @@ beforeEach(() => {
|
|||||||
list.mockResolvedValue({ notes: [detail] })
|
list.mockResolvedValue({ notes: [detail] })
|
||||||
get.mockResolvedValue(detail)
|
get.mockResolvedValue(detail)
|
||||||
listTodos.mockResolvedValue({ todos: [noteTodo, manualTodo] })
|
listTodos.mockResolvedValue({ todos: [noteTodo, manualTodo] })
|
||||||
|
remove.mockResolvedValue()
|
||||||
createTodo.mockResolvedValue({
|
createTodo.mockResolvedValue({
|
||||||
...manualTodo,
|
...manualTodo,
|
||||||
id: '00000000-0000-4000-8000-000000000606',
|
id: '00000000-0000-4000-8000-000000000606',
|
||||||
@@ -166,6 +168,7 @@ beforeEach(() => {
|
|||||||
list,
|
list,
|
||||||
get,
|
get,
|
||||||
listTodos,
|
listTodos,
|
||||||
|
remove,
|
||||||
createTodo,
|
createTodo,
|
||||||
updateTodo,
|
updateTodo,
|
||||||
removeTodo,
|
removeTodo,
|
||||||
@@ -181,6 +184,68 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('MagicNotesWorkspace', () => {
|
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 () => {
|
it('aggregates note and manual todos without AI-created todo actions', async () => {
|
||||||
render(
|
render(
|
||||||
<MagicNotesWorkspace
|
<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 () => {
|
it('creates a manual todo with a dedicated title and details form', async () => {
|
||||||
render(
|
render(
|
||||||
<MagicNotesWorkspace
|
<MagicNotesWorkspace
|
||||||
@@ -289,7 +401,7 @@ describe('MagicNotesWorkspace', () => {
|
|||||||
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
|
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
|
||||||
fireEvent.click(screen.getByRole('button', { name: '新建待办' }))
|
fireEvent.click(screen.getByRole('button', { name: '新建待办' }))
|
||||||
expect(createTodo).not.toHaveBeenCalled()
|
expect(createTodo).not.toHaveBeenCalled()
|
||||||
fireEvent.click(screen.getByRole('button', { name: '创建' }))
|
fireEvent.click(screen.getByRole('button', { name: '创建待办' }))
|
||||||
expect(screen.getByRole('alert')).toHaveTextContent('请输入待办标题')
|
expect(screen.getByRole('alert')).toHaveTextContent('请输入待办标题')
|
||||||
expect(onNotify).not.toHaveBeenCalled()
|
expect(onNotify).not.toHaveBeenCalled()
|
||||||
|
|
||||||
@@ -300,7 +412,7 @@ describe('MagicNotesWorkspace', () => {
|
|||||||
fireEvent.change(screen.getByLabelText('说明'), {
|
fireEvent.change(screen.getByLabelText('说明'), {
|
||||||
target: { value: '新增说明' }
|
target: { value: '新增说明' }
|
||||||
})
|
})
|
||||||
fireEvent.click(screen.getByRole('button', { name: '创建' }))
|
fireEvent.click(screen.getByRole('button', { name: '创建待办' }))
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(createTodo).toHaveBeenCalledWith({
|
expect(createTodo).toHaveBeenCalledWith({
|
||||||
@@ -335,6 +447,41 @@ describe('MagicNotesWorkspace', () => {
|
|||||||
).toBeInTheDocument()
|
).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 () => {
|
it('clears delete confirmation before selecting the next todo', async () => {
|
||||||
render(
|
render(
|
||||||
<MagicNotesWorkspace
|
<MagicNotesWorkspace
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export type MagicNotesWorkspaceProps = {
|
|||||||
|
|
||||||
type LibraryView = 'notes' | 'todos'
|
type LibraryView = 'notes' | 'todos'
|
||||||
type TodoFilter = 'active' | 'completed' | 'all'
|
type TodoFilter = 'active' | 'completed' | 'all'
|
||||||
|
type LoadStatus = 'loading' | 'ready' | 'error'
|
||||||
type ValidationTarget =
|
type ValidationTarget =
|
||||||
| 'create-note'
|
| 'create-note'
|
||||||
| 'create-todo'
|
| 'create-todo'
|
||||||
@@ -150,7 +151,13 @@ export function MagicNotesWorkspace({
|
|||||||
const [selectedNoteId, setSelectedNoteId] = useState('')
|
const [selectedNoteId, setSelectedNoteId] = useState('')
|
||||||
const [selectedTodoId, setSelectedTodoId] = useState('')
|
const [selectedTodoId, setSelectedTodoId] = useState('')
|
||||||
const [detail, setDetail] = useState<MagicNoteDetail>()
|
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 [busy, setBusy] = useState('')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
@@ -172,7 +179,9 @@ export function MagicNotesWorkspace({
|
|||||||
message: string
|
message: string
|
||||||
}>()
|
}>()
|
||||||
const detailRequestRef = useRef(0)
|
const detailRequestRef = useRef(0)
|
||||||
|
const requestedNoteIdRef = useRef('')
|
||||||
const refreshRequestRef = useRef(0)
|
const refreshRequestRef = useRef(0)
|
||||||
|
const hasLoadedRef = useRef(false)
|
||||||
const busyRef = useRef('')
|
const busyRef = useRef('')
|
||||||
const composerContentRef = useRef<MagicNoteRichContent | undefined>(
|
const composerContentRef = useRef<MagicNoteRichContent | undefined>(
|
||||||
undefined
|
undefined
|
||||||
@@ -258,28 +267,40 @@ export function MagicNotesWorkspace({
|
|||||||
const loadDetail = useCallback(
|
const loadDetail = useCallback(
|
||||||
async (noteId: string): Promise<void> => {
|
async (noteId: string): Promise<void> => {
|
||||||
const requestId = ++detailRequestRef.current
|
const requestId = ++detailRequestRef.current
|
||||||
|
requestedNoteIdRef.current = noteId
|
||||||
|
setDetailLoadError(undefined)
|
||||||
try {
|
try {
|
||||||
const nextDetail = await window.goodbuddy.magicNotes.get(noteId)
|
const nextDetail = await window.goodbuddy.magicNotes.get(noteId)
|
||||||
if (detailRequestRef.current === requestId) {
|
if (detailRequestRef.current === requestId) {
|
||||||
|
setSelectedNoteId(noteId)
|
||||||
applyDetail(nextDetail)
|
applyDetail(nextDetail)
|
||||||
}
|
}
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
if (detailRequestRef.current === requestId) {
|
if (detailRequestRef.current === requestId) {
|
||||||
notifyError(loadError)
|
setDetailLoadError({
|
||||||
|
message: errorMessage(loadError),
|
||||||
|
noteId
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[applyDetail, notifyError]
|
[applyDetail]
|
||||||
)
|
)
|
||||||
|
|
||||||
const refreshNotes = useCallback(
|
const refreshNotes = useCallback(
|
||||||
async (preferredId?: string): Promise<void> => {
|
async (preferredId?: string): Promise<void> => {
|
||||||
const requestId = ++refreshRequestRef.current
|
const requestId = ++refreshRequestRef.current
|
||||||
|
const detailRequestAtStart = detailRequestRef.current
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
if (refreshRequestRef.current !== requestId) {
|
if (refreshRequestRef.current !== requestId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLoading(true)
|
const isInitialLoad = !hasLoadedRef.current
|
||||||
|
if (isInitialLoad) {
|
||||||
|
setLoadStatus('loading')
|
||||||
|
setLoadError('')
|
||||||
|
}
|
||||||
|
setRefreshError('')
|
||||||
try {
|
try {
|
||||||
const [snapshot, todoSnapshot] = await Promise.all([
|
const [snapshot, todoSnapshot] = await Promise.all([
|
||||||
window.goodbuddy.magicNotes.list(projectId),
|
window.goodbuddy.magicNotes.list(projectId),
|
||||||
@@ -295,23 +316,37 @@ export function MagicNotesWorkspace({
|
|||||||
if (refreshRequestRef.current !== requestId) {
|
if (refreshRequestRef.current !== requestId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const requestedNoteId = requestedNoteIdRef.current
|
||||||
|
const preserveNewerSelection =
|
||||||
|
detailRequestRef.current !== detailRequestAtStart &&
|
||||||
|
snapshot.notes.some((note) => note.id === requestedNoteId)
|
||||||
setNotes(snapshot.notes)
|
setNotes(snapshot.notes)
|
||||||
setTodos(todoSnapshot.todos)
|
setTodos(todoSnapshot.todos)
|
||||||
setSelectedNoteId(nextId)
|
|
||||||
setSelectedTodoId(todoSnapshot.todos[0]?.id ?? '')
|
setSelectedTodoId(todoSnapshot.todos[0]?.id ?? '')
|
||||||
|
hasLoadedRef.current = true
|
||||||
|
setLoadStatus('ready')
|
||||||
|
if (preserveNewerSelection) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detailRequestRef.current += 1
|
||||||
|
requestedNoteIdRef.current = nextId
|
||||||
|
setSelectedNoteId(nextId)
|
||||||
setDetail(nextDetail)
|
setDetail(nextDetail)
|
||||||
setTitleDraft(nextDetail?.title ?? '')
|
setTitleDraft(nextDetail?.title ?? '')
|
||||||
|
setDetailLoadError(undefined)
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
if (refreshRequestRef.current === requestId) {
|
if (refreshRequestRef.current === requestId) {
|
||||||
notifyError(loadError)
|
const message = errorMessage(loadError)
|
||||||
}
|
if (hasLoadedRef.current) {
|
||||||
} finally {
|
setRefreshError(message)
|
||||||
if (refreshRequestRef.current === requestId) {
|
} else {
|
||||||
setLoading(false)
|
setLoadError(message)
|
||||||
|
setLoadStatus('error')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[notifyError, projectId]
|
[projectId]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -321,6 +356,7 @@ export function MagicNotesWorkspace({
|
|||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(timeout)
|
window.clearTimeout(timeout)
|
||||||
refreshRequestRef.current += 1
|
refreshRequestRef.current += 1
|
||||||
|
detailRequestRef.current += 1
|
||||||
}
|
}
|
||||||
}, [refreshNotes])
|
}, [refreshNotes])
|
||||||
|
|
||||||
@@ -391,6 +427,7 @@ export function MagicNotesWorkspace({
|
|||||||
title
|
title
|
||||||
})
|
})
|
||||||
applyDetail(created)
|
applyDetail(created)
|
||||||
|
requestedNoteIdRef.current = created.id
|
||||||
setSelectedNoteId(created.id)
|
setSelectedNoteId(created.id)
|
||||||
setNewTitle('')
|
setNewTitle('')
|
||||||
setCreating(false)
|
setCreating(false)
|
||||||
@@ -649,7 +686,7 @@ export function MagicNotesWorkspace({
|
|||||||
setCreating(true)
|
setCreating(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus size={15} />
|
<Plus aria-hidden="true" size={15} />
|
||||||
{libraryView === 'notes' ? '新建笔记' : '新建待办'}
|
{libraryView === 'notes' ? '新建笔记' : '新建待办'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -666,6 +703,36 @@ export function MagicNotesWorkspace({
|
|||||||
title="魔法笔记"
|
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
|
<div
|
||||||
aria-busy={Boolean(busy)}
|
aria-busy={Boolean(busy)}
|
||||||
className={`magic-notes-layout${
|
className={`magic-notes-layout${
|
||||||
@@ -760,18 +827,31 @@ export function MagicNotesWorkspace({
|
|||||||
disabled={busy === 'create-note'}
|
disabled={busy === 'create-note'}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
创建
|
创建笔记
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
<div className="magic-notes-list">
|
<div className="magic-notes-list">
|
||||||
{loading ? (
|
{loadStatus === 'loading' ? (
|
||||||
<p className="magic-notes-muted">正在加载笔记…</p>
|
<p className="magic-notes-muted">正在加载笔记…</p>
|
||||||
) : visibleNotes.length === 0 ? (
|
) : visibleNotes.length === 0 ? (
|
||||||
<p className="magic-notes-muted">
|
<>
|
||||||
{search ? '没有符合条件的笔记' : '还没有笔记'}
|
<p className="magic-notes-muted">
|
||||||
</p>
|
{search.trim()
|
||||||
|
? '没有符合条件的笔记'
|
||||||
|
: '还没有笔记'}
|
||||||
|
</p>
|
||||||
|
{search.trim() && (
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
清除筛选
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
visibleNotes.map((note) => (
|
visibleNotes.map((note) => (
|
||||||
<button
|
<button
|
||||||
@@ -785,7 +865,6 @@ export function MagicNotesWorkspace({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setValidation(undefined)
|
setValidation(undefined)
|
||||||
setSelectedNoteId(note.id)
|
|
||||||
setDeletingNote(false)
|
setDeletingNote(false)
|
||||||
setEditingEntry(undefined)
|
setEditingEntry(undefined)
|
||||||
editingContentRef.current = undefined
|
editingContentRef.current = undefined
|
||||||
@@ -895,22 +974,38 @@ export function MagicNotesWorkspace({
|
|||||||
disabled={busy === 'create-todo'}
|
disabled={busy === 'create-todo'}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
创建
|
创建待办
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
<div className="magic-notes-list">
|
<div className="magic-notes-list">
|
||||||
{loading ? (
|
{loadStatus === 'loading' ? (
|
||||||
<p className="magic-notes-muted">正在加载待办…</p>
|
<p className="magic-notes-muted">正在加载待办…</p>
|
||||||
) : visibleTodos.length === 0 ? (
|
) : visibleTodos.length === 0 ? (
|
||||||
<p className="magic-notes-muted">
|
<>
|
||||||
{todos.length === 0
|
<p className="magic-notes-muted">
|
||||||
? '还没有待办'
|
{todos.length === 0
|
||||||
: search || todoFilter !== 'all'
|
? '还没有待办'
|
||||||
? '没有符合条件的待办'
|
: search.trim() || todoFilter !== 'all'
|
||||||
: '还没有待办'}
|
? '没有符合条件的待办'
|
||||||
</p>
|
: '还没有待办'}
|
||||||
|
</p>
|
||||||
|
{todos.length > 0 &&
|
||||||
|
(Boolean(search.trim()) ||
|
||||||
|
todoFilter !== 'all') && (
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => {
|
||||||
|
setSearch('')
|
||||||
|
setTodoFilter('all')
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
清除筛选
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
visibleTodos.map((todo) => (
|
visibleTodos.map((todo) => (
|
||||||
<button
|
<button
|
||||||
@@ -955,7 +1050,7 @@ export function MagicNotesWorkspace({
|
|||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main
|
<section
|
||||||
aria-label={libraryView === 'notes' ? '笔记记录' : '待办详情'}
|
aria-label={libraryView === 'notes' ? '笔记记录' : '待办详情'}
|
||||||
className="magic-notes-stream-pane"
|
className="magic-notes-stream-pane"
|
||||||
>
|
>
|
||||||
@@ -964,10 +1059,27 @@ export function MagicNotesWorkspace({
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
description="从左侧选择笔记,或新建一篇笔记开始记录。"
|
description="从左侧选择笔记,或新建一篇笔记开始记录。"
|
||||||
icon={<FileText size={24} />}
|
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">
|
<header className="magic-note-detail-header">
|
||||||
<input
|
<input
|
||||||
aria-describedby={
|
aria-describedby={
|
||||||
@@ -1278,7 +1390,9 @@ export function MagicNotesWorkspace({
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
description="从左侧选择待办,或新建一个手动待办。"
|
description="从左侧选择待办,或新建一个手动待办。"
|
||||||
icon={<ListTodo size={24} />}
|
icon={<ListTodo size={24} />}
|
||||||
title={loading ? '正在加载' : '还没有选择待办'}
|
title={
|
||||||
|
loadStatus === 'loading' ? '正在加载' : '还没有选择待办'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<section className="magic-todo-detail">
|
<section className="magic-todo-detail">
|
||||||
@@ -1451,7 +1565,6 @@ export function MagicNotesWorkspace({
|
|||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLibraryView('notes')
|
setLibraryView('notes')
|
||||||
setSelectedNoteId(selectedTodo.noteId!)
|
|
||||||
void loadDetail(selectedTodo.noteId!).then(() => {
|
void loadDetail(selectedTodo.noteId!).then(() => {
|
||||||
requestAnimationFrame(() =>
|
requestAnimationFrame(() =>
|
||||||
document
|
document
|
||||||
@@ -1472,7 +1585,7 @@ export function MagicNotesWorkspace({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</main>
|
</section>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
aria-label="AI 评论"
|
aria-label="AI 评论"
|
||||||
@@ -1533,6 +1646,8 @@ export function MagicNotesWorkspace({
|
|||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</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', () => {
|
describe('SettingsPanel runtime files', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
applicationSettings = {
|
||||||
|
checkUpdatesOnStartup: true,
|
||||||
|
magicNotesEnabled: false
|
||||||
|
}
|
||||||
embeddingStatusListeners.splice(0)
|
embeddingStatusListeners.splice(0)
|
||||||
Object.defineProperty(window, 'goodbuddy', {
|
Object.defineProperty(window, 'goodbuddy', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -377,6 +397,13 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
rebuild: rebuildEmbeddingIndex,
|
rebuild: rebuildEmbeddingIndex,
|
||||||
cancel: cancelEmbeddingIndex,
|
cancel: cancelEmbeddingIndex,
|
||||||
onStatus: onEmbeddingStatus
|
onStatus: onEmbeddingStatus
|
||||||
|
},
|
||||||
|
updates: {
|
||||||
|
getSettings: getApplicationSettings,
|
||||||
|
updateSettings: updateApplicationSettings,
|
||||||
|
check: vi.fn(),
|
||||||
|
openReleasePage: vi.fn(),
|
||||||
|
onResult: vi.fn(() => () => {})
|
||||||
}
|
}
|
||||||
} as unknown as DesktopApi
|
} as unknown as DesktopApi
|
||||||
})
|
})
|
||||||
@@ -408,6 +435,35 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
expect(onAppearanceThemeChange).toHaveBeenCalledWith('dark')
|
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', () => {
|
it('keeps page navigation beside an independently scrollable panel', () => {
|
||||||
render(
|
render(
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
@@ -429,6 +485,63 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
expect(content).toHaveClass('settings-panel__content')
|
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', () => {
|
it('supports keyboard navigation between settings tabs', () => {
|
||||||
render(
|
render(
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ import { SkillsSettingsSection } from './SkillsSettingsSection'
|
|||||||
import { HeartbeatSettings } from './HeartbeatSettings'
|
import { HeartbeatSettings } from './HeartbeatSettings'
|
||||||
import { ChannelSettingsSection } from './ChannelSettingsSection'
|
import { ChannelSettingsSection } from './ChannelSettingsSection'
|
||||||
import { UpdateSettingsSection } from './UpdateSettingsSection'
|
import { UpdateSettingsSection } from './UpdateSettingsSection'
|
||||||
|
import { PlatformFeaturesSettingsSection } from './PlatformFeaturesSettingsSection'
|
||||||
import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
|
import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
|
||||||
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
|
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
|
||||||
import { SegmentedControl } from './WorkspacePrimitives'
|
import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
|
||||||
import type { AppearanceTheme } from './theme'
|
import type { AppearanceTheme } from './theme'
|
||||||
import type { AppNotificationInput } from './notifications'
|
import type { AppNotificationInput } from './notifications'
|
||||||
import type {
|
import type {
|
||||||
@@ -46,6 +47,7 @@ import type {
|
|||||||
|
|
||||||
type SettingsTab =
|
type SettingsTab =
|
||||||
| 'appearance'
|
| 'appearance'
|
||||||
|
| 'platform-features'
|
||||||
| 'model'
|
| 'model'
|
||||||
| 'runtime'
|
| 'runtime'
|
||||||
| 'security'
|
| 'security'
|
||||||
@@ -64,6 +66,7 @@ type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & {
|
|||||||
|
|
||||||
const settingsTabs: readonly SettingsTab[] = [
|
const settingsTabs: readonly SettingsTab[] = [
|
||||||
'appearance',
|
'appearance',
|
||||||
|
'platform-features',
|
||||||
'model',
|
'model',
|
||||||
'runtime',
|
'runtime',
|
||||||
'security',
|
'security',
|
||||||
@@ -93,6 +96,7 @@ type SettingsPanelProps = {
|
|||||||
onRunHeartbeat: (heartbeatId: string) => Promise<void>
|
onRunHeartbeat: (heartbeatId: string) => Promise<void>
|
||||||
appearanceTheme?: AppearanceTheme
|
appearanceTheme?: AppearanceTheme
|
||||||
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
|
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
|
||||||
|
onMagicNotesEnabledChange?: (enabled: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentialLabels: Record<
|
const credentialLabels: Record<
|
||||||
@@ -255,7 +259,8 @@ export function SettingsPanel({
|
|||||||
onRunHeartbeat,
|
onRunHeartbeat,
|
||||||
onExpertsChanged = () => {},
|
onExpertsChanged = () => {},
|
||||||
appearanceTheme = 'system',
|
appearanceTheme = 'system',
|
||||||
onAppearanceThemeChange = () => {}
|
onAppearanceThemeChange = () => {},
|
||||||
|
onMagicNotesEnabledChange = () => {}
|
||||||
}: SettingsPanelProps): React.JSX.Element | null {
|
}: SettingsPanelProps): React.JSX.Element | null {
|
||||||
const [settings, setSettings] = useState<RuntimeSettings>()
|
const [settings, setSettings] = useState<RuntimeSettings>()
|
||||||
const [provider, setProvider] =
|
const [provider, setProvider] =
|
||||||
@@ -328,6 +333,7 @@ export function SettingsPanel({
|
|||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
const [connectionResult, setConnectionResult] = useState<string>()
|
const [connectionResult, setConnectionResult] = useState<string>()
|
||||||
const [confirmingClear, setConfirmingClear] = useState(false)
|
const [confirmingClear, setConfirmingClear] = useState(false)
|
||||||
|
const [clearingLocalData, setClearingLocalData] = useState(false)
|
||||||
const [detection, setDetection] = useState<AgentRuntimeDetection>()
|
const [detection, setDetection] = useState<AgentRuntimeDetection>()
|
||||||
const [detecting, setDetecting] = useState(false)
|
const [detecting, setDetecting] = useState(false)
|
||||||
const [activeTab, setActiveTab] = useState<SettingsTab>('runtime')
|
const [activeTab, setActiveTab] = useState<SettingsTab>('runtime')
|
||||||
@@ -382,6 +388,7 @@ export function SettingsPanel({
|
|||||||
setSaved(false)
|
setSaved(false)
|
||||||
setConnectionResult(undefined)
|
setConnectionResult(undefined)
|
||||||
setConfirmingClear(false)
|
setConfirmingClear(false)
|
||||||
|
setClearingLocalData(false)
|
||||||
setModelType('llm')
|
setModelType('llm')
|
||||||
setAgentRuntimeType('opencode')
|
setAgentRuntimeType('opencode')
|
||||||
setSettings(value)
|
setSettings(value)
|
||||||
@@ -951,23 +958,26 @@ export function SettingsPanel({
|
|||||||
className="settings-panel"
|
className="settings-panel"
|
||||||
role={presentation === 'modal' ? 'dialog' : 'region'}
|
role={presentation === 'modal' ? 'dialog' : 'region'}
|
||||||
>
|
>
|
||||||
<header className="settings-panel__header">
|
<div className="settings-panel__header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">SETTINGS</p>
|
<PageHeader
|
||||||
<h2 id="settings-title">设置中心</h2>
|
actions={
|
||||||
<p className="settings-panel__description">
|
<button
|
||||||
管理模型连接、Agent Runtime、自动化、扩展能力和本地数据。
|
aria-label="关闭设置"
|
||||||
</p>
|
className="icon-button"
|
||||||
|
onClick={close}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X aria-hidden="true" size={19} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
description="管理模型连接、Agent Runtime、自动化、扩展能力和本地数据。"
|
||||||
|
eyebrow="SETTINGS"
|
||||||
|
headingId="settings-title"
|
||||||
|
title="设置中心"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
</div>
|
||||||
aria-label="关闭设置"
|
|
||||||
className="icon-button"
|
|
||||||
onClick={close}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<X size={19} />
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="settings-panel__body">
|
<div className="settings-panel__body">
|
||||||
<nav
|
<nav
|
||||||
@@ -992,6 +1002,22 @@ export function SettingsPanel({
|
|||||||
<strong>外观</strong>
|
<strong>外观</strong>
|
||||||
<small>亮色、暗色与系统主题</small>
|
<small>亮色、暗色与系统主题</small>
|
||||||
</button>
|
</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
|
<button
|
||||||
aria-controls="settings-panel-model"
|
aria-controls="settings-panel-model"
|
||||||
aria-label="模型连接"
|
aria-label="模型连接"
|
||||||
@@ -1189,6 +1215,11 @@ export function SettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{activeTab === 'platform-features' && (
|
||||||
|
<PlatformFeaturesSettingsSection
|
||||||
|
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{activeTab === 'runtime' && (
|
{activeTab === 'runtime' && (
|
||||||
<>
|
<>
|
||||||
{settings?.warning && (
|
{settings?.warning && (
|
||||||
@@ -2165,6 +2196,7 @@ export function SettingsPanel({
|
|||||||
<div className="danger-actions">
|
<div className="danger-actions">
|
||||||
<button
|
<button
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
|
disabled={clearingLocalData}
|
||||||
onClick={() => setConfirmingClear(false)}
|
onClick={() => setConfirmingClear(false)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -2172,12 +2204,15 @@ export function SettingsPanel({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="danger-button"
|
className="danger-button"
|
||||||
|
disabled={clearingLocalData}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setClearingLocalData(true)
|
||||||
|
setError(undefined)
|
||||||
|
setSaved(false)
|
||||||
|
setConnectionResult(undefined)
|
||||||
void onClearLocalData()
|
void onClearLocalData()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setConfirmingClear(false)
|
setConfirmingClear(false)
|
||||||
setConnectionResult('本地数据已清除')
|
|
||||||
setSaved(true)
|
|
||||||
})
|
})
|
||||||
.catch((reason: unknown) => {
|
.catch((reason: unknown) => {
|
||||||
setError(
|
setError(
|
||||||
@@ -2187,10 +2222,13 @@ export function SettingsPanel({
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
.finally(() => setClearingLocalData(false))
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
确认清除
|
{clearingLocalData
|
||||||
|
? '正在清除…'
|
||||||
|
: '清除本地数据'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ describe('UpdateSettingsSection', () => {
|
|||||||
it('checks the official release manifest and updates the startup preference', async () => {
|
it('checks the official release manifest and updates the startup preference', async () => {
|
||||||
const updateSettings = vi.fn<
|
const updateSettings = vi.fn<
|
||||||
NonNullable<DesktopApi['updates']>['updateSettings']
|
NonNullable<DesktopApi['updates']>['updateSettings']
|
||||||
>(async (input) => input)
|
>(async (input) => ({
|
||||||
|
checkUpdatesOnStartup:
|
||||||
|
input.checkUpdatesOnStartup ?? true,
|
||||||
|
magicNotesEnabled: input.magicNotesEnabled ?? true
|
||||||
|
}))
|
||||||
const check = vi.fn<
|
const check = vi.fn<
|
||||||
NonNullable<DesktopApi['updates']>['check']
|
NonNullable<DesktopApi['updates']>['check']
|
||||||
>(async () => ({
|
>(async () => ({
|
||||||
@@ -54,7 +58,8 @@ describe('UpdateSettingsSection', () => {
|
|||||||
},
|
},
|
||||||
updates: {
|
updates: {
|
||||||
getSettings: vi.fn(async () => ({
|
getSettings: vi.fn(async () => ({
|
||||||
checkUpdatesOnStartup: true
|
checkUpdatesOnStartup: true,
|
||||||
|
magicNotesEnabled: true
|
||||||
})),
|
})),
|
||||||
updateSettings,
|
updateSettings,
|
||||||
check,
|
check,
|
||||||
@@ -101,9 +106,13 @@ describe('UpdateSettingsSection', () => {
|
|||||||
},
|
},
|
||||||
updates: {
|
updates: {
|
||||||
getSettings: vi.fn(async () => ({
|
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 () => {
|
check: vi.fn(async () => {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Error invoking remote method 'application:update:check': TypeError: fetch failed"
|
"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', () => {
|
it('renders a consistent page shell and scoped header', () => {
|
||||||
render(
|
render(
|
||||||
<PageShell variant="dashboard">
|
<PageShell variant="dashboard">
|
||||||
@@ -188,8 +216,10 @@ describe('WorkspacePrimitives', () => {
|
|||||||
const onCancel = vi.fn()
|
const onCancel = vi.fn()
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<DestructiveConfirmActions
|
<DestructiveConfirmActions
|
||||||
|
confirmAriaLabel="永久删除对象"
|
||||||
confirmLabel="确认删除"
|
confirmLabel="确认删除"
|
||||||
confirming={false}
|
confirming={false}
|
||||||
|
icon={<span data-testid="delete-icon">×</span>}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
onRequestConfirm={onRequestConfirm}
|
onRequestConfirm={onRequestConfirm}
|
||||||
@@ -197,11 +227,16 @@ describe('WorkspacePrimitives', () => {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('delete-icon').parentElement).toHaveAttribute(
|
||||||
|
'aria-hidden',
|
||||||
|
'true'
|
||||||
|
)
|
||||||
fireEvent.click(screen.getByRole('button', { name: '删除' }))
|
fireEvent.click(screen.getByRole('button', { name: '删除' }))
|
||||||
expect(onRequestConfirm).toHaveBeenCalledOnce()
|
expect(onRequestConfirm).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<DestructiveConfirmActions
|
<DestructiveConfirmActions
|
||||||
|
confirmAriaLabel="永久删除对象"
|
||||||
confirmLabel="确认删除"
|
confirmLabel="确认删除"
|
||||||
confirming
|
confirming
|
||||||
message="删除此对象?"
|
message="删除此对象?"
|
||||||
@@ -211,11 +246,74 @@ describe('WorkspacePrimitives', () => {
|
|||||||
triggerLabel="删除"
|
triggerLabel="删除"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
expect(screen.getByRole('button', { name: '取消' })).toHaveFocus()
|
const dialog = screen.getByRole('alertdialog', {
|
||||||
fireEvent.click(screen.getByRole('button', { name: '确认删除' }))
|
name: '永久删除对象'
|
||||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
})
|
||||||
expect(onConfirm).toHaveBeenCalledOnce()
|
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()
|
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([
|
it.each([
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useId,
|
||||||
useRef,
|
useRef,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
type ReactNode
|
type ReactNode
|
||||||
@@ -326,30 +327,97 @@ export function DestructiveConfirmActions({
|
|||||||
triggerLabel: string
|
triggerLabel: string
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null)
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
const wasConfirming = useRef(confirming)
|
const wasConfirming = useRef(confirming)
|
||||||
|
const shouldRestoreTrigger = useRef(false)
|
||||||
|
const titleId = useId()
|
||||||
|
const descriptionId = useId()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (confirming && !wasConfirming.current) {
|
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 (
|
} else if (
|
||||||
|
confirming &&
|
||||||
|
!disabled &&
|
||||||
|
document.activeElement === dialogRef.current
|
||||||
|
) {
|
||||||
|
cancelRef.current?.focus()
|
||||||
|
} else if (!confirming && wasConfirming.current) {
|
||||||
|
shouldRestoreTrigger.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
!confirming &&
|
!confirming &&
|
||||||
wasConfirming.current &&
|
shouldRestoreTrigger.current &&
|
||||||
!triggerRef.current?.disabled
|
!triggerRef.current?.disabled
|
||||||
) {
|
) {
|
||||||
triggerRef.current?.focus()
|
triggerRef.current?.focus()
|
||||||
|
shouldRestoreTrigger.current = false
|
||||||
}
|
}
|
||||||
wasConfirming.current = confirming
|
wasConfirming.current = confirming
|
||||||
}, [confirming])
|
}, [confirming, disabled])
|
||||||
|
|
||||||
return confirming ? (
|
return confirming ? (
|
||||||
<div
|
<div
|
||||||
aria-label={confirmAriaLabel}
|
aria-describedby={descriptionId}
|
||||||
|
aria-labelledby={titleId}
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
|
aria-modal="true"
|
||||||
className="danger-confirm"
|
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"
|
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
|
<button
|
||||||
aria-label={cancelAriaLabel}
|
aria-label={cancelAriaLabel}
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
@@ -365,6 +433,7 @@ export function DestructiveConfirmActions({
|
|||||||
className="danger-button"
|
className="danger-button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
|
ref={confirmRef}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
@@ -379,7 +448,7 @@ export function DestructiveConfirmActions({
|
|||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{icon}
|
{icon && <span aria-hidden="true">{icon}</span>}
|
||||||
{triggerLabel}
|
{triggerLabel}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ function makeRecord(index: number): ActivityRecord {
|
|||||||
id: `activity-${index}`,
|
id: `activity-${index}`,
|
||||||
conversationId: 'conversation-1',
|
conversationId: 'conversation-1',
|
||||||
requestId: 'request-1',
|
requestId: 'request-1',
|
||||||
|
scope: { kind: 'global' },
|
||||||
kind: 'tool',
|
kind: 'tool',
|
||||||
title: `工具调用 ${index}`,
|
title: `工具调用 ${index}`,
|
||||||
detail: '读取文件',
|
detail: '读取文件',
|
||||||
@@ -55,6 +56,41 @@ describe('activity-store', () => {
|
|||||||
expect(loadActivityRecords()).toEqual([validRecord])
|
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', () => {
|
it('persists no more than the record limit', () => {
|
||||||
const records = Array.from(
|
const records = Array.from(
|
||||||
{ length: MAX_ACTIVITY_RECORDS + 1 },
|
{ length: MAX_ACTIVITY_RECORDS + 1 },
|
||||||
@@ -102,10 +138,32 @@ describe('activity-store', () => {
|
|||||||
.toMatchObject({
|
.toMatchObject({
|
||||||
id: first.id,
|
id: first.id,
|
||||||
createdAt: first.createdAt,
|
createdAt: first.createdAt,
|
||||||
|
scope: first.scope,
|
||||||
status: 'failed'
|
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', () => {
|
it('persists and upserts Subagent state transitions', () => {
|
||||||
const queued: ActivityRecord = {
|
const queued: ActivityRecord = {
|
||||||
...makeRecord(1),
|
...makeRecord(1),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const MAX_ACTIVITY_DETAIL_LENGTH = 4_000
|
|||||||
const MAX_STORED_JSON_LENGTH = 2_000_000
|
const MAX_STORED_JSON_LENGTH = 2_000_000
|
||||||
const MAX_ID_LENGTH = 256
|
const MAX_ID_LENGTH = 256
|
||||||
const MAX_TITLE_LENGTH = 240
|
const MAX_TITLE_LENGTH = 240
|
||||||
|
const MAX_PROJECT_NAME_LENGTH = 120
|
||||||
|
|
||||||
const activityKinds = [
|
const activityKinds = [
|
||||||
'request',
|
'request',
|
||||||
@@ -30,6 +31,10 @@ export type ActivityRecord = {
|
|||||||
conversationId: string
|
conversationId: string
|
||||||
requestId: string
|
requestId: string
|
||||||
callId?: string
|
callId?: string
|
||||||
|
scope:
|
||||||
|
| { kind: 'global' }
|
||||||
|
| { kind: 'project'; projectId: string; projectName: string }
|
||||||
|
| { kind: 'unavailable' }
|
||||||
kind: (typeof activityKinds)[number]
|
kind: (typeof activityKinds)[number]
|
||||||
title: string
|
title: string
|
||||||
detail: 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)) {
|
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>
|
const candidate = value as Record<string, unknown>
|
||||||
return (
|
if (
|
||||||
isBoundedString(candidate.id, MAX_ID_LENGTH) &&
|
!isBoundedString(candidate.id, MAX_ID_LENGTH) ||
|
||||||
isBoundedString(candidate.conversationId, MAX_ID_LENGTH) &&
|
!isBoundedString(candidate.conversationId, MAX_ID_LENGTH) ||
|
||||||
isBoundedString(candidate.requestId, MAX_ID_LENGTH) &&
|
!isBoundedString(candidate.requestId, MAX_ID_LENGTH) ||
|
||||||
(candidate.callId === undefined ||
|
(candidate.callId !== undefined &&
|
||||||
isBoundedString(candidate.callId, MAX_ID_LENGTH)) &&
|
!isBoundedString(candidate.callId, MAX_ID_LENGTH)) ||
|
||||||
activityKinds.some((kind) => kind === candidate.kind) &&
|
!activityKinds.some((kind) => kind === candidate.kind) ||
|
||||||
isBoundedString(candidate.title, MAX_TITLE_LENGTH) &&
|
!isBoundedString(candidate.title, MAX_TITLE_LENGTH) ||
|
||||||
isBoundedString(
|
!isBoundedString(
|
||||||
candidate.detail,
|
candidate.detail,
|
||||||
MAX_ACTIVITY_DETAIL_LENGTH,
|
MAX_ACTIVITY_DETAIL_LENGTH,
|
||||||
true
|
true
|
||||||
) &&
|
) ||
|
||||||
activityStatuses.some((status) => status === candidate.status) &&
|
!activityStatuses.some((status) => status === candidate.status) ||
|
||||||
typeof candidate.createdAt === 'number' &&
|
typeof candidate.createdAt !== 'number' ||
|
||||||
Number.isFinite(candidate.createdAt) &&
|
!Number.isFinite(candidate.createdAt) ||
|
||||||
candidate.createdAt >= 0
|
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(
|
export function upsertActivityRecord(
|
||||||
@@ -109,7 +166,8 @@ export function upsertActivityRecord(
|
|||||||
{
|
{
|
||||||
...incoming,
|
...incoming,
|
||||||
id: existing.id,
|
id: existing.id,
|
||||||
createdAt: existing.createdAt
|
createdAt: existing.createdAt,
|
||||||
|
scope: existing.scope
|
||||||
},
|
},
|
||||||
...records.filter((_, index) => index !== existingIndex)
|
...records.filter((_, index) => index !== existingIndex)
|
||||||
].slice(0, MAX_ACTIVITY_RECORDS)
|
].slice(0, MAX_ACTIVITY_RECORDS)
|
||||||
@@ -200,8 +258,9 @@ export function loadActivityRecords(
|
|||||||
|
|
||||||
const records: ActivityRecord[] = []
|
const records: ActivityRecord[] = []
|
||||||
for (const candidate of parsed) {
|
for (const candidate of parsed) {
|
||||||
if (isActivityRecord(candidate)) {
|
const record = parseActivityRecord(candidate)
|
||||||
records.push(candidate)
|
if (record) {
|
||||||
|
records.push(record)
|
||||||
}
|
}
|
||||||
if (records.length === MAX_ACTIVITY_RECORDS) {
|
if (records.length === MAX_ACTIVITY_RECORDS) {
|
||||||
break
|
break
|
||||||
@@ -227,8 +286,9 @@ export function saveActivityRecords(
|
|||||||
|
|
||||||
const safeRecords: ActivityRecord[] = []
|
const safeRecords: ActivityRecord[] = []
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
if (isActivityRecord(record)) {
|
const safeRecord = parseActivityRecord(record)
|
||||||
safeRecords.push(record)
|
if (safeRecord) {
|
||||||
|
safeRecords.push(safeRecord)
|
||||||
}
|
}
|
||||||
if (safeRecords.length === MAX_ACTIVITY_RECORDS) {
|
if (safeRecords.length === MAX_ACTIVITY_RECORDS) {
|
||||||
break
|
break
|
||||||
|
|||||||
+205
-49
@@ -34,7 +34,7 @@
|
|||||||
--space-3: 12px;
|
--space-3: 12px;
|
||||||
--space-4: 16px;
|
--space-4: 16px;
|
||||||
--space-6: 24px;
|
--space-6: 24px;
|
||||||
--page-gutter: clamp(16px, 2vw, 28px);
|
--page-gutter: 32px;
|
||||||
--content-reading: 820px;
|
--content-reading: 820px;
|
||||||
--content-standard: 960px;
|
--content-standard: 960px;
|
||||||
--content-dashboard: 1040px;
|
--content-dashboard: 1040px;
|
||||||
@@ -60,6 +60,18 @@
|
|||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1199px) {
|
||||||
|
:root {
|
||||||
|
--page-gutter: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 959px) {
|
||||||
|
:root {
|
||||||
|
--page-gutter: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.magic-notes-page {
|
.magic-notes-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -785,8 +797,9 @@ button {
|
|||||||
|
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
input:focus-visible,
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
textarea:focus-visible {
|
textarea:focus-visible {
|
||||||
outline: 2px solid #1677ff;
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -819,6 +832,10 @@ textarea:focus-visible {
|
|||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-backdrop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1330,6 +1347,11 @@ textarea:focus-visible {
|
|||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.window-control > svg,
|
||||||
|
.icon-button > svg {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.icon-button {
|
.icon-button {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 34px;
|
width: 34px;
|
||||||
@@ -1337,18 +1359,18 @@ textarea:focus-visible {
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #595959;
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-button:hover {
|
.icon-button:hover {
|
||||||
background: #e6f4ff;
|
background: var(--accent-subtle);
|
||||||
color: #1677ff;
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-button--active {
|
.icon-button--active {
|
||||||
background: #e6f4ff;
|
background: var(--accent-subtle);
|
||||||
color: #1677ff;
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar {
|
.assistant-sidebar {
|
||||||
@@ -3858,6 +3880,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-panel__header {
|
.settings-panel__header {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 22px 24px 18px;
|
padding: 22px 24px 18px;
|
||||||
@@ -3868,6 +3891,25 @@ textarea:focus-visible {
|
|||||||
flex: 1;
|
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 {
|
.settings-panel__header .eyebrow {
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
@@ -5050,17 +5092,17 @@ details.settings-section > :not(summary) + :not(summary) {
|
|||||||
.capability-card {
|
.capability-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 12px;
|
padding: var(--space-3);
|
||||||
border: 1px solid #e8e8e8;
|
border: 1px solid var(--border-default);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-card);
|
||||||
background: #fff;
|
background: var(--surface-raised);
|
||||||
gap: 8px;
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.capability-card__header {
|
.capability-card__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.capability-card__header > div:first-child {
|
.capability-card__header > div:first-child {
|
||||||
@@ -5071,30 +5113,30 @@ details.settings-section > :not(summary) + :not(summary) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.capability-card strong {
|
.capability-card strong {
|
||||||
color: #1f1f1f;
|
color: var(--text-primary);
|
||||||
font-size: 11px;
|
font-size: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
.capability-card small,
|
.capability-card small,
|
||||||
.runtime-assignments small {
|
.runtime-assignments small {
|
||||||
color: #8c8c8c;
|
color: var(--text-muted);
|
||||||
font-size: 9px;
|
font-size: var(--font-caption);
|
||||||
}
|
}
|
||||||
|
|
||||||
.capability-card p {
|
.capability-card p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #595959;
|
color: var(--text-secondary);
|
||||||
font-size: 10px;
|
font-size: var(--font-caption);
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.capability-card code {
|
.capability-card code {
|
||||||
padding: 7px 8px;
|
padding: var(--space-2);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-control);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #f5f5f5;
|
background: var(--surface-subtle);
|
||||||
color: #595959;
|
color: var(--text-secondary);
|
||||||
font-size: 9px;
|
font-size: var(--font-caption);
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -5111,9 +5153,69 @@ details.settings-section > :not(summary) + :not(summary) {
|
|||||||
|
|
||||||
.capability-switch,
|
.capability-switch,
|
||||||
.runtime-assignments label {
|
.runtime-assignments label {
|
||||||
color: #595959;
|
color: var(--text-secondary);
|
||||||
font-size: 9px;
|
font-size: var(--font-caption);
|
||||||
gap: 4px;
|
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 {
|
.runtime-assignments {
|
||||||
@@ -5770,7 +5872,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-shell--master-detail {
|
.page-shell--master-detail {
|
||||||
padding: clamp(14px, 2vw, 28px);
|
padding: var(--page-gutter);
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
@@ -5886,7 +5988,9 @@ details.settings-section > :not(summary) + :not(summary) {
|
|||||||
|
|
||||||
.page-tabs {
|
.page-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
flex-wrap: nowrap;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5900,9 +6004,11 @@ details.settings-section > :not(summary) + :not(summary) {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
flex: 0 0 auto;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-tabs__tab:hover {
|
.page-tabs__tab:hover {
|
||||||
@@ -7876,15 +7982,6 @@ details.settings-section > :not(summary) + :not(summary) {
|
|||||||
grid-template-columns: 1fr;
|
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 {
|
.heartbeat-center__trend-row {
|
||||||
grid-template-columns: 70px minmax(0, 1fr) 18px;
|
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) {
|
@media (max-width: 719px) {
|
||||||
.assistant-sidebar__resize-handle {
|
.assistant-sidebar__resize-handle {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -8007,7 +8130,49 @@ details.settings-section > :not(summary) + :not(summary) {
|
|||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.workspace-panel-scroll {
|
.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 {
|
.agent-runtime-navigation {
|
||||||
@@ -8072,7 +8237,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
|||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
.workspace-panel-scroll {
|
.workspace-panel-scroll {
|
||||||
padding: 16px;
|
padding: var(--page-gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-connection-add {
|
.model-connection-add {
|
||||||
@@ -8094,15 +8259,6 @@ details.settings-section > :not(summary) + :not(summary) {
|
|||||||
grid-template-columns: 1fr;
|
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 {
|
.heartbeat-center__trend-row {
|
||||||
grid-template-columns: 70px minmax(0, 1fr) 18px;
|
grid-template-columns: 70px minmax(0, 1fr) 18px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,25 @@ import { z } from 'zod'
|
|||||||
|
|
||||||
export const applicationSettingsSchema = z
|
export const applicationSettingsSchema = z
|
||||||
.object({
|
.object({
|
||||||
checkUpdatesOnStartup: z.boolean()
|
checkUpdatesOnStartup: z.boolean(),
|
||||||
|
magicNotesEnabled: z.boolean()
|
||||||
})
|
})
|
||||||
.strict()
|
.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<
|
export type ApplicationSettings = z.infer<
|
||||||
typeof applicationSettingsSchema
|
typeof applicationSettingsSchema
|
||||||
>
|
>
|
||||||
|
|
||||||
|
export type ApplicationSettingsUpdate = z.infer<
|
||||||
|
typeof applicationSettingsUpdateSchema
|
||||||
|
>
|
||||||
|
|
||||||
export type VersionCheckFile = {
|
export type VersionCheckFile = {
|
||||||
name: string
|
name: string
|
||||||
size: number
|
size: number
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import type {
|
|||||||
} from './channel-settings-contracts'
|
} from './channel-settings-contracts'
|
||||||
import type {
|
import type {
|
||||||
ApplicationSettings,
|
ApplicationSettings,
|
||||||
|
ApplicationSettingsUpdate,
|
||||||
VersionCheckResult
|
VersionCheckResult
|
||||||
} from './application-settings-contracts'
|
} from './application-settings-contracts'
|
||||||
import type {
|
import type {
|
||||||
@@ -981,7 +982,7 @@ export type DesktopApi = {
|
|||||||
updates?: {
|
updates?: {
|
||||||
getSettings: () => Promise<ApplicationSettings>
|
getSettings: () => Promise<ApplicationSettings>
|
||||||
updateSettings: (
|
updateSettings: (
|
||||||
input: ApplicationSettings
|
input: ApplicationSettingsUpdate
|
||||||
) => Promise<ApplicationSettings>
|
) => Promise<ApplicationSettings>
|
||||||
check: () => Promise<VersionCheckResult>
|
check: () => Promise<VersionCheckResult>
|
||||||
openReleasePage: () => Promise<void>
|
openReleasePage: () => Promise<void>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export const remoteChannelActivitySchema = z
|
|||||||
.object({
|
.object({
|
||||||
requestId: z.string().uuid(),
|
requestId: z.string().uuid(),
|
||||||
conversationId: z.string().uuid(),
|
conversationId: z.string().uuid(),
|
||||||
|
projectId: z.string().uuid(),
|
||||||
|
projectName: z.string().trim().min(1).max(120),
|
||||||
channel: projectChannelSchema,
|
channel: projectChannelSchema,
|
||||||
kind: z.enum(['request', 'tool', 'result']),
|
kind: z.enum(['request', 'tool', 'result']),
|
||||||
title: z.string().trim().min(1).max(240),
|
title: z.string().trim().min(1).max(240),
|
||||||
|
|||||||
Reference in New Issue
Block a user