feat: improve desktop reliability and customization

Address reliability and consistency gaps across Agent Runtimes, persistence, settings, Knowledge, Magic Notes, Smart Heartbeat, and the download site. Runtime processes now have bounded lifecycle cleanup and atomic configuration rollback, while model packages and persisted mutations recover safely.

Add a configurable global shortcut, protect unsaved work, improve modal and keyboard behavior, localize the built-in project without rewriting stored data, and lazy-load heavy renderer routes under enforced bundle budgets. Align project forms and disabled controls with shared typography and interaction states, and strengthen website release metadata validation and navigation accessibility.

Release note: 修复 Runtime、设置、知识库、魔法笔记与智能心跳中的可靠性和交互一致性问题;新增可配置全局快捷键,改进无障碍与加载性能,并强化官网下载校验。
This commit is contained in:
mesalogo
2026-08-20 10:11:06 +08:00
parent 20c15f74c6
commit f44a0dc907
129 changed files with 15511 additions and 2112 deletions
+137
View File
@@ -7,6 +7,10 @@ import {
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TokenUsageSummary } from '../../shared/assistant-contracts'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName
} from '../../shared/assistant-contracts'
import { ActivityPanel } from './ActivityPanel'
import {
MAX_ACTIVITY_RECORDS,
@@ -361,6 +365,139 @@ describe('ActivityPanel', () => {
expect(screen.getByText('范围不可用')).toBeInTheDocument()
})
it('localizes current project IDs while preserving unknown snapshots', async () => {
await i18n.changeLanguage('en-US')
const currentProjectId =
'00000000-0000-4000-8000-000000000101'
const currentRecord: ActivityRecord = {
...makeRecord(1),
scope: {
kind: 'project',
projectId: currentProjectId,
projectName: builtInDefaultProjectSeedName
}
}
const deletedRecord: ActivityRecord = {
...makeRecord(2),
scope: {
kind: 'project',
projectId: 'deleted-project',
projectName: 'Deleted project snapshot'
}
}
const usage = makeTokenUsage()
usage.records = [
{
...usage.records[0]!,
projectId: currentProjectId,
projectName: builtInDefaultProjectSeedName
},
{
...usage.records[0]!,
requestId: 'request-deleted',
projectId: 'deleted-project',
projectName: 'Deleted project snapshot'
}
]
render(
<ActivityPanel
onClear={vi.fn()}
onOpenConversation={vi.fn()}
projects={[
{
id: currentProjectId,
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: 'C:\\Workspace',
defaultWorkMode: 'ask',
kind: 'user',
builtInDefault: true,
status: 'active',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
]}
records={[currentRecord, deletedRecord]}
tokenUsage={usage}
/>
)
expect(screen.getByText('Project: Default project')).toBeInTheDocument()
expect(
screen.getByText('Project: Deleted project snapshot')
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: 'Usage analytics' })
)
expect(screen.getByText('Default project')).toBeInTheDocument()
expect(
screen.getByText('Deleted project snapshot')
).toBeInTheDocument()
})
it('preserves ordinary activity and token snapshots after a project rename', async () => {
await i18n.changeLanguage('en-US')
const projectId = 'ordinary-project'
const usage = makeTokenUsage()
usage.records = [
{
...usage.records[0]!,
projectId,
projectName: 'Original project snapshot'
}
]
render(
<ActivityPanel
onClear={vi.fn()}
onOpenConversation={vi.fn()}
projects={[
{
id: projectId,
name: 'Renamed current project',
description: '',
rootPath: 'C:\\Renamed',
defaultWorkMode: 'ask',
kind: 'user',
status: 'active',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-02T00:00:00.000Z'
}
]}
records={[
{
...makeRecord(1),
scope: {
kind: 'project',
projectId,
projectName: 'Original project snapshot'
}
}
]}
tokenUsage={usage}
/>
)
expect(
screen.getByText('Project: Original project snapshot')
).toBeInTheDocument()
expect(
screen.queryByText('Project: Renamed current project')
).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: 'Usage analytics' })
)
expect(
screen.getByText('Original project snapshot')
).toBeInTheDocument()
expect(
screen.queryByText('Renamed current project')
).not.toBeInTheDocument()
})
it('uses shared page tabs and explicit global scope', () => {
render(
<ActivityPanel
+66 -9
View File
@@ -1,7 +1,11 @@
import { Activity, Trash2 } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { TokenUsageSummary } from '../../shared/assistant-contracts'
import type {
AssistantProject,
TokenUsageSummary
} from '../../shared/assistant-contracts'
import { isUntouchedBuiltInDefaultProject } from '../../shared/assistant-contracts'
import {
MAX_ACTIVITY_RECORDS,
type ActivityRecord
@@ -20,6 +24,7 @@ import {
SegmentedControl,
type WorkspaceScope
} from './WorkspacePrimitives'
import { getProjectDisplayText } from './project-display'
type ActivityFilter = 'all' | 'active' | 'failed'
type ActivityView = 'tasks' | 'timeline' | 'usage'
@@ -31,6 +36,7 @@ type ActivityActorKind =
| 'approval'
export type ActivityPanelProps = {
projects?: readonly AssistantProject[]
records: readonly ActivityRecord[]
tokenUsage: TokenUsageSummary
onClear: () => void
@@ -234,12 +240,14 @@ function groupActivityRecordsByProject(
}
export function ActivityPanel({
projects = [],
records,
tokenUsage,
onClear,
onOpenConversation
}: ActivityPanelProps): React.JSX.Element {
const { t, i18n } = useTranslation('activity')
const { t: tWorkspace } = useTranslation('workspace')
const [activeView, setActiveView] =
useState<ActivityView>('tasks')
const [filter, setFilter] = useState<ActivityFilter>('all')
@@ -329,17 +337,66 @@ export function ActivityPanel({
}
]
const projectDisplayNames = useMemo(
() =>
new Map(
projects.flatMap((project) => {
const displayName = getProjectDisplayText(
project,
tWorkspace
).name
return isUntouchedBuiltInDefaultProject(project) &&
displayName !== project.name
? [[project.id, displayName] as const]
: []
})
),
[projects, tWorkspace]
)
const displayRecords = useMemo(
() =>
records.map((record) => {
if (record.scope.kind !== 'project') {
return record
}
const projectName = projectDisplayNames.get(
record.scope.projectId
)
return projectName
? {
...record,
scope: {
...record.scope,
projectName
}
}
: record
}),
[projectDisplayNames, records]
)
const displayTokenUsage = useMemo(
() => ({
...tokenUsage,
records: tokenUsage.records.map((record) => {
const projectName = record.projectId
? projectDisplayNames.get(record.projectId)
: undefined
return projectName ? { ...record, projectName } : record
})
}),
[projectDisplayNames, tokenUsage]
)
const visibleRecords = useMemo(
() => records.slice(0, MAX_ACTIVITY_RECORDS),
[records]
() => displayRecords.slice(0, MAX_ACTIVITY_RECORDS),
[displayRecords]
)
const filteredRecords = useMemo(
() => visibleRecords.filter((record) => matchesFilter(record, filter)),
[filter, visibleRecords]
)
const projectGroups = useMemo(
() => groupActivityRecordsByProject(filteredRecords, records),
[filteredRecords, records]
() => groupActivityRecordsByProject(filteredRecords, displayRecords),
[displayRecords, filteredRecords]
)
const timelineBounds = useMemo(() => {
const timestamps = filteredRecords.map((record) => record.createdAt)
@@ -366,8 +423,8 @@ export function ActivityPanel({
(record) => record.id === selectedTimelineRecordId
)
const conversationTitles = useMemo(
() => getConversationTitles(records),
[records]
() => getConversationTitles(displayRecords),
[displayRecords]
)
const activeCount = visibleRecords.filter(isActive).length
const failedCount = visibleRecords.filter(isFailed).length
@@ -376,8 +433,8 @@ export function ActivityPanel({
[tokenUsage]
)
const tokenRows = useMemo(
() => groupTokenUsage(tokenUsage, tokenGroup),
[tokenGroup, tokenUsage]
() => groupTokenUsage(displayTokenUsage, tokenGroup),
[displayTokenUsage, tokenGroup]
)
const tokenGroupLabel =
tokenGroups.find((item) => item.value === tokenGroup)?.columnLabel ??
+452 -20
View File
@@ -16,12 +16,17 @@ import type {
DesktopApi
} from '../../shared/contracts'
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
import type {
AssistantProject,
AssistantSchedule,
AssistantTask,
ConversationSnapshot
} from '../../shared/assistant-contracts'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName
} from '../../shared/assistant-contracts'
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
const speechRecognitionMocks = vi.hoisted(() => ({
@@ -48,6 +53,14 @@ const lazyRouteMocks = vi.hoisted(() => {
}
})
const routeModuleLoads = vi.hoisted(() => ({
activity: 0,
heartbeat: 0,
knowledge: 0,
magicNotes: 0,
settings: 0
}))
vi.mock('./speech-recognition', async (importOriginal) => ({
...(await importOriginal<
typeof import('./speech-recognition')
@@ -57,9 +70,30 @@ vi.mock('./speech-recognition', async (importOriginal) => ({
vi.mock('./KnowledgeWorkspace', async (importOriginal) => {
await lazyRouteMocks.waitForKnowledgeRoute()
routeModuleLoads.knowledge += 1
return importOriginal<typeof import('./KnowledgeWorkspace')>()
})
vi.mock('./HeartbeatCenter', async (importOriginal) => {
routeModuleLoads.heartbeat += 1
return importOriginal<typeof import('./HeartbeatCenter')>()
})
vi.mock('./MagicNotesWorkspace', async (importOriginal) => {
routeModuleLoads.magicNotes += 1
return importOriginal<typeof import('./MagicNotesWorkspace')>()
})
vi.mock('./SettingsPanel', async (importOriginal) => {
routeModuleLoads.settings += 1
return importOriginal<typeof import('./SettingsPanel')>()
})
vi.mock('./ActivityPanel', async (importOriginal) => {
routeModuleLoads.activity += 1
return importOriginal<typeof import('./ActivityPanel')>()
})
import App from './App'
import { loadActivityRecords } from './activity-store'
import { changeUiLocale } from './i18n'
@@ -89,11 +123,12 @@ const modelProfileId = '00000000-0000-4000-8000-000000000001'
const projectId = '00000000-0000-4000-8000-000000000101'
const project = {
id: projectId,
name: '默认项目',
description: '测试项目',
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: 'C:\\Users\\test',
defaultWorkMode: 'ask' as const,
kind: 'user' as const,
builtInDefault: true,
status: 'active' as const,
createdAt: '2026-07-31T00:00:00.000Z',
updatedAt: '2026-07-31T00:00:00.000Z'
@@ -981,9 +1016,22 @@ describe('App', () => {
expect(screen.getByText('GOODBUDDY WORKSPACE')).toBeInTheDocument()
expect(
screen.getByRole('heading', {
level: 1,
name: 'Conversation'
})
).toBeInTheDocument()
expect(
screen.getByRole('heading', {
level: 2,
name: 'What would you like to accomplish today?'
})
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Current project' })
).toHaveTextContent('Default project')
expect(screen.getByText('Project: Default project')).toHaveClass(
'scope-badge'
)
expect(
screen.getByText(/Hi, I’m GoodBuddy/u)
).toBeInTheDocument()
@@ -999,6 +1047,140 @@ describe('App', () => {
}
})
it('localizes untouched project labels but keeps settings and destructive values raw', async () => {
const secondProject = {
...project,
id: '00000000-0000-4000-8000-000000000102',
name: 'Second project',
description: 'Another workspace',
rootPath: 'C:\\Second'
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
secondProject
])
vi.mocked(api.tasks.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000901',
projectId,
title: 'Seed project task',
instructions: 'Verify the project label',
origin: 'schedule',
status: 'queued',
workMode: 'ask',
createdAt: '2026-07-31T01:00:00.000Z'
}
])
await changeUiLocale('en-US')
try {
render(<App />)
const projectTrigger = await screen.findByRole('button', {
name: 'Current project'
})
expect(projectTrigger).toHaveTextContent('Default project')
fireEvent.click(projectTrigger)
expect(
within(
screen.getByRole('menu', { name: 'Current project' })
).getByText('Default project', { selector: 'b' })
).toBeInTheDocument()
fireEvent.click(projectTrigger)
fireEvent.click(
screen.getByLabelText('Toggle assistant workspace')
)
fireEvent.click(
await screen.findByRole('tab', { name: 'Task center' })
)
const assistantSidebar = screen.getByLabelText(
'Assistant workspace'
)
expect(
within(assistantSidebar).getByText('Project: Default project')
).toBeInTheDocument()
fireEvent.click(
within(assistantSidebar).getByRole('button', {
name: 'New custom task'
})
)
const taskDialog = screen.getByRole('dialog', {
name: 'New custom task'
})
expect(
within(taskDialog).getByText('Default project')
).toBeInTheDocument()
fireEvent.click(
within(taskDialog).getByRole('button', {
name: 'Close new custom task'
})
)
fireEvent.click(
within(assistantSidebar).getByRole('button', {
name: 'Close assistant workspace'
})
)
fireEvent.click(screen.getByLabelText('Project settings'))
const settingsDialog = screen.getByRole('dialog', {
name: 'Project settings'
})
expect(
within(settingsDialog).getByLabelText('Name')
).toHaveValue(builtInDefaultProjectSeedName)
expect(
within(settingsDialog).getByLabelText('Description')
).toHaveValue(builtInDefaultProjectSeedDescription)
fireEvent.click(
within(settingsDialog).getByRole('button', {
name: 'Delete project'
})
)
const confirmation = within(settingsDialog).getByLabelText(
`Enter “${builtInDefaultProjectSeedName}” to confirm deletion`
)
const deleteButton = within(settingsDialog).getByRole('button', {
name: 'Permanently delete project'
})
fireEvent.change(confirmation, {
target: { value: 'Default project' }
})
expect(deleteButton).toBeDisabled()
fireEvent.change(confirmation, {
target: { value: builtInDefaultProjectSeedName }
})
expect(deleteButton).toBeEnabled()
fireEvent.click(
within(settingsDialog).getByRole('button', {
name: 'Cancel deletion'
})
)
fireEvent.click(
within(settingsDialog).getByRole('button', {
name: 'Save project'
})
)
await waitFor(() =>
expect(api.projects.update).toHaveBeenCalledWith(
projectId,
expect.objectContaining({
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription
})
)
)
expect(
screen.getByRole('button', { name: 'Current project' })
).toHaveTextContent('Default project')
expect(api.projects.delete).not.toHaveBeenCalled()
} finally {
cleanup()
await changeUiLocale('zh-CN')
}
})
it('renders localized workspace branding in Chinese', async () => {
render(<App />)
@@ -1347,13 +1529,41 @@ describe('App', () => {
.toBeInTheDocument()
})
it('schedules lazy workspace routes for idle preloading', () => {
it('idle-preloads only the small Heartbeat route at startup', async () => {
render(<App />)
expect(window.requestIdleCallback).toHaveBeenCalledWith(
expect.any(Function),
{ timeout: 2000 }
)
const idleCallback = vi.mocked(window.requestIdleCallback)
.mock.calls[0]?.[0]
await act(async () => {
idleCallback?.({
didTimeout: false,
timeRemaining: () => 50
})
})
await waitFor(() => expect(routeModuleLoads.heartbeat).toBe(1))
expect(routeModuleLoads).toMatchObject({
activity: 0,
knowledge: 0,
magicNotes: 0,
settings: 0
})
})
it('preloads a heavy route once across repeated pointer and focus intent', async () => {
render(<App />)
const activity = await screen.findByRole('button', {
name: '运行记录'
})
fireEvent.pointerEnter(activity)
fireEvent.focus(activity)
fireEvent.pointerEnter(activity)
await waitFor(() => expect(routeModuleLoads.activity).toBe(1))
})
it('waits for project bootstrap before project-scoped startup loads', async () => {
@@ -1440,6 +1650,33 @@ describe('App', () => {
expect(route).not.toHaveAttribute('hidden')
})
it('enforces the workspace KeepAlive cap during rapid visits', async () => {
let now = 2_000_000
vi.spyOn(Date, 'now').mockImplementation(() => ++now)
render(<App />)
fireEvent.click(
await screen.findByRole('button', { name: '知识库' })
)
await screen.findByRole('heading', { name: '知识库' })
fireEvent.click(screen.getByRole('button', { name: '智能心跳' }))
await screen.findByRole('heading', { name: '智能心跳' })
fireEvent.click(screen.getByRole('button', { name: '运行记录' }))
await screen.findByRole('heading', { name: '运行记录' })
fireEvent.click(screen.getByRole('button', { name: '对话' }))
fireEvent.click(
screen.getByRole('button', { name: /本地工作区/u })
)
await screen.findByRole('heading', { name: '设置中心' })
expect(
document.querySelectorAll('.workspace-route-cache')
).toHaveLength(4)
expect(
document.querySelector('[data-route="knowledge"]')
).not.toBeInTheDocument()
})
it('preserves title, message, and project filtering with deferred search', async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
@@ -2403,14 +2640,9 @@ describe('App', () => {
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(fireEvent.keyDown(dialog, { key: 'Tab' })).toBe(true)
expect(cancel).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() =>
@@ -3309,6 +3541,136 @@ describe('App', () => {
).toBeInTheDocument()
})
it('guards sidebar navigation away from dirty Settings drafts', async () => {
render(<App />)
fireEvent.click(await screen.findByText('本地工作区'))
await screen.findByRole('heading', { name: '设置中心' })
fireEvent.change(await screen.findByLabelText('默认工作区目录'), {
target: { value: 'C:\\Unsaved from App' }
})
fireEvent.click(
screen.getByRole('button', { name: '知识库' })
)
expect(
screen.getByRole('heading', { name: '设置中心' })
).toBeVisible()
expect(screen.getByRole('alert')).toHaveTextContent(
'当前设置有未保存更改'
)
fireEvent.click(
screen.getByRole('button', { name: '放弃更改并关闭' })
)
expect(
await screen.findByRole('heading', { name: '知识库' })
).toBeVisible()
})
it('updates and removes the composer shortcut hint immediately after saving Settings', async () => {
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: false,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
}
let shortcutSnapshot: GlobalShortcutSettingsSnapshot = {
settings: {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
defaultSettings: {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
platform: 'win32',
displayAccelerator: 'Ctrl+Shift+Space',
registered: true,
registeredAccelerator: 'CommandOrControl+Shift+Space',
status: 'registered'
}
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(() => () => {})
}
api.shortcuts = {
getSettings: vi.fn(async () => shortcutSnapshot),
updateSettings: vi.fn(async (input) => {
shortcutSnapshot = {
...shortcutSnapshot,
settings: input,
displayAccelerator:
input.accelerator === 'Control+Alt+K'
? 'Ctrl+Alt+K'
: 'Ctrl+Shift+Space',
registered: input.enabled,
registeredAccelerator: input.enabled
? input.accelerator
: undefined,
status: input.enabled ? 'registered' : 'disabled'
}
return { ok: true as const, snapshot: shortcutSnapshot }
})
}
try {
render(<App />)
expect(
await screen.findByText('Ctrl+Shift+Space')
).toBeInTheDocument()
fireEvent.click(await screen.findByText('本地工作区'))
await screen.findByRole('heading', { name: '设置中心' })
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const shortcutInput = await screen.findByLabelText('快捷键')
fireEvent.change(shortcutInput, {
target: { value: 'Control+Alt+K' }
})
fireEvent.click(
screen.getByRole('button', { name: '保存快捷键' })
)
await waitFor(() =>
expect(api.shortcuts?.updateSettings).toHaveBeenCalledWith({
enabled: true,
accelerator: 'Control+Alt+K'
})
)
fireEvent.click(screen.getByRole('button', { name: '对话' }))
expect(await screen.findByText('Ctrl+Alt+K')).toBeInTheDocument()
fireEvent.click(await screen.findByText('本地工作区'))
await screen.findByRole('heading', { name: '设置中心' })
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const shortcutSwitch = await screen.findByRole('switch', {
name: '启用全局快捷键'
})
fireEvent.click(shortcutSwitch)
fireEvent.click(
screen.getByRole('button', { name: '保存快捷键' })
)
await waitFor(() =>
expect(api.shortcuts?.updateSettings).toHaveBeenCalledWith({
enabled: false,
accelerator: 'Control+Alt+K'
})
)
fireEvent.click(screen.getByRole('button', { name: '对话' }))
await screen.findByLabelText('向 GoodBuddy 提问')
expect(
screen.queryByText('Ctrl+Alt+K')
).not.toBeInTheDocument()
} finally {
delete api.shortcuts
delete api.updates
}
})
it('restores persisted context usage and compression state after restart', async () => {
const settings = await api.settings.getRuntime()
const profile = settings.modelProfiles[0]!
@@ -3486,11 +3848,52 @@ describe('App', () => {
evidence: []
})
render(<App />)
await screen.findByRole('button', {
const knowledgeScopeTrigger = await screen.findByRole('button', {
name: '选择知识库,本次已启用 1 个'
})
expect(knowledgeScopeTrigger).toHaveAttribute(
'aria-haspopup',
'dialog'
)
fireEvent.click(knowledgeScopeTrigger)
expect(knowledgeScopeTrigger).toHaveAttribute(
'aria-expanded',
'true'
)
expect(
screen.getByRole('dialog', {
name: '本次对话检索范围'
})
).toBeInTheDocument()
await waitFor(() =>
expect(
screen.getByRole('checkbox', { name: /产品知识/u })
).toHaveFocus()
)
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(knowledgeScopeTrigger).toHaveAttribute(
'aria-expanded',
'false'
)
expect(knowledgeScopeTrigger).toHaveFocus()
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
fireEvent.click(knowledgeScopeTrigger)
const scopeCheckbox = screen.getByRole('checkbox', {
name: /产品知识/u
})
await waitFor(() => expect(scopeCheckbox).toHaveFocus())
const composerInput = screen.getByLabelText('向 GoodBuddy 提问')
fireEvent.focusOut(scopeCheckbox, {
relatedTarget: composerInput
})
composerInput.focus()
expect(knowledgeScopeTrigger).toHaveAttribute(
'aria-expanded',
'false'
)
expect(composerInput).toHaveFocus()
fireEvent.change(composerInput, {
target: { value: '发布流程是什么?' }
})
fireEvent.click(await screen.findByLabelText('发送'))
@@ -3896,25 +4299,35 @@ describe('App', () => {
expect(
within(userArticle).getByRole('img', { name: '页面截图.png' })
).toHaveAttribute('src', imageAttachment.contentUrl)
fireEvent.click(
within(userArticle).getByRole('button', {
name: '查看图片 页面截图.png'
})
)
const viewerTrigger = within(userArticle).getByRole('button', {
name: '查看图片 页面截图.png'
})
fireEvent.click(viewerTrigger)
const imageDialog = await screen.findByRole('dialog', {
name: '页面截图.png'
})
expect(
within(imageDialog).getByRole('img', { name: '页面截图.png' })
).toHaveAttribute('src', imageAttachment.contentUrl)
fireEvent.click(
const closeViewer = within(imageDialog).getByRole('button', {
name: '关闭图片查看器'
})
expect(closeViewer).toHaveFocus()
expect(document.querySelector('main')?.inert).toBe(true)
fireEvent.keyDown(closeViewer, { key: 'Tab' })
expect(
within(imageDialog).getByRole('button', {
name: '关闭图片查看器'
name: '下载图片'
})
).toHaveFocus()
fireEvent.keyDown(imageDialog, { key: 'Escape' })
await waitFor(() =>
expect(viewerTrigger).toHaveFocus()
)
expect(
screen.queryByRole('dialog', { name: '页面截图.png' })
).not.toBeInTheDocument()
expect(document.querySelector('main')?.inert).toBe(false)
fireEvent.click(
within(userArticle).getByRole('button', {
name: '下载图片 页面截图.png'
@@ -4402,6 +4815,7 @@ describe('App', () => {
await waitFor(() =>
expect(api.workspace.getChanges).toHaveBeenCalledWith(projectId)
)
fireEvent.click(screen.getByLabelText('关闭助手工作栏'))
selectProjectOption(secondProject.name)
await waitFor(() =>
expect(api.workspace.getChanges).toHaveBeenCalledWith(
@@ -4417,6 +4831,7 @@ describe('App', () => {
files: [{ path: 'second.md', status: '??' }],
truncated: false
})
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
expect(await screen.findByText('second.md')).toBeInTheDocument()
resolveFirst?.({
rootPath: project.rootPath,
@@ -7296,8 +7711,19 @@ describe('App', () => {
const sidebar = screen.getByLabelText('助手工作栏')
expect(sidebar).not.toHaveClass('assistant-sidebar--open')
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
const assistantTrigger =
screen.getByLabelText('切换助手工作栏')
fireEvent.click(assistantTrigger)
expect(sidebar).toHaveClass('assistant-sidebar--open')
expect(sidebar).toHaveAttribute('role', 'dialog')
expect(sidebar).toHaveAttribute('aria-modal', 'true')
const main = document.querySelector('main')
expect(main).toHaveAttribute('inert')
await waitFor(() =>
expect(
screen.getByRole('tab', { name: '任务中心' })
).toHaveFocus()
)
expect(
screen.getByRole('tab', { name: '任务中心' })
@@ -7328,6 +7754,12 @@ describe('App', () => {
).not.toBeInTheDocument()
fireEvent.click(screen.getByLabelText('关闭助手工作栏'))
expect(sidebar).not.toHaveClass('assistant-sidebar--open')
expect(main).not.toHaveAttribute('inert')
await waitFor(() => expect(assistantTrigger).toHaveFocus())
fireEvent.click(assistantTrigger)
fireEvent.keyDown(sidebar, { key: 'Escape' })
expect(sidebar).not.toHaveClass('assistant-sidebar--open')
})
it('keeps completed chat replies out of the results sidebar', async () => {
@@ -7757,7 +8189,7 @@ describe('App', () => {
)
fireEvent.click(
screen.getByRole('button', { name: '关闭侧栏' })
within(sidebar).getByRole('button', { name: '知识库' })
)
await waitFor(() => expect(toggle).toHaveFocus())
expect(sidebar).toHaveClass('sidebar--closed')
+325 -44
View File
@@ -127,7 +127,6 @@ import {
normalizeInteractiveWorkMode,
projectChannelLabels
} from '../../shared/assistant-contracts'
import { ActivityPanel } from './ActivityPanel'
import {
ChatTimeline,
type ImageViewerItem,
@@ -171,6 +170,8 @@ import { ConversationInputQueue } from './ConversationInputQueue'
import { OverflowMarquee } from './OverflowMarquee'
import { findTaskSchedule } from './TaskScheduleActions'
import type { SettingsCategoryId } from './settings-categories'
import type { SettingsLeaveRequester } from './SettingsPanel'
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
import goodbuddyDarkIcon from './assets/goodbuddy-dark.png'
import goodbuddyLightIcon from './assets/goodbuddy-light.png'
import {
@@ -202,10 +203,13 @@ import {
import { formatMediumDateTime } from './locale-formatters'
import { formatCompactTokens } from './token-format'
import {
filterKeepAliveEntries,
pruneKeepAliveEntries,
touchKeepAliveEntry,
touchAndPruneKeepAliveEntries,
type KeepAliveCacheEntry
} from './keep-alive-cache'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { getProjectDisplayText } from './project-display'
const knowledgeWorkspaceRoute = createPreloadableComponent(
() => import('./KnowledgeWorkspace'),
@@ -223,17 +227,19 @@ const settingsPanelRoute = createPreloadableComponent(
() => import('./SettingsPanel'),
(module) => module.SettingsPanel
)
const activityPanelRoute = createPreloadableComponent(
() => import('./ActivityPanel'),
(module) => module.ActivityPanel
)
const idleRouteModuleLoaders = [
knowledgeWorkspaceRoute.preload,
heartbeatCenterRoute.preload,
magicNotesWorkspaceRoute.preload,
settingsPanelRoute.preload
heartbeatCenterRoute.preload
] as const
const KnowledgeWorkspace = knowledgeWorkspaceRoute.Component
const HeartbeatCenter = heartbeatCenterRoute.Component
const MagicNotesWorkspace = magicNotesWorkspaceRoute.Component
const SettingsPanel = settingsPanelRoute.Component
const ActivityPanel = activityPanelRoute.Component
const messageRenderBatchSize = 80
const conversationPersistenceIntervalMs = 500
@@ -501,6 +507,24 @@ type WorkspaceView =
| 'activity'
| 'settings'
const intentRoutePreloaders: Partial<
Record<WorkspaceView, () => Promise<unknown>>
> = {
'magic-notes': magicNotesWorkspaceRoute.preload,
knowledge: knowledgeWorkspaceRoute.preload,
activity: activityPanelRoute.preload,
settings: settingsPanelRoute.preload
}
function preloadWorkspaceRouteOnIntent(view: WorkspaceView): void {
const preload = intentRoutePreloaders[view]
if (preload) {
void preload().catch(() => {
// Click and programmatic navigation retain their local retry boundary.
})
}
}
const emptyTokenUsage: TokenUsageSummary = {
totals: {
callCount: 0,
@@ -743,6 +767,7 @@ function ChatHistoryPane({
visibleMessageCount: number
}): React.JSX.Element {
const { t } = useTranslation('app')
const headingId = `chat-heading-${conversation.id}`
const scrollRef = useRef<HTMLElement>(null)
const pinnedToBottomRef = useRef(
scrollSnapshot?.pinnedToBottom ?? true
@@ -948,8 +973,12 @@ function ChatHistoryPane({
hidden={!active}
inert={!active}
>
<h1 className="sr-only" id={headingId}>
{t('chat.heading')}
</h1>
{taskStrip}
<section
aria-labelledby={headingId}
className="chat"
id={active ? 'chat-message-list' : undefined}
onScroll={updateScrollPosition}
@@ -961,7 +990,7 @@ function ChatHistoryPane({
<Sparkles size={18} />
</div>
<p className="eyebrow">{t('chat.welcome.eyebrow')}</p>
<h1>{t('chat.welcome.title')}</h1>
<h2>{t('chat.welcome.title')}</h2>
<p className="welcome__description">
{t('chat.welcome.description')}
</p>
@@ -2112,12 +2141,17 @@ function App(): React.JSX.Element {
const [assistantSidebarOpen, setAssistantSidebarOpen] = useState(
() => window.innerWidth >= 1280
)
const [assistantSidebarOverlay, setAssistantSidebarOverlay] =
useState(() => window.innerWidth < 1280)
const [assistantSidebarTab, setAssistantSidebarTab] =
useState<AssistantSidebarTab>('tasks')
const [browserStates, setBrowserStates] = useState<
Record<string, BrowserLiveState>
>({})
const [view, setViewState] = useState<WorkspaceView>('chat')
const settingsLeaveRequesterRef = useRef<
SettingsLeaveRequester | undefined
>(undefined)
const [cachedWorkspaceViews, setCachedWorkspaceViews] = useState<
KeepAliveCacheEntry<WorkspaceView>[]
>(() => [{ key: 'chat', lastVisitedAt: Date.now() }])
@@ -2128,17 +2162,83 @@ function App(): React.JSX.Element {
? [{ key: activeId, lastVisitedAt: Date.now() }]
: []
)
const commitView = useCallback(
(next: WorkspaceView): void => {
const now = Date.now()
const runningConversationIds = new Set(
[...activeRuns.current.values()].map((run) => run.conversationId)
)
preparingConversations.current.forEach((conversationId) =>
runningConversationIds.add(conversationId)
)
const protectedWorkspaceViews = new Set<WorkspaceView>()
if (runningConversationIds.size > 0) {
protectedWorkspaceViews.add('chat')
protectedWorkspaceViews.add('activity')
}
if (knowledgeOperationCountRef.current > 0) {
protectedWorkspaceViews.add('knowledge')
protectedWorkspaceViews.add('activity')
}
if (
assistantTasksRef.current.some(
(task) =>
task.status === 'queued' ||
task.status === 'running' ||
task.status === 'waiting_approval'
)
) {
protectedWorkspaceViews.add('activity')
}
viewRef.current = next
setCachedWorkspaceViews((current) =>
touchAndPruneKeepAliveEntries(current, next, now, {
expiresAfterMs: keepAliveExpirationMs,
maximumEntries: maximumCachedWorkspaceViews,
protectedKeys: protectedWorkspaceViews,
recentEntries: recentCachedWorkspaceViews
})
)
setViewState(next)
},
[]
)
const setView = useCallback(
(update: SetStateAction<WorkspaceView>): void => {
const next =
typeof update === 'function'
? update(viewRef.current)
: update
viewRef.current = next
setCachedWorkspaceViews((current) =>
touchKeepAliveEntry(current, next, Date.now())
if (viewRef.current === 'settings' && next !== 'settings') {
const requestLeave = settingsLeaveRequesterRef.current
if (requestLeave) {
requestLeave(() => commitView(next))
return
}
}
commitView(next)
},
[commitView]
)
const registerSettingsLeaveRequester = useCallback(
(requester: SettingsLeaveRequester | undefined): void => {
settingsLeaveRequesterRef.current = requester
},
[]
)
const handleShortcutSettingsChanged = useCallback(
(snapshot: GlobalShortcutSettingsSnapshot): void => {
setAppInfo((current) =>
current
? {
...current,
shortcut: snapshot.registered
? snapshot.displayAccelerator
: '',
shortcutStatus: snapshot.status
}
: current
)
setViewState(next)
},
[]
)
@@ -2150,8 +2250,20 @@ function App(): React.JSX.Element {
: update
activeConversationIdRef.current = next
if (next) {
const now = Date.now()
const runningConversationIds = new Set(
[...activeRuns.current.values()].map((run) => run.conversationId)
)
preparingConversations.current.forEach((conversationId) =>
runningConversationIds.add(conversationId)
)
setCachedConversationViews((current) =>
touchKeepAliveEntry(current, next, Date.now())
touchAndPruneKeepAliveEntries(current, next, now, {
expiresAfterMs: keepAliveExpirationMs,
maximumEntries: maximumCachedConversations,
protectedKeys: runningConversationIds,
recentEntries: recentCachedConversations
})
)
}
setActiveIdState(next)
@@ -2234,6 +2346,14 @@ function App(): React.JSX.Element {
const imageViewerTriggerRef = useRef<HTMLElement | undefined>(
undefined
)
const imageViewerDialogRef = useRef<HTMLElement>(null)
const imageViewerCloseRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!imageViewerItem) {
return
}
return activateModalFocus(() => imageViewerCloseRef.current)
}, [imageViewerItem])
useEffect(
() =>
window.goodbuddy.context.onFileSelectionProgress((progress) => {
@@ -2255,6 +2375,7 @@ function App(): React.JSX.Element {
const [knowledgeLoading, setKnowledgeLoading] = useState(true)
const [knowledgeLoadError, setKnowledgeLoadError] = useState<string>()
const [knowledgeOperationCount, setKnowledgeOperationCount] = useState(0)
const knowledgeOperationCountRef = useRef(knowledgeOperationCount)
const knowledgeLoadRequestRef = useRef(0)
const failedKnowledgeLibraryIdRef = useRef<string | undefined>(
undefined
@@ -2263,6 +2384,8 @@ function App(): React.JSX.Element {
string[]
>([])
const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false)
const knowledgeScopeTriggerRef = useRef<HTMLButtonElement>(null)
const knowledgeScopePopoverRef = useRef<HTMLDivElement>(null)
const [activityRecords, setActivityRecords] = useState<ActivityRecord[]>(
loadActivityRecords
)
@@ -2314,6 +2437,7 @@ function App(): React.JSX.Element {
>({})
const sidebarRef = useRef<HTMLElement>(null)
const sidebarToggleRef = useRef<HTMLButtonElement>(null)
const assistantSidebarToggleRef = useRef<HTMLButtonElement>(null)
const conversationActionTriggerRefs = useRef(
new Map<string, HTMLButtonElement>()
)
@@ -2339,6 +2463,15 @@ function App(): React.JSX.Element {
setSidebarOpen(false)
requestAnimationFrame(() => sidebarToggleRef.current?.focus())
}, [])
const navigateFromSidebar = useCallback(
(nextView: WorkspaceView): void => {
setView(nextView)
if (narrowWindow) {
closeNarrowSidebar()
}
},
[closeNarrowSidebar, narrowWindow, setView]
)
useEffect(() => {
if (!conversationStoreReady) {
@@ -2399,7 +2532,9 @@ function App(): React.JSX.Element {
}
setCachedConversationViews((current) =>
pruneKeepAliveEntries(
current.filter((entry) => conversationIds.has(entry.key)),
filterKeepAliveEntries(current, (entry) =>
conversationIds.has(entry.key)
),
{
currentKey: activeId,
expiresAfterMs: keepAliveExpirationMs,
@@ -2429,6 +2564,7 @@ function App(): React.JSX.Element {
const collapseSidebarAtNarrowWidth = (): void => {
const narrow = window.innerWidth < 900
setNarrowWindow(narrow)
setAssistantSidebarOverlay(window.innerWidth < 1280)
if (narrow) {
setSidebarOpen(false)
}
@@ -2462,6 +2598,41 @@ function App(): React.JSX.Element {
}
}, [closeNarrowSidebar, narrowWindow, sidebarOpen])
useEffect(() => {
if (!knowledgeScopeOpen) {
return
}
const focusFrame = requestAnimationFrame(() => {
knowledgeScopePopoverRef.current
?.querySelector<HTMLInputElement>('input')
?.focus()
})
const isScopeTarget = (target: EventTarget | null): boolean =>
target instanceof Node &&
(knowledgeScopePopoverRef.current?.contains(target) === true ||
knowledgeScopeTriggerRef.current?.contains(target) === true)
const closeOnOutsidePointer = (event: PointerEvent): void => {
if (!isScopeTarget(event.target)) {
setKnowledgeScopeOpen(false)
}
}
const closeOnEscape = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') {
return
}
event.preventDefault()
setKnowledgeScopeOpen(false)
knowledgeScopeTriggerRef.current?.focus()
}
document.addEventListener('pointerdown', closeOnOutsidePointer)
document.addEventListener('keydown', closeOnEscape)
return () => {
cancelAnimationFrame(focusFrame)
document.removeEventListener('pointerdown', closeOnOutsidePointer)
document.removeEventListener('keydown', closeOnEscape)
}
}, [knowledgeScopeOpen])
useLayoutEffect(() => {
conversationsRef.current = conversations
}, [conversations])
@@ -3094,6 +3265,9 @@ function App(): React.JSX.Element {
() => projects.find((project) => project.id === activeProjectId),
[activeProjectId, projects]
)
const activeProjectDisplayName = activeProject
? getProjectDisplayText(activeProject, tWorkspace).name
: undefined
const filteredConversations = useMemo(() => {
const query = deferredSearchQuery.trim().toLocaleLowerCase()
const candidates = query
@@ -3157,8 +3331,13 @@ function App(): React.JSX.Element {
)
const projectNames = useMemo(
() =>
new Map(projects.map((project) => [project.id, project.name])),
[projects]
new Map(
projects.map((project) => [
project.id,
getProjectDisplayText(project, tWorkspace).name
])
),
[projects, tWorkspace]
)
const pendingSidebarApprovals = useMemo<PendingSidebarApproval[]>(
() =>
@@ -5680,11 +5859,10 @@ function App(): React.JSX.Element {
}, [])
const closeImageViewer = (): void => {
const trigger = imageViewerTriggerRef.current
setImageViewerItem(undefined)
requestAnimationFrame(() => {
imageViewerTriggerRef.current?.focus()
imageViewerTriggerRef.current = undefined
})
imageViewerTriggerRef.current = undefined
requestAnimationFrame(() => trigger?.focus())
}
const openCitationContext = useCallback(async (
@@ -6665,7 +6843,11 @@ function App(): React.JSX.Element {
const runKnowledgeSourceAction = async <T,>(
action: () => Promise<T>
): Promise<T> => {
setKnowledgeOperationCount((count) => count + 1)
setKnowledgeOperationCount((count) => {
const next = count + 1
knowledgeOperationCountRef.current = next
return next
})
try {
const result = await action()
await refreshSelectedKnowledge()
@@ -6674,7 +6856,11 @@ function App(): React.JSX.Element {
await refreshSelectedKnowledge().catch(() => undefined)
throw error
} finally {
setKnowledgeOperationCount((count) => Math.max(0, count - 1))
setKnowledgeOperationCount((count) => {
const next = Math.max(0, count - 1)
knowledgeOperationCountRef.current = next
return next
})
}
}
@@ -7079,16 +7265,28 @@ function App(): React.JSX.Element {
runtimeSettings
])
const assistantOverlayOpen =
assistantSidebarOverlay &&
assistantSidebarOpen &&
view === 'chat'
const mainSidebarOpen = narrowWindow && sidebarOpen
const backgroundIsolated = mainSidebarOpen || assistantOverlayOpen
return (
<div className="app-shell">
<aside
aria-label={
narrowWindow && sidebarOpen ? t('sidebar.label') : undefined
}
aria-hidden={!sidebarOpen}
aria-hidden={!sidebarOpen || assistantOverlayOpen}
aria-modal={narrowWindow && sidebarOpen ? 'true' : undefined}
className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'}
inert={!sidebarOpen}
inert={!sidebarOpen || assistantOverlayOpen}
onKeyDown={(event) => {
if (mainSidebarOpen) {
trapTabFocus(event, sidebarRef.current)
}
}}
ref={sidebarRef}
role={narrowWindow && sidebarOpen ? 'dialog' : undefined}
>
@@ -7152,7 +7350,7 @@ function App(): React.JSX.Element {
className={
view === 'chat' ? 'nav-item nav-item--active' : 'nav-item'
}
onClick={() => setView('chat')}
onClick={() => navigateFromSidebar('chat')}
type="button"
>
<MessageSquare aria-hidden="true" size={17} />
@@ -7166,7 +7364,11 @@ function App(): React.JSX.Element {
? 'nav-item nav-item--active'
: 'nav-item'
}
onClick={() => setView('magic-notes')}
onFocus={() => preloadWorkspaceRouteOnIntent('magic-notes')}
onClick={() => navigateFromSidebar('magic-notes')}
onPointerEnter={() =>
preloadWorkspaceRouteOnIntent('magic-notes')
}
type="button"
>
<Sparkles aria-hidden="true" size={17} />
@@ -7180,7 +7382,11 @@ function App(): React.JSX.Element {
? 'nav-item nav-item--active'
: 'nav-item'
}
onClick={() => setView('knowledge')}
onFocus={() => preloadWorkspaceRouteOnIntent('knowledge')}
onClick={() => navigateFromSidebar('knowledge')}
onPointerEnter={() =>
preloadWorkspaceRouteOnIntent('knowledge')
}
type="button"
>
<Library aria-hidden="true" size={17} />
@@ -7193,7 +7399,7 @@ function App(): React.JSX.Element {
? 'nav-item nav-item--active'
: 'nav-item'
}
onClick={() => setView('heartbeat')}
onClick={() => navigateFromSidebar('heartbeat')}
type="button"
>
<HeartPulse aria-hidden="true" size={17} />
@@ -7216,7 +7422,11 @@ function App(): React.JSX.Element {
? 'nav-item nav-item--active'
: 'nav-item'
}
onClick={() => setView('activity')}
onFocus={() => preloadWorkspaceRouteOnIntent('activity')}
onClick={() => navigateFromSidebar('activity')}
onPointerEnter={() =>
preloadWorkspaceRouteOnIntent('activity')
}
type="button"
>
<TerminalSquare aria-hidden="true" size={17} />
@@ -7292,6 +7502,9 @@ function App(): React.JSX.Element {
return next
})
setView('chat')
if (narrowWindow) {
closeNarrowSidebar()
}
}}
>
<span className="conversation-item__primary">
@@ -7517,7 +7730,12 @@ function App(): React.JSX.Element {
? 'conversation-task-child conversation-task-child--active'
: 'conversation-task-child'
}
onClick={() => openAssistantTask(task)}
onClick={() => {
openAssistantTask(task)
if (narrowWindow) {
closeNarrowSidebar()
}
}}
type="button"
>
<span
@@ -7549,6 +7767,9 @@ function App(): React.JSX.Element {
)
setActiveId(conversation.id)
setView('chat')
if (narrowWindow) {
closeNarrowSidebar()
}
}}
type="button"
>
@@ -7577,7 +7798,11 @@ function App(): React.JSX.Element {
<button
className="user-card"
type="button"
onClick={() => setView('settings')}
onFocus={() => preloadWorkspaceRouteOnIntent('settings')}
onClick={() => navigateFromSidebar('settings')}
onPointerEnter={() =>
preloadWorkspaceRouteOnIntent('settings')
}
>
<span className="avatar">GB</span>
<span className="user-card__copy">
@@ -7602,9 +7827,9 @@ function App(): React.JSX.Element {
)}
<main
aria-hidden={narrowWindow && sidebarOpen ? 'true' : undefined}
aria-hidden={backgroundIsolated ? 'true' : undefined}
className="workspace"
inert={narrowWindow && sidebarOpen}
inert={backgroundIsolated}
>
<header className="topbar">
<button
@@ -7648,7 +7873,8 @@ function App(): React.JSX.Element {
activeProject
? {
kind: 'project',
projectName: activeProject.name
projectName:
activeProjectDisplayName ?? activeProject.name
}
: {
kind: 'unavailable',
@@ -7689,6 +7915,7 @@ function App(): React.JSX.Element {
onClick={() =>
setAssistantSidebarOpen((current) => !current)
}
ref={assistantSidebarToggleRef}
type="button"
>
<PanelRightOpen size={18} />
@@ -8065,8 +8292,20 @@ function App(): React.JSX.Element {
</button>
</div>
{knowledgeSnapshot.libraries.length > 0 && (
<div className="knowledge-scope">
<div
className="knowledge-scope"
onBlurCapture={(event) => {
if (
!(event.relatedTarget instanceof Node) ||
!event.currentTarget.contains(event.relatedTarget)
) {
setKnowledgeScopeOpen(false)
}
}}
>
<button
aria-controls="knowledge-scope-popover"
aria-haspopup="dialog"
aria-label={t('composer.knowledge.select', {
count: enabledKnowledgeLibraryIds.length
})}
@@ -8074,6 +8313,7 @@ function App(): React.JSX.Element {
onClick={() =>
setKnowledgeScopeOpen((current) => !current)
}
ref={knowledgeScopeTriggerRef}
title={t('composer.knowledge.title')}
type="button"
>
@@ -8084,7 +8324,13 @@ function App(): React.JSX.Element {
</span>
</button>
{knowledgeScopeOpen && (
<div className="knowledge-scope__popover">
<div
aria-label={t('composer.knowledge.scope')}
className="knowledge-scope__popover"
id="knowledge-scope-popover"
ref={knowledgeScopePopoverRef}
role="dialog"
>
<strong>{t('composer.knowledge.scope')}</strong>
{knowledgeSnapshot.libraries.map((library) => (
<label key={library.id}>
@@ -9132,7 +9378,7 @@ function App(): React.JSX.Element {
onClose={() => {
setSettingsInitialCategory(undefined)
setSettingsInitialChannel(undefined)
setView('chat')
commitView('chat')
}}
onExpertsChanged={(experts) => {
setAssistantExperts(experts)
@@ -9151,9 +9397,11 @@ function App(): React.JSX.Element {
setMagicNotesEnabled(enabled)
}}
onNotify={notify}
onLeaveRequestReady={registerSettingsLeaveRequester}
onSaved={(settings) => {
setRuntimeSettings(settings)
}}
onShortcutSettingsChanged={handleShortcutSettingsChanged}
onUpdateProject={updateProject}
open={view === 'settings'}
presentation="page"
@@ -9170,12 +9418,29 @@ function App(): React.JSX.Element {
route="activity"
>
<PageShell variant="dashboard">
<ActivityPanel
onClear={() => setActivityRecords([])}
onOpenConversation={openActivityConversation}
records={activityRecords}
tokenUsage={tokenUsage}
/>
<RouteErrorBoundary
key="activity"
fallback={
<RouteLoadError
message={t('route.loadFailed')}
reloadLabel={t('route.reload')}
/>
}
>
<Suspense
fallback={
<RouteLoadingStatus label={t('route.loading')} />
}
>
<ActivityPanel
onClear={() => setActivityRecords([])}
onOpenConversation={openActivityConversation}
projects={projects}
records={activityRecords}
tokenUsage={tokenUsage}
/>
</Suspense>
</RouteErrorBoundary>
</PageShell>
</KeepAliveRoute>
)}
@@ -9233,9 +9498,13 @@ function App(): React.JSX.Element {
className="image-viewer-dialog"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
closeImageViewer()
return
}
trapTabFocus(event, imageViewerDialogRef.current)
}}
ref={imageViewerDialogRef}
role="dialog"
>
<header className="image-viewer-dialog__header">
@@ -9253,9 +9522,9 @@ function App(): React.JSX.Element {
</button>
<button
aria-label={t('chat.images.closeViewer')}
autoFocus
className="icon-button"
onClick={closeImageViewer}
ref={imageViewerCloseRef}
type="button"
>
<X size={16} />
@@ -9283,7 +9552,9 @@ function App(): React.JSX.Element {
onClose={() => setCustomTaskDialog(undefined)}
onCreate={createCustomTask}
projectId={activeProject.id}
projectName={activeProject.name}
projectName={
activeProjectDisplayName ?? activeProject.name
}
runtimeLabel={activeRuntimeLabel}
supportsToolExecution={Boolean(
runtime?.supportsToolExecution
@@ -9293,6 +9564,14 @@ function App(): React.JSX.Element {
}
/>
)}
{assistantOverlayOpen && (
<button
aria-label={tWorkspace('sidebar.dismissOverlay')}
className="assistant-sidebar-backdrop"
onClick={() => setAssistantSidebarOpen(false)}
type="button"
/>
)}
<RightAssistantSidebar
approvals={pendingSidebarApprovals}
artifacts={sidebarArtifacts}
@@ -9386,6 +9665,8 @@ function App(): React.JSX.Element {
open={
assistantSidebarOpen && view === 'chat'
}
overlay={assistantSidebarOverlay}
restoreFocusRef={assistantSidebarToggleRef}
tab={assistantSidebarTab}
workspaceChanges={workspaceChanges}
workspaceProjectId={activeProjectId || undefined}
@@ -123,6 +123,7 @@ const snapshot: DocumentParsingSnapshot = {
}
const getSnapshot = vi.fn(async () => snapshot)
const getOcrModelProgress = vi.fn(async () => ({ operations: [] }))
const update = vi.fn(async (input: DocumentParsingSettings) => ({
...snapshot,
settings: input
@@ -181,6 +182,7 @@ describe('DocumentParsingSettingsSection', () => {
value: {
documentParsing: {
getSnapshot,
getOcrModelProgress,
update,
test,
installOcrModel,
@@ -319,6 +321,51 @@ describe('DocumentParsingSettingsSection', () => {
)
})
it('waits for each OCR progress request before scheduling the next', async () => {
let resolveProgress:
| ((value: { operations: [] }) => void)
| undefined
let resolveInstall:
| ((value: DocumentParsingSnapshot) => void)
| undefined
getOcrModelProgress.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveProgress = resolve
})
)
installOcrModel.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveInstall = resolve
})
)
render(<DocumentParsingSettingsSection />)
const installButton = await screen.findByRole('button', {
name: '下载 PP-OCRv6 Tiny'
})
vi.useFakeTimers()
try {
fireEvent.click(installButton)
await vi.advanceTimersByTimeAsync(300)
expect(getOcrModelProgress).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1_200)
expect(getOcrModelProgress).toHaveBeenCalledTimes(1)
resolveProgress?.({ operations: [] })
await vi.advanceTimersByTimeAsync(299)
expect(getOcrModelProgress).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(getOcrModelProgress).toHaveBeenCalledTimes(2)
resolveInstall?.(snapshot)
await vi.runAllTimersAsync()
} finally {
vi.useRealTimers()
}
})
it('downloads and selects an uninstalled model in one action', async () => {
const installedMedium = {
...snapshot,
@@ -463,17 +510,35 @@ describe('DocumentParsingSettingsSection', () => {
render(<DocumentParsingSettingsSection />)
await screen.findByText('PP-OCRv6 Tiny')
fireEvent.click(
screen.getByRole('button', {
name: '测试聊天与成果模式'
})
)
const trigger = screen.getByRole('button', {
name: '测试聊天与成果模式'
})
trigger.focus()
fireEvent.click(trigger)
const dialog = await screen.findByRole('dialog', {
name: '解析测试结果'
})
expect(dialog).toHaveTextContent('扫描件识别正文')
const close = screen.getByRole('button', {
name: '关闭结果'
})
expect(close).toHaveFocus()
const backgroundSection = trigger.closest<HTMLElement>(
'.settings-section'
)
expect(backgroundSection?.inert).toBe(true)
expect(
await screen.findByRole('dialog', {
name: '解析测试结果'
})
).toHaveTextContent('扫描件识别正文')
fireEvent.keyDown(dialog, { key: 'Tab' })
).toBe(false)
expect(close).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
expect(trigger).toHaveFocus()
expect(backgroundSection?.inert).toBe(false)
expect(test).toHaveBeenCalledWith('chat-attachment')
expect(update).not.toHaveBeenCalled()
})
@@ -29,6 +29,7 @@ import type {
DocumentParsingTestPurpose
} from '../../shared/document-parsing-contracts'
import type { AppNotificationInput } from './notifications'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import {
SettingsCategoryHeader,
SettingsWarningList
@@ -37,6 +38,7 @@ import {
type DocumentParsingSettingsSectionProps = {
onNotify?: (notification: AppNotificationInput) => void
onOpenModelDownloadSourceSettings?: () => void
onDirtyChange?: (dirty: boolean) => void
}
function errorMessage(reason: unknown, fallback: string): string {
@@ -117,9 +119,10 @@ function DiagnosticDialog({
onClose: () => void
}): React.JSX.Element {
const { t } = useTranslation('settings')
const dialogRef = useRef<HTMLElement>(null)
const closeRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
closeRef.current?.focus()
return activateModalFocus(() => closeRef.current)
}, [])
return (
<div
@@ -136,9 +139,13 @@ function DiagnosticDialog({
className="document-parsing-diagnostic"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
trapTabFocus(event, dialogRef.current)
}}
ref={dialogRef}
role="dialog"
>
<header>
@@ -209,7 +216,8 @@ function DiagnosticDialog({
export function DocumentParsingSettingsSection({
onNotify,
onOpenModelDownloadSourceSettings
onOpenModelDownloadSourceSettings,
onDirtyChange
}: DocumentParsingSettingsSectionProps): React.JSX.Element {
const { t } = useTranslation('settings')
const [snapshot, setSnapshot] = useState<DocumentParsingSnapshot>()
@@ -223,6 +231,14 @@ export function DocumentParsingSettingsSection({
const [diagnostic, setDiagnostic] =
useState<DocumentParsingDiagnostic>()
const mountedRef = useRef(false)
const settingsDirty =
snapshot !== undefined &&
draft !== undefined &&
JSON.stringify(draft) !== JSON.stringify(snapshot.settings)
useEffect(() => {
onDirtyChange?.(settingsDirty)
}, [onDirtyChange, settingsDirty])
const refresh = useCallback(async (): Promise<void> => {
const api = window.goodbuddy.documentParsing
@@ -235,6 +251,27 @@ export function DocumentParsingSettingsSection({
}
}, [t])
const refreshProgress = useCallback(async (): Promise<void> => {
const api = window.goodbuddy.documentParsing
if (!api) {
return
}
const progress = await api.getOcrModelProgress()
if (mountedRef.current) {
setSnapshot((current) =>
current
? {
...current,
ocrModels: {
...current.ocrModels,
operations: progress.operations
}
}
: current
)
}
}, [])
useEffect(() => {
const api = window.goodbuddy.documentParsing
let active = true
@@ -274,11 +311,27 @@ export function DocumentParsingSettingsSection({
if (!shouldPoll) {
return
}
const timer = window.setInterval(() => {
void refresh().catch(() => undefined)
}, 300)
return () => window.clearInterval(timer)
}, [refresh, shouldPoll])
let active = true
let timer: number | undefined
const poll = async (): Promise<void> => {
try {
await refreshProgress()
} catch {
// The final full refresh reports actionable operation errors.
} finally {
if (active) {
timer = window.setTimeout(() => void poll(), 300)
}
}
}
timer = window.setTimeout(() => void poll(), 300)
return () => {
active = false
if (timer !== undefined) {
window.clearTimeout(timer)
}
}
}, [refreshProgress, shouldPoll])
const updateDraft = <Key extends keyof DocumentParsingSettings>(
key: Key,
@@ -450,8 +503,6 @@ export function DocumentParsingSettingsSection({
modelDownloadAvailability?.available === true
const pendingModelSelection =
draft.localOcrModelId !== snapshot.settings.localOcrModelId
const settingsDirty =
JSON.stringify(draft) !== JSON.stringify(snapshot.settings)
const selectedModelReady = installedModel !== undefined
const invalidPendingModel =
pendingModelSelection && !selectedModelReady
+114 -3
View File
@@ -15,6 +15,10 @@ import type {
AssistantMemory,
AssistantTask
} from '../../shared/assistant-contracts'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName
} from '../../shared/assistant-contracts'
import { HeartbeatCenter, type HeartbeatCenterProps } from './HeartbeatCenter'
import i18n from './i18n'
@@ -110,11 +114,12 @@ function createProps(
projects: [
{
id: '00000000-0000-4000-8000-000000000101',
name: '默认项目',
description: '',
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: 'C:\\Workspace',
defaultWorkMode: 'ask',
kind: 'user',
builtInDefault: true,
status: 'active',
createdAt: '2026-07-31T01:00:00.000Z',
updatedAt: '2026-07-31T01:00:00.000Z'
@@ -155,6 +160,12 @@ describe('HeartbeatCenter', () => {
screen.getByRole('button', { name: 'Run heartbeat now' })
).toBeInTheDocument()
expect(screen.getByText(entry.summary)).toBeInTheDocument()
expect(screen.getByText('Project: Default project')).toHaveClass(
'scope-badge'
)
expect(
screen.getByText(/Every day at 09:00 · Default project/u)
).toBeInTheDocument()
const englishDate = new Intl.DateTimeFormat('en-US', {
month: '2-digit',
day: '2-digit',
@@ -171,6 +182,14 @@ describe('HeartbeatCenter', () => {
expect(
screen.getByRole('button', { name: /Handle in conversation/ })
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: 'Heartbeat plans' })
)
fireEvent.click(
screen.getByRole('button', { name: 'Selected projects' })
)
expect(screen.getByLabelText('Default project')).toBeInTheDocument()
})
it('shows heartbeat health, growth dimensions, and the latest report', () => {
@@ -179,7 +198,7 @@ describe('HeartbeatCenter', () => {
expect(
screen.getByRole('heading', { level: 1, name: '智能心跳' })
).toBeInTheDocument()
expect(screen.getByText('全局')).toHaveClass(
expect(screen.getByText('项目:默认项目')).toHaveClass(
'scope-badge'
)
expect(screen.getByText(/每天 09:00 · 默认项目/u)).toBeInTheDocument()
@@ -199,6 +218,98 @@ describe('HeartbeatCenter', () => {
expect(within(dimensions).getByText('2')).toBeInTheDocument()
})
it('distinguishes multi-project-only scope from global and project scope', async () => {
const secondProject = {
...createProps().projects[0]!,
id: '00000000-0000-4000-8000-000000000102',
name: '第二项目',
rootPath: 'C:\\Second'
}
const projectConfig: AssistantHeartbeatConfig = {
...config,
scope: {
kind: 'projects',
projectIds: [createProps().projects[0]!.id, secondProject.id]
}
}
const { rerender } = render(
<HeartbeatCenter
{...createProps({
configs: [projectConfig],
projects: [...createProps().projects, secondProject]
})}
/>
)
expect(screen.getByLabelText('2 个项目')).toHaveTextContent(
'2 个项目'
)
expect(screen.queryByText(/全局/u)).not.toBeInTheDocument()
rerender(
<HeartbeatCenter
{...createProps({
configs: [
projectConfig,
{
...config,
id: 'heartbeat-global',
scope: { kind: 'global' }
}
],
projects: [...createProps().projects, secondProject]
})}
/>
)
expect(screen.getByLabelText('项目 + 全局')).toBeInTheDocument()
await i18n.changeLanguage('en-US')
rerender(
<HeartbeatCenter
{...createProps({
configs: [projectConfig],
projects: [...createProps().projects, secondProject]
})}
/>
)
expect(screen.getByLabelText('2 projects')).toBeInTheDocument()
})
it('uses level-two headings for direct tab panel sections', () => {
render(<HeartbeatCenter {...createProps()} />)
expect(
screen.getByRole('heading', { level: 2, name: '当前状态' })
).toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 2, name: '成长趋势' })
).toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 2, name: '本次心跳' })
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: /待处理建议/u }))
expect(
screen.getByRole('heading', { level: 2, name: '待确认记忆' })
).toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 2, name: '行动建议' })
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '心跳轨迹' }))
expect(
screen.getByRole('heading', { level: 2, name: '成长轨迹' })
).toBeInTheDocument()
expect(
screen.getByRole('heading', { level: 2, name: '运行记录' })
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '心跳计划' }))
expect(
screen.getByRole('heading', { level: 2, name: '智能心跳' })
).toBeInTheDocument()
})
it('turns heartbeat findings into explicit user actions', async () => {
const onSetMemoryStatus = vi.fn(async () => {})
const onSetTaskStatus = vi.fn(async () => {})
+71 -21
View File
@@ -24,10 +24,12 @@ import type {
HeartbeatUpdateInput
} from '../../shared/assistant-contracts'
import { HeartbeatSettings } from './HeartbeatSettings'
import { getProjectDisplayText } from './project-display'
import {
EmptyState,
PageHeader,
PageTabs
PageTabs,
type WorkspaceScope
} from './WorkspacePrimitives'
type HeartbeatCenterTab =
@@ -101,6 +103,7 @@ export function HeartbeatCenter({
onRetryLoad
}: HeartbeatCenterProps): React.JSX.Element {
const { t, i18n } = useTranslation('heartbeat')
const { t: tWorkspace } = useTranslation('workspace')
const [tab, setTab] = useState<HeartbeatCenterTab>('overview')
const [pendingAction, setPendingAction] = useState<string>()
const [error, setError] = useState<string>()
@@ -194,11 +197,14 @@ export function HeartbeatCenter({
if (config.scope.kind === 'global') {
return t('center.scope.global')
}
const projectNames = config.scope.projectIds.map(
(projectId) =>
projects.find((project) => project.id === projectId)?.name ??
t('settings.scope.unavailableProject')
)
const projectNames = config.scope.projectIds.map((projectId) => {
const project = projects.find(
(candidate) => candidate.id === projectId
)
return project
? getProjectDisplayText(project, tWorkspace).name
: t('settings.scope.unavailableProject')
})
return projectNames.join(t('settings.scope.nameSeparator'))
}
@@ -276,6 +282,50 @@ export function HeartbeatCenter({
)
const hasHeartbeatData =
configs.length > 0 || runs.length > 0 || entries.length > 0
const heartbeatScope = useMemo<WorkspaceScope>(() => {
if (
configs.length === 0 ||
configs.every((config) => config.scope.kind === 'global')
) {
return { kind: 'global' }
}
const includesGlobal = configs.some(
(config) => config.scope.kind === 'global'
)
const projectIds = [
...new Set(
configs.flatMap((config) =>
config.scope.kind === 'projects'
? config.scope.projectIds
: []
)
)
]
if (!includesGlobal && projectIds.length === 1) {
const project = projects.find(
(candidate) => candidate.id === projectIds[0]
)
return project
? {
kind: 'project',
projectName: getProjectDisplayText(
project,
tWorkspace
).name
}
: {
kind: 'unavailable',
explanation: t('settings.scope.unavailableProject')
}
}
if (!includesGlobal && projectIds.length > 1) {
return {
kind: 'projects',
projectCount: projectIds.length
}
}
return { kind: 'mixed' }
}, [configs, projects, t, tWorkspace])
const initialLoadBlocked =
!hasHeartbeatData && (loading || loadError !== undefined)
@@ -368,7 +418,7 @@ export function HeartbeatCenter({
eyebrow={t('center.eyebrow')}
headingId="heartbeat-center-title"
icon={<HeartPulse size={22} />}
scope={{ kind: 'global' }}
scope={heartbeatScope}
title={t('center.title')}
/>
@@ -445,9 +495,9 @@ export function HeartbeatCenter({
<p className="eyebrow">
{t('center.currentStatus.eyebrow')}
</p>
<h3 id="heartbeat-status-title">
<h2 id="heartbeat-status-title">
{t('center.currentStatus.title')}
</h3>
</h2>
</div>
<span
className={
@@ -666,9 +716,9 @@ export function HeartbeatCenter({
<p className="eyebrow">
{t('center.trend.eyebrow')}
</p>
<h3 id="heartbeat-trend-title">
<h2 id="heartbeat-trend-title">
{t('center.trend.title')}
</h3>
</h2>
</div>
</div>
{recentTrend.length === 0 ? (
@@ -760,9 +810,9 @@ export function HeartbeatCenter({
<p className="eyebrow">
{t('center.latest.eyebrow')}
</p>
<h3 id="latest-heartbeat-title">
<h2 id="latest-heartbeat-title">
{t('center.latest.title')}
</h3>
</h2>
</div>
{latestEntry && (
<time dateTime={latestEntry.createdAt}>
@@ -829,9 +879,9 @@ export function HeartbeatCenter({
<p className="eyebrow">
{t('center.suggestions.memoryEyebrow')}
</p>
<h3 id="heartbeat-memory-title">
<h2 id="heartbeat-memory-title">
{t('center.suggestions.memoryTitle')}
</h3>
</h2>
</div>
<span>
{t('center.suggestions.memoryCount', {
@@ -943,9 +993,9 @@ export function HeartbeatCenter({
<p className="eyebrow">
{t('center.suggestions.taskEyebrow')}
</p>
<h3 id="heartbeat-task-title">
<h2 id="heartbeat-task-title">
{t('center.suggestions.taskTitle')}
</h3>
</h2>
</div>
<span>
{t('center.suggestions.taskCount', {
@@ -1065,10 +1115,10 @@ export function HeartbeatCenter({
<p className="eyebrow">
{t('center.history.timelineEyebrow')}
</p>
<h3 id="heartbeat-reports-title">
<h2 id="heartbeat-reports-title">
<History aria-hidden="true" size={16} />
{t('center.history.timelineTitle')}
</h3>
</h2>
</div>
<span>
{t('center.history.reportCount', {
@@ -1168,9 +1218,9 @@ export function HeartbeatCenter({
<p className="eyebrow">
{t('center.history.auditEyebrow')}
</p>
<h3 id="heartbeat-runs-title">
<h2 id="heartbeat-runs-title">
{t('center.history.auditTitle')}
</h3>
</h2>
</div>
<span>
{t('center.history.runCount', {
+13 -8
View File
@@ -12,6 +12,7 @@ import {
DestructiveConfirmActions,
SegmentedControl
} from './WorkspacePrimitives'
import { getProjectDisplayText } from './project-display'
type HeartbeatSettingsProps = {
heartbeats: AssistantHeartbeatConfig[]
@@ -38,6 +39,7 @@ export function HeartbeatSettings({
onRunNow
}: HeartbeatSettingsProps): React.JSX.Element {
const { t, i18n } = useTranslation('heartbeat')
const { t: tWorkspace } = useTranslation('workspace')
const [editingId, setEditingId] = useState<string>()
const [name, setName] = useState(t('settings.defaultName'))
const [time, setTime] = useState('09:00')
@@ -162,11 +164,12 @@ export function HeartbeatSettings({
if (heartbeat.scope.kind === 'global') {
return t('settings.scope.global')
}
const names = heartbeat.scope.projectIds.map(
(projectId) =>
projectById.get(projectId)?.name ??
t('settings.scope.unavailableProject')
)
const names = heartbeat.scope.projectIds.map((projectId) => {
const project = projectById.get(projectId)
return project
? getProjectDisplayText(project, tWorkspace).name
: t('settings.scope.unavailableProject')
})
return t('settings.scope.selectedProjectsSummary', {
count: names.length,
names: names.join(t('settings.scope.nameSeparator'))
@@ -176,10 +179,10 @@ export function HeartbeatSettings({
return (
<div className="heartbeat-settings">
<div className="heartbeat-settings__intro">
<h3>
<h2>
<HeartPulse size={15} />
{t('settings.title')}
</h3>
</h2>
<p>{t('settings.description')}</p>
</div>
<div className="heartbeat-settings__editor">
@@ -250,7 +253,9 @@ export function HeartbeatSettings({
}
type="checkbox"
/>
<span>{project.name}</span>
<span>
{getProjectDisplayText(project, tWorkspace).name}
</span>
{project.status !== 'active' && (
<small>{t('settings.scope.archived')}</small>
)}
+1 -1
View File
@@ -30,7 +30,7 @@ type ChartKnowledgeGraphRelation = Omit<
evidenceIds?: readonly string[]
}
type KnowledgeGraphChartProps = {
export type KnowledgeGraphChartProps = {
nodes: readonly ChartKnowledgeGraphNode[]
relations: readonly ChartKnowledgeGraphRelation[]
selectedNodeId?: string
+257 -16
View File
@@ -7,8 +7,11 @@ import {
waitFor,
within
} from '@testing-library/react'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
KnowledgeGraphChartLoader,
KnowledgeWorkspace,
type KnowledgeWorkspaceProps
} from './KnowledgeWorkspace'
@@ -17,6 +20,17 @@ import {
defaultKnowledgeOntologySettings
} from '../../shared/knowledge-ontology'
const knowledgeWorkspaceSource = readFileSync(
join(
process.cwd(),
'src',
'renderer',
'src',
'KnowledgeWorkspace.tsx'
),
'utf8'
)
const g6Mock = vi.hoisted(() => {
const handlers = new Map<string, (event: unknown) => void>()
const graph = {
@@ -460,11 +474,13 @@ describe('KnowledgeWorkspace', () => {
expect(screen.getByText('第二份文档内容')).toBeInTheDocument()
})
it('switches to the graph and opens entity details', () => {
it('switches to the graph and opens entity details', async () => {
render(<KnowledgeWorkspace {...createProps()} />)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
expect(screen.getByLabelText('实体关系图')).toBeInTheDocument()
expect(
await screen.findByLabelText('实体关系图')
).toBeInTheDocument()
expect(
screen.getByRole('tab', { name: /拓扑/u })
).toHaveAttribute('aria-selected', 'true')
@@ -1217,6 +1233,9 @@ describe('KnowledgeWorkspace', () => {
render(<KnowledgeWorkspace {...createProps()} />)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
await waitFor(() =>
expect(g6Mock.graph.setOptions).toHaveBeenCalled()
)
expect(
screen.getByRole('option', { name: 'GoodBuddy · 概念 (CONCEPT)' })
).toBeInTheDocument()
@@ -1265,9 +1284,7 @@ describe('KnowledgeWorkspace', () => {
const workspace = screen.getByLabelText('知识工作区')
expect(workspace).toHaveClass('knowledge-workspace')
expect(workspace).toHaveStyle({
background: 'var(--surface-canvas)'
})
expect(workspace).not.toHaveAttribute('style')
expect(workspace.querySelector('aside')).toHaveClass(
'knowledge-workspace__sidebar'
)
@@ -1276,9 +1293,7 @@ describe('KnowledgeWorkspace', () => {
name: '知识库详情'
})
expect(detailRegion).toHaveClass('knowledge-workspace__main')
expect(detailRegion).toHaveStyle({
background: 'var(--surface-raised)'
})
expect(detailRegion).not.toHaveAttribute('style')
expect(screen.getByText('全局')).toHaveClass('scope-badge')
const mobileBack = screen.getByRole('button', {
name: '返回知识库列表'
@@ -1286,11 +1301,14 @@ describe('KnowledgeWorkspace', () => {
expect(mobileBack).toHaveClass('knowledge-workspace__mobile-back')
fireEvent.click(mobileBack)
expect(workspace).toHaveClass('knowledge-workspace--mobile-list')
fireEvent.click(
screen.getByRole('button', {
name: /^产品知识 1 个文档/u
})
const selectedLibraryButton = screen.getByRole('button', {
name: /^产品知识 1 个文档/u
})
expect(selectedLibraryButton).toHaveClass(
'knowledge-workspace__library-button--selected'
)
expect(selectedLibraryButton).not.toHaveAttribute('style')
fireEvent.click(selectedLibraryButton)
expect(workspace).not.toHaveClass('knowledge-workspace--mobile-list')
expect(screen.getByRole('tablist', { name: '知识库视图' })).toHaveClass(
'page-tabs'
@@ -1324,13 +1342,19 @@ describe('KnowledgeWorkspace', () => {
target: { value: 'entity-1' }
})
expect(screen.getByLabelText('知识图谱画布').parentElement).toHaveClass(
'knowledge-graph--with-details'
'knowledge-graph'
)
expect(screen.getByLabelText('知识图谱画布').parentElement)
.not.toHaveClass('knowledge-graph--with-details')
expect(screen.getByLabelText('实体详情')).toHaveClass(
'knowledge-graph__detail'
)
})
it('keeps Knowledge workspace presentation in semantic classes', () => {
expect(knowledgeWorkspaceSource).not.toMatch(/\bstyle\s*=/u)
})
it('manages the G6 graph, zoom, selection, movement, and cleanup', async () => {
const onMoveNode = vi.fn()
const { rerender, unmount } = render(
@@ -1338,7 +1362,7 @@ describe('KnowledgeWorkspace', () => {
)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
const graph = screen.getByLabelText('实体关系图')
const graph = await screen.findByLabelText('实体关系图')
expect(graph).toHaveClass('knowledge-graph__chart')
expect(g6Mock.Graph).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1491,6 +1515,7 @@ describe('KnowledgeWorkspace', () => {
it('preserves the G6 instance and refreshes theme colors', async () => {
render(<KnowledgeWorkspace {...createProps()} />)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
await screen.findByLabelText('实体关系图')
g6Mock.graph.getZoom.mockReturnValueOnce(1.3)
act(() => {
@@ -1523,7 +1548,7 @@ describe('KnowledgeWorkspace', () => {
delete document.documentElement.dataset.theme
})
it('sizes dense nodes by degree and labels key entities', () => {
it('sizes dense nodes by degree and labels key entities', async () => {
const graphNodes = Array.from({ length: 30 }, (_, index) => ({
id: `entity-${index}`,
label: `实体 ${index}`,
@@ -1553,6 +1578,7 @@ describe('KnowledgeWorkspace', () => {
/>
)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
await screen.findByLabelText('实体关系图')
expect(g6Mock.graph.setOptions).toHaveBeenLastCalledWith(
expect.objectContaining({
@@ -1598,6 +1624,50 @@ describe('KnowledgeWorkspace', () => {
)
})
it('shows a graph-local loading state and retries a failed chart chunk', async () => {
let attempts = 0
const loadModule = vi.fn(async () => {
attempts += 1
if (attempts <= 2) {
throw new Error('chunk unavailable')
}
return {
KnowledgeGraphChart: () => (
<div aria-label="已加载的图谱测试组件" />
)
} as unknown as typeof import('./KnowledgeGraphChart')
})
const props = createProps()
render(
<KnowledgeGraphChartLoader
fitViewRequest={0}
loadModule={loadModule}
nodes={props.graphNodes}
onMoveNode={props.onMoveNode}
onSelectNode={vi.fn()}
onZoomChange={vi.fn()}
relations={props.graphRelations}
zoom={1}
/>
)
expect(
screen.getByRole('status')
).toHaveTextContent('正在加载知识图谱…')
expect(
await screen.findByRole('alert')
).toHaveTextContent('知识图谱未能加载,请重试。')
expect(loadModule).toHaveBeenCalledTimes(2)
fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(
await screen.findByLabelText('已加载的图谱测试组件')
).toBeInTheDocument()
expect(loadModule).toHaveBeenCalledTimes(3)
})
it('creates relationships, merges entities, and opens graph evidence', async () => {
const onCreateRelation = vi.fn()
const onMergeEntities = vi.fn()
@@ -1644,7 +1714,118 @@ describe('KnowledgeWorkspace', () => {
target: { value: 'entity-2' }
})
fireEvent.click(screen.getByRole('button', { name: '合并到目标实体' }))
expect(onMergeEntities).toHaveBeenCalledWith('entity-1', 'entity-2')
expect(
screen.getByRole('alertdialog', {
name: '将“GoodBuddy”合并到“Electron”?'
})
).toHaveAccessibleDescription(
'“GoodBuddy”的关系、别名和证据将并入“Electron”,随后删除源实体。此操作无法恢复。'
)
expect(onMergeEntities).not.toHaveBeenCalled()
fireEvent.click(
screen.getByRole('button', { name: '合并实体' })
)
await waitFor(() =>
expect(onMergeEntities).toHaveBeenCalledWith(
'entity-1',
'entity-2'
)
)
await waitFor(() =>
expect(screen.getByLabelText('选择图谱实体')).toHaveFocus()
)
})
it('confirms graph entity and relation deletion with concrete impact', async () => {
const onDeleteEntity = vi.fn(async () => {})
const onDeleteRelation = vi.fn(async () => {})
render(
<KnowledgeWorkspace
{...createProps({ onDeleteEntity, onDeleteRelation })}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
fireEvent.change(screen.getByLabelText('选择图谱实体'), {
target: { value: 'entity-1' }
})
fireEvent.click(
screen.getByRole('button', { name: '删除实体 GoodBuddy' })
)
expect(
screen.getByRole('alertdialog', {
name: '删除实体“GoodBuddy”?'
})
).toHaveAccessibleDescription(
'将永久删除实体“GoodBuddy”及其 1 条关联关系;相关证据引用也会从图谱中移除,且无法恢复。'
)
expect(onDeleteEntity).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '取消' }))
fireEvent.click(
screen.getByRole('button', { name: '删除关系 使用' })
)
expect(
screen.getByRole('alertdialog', {
name: '删除关系“使用 (USES)”?'
})
).toHaveAccessibleDescription(
'将永久删除“GoodBuddy”到“Electron”的“使用 (USES)”关系及其图谱证据引用,且无法恢复。两个实体本身会保留。'
)
fireEvent.click(
screen.getByRole('button', { name: '删除关系' })
)
await waitFor(() =>
expect(onDeleteRelation).toHaveBeenCalledWith('relation-1')
)
await waitFor(() =>
expect(screen.getByLabelText('选择图谱实体')).toHaveFocus()
)
fireEvent.click(
screen.getByRole('button', { name: '删除实体 GoodBuddy' })
)
fireEvent.click(
screen.getByRole('button', { name: '删除实体' })
)
await waitFor(() =>
expect(onDeleteEntity).toHaveBeenCalledWith('entity-1')
)
await waitFor(() =>
expect(screen.getByLabelText('选择图谱实体')).toHaveFocus()
)
})
it('keeps a failed graph confirmation open and focused', async () => {
const onDeleteEntity = vi.fn(async () => {
throw new Error('删除实体失败')
})
render(
<KnowledgeWorkspace
{...createProps({ onDeleteEntity })}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
fireEvent.change(screen.getByLabelText('选择图谱实体'), {
target: { value: 'entity-1' }
})
fireEvent.click(
screen.getByRole('button', { name: '删除实体 GoodBuddy' })
)
const confirm = screen.getByRole('button', {
name: '删除实体'
})
confirm.focus()
fireEvent.click(confirm)
expect(await screen.findByText('删除实体失败')).toBeInTheDocument()
expect(
screen.getByRole('alertdialog', {
name: '删除实体“GoodBuddy”?'
})
).toBeInTheDocument()
expect(confirm).toHaveFocus()
})
it('renders an explicit empty graph state', () => {
@@ -1660,6 +1841,25 @@ describe('KnowledgeWorkspace', () => {
).toBeInTheDocument()
})
it('keeps one primary creation action in the empty library state', () => {
render(
<KnowledgeWorkspace
{...createProps({
libraries: [],
selectedLibraryId: undefined
})}
/>
)
expect(screen.getByText('建立第一个知识库')).toBeInTheDocument()
expect(
screen.getAllByRole('button', { name: '新建知识库' })
).toHaveLength(1)
expect(
screen.queryByRole('button', { name: '创建知识库' })
).not.toBeInTheDocument()
})
it('keeps loading distinct from the first-library empty state', () => {
render(
<KnowledgeWorkspace
@@ -1718,6 +1918,37 @@ describe('KnowledgeWorkspace', () => {
).toHaveAttribute('aria-current', 'page')
})
it('isolates and traps focus in the library edit dialog', async () => {
render(<KnowledgeWorkspace {...createProps()} />)
const trigger = screen.getByRole('button', { name: '编辑' })
trigger.focus()
fireEvent.click(trigger)
const dialog = screen.getByRole('dialog', {
name: '编辑知识库'
})
const nameInput = screen.getByLabelText('名称')
expect(nameInput).toHaveFocus()
expect(
document.querySelector<HTMLElement>(
'.knowledge-workspace__main'
)?.inert
).toBe(true)
fireEvent.keyDown(nameInput, { key: 'Tab', shiftKey: true })
expect(
screen.getByRole('button', { name: '保存修改' })
).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() => expect(trigger).toHaveFocus())
expect(
document.querySelector<HTMLElement>(
'.knowledge-workspace__main'
)?.inert
).toBe(false)
})
it('confirms that deleting a managed library removes managed copies', async () => {
const onDeleteLibrary = vi.fn()
render(
@@ -1734,8 +1965,18 @@ describe('KnowledgeWorkspace', () => {
name: '删除知识库确认'
})
expect(screen.getByRole('button', { name: '取消' })).toHaveFocus()
expect(
document.querySelector<HTMLElement>(
'.knowledge-workspace__main'
)?.inert
).toBe(true)
fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() => expect(trigger).toHaveFocus())
expect(
document.querySelector<HTMLElement>(
'.knowledge-workspace__main'
)?.inert
).toBe(false)
fireEvent.click(trigger)
expect(
screen.getByText(
File diff suppressed because it is too large Load Diff
@@ -151,8 +151,10 @@ const summaryFromDetail = (
const list = vi.fn<() => Promise<MagicNotesSnapshot>>()
const get = vi.fn<(noteId: string) => Promise<MagicNoteDetail>>()
const listTodos = vi.fn<() => Promise<MagicTodosSnapshot>>()
const create = vi.fn<DesktopApi['magicNotes']['create']>()
const remove = vi.fn<DesktopApi['magicNotes']['remove']>()
const createEntry = vi.fn<DesktopApi['magicNotes']['createEntry']>()
const updateEntry = vi.fn<DesktopApi['magicNotes']['updateEntry']>()
const analyze = vi.fn<DesktopApi['magicNotes']['analyze']>()
const updateTodo = vi.fn<DesktopApi['magicNotes']['updateTodo']>()
const analyzeTodo = vi.fn<DesktopApi['magicNotes']['analyzeTodo']>()
@@ -189,6 +191,7 @@ beforeEach(() => {
list.mockResolvedValue({ notes: [detail] })
get.mockResolvedValue(detail)
listTodos.mockResolvedValue({ todos: [noteTodo, manualTodo] })
create.mockResolvedValue(alternateDetail(thirdNoteId, '新笔记'))
remove.mockResolvedValue()
const createdDetail: MagicNoteDetail = {
...detail,
@@ -213,6 +216,19 @@ beforeEach(() => {
]
}
createEntry.mockResolvedValue(createdDetail)
updateEntry.mockResolvedValue({
...detail,
revision: detail.revision + 1,
entries: detail.entries.map((entry) => ({
...entry,
content: {
version: 1,
ops: [{ insert: '新的句子\n' }]
},
plainText: '新的句子',
revision: entry.revision + 1
}))
})
updateTodo.mockImplementation(async (input) => ({
...noteTodo,
completed: input.completed,
@@ -267,8 +283,10 @@ beforeEach(() => {
list,
get,
listTodos,
create,
remove,
createEntry,
updateEntry,
analyze,
updateTodo,
analyzeTodo,
@@ -586,6 +604,298 @@ describe('MagicNotesWorkspace', () => {
)
})
it('keeps a non-empty composer draft until note switching is confirmed', async () => {
const second = alternateDetail(secondNoteId, '第二篇笔记')
list.mockResolvedValue({
notes: [summaryFromDetail(detail), summaryFromDetail(second)]
})
get.mockImplementation((requestedId) =>
Promise.resolve(requestedId === second.id ? second : detail)
)
render(<MagicNotesWorkspace onNotify={onNotify} />)
await screen.findByText('记录正文')
fireEvent.click(screen.getByText('模拟输入并回车'))
const callsBeforeSwitch = get.mock.calls.length
fireEvent.click(screen.getByText(second.title).closest('button')!)
const confirmation = screen.getByRole('alertdialog', {
name: '放弃当前未保存草稿?'
})
const continueEditing = screen.getByRole('button', {
name: '继续编辑'
})
const discardAndSwitch = screen.getByRole('button', {
name: '放弃草稿并切换'
})
expect(confirmation).toHaveAccessibleDescription(
'切换后,当前记录草稿中的文字和附件将被丢弃。'
)
expect(continueEditing).toHaveFocus()
expect(get).toHaveBeenCalledTimes(callsBeforeSwitch)
expect(
screen.getByRole('button', { name: /发布笔记/ })
).toHaveAttribute('aria-pressed', 'true')
discardAndSwitch.focus()
fireEvent.keyDown(discardAndSwitch, { key: 'Tab' })
expect(continueEditing).toHaveFocus()
fireEvent.keyDown(continueEditing, { key: 'Tab', shiftKey: true })
expect(discardAndSwitch).toHaveFocus()
fireEvent.keyDown(confirmation, { key: 'Escape' })
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
await waitFor(() =>
expect(screen.getByTestId('magic-note-editor')).toHaveFocus()
)
fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
await waitFor(() =>
expect(createEntry).toHaveBeenCalledWith({
noteId,
content: {
version: 1,
ops: [{ insert: '新的句子\n' }]
}
})
)
fireEvent.click(screen.getByText('模拟输入并回车'))
fireEvent.click(screen.getByText(second.title).closest('button')!)
fireEvent.click(
screen.getByRole('button', { name: '放弃草稿并切换' })
)
expect(await screen.findByDisplayValue(second.title)).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /第二篇笔记/ })
).toHaveAttribute('aria-pressed', 'true')
})
it('guards dirty existing-entry edits and keeps the first pending target', async () => {
const second = alternateDetail(secondNoteId, '第二篇笔记')
const third = alternateDetail(thirdNoteId, '第三篇笔记')
list.mockResolvedValue({
notes: [
summaryFromDetail(detail),
summaryFromDetail(second),
summaryFromDetail(third)
]
})
get.mockImplementation((requestedId) =>
Promise.resolve(
requestedId === second.id
? second
: requestedId === third.id
? third
: detail
)
)
const { container } = render(
<MagicNotesWorkspace onNotify={onNotify} />
)
await screen.findByText('记录正文')
fireEvent.click(screen.getByRole('button', { name: '编辑' }))
const editEditor = container.querySelector<HTMLButtonElement>(
'.magic-note-entry__editor [data-testid="magic-note-editor"]'
)!
fireEvent.click(editEditor)
const secondButton = screen.getByText(second.title).closest('button')!
const thirdButton = screen.getByText(third.title).closest('button')!
fireEvent.click(secondButton)
const dialog = screen.getByRole('alertdialog', {
name: '放弃当前未保存草稿?'
})
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(
container.querySelector<HTMLElement>('.magic-notes-list-pane')?.inert
).toBe(true)
fireEvent.click(thirdButton)
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
expect(
container.querySelector<HTMLElement>('.magic-notes-list-pane')?.inert
).toBe(false)
await waitFor(() => expect(editEditor).toHaveFocus())
fireEvent.click(secondButton)
fireEvent.click(
screen.getByRole('button', { name: '放弃草稿并切换' })
)
expect(await screen.findByDisplayValue(second.title)).toBeInTheDocument()
await waitFor(() => expect(secondButton).toHaveFocus())
expect(get).not.toHaveBeenCalledWith(third.id)
})
it('guards keyboard tab selection and switches only after discard', async () => {
render(<MagicNotesWorkspace onNotify={onNotify} />)
await screen.findByText('记录正文')
fireEvent.click(screen.getByText('模拟输入并回车'))
const notesTab = screen.getByRole('tab', { name: '笔记' })
const todosTab = screen.getByRole('tab', { name: '待办' })
notesTab.focus()
fireEvent.keyDown(notesTab, { key: 'ArrowRight' })
expect(todosTab).toHaveAttribute('aria-selected', 'false')
expect(notesTab).toHaveAttribute('aria-selected', 'true')
expect(
screen.getByRole('alertdialog', {
name: '放弃当前未保存草稿?'
})
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '放弃草稿并切换' })
)
expect(todosTab).toHaveAttribute('aria-selected', 'true')
expect(screen.getByText('准备演示')).toBeInTheDocument()
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
it('does not create and select another note before the draft is discarded', async () => {
const { container } = render(
<MagicNotesWorkspace onNotify={onNotify} />
)
await screen.findByText('记录正文')
fireEvent.click(screen.getByText('模拟输入并回车'))
fireEvent.click(screen.getByRole('button', { name: '新建笔记' }))
fireEvent.change(
container.querySelector<HTMLInputElement>(
'.magic-notes-create input'
)!,
{
target: { value: '新笔记' }
}
)
fireEvent.click(screen.getByRole('button', { name: '创建笔记' }))
expect(create).not.toHaveBeenCalled()
expect(
screen.getByRole('button', { name: /发布笔记/ })
).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(
screen.getByRole('button', { name: '放弃草稿并切换' })
)
await waitFor(() =>
expect(create).toHaveBeenCalledWith({ title: '新笔记' })
)
expect(await screen.findByDisplayValue('新笔记')).toBeInTheDocument()
})
it('preserves the composer draft across save errors and unrelated rerenders', async () => {
createEntry.mockRejectedValueOnce(new Error('暂时无法保存'))
render(<MagicNotesWorkspace onNotify={onNotify} />)
await screen.findByText('记录正文')
fireEvent.click(screen.getByText('模拟输入并回车'))
await i18n.changeLanguage('en-US')
fireEvent.click(screen.getByRole('button', { name: 'Save entry' }))
await waitFor(() =>
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
tone: 'error',
message: '暂时无法保存'
})
)
)
fireEvent.click(screen.getByRole('button', { name: 'Save entry' }))
await waitFor(() => expect(createEntry).toHaveBeenCalledTimes(2))
expect(createEntry).toHaveBeenNthCalledWith(1, {
noteId,
content: {
version: 1,
ops: [{ insert: '新的句子\n' }]
}
})
expect(createEntry).toHaveBeenNthCalledWith(2, {
noteId,
content: {
version: 1,
ops: [{ insert: '新的句子\n' }]
}
})
await i18n.changeLanguage('zh-CN')
})
it('finalizes a created entry before a to-do refresh failure', async () => {
render(<MagicNotesWorkspace onNotify={onNotify} />)
await screen.findByText('记录正文')
listTodos.mockRejectedValueOnce(new Error('待办刷新失败'))
fireEvent.click(screen.getByText('模拟输入并回车'))
fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
await waitFor(() => expect(createEntry).toHaveBeenCalledOnce())
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
tone: 'success',
message: '记录已保存'
})
)
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
tone: 'error',
message: '待办刷新失败'
})
)
fireEvent.click(screen.getByRole('button', { name: '保存记录' }))
expect(createEntry).toHaveBeenCalledOnce()
expect(screen.getByText('请先输入记录内容')).toBeInTheDocument()
})
it('finalizes an edited entry before a to-do refresh failure', async () => {
const { container } = render(
<MagicNotesWorkspace onNotify={onNotify} />
)
await screen.findByText('记录正文')
listTodos.mockRejectedValueOnce(new Error('待办刷新失败'))
fireEvent.click(screen.getByRole('button', { name: '编辑' }))
fireEvent.click(
container.querySelector<HTMLButtonElement>(
'.magic-note-entry__editor [data-testid="magic-note-editor"]'
)!
)
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(updateEntry).toHaveBeenCalledOnce())
expect(updateEntry).toHaveBeenCalledWith({
entryId,
content: {
version: 1,
ops: [{ insert: '新的句子\n' }]
},
expectedRevision: detail.entries[0]!.revision
})
expect(
screen.queryByRole('button', { name: '保存修改' })
).not.toBeInTheDocument()
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
tone: 'success',
message: '记录已更新,原 AI 评论已清除'
})
)
expect(onNotify).toHaveBeenCalledWith(
expect.objectContaining({
tone: 'error',
message: '待办刷新失败'
})
)
})
it('does not override a note selected while retrying a refresh', async () => {
const second = alternateDetail(secondNoteId, '第二篇笔记')
list.mockResolvedValue({
+350 -59
View File
@@ -19,6 +19,7 @@ import {
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState
@@ -40,6 +41,7 @@ import type { MagicNoteCommentMode } from '../../shared/application-settings-con
import { MagicNoteContent } from './MagicNoteContent'
import { MagicNoteEditor } from './MagicNoteEditor'
import { MarkdownRenderer } from './MarkdownRenderer'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import type { AppNotificationInput } from './notifications'
import {
EmptyState,
@@ -61,6 +63,12 @@ type ValidationTarget =
| 'note-title'
| 'new-entry'
| 'edit-entry'
type DraftSwitchTarget =
| { kind: 'library-view'; value: LibraryView }
| { kind: 'create-note'; title: string }
| { kind: 'edit-entry'; entry: MagicNoteEntry }
| { kind: 'note'; noteId: string; entryId?: string }
| { kind: 'todo'; todoId: string }
const defaultAiPaneWidth = 280
const minimumAiPaneWidth = 240
@@ -117,6 +125,13 @@ function hasContent(content?: MagicNoteRichContent): boolean {
)
}
function richContentEqual(
left: MagicNoteRichContent | undefined,
right: MagicNoteRichContent | undefined
): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}
function errorMessage(error: unknown, fallback: string): string {
if (typeof error === 'string') {
return error
@@ -174,12 +189,14 @@ function AiComment({
function TodoListItem({
disabled,
id,
onSelect,
onToggle,
selected,
todo
}: {
disabled: boolean
id: string
onSelect: () => void
onToggle: () => void
selected: boolean
@@ -214,6 +231,7 @@ function TodoListItem({
<button
aria-pressed={selected}
className="magic-todo-list-item__content"
id={id}
onClick={onSelect}
type="button"
>
@@ -351,6 +369,8 @@ export function MagicNotesWorkspace({
target: ValidationTarget
message: string
}>()
const [pendingDraftSwitch, setPendingDraftSwitch] =
useState<DraftSwitchTarget>()
const detailRequestRef = useRef(0)
const requestedNoteIdRef = useRef('')
const refreshRequestRef = useRef(0)
@@ -374,6 +394,12 @@ export function MagicNotesWorkspace({
const magicNotesLayoutRef = useRef<HTMLDivElement>(null)
const liveAiPaneWidthRef = useRef(defaultAiPaneWidth)
const aiResizePointerIdRef = useRef<number | undefined>(undefined)
const composerRef = useRef<HTMLDivElement>(null)
const continueEditingRef = useRef<HTMLButtonElement>(null)
const discardDraftRef = useRef<HTMLButtonElement>(null)
const discardDraftDialogRef = useRef<HTMLDivElement>(null)
const discardDraftTitleId = useId()
const discardDraftDescriptionId = useId()
const runDraftAnalysisRef = useRef<
(content: MagicNoteRichContent) => Promise<void>
>(async () => undefined)
@@ -716,6 +742,8 @@ export function MagicNotesWorkspace({
window.clearTimeout(draftAnalysisTimerRef.current)
draftAnalysisTimerRef.current = undefined
}
setEditingEntry(undefined)
editingContentRef.current = undefined
setSelectedNoteId(noteId)
applyDetail(nextDetail)
}
@@ -734,6 +762,226 @@ export function MagicNotesWorkspace({
[applyDetail]
)
const discardComposerDraft = useCallback((): void => {
composerContentRef.current = undefined
draftAnalysisArmedRef.current = false
draftAnalysisQueuedRef.current = false
draftAnalysisContextRef.current += 1
setDraftAnalyses([])
if (draftAnalysisTimerRef.current !== undefined) {
window.clearTimeout(draftAnalysisTimerRef.current)
draftAnalysisTimerRef.current = undefined
}
setComposerKey((current) => current + 1)
}, [])
const discardEditingDraft = useCallback((): void => {
setEditingEntry(undefined)
editingContentRef.current = undefined
clearValidation('edit-entry')
}, [clearValidation])
const hasDirtyEditingDraft = useCallback(
(): boolean =>
Boolean(
editingEntry &&
!richContentEqual(
editingContentRef.current,
editingEntry.content
)
),
[editingEntry]
)
const createNote = useCallback(
async (title: string, discardDraft: boolean): Promise<void> => {
const operation = 'create-note'
if (!beginBusy(operation)) {
return
}
try {
const created = await window.goodbuddy.magicNotes.create({
title
})
if (discardDraft) {
discardComposerDraft()
discardEditingDraft()
}
applyDetail(created)
requestedNoteIdRef.current = created.id
setSelectedNoteId(created.id)
setNewTitle('')
setCreating(false)
notifySuccess(t('notifications.noteCreated'))
} catch (createError) {
notifyError(createError)
} finally {
endBusy(operation)
}
},
[
applyDetail,
beginBusy,
discardComposerDraft,
discardEditingDraft,
endBusy,
notifyError,
notifySuccess,
t
]
)
const focusSwitchTarget = useCallback(
(target: DraftSwitchTarget): void => {
requestAnimationFrame(() => {
const focusTarget =
target.kind === 'library-view'
? document.getElementById(`magic-library-tab-${target.value}`)
: target.kind === 'note'
? document.getElementById(
`magic-note-select-${target.noteId}`
)
: target.kind === 'todo'
? document.getElementById(
`magic-todo-select-${target.todoId}`
)
: target.kind === 'edit-entry'
? document
.getElementById(
`magic-note-entry-${target.entry.id}`
)
?.querySelector<HTMLElement>(
'.ql-editor, [data-testid="magic-note-editor"]'
)
: composerRef.current?.querySelector<HTMLElement>(
'.ql-editor, [data-testid="magic-note-editor"]'
)
focusTarget?.focus()
})
},
[]
)
const performDraftSwitch = useCallback(
(target: DraftSwitchTarget): void => {
setPendingDraftSwitch(undefined)
setValidation(undefined)
if (target.kind === 'library-view') {
if (target.value === 'todos') {
discardComposerDraft()
discardEditingDraft()
}
setLibraryView(target.value)
setCreating(false)
setSearch('')
focusSwitchTarget(target)
return
}
if (target.kind === 'todo') {
setSelectedTodoId(target.todoId)
focusSwitchTarget(target)
return
}
if (target.kind === 'edit-entry') {
discardEditingDraft()
setDeletingEntryId('')
setEditingEntry(target.entry)
editingContentRef.current = target.entry.content
focusSwitchTarget(target)
return
}
if (target.kind === 'create-note') {
void createNote(target.title, true)
.then(() => focusSwitchTarget(target))
return
}
setDeletingNote(false)
setLibraryView('notes')
void loadDetail(target.noteId).then(() => {
focusSwitchTarget(target)
if (!target.entryId) {
return
}
requestAnimationFrame(() =>
document
.getElementById(`magic-note-entry-${target.entryId}`)
?.scrollIntoView({ block: 'center' })
)
})
},
[
createNote,
discardComposerDraft,
discardEditingDraft,
focusSwitchTarget,
loadDetail
]
)
const requestDraftSwitch = useCallback(
(target: DraftSwitchTarget): void => {
if (pendingDraftSwitch) {
return
}
const changesContext =
target.kind === 'library-view'
? target.value !== libraryView
: target.kind === 'note'
? target.noteId !== selectedNoteId ||
target.entryId !== undefined
: target.kind === 'todo'
? target.todoId !== selectedTodoId
: target.kind === 'edit-entry'
? target.entry.id !== editingEntry?.id
: true
if (!changesContext) {
return
}
const wouldClearComposer = target.kind !== 'edit-entry'
const wouldClearEditing = target.kind !== 'todo'
if (
(wouldClearComposer && hasContent(composerContentRef.current)) ||
(wouldClearEditing && hasDirtyEditingDraft())
) {
setPendingDraftSwitch(target)
return
}
performDraftSwitch(target)
},
[
editingEntry?.id,
hasDirtyEditingDraft,
libraryView,
pendingDraftSwitch,
performDraftSwitch,
selectedNoteId,
selectedTodoId
]
)
const continueEditing = useCallback((): void => {
setPendingDraftSwitch(undefined)
requestAnimationFrame(() => {
const editor = editingEntry
? document
.getElementById(`magic-note-entry-${editingEntry.id}`)
?.querySelector<HTMLElement>(
'.ql-editor, [data-testid="magic-note-editor"]'
)
: composerRef.current?.querySelector<HTMLElement>(
'.ql-editor, [data-testid="magic-note-editor"]'
)
editor?.focus()
})
}, [editingEntry])
useEffect(() => {
if (!pendingDraftSwitch) {
return
}
return activateModalFocus(() => continueEditingRef.current)
}, [pendingDraftSwitch])
const refreshNotes = useCallback(
async (preferredId?: string): Promise<void> => {
const requestId = ++refreshRequestRef.current
@@ -884,7 +1132,7 @@ export function MagicNotesWorkspace({
const aiPaneWidthLimits = getAiPaneWidthLimits(magicNotesLayoutWidth)
const canResizeAiPane = aiPaneOpen && magicNotesLayoutWidth > 800
const createNote = async (): Promise<void> => {
const submitCreateNote = async (): Promise<void> => {
const title = newTitle.trim()
if (!title) {
setValidation({
@@ -894,25 +1142,10 @@ export function MagicNotesWorkspace({
return
}
clearValidation('create-note')
const operation = 'create-note'
if (!beginBusy(operation)) {
return
}
try {
const created = await window.goodbuddy.magicNotes.create({
title
})
applyDetail(created)
requestedNoteIdRef.current = created.id
setSelectedNoteId(created.id)
setNewTitle('')
setCreating(false)
notifySuccess(t('notifications.noteCreated'))
} catch (createError) {
notifyError(createError)
} finally {
endBusy(operation)
}
requestDraftSwitch({
kind: 'create-note',
title
})
}
const analyzeTodo = async (todoId: string): Promise<void> => {
@@ -1028,8 +1261,8 @@ export function MagicNotesWorkspace({
content: composerContent
})
applyDetail(updated)
await reloadTodos()
composerContentRef.current = undefined
setPendingDraftSwitch(undefined)
draftAnalysisArmedRef.current = false
draftAnalysisQueuedRef.current = false
draftAnalysisContextRef.current += 1
@@ -1040,6 +1273,11 @@ export function MagicNotesWorkspace({
}
setComposerKey((current) => current + 1)
notifySuccess(t('notifications.entrySaved'))
try {
await reloadTodos()
} catch (refreshTodosError) {
notifyError(refreshTodosError)
}
const createdEntry = updated.entries.find(
(entry) => !existingEntryIds.has(entry.id)
)
@@ -1097,10 +1335,14 @@ export function MagicNotesWorkspace({
expectedRevision: editingEntry.revision
})
applyDetail(updated)
await reloadTodos()
setEditingEntry(undefined)
editingContentRef.current = undefined
notifySuccess(t('notifications.entryUpdated'))
try {
await reloadTodos()
} catch (refreshTodosError) {
notifyError(refreshTodosError)
}
if (commentMode === 'after-save-auto') {
const options = await createAnalysisOptions()
setLiveAnalysis({
@@ -1268,12 +1510,12 @@ export function MagicNotesWorkspace({
<PageTabs
ariaLabel={t('page.contentLabel')}
idPrefix="magic-library"
onChange={(value) => {
setLibraryView(value)
setCreating(false)
setValidation(undefined)
setSearch('')
}}
onChange={(value) =>
requestDraftSwitch({
kind: 'library-view',
value
})
}
tabs={libraryTabs}
value={libraryView}
variant="segmented"
@@ -1303,7 +1545,7 @@ export function MagicNotesWorkspace({
className="magic-notes-create"
onSubmit={(event) => {
event.preventDefault()
void createNote()
void submitCreateNote()
}}
>
<label>
@@ -1380,6 +1622,7 @@ export function MagicNotesWorkspace({
visibleNotes.map((note) => (
<button
key={note.id}
id={`magic-note-select-${note.id}`}
aria-pressed={selectedNoteId === note.id}
className={`magic-note-list-item ${
selectedNoteId === note.id
@@ -1387,13 +1630,12 @@ export function MagicNotesWorkspace({
: ''
}`}
type="button"
onClick={() => {
setValidation(undefined)
setDeletingNote(false)
setEditingEntry(undefined)
editingContentRef.current = undefined
void loadDetail(note.id)
}}
onClick={() =>
requestDraftSwitch({
kind: 'note',
noteId: note.id
})
}
>
<span className="magic-note-list-item__title">
{note.pinned && (
@@ -1487,11 +1729,14 @@ export function MagicNotesWorkspace({
{directory.todos.map((todo) => (
<TodoListItem
disabled={busy === `update-todo-${todo.id}`}
id={`magic-todo-select-${todo.id}`}
key={todo.id}
onSelect={() => {
setValidation(undefined)
setSelectedTodoId(todo.id)
}}
onSelect={() =>
requestDraftSwitch({
kind: 'todo',
todoId: todo.id
})
}
onToggle={() =>
void updateTodoCompletion(todo)
}
@@ -1538,7 +1783,12 @@ export function MagicNotesWorkspace({
</span>
<button
className="secondary-button"
onClick={() => void loadDetail(detailLoadError.noteId)}
onClick={() =>
requestDraftSwitch({
kind: 'note',
noteId: detailLoadError.noteId
})
}
type="button"
>
{t('actions.retry')}
@@ -1670,7 +1920,7 @@ export function MagicNotesWorkspace({
</div>
)}
<div className="magic-note-composer">
<div className="magic-note-composer" ref={composerRef}>
<MagicNoteEditor
key={`${detail.id}-${composerKey}`}
ariaDescribedBy={
@@ -1703,6 +1953,52 @@ export function MagicNotesWorkspace({
}
}}
/>
{pendingDraftSwitch && (
<div
aria-describedby={discardDraftDescriptionId}
aria-labelledby={discardDraftTitleId}
aria-modal="true"
className="magic-note-draft-confirmation"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
continueEditing()
return
}
trapTabFocus(event, discardDraftDialogRef.current)
}}
ref={discardDraftDialogRef}
role="alertdialog"
tabIndex={-1}
>
<strong id={discardDraftTitleId}>
{t('confirmations.discardDraftTitle')}
</strong>
<span id={discardDraftDescriptionId}>
{t('confirmations.discardDraftDescription')}
</span>
<div>
<button
className="secondary-button"
onClick={continueEditing}
ref={continueEditingRef}
type="button"
>
{t('actions.continueEditing')}
</button>
<button
className="danger-button"
onClick={() =>
performDraftSwitch(pendingDraftSwitch)
}
ref={discardDraftRef}
type="button"
>
{t('actions.discardAndSwitch')}
</button>
</div>
</div>
)}
{validation?.target === 'new-entry' && (
<p
className="magic-notes-field-error"
@@ -1764,12 +2060,12 @@ export function MagicNotesWorkspace({
<button
className="secondary-button"
type="button"
onClick={() => {
clearValidation('edit-entry')
setDeletingEntryId('')
setEditingEntry(entry)
editingContentRef.current = entry.content
}}
onClick={() =>
requestDraftSwitch({
kind: 'edit-entry',
entry
})
}
>
{t('actions.edit')}
</button>
@@ -1975,18 +2271,13 @@ export function MagicNotesWorkspace({
</p>
<button
className="secondary-button"
onClick={() => {
setLibraryView('notes')
void loadDetail(selectedTodo.noteId).then(() => {
requestAnimationFrame(() =>
document
.getElementById(
`magic-note-entry-${selectedTodo.entryId}`
)
?.scrollIntoView({ block: 'center' })
)
onClick={() =>
requestDraftSwitch({
kind: 'note',
noteId: selectedTodo.noteId,
entryId: selectedTodo.entryId
})
}}
}
type="button"
>
<BookOpen size={14} />
@@ -241,6 +241,8 @@ flowchart LR
)
expect(mermaidMock.initialize).toHaveBeenCalledWith(
expect.objectContaining({
fontFamily:
'"Inter Variable", "Segoe UI Variable", "SF Pro Text", "PingFang SC", "Microsoft YaHei UI", "Noto Sans SC Variable", "Noto Sans CJK SC", "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif',
htmlLabels: false,
maxEdges: 300,
maxTextSize: 20_000,
+1 -1
View File
@@ -192,7 +192,7 @@ function mermaidConfig(darkTheme: boolean): MermaidConfig {
deterministicIds: true,
dompurifyConfig: sharedSanitizerConfig,
fontFamily:
'"Inter Variable", "Noto Sans SC Variable", "Segoe UI Variable", sans-serif',
'"Inter Variable", "Segoe UI Variable", "SF Pro Text", "PingFang SC", "Microsoft YaHei UI", "Noto Sans SC Variable", "Noto Sans CJK SC", "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif',
htmlLabels: false,
logLevel: 'fatal',
maxEdges: MAX_MERMAID_EDGES,
@@ -1,3 +1,4 @@
import { RotateCcw, Save } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
@@ -6,6 +7,12 @@ import type {
ModelDownloadSource
} from '../../shared/application-settings-contracts'
import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts'
import {
canonicalizeShortcutAccelerator,
type GlobalShortcutSettings,
type GlobalShortcutSettingsSnapshot,
type GlobalShortcutUpdateErrorCode
} from '../../shared/shortcut'
import type { AppNotificationInput } from './notifications'
import {
PageTabs,
@@ -19,18 +26,47 @@ import {
type PlatformFeaturesSettingsSectionProps = {
onMagicNotesEnabledChange: (enabled: boolean) => void
onNotify?: (notification: AppNotificationInput) => void
onDirtyChange?: (dirty: boolean) => void
onShortcutSettingsChanged?: (
snapshot: GlobalShortcutSettingsSnapshot
) => void
}
type PlatformFeaturesTab = 'general' | 'magic-notes'
const shortcutErrorTranslationKeys: Record<
GlobalShortcutUpdateErrorCode,
| 'platformFeatures.shortcut.errors.conflict'
| 'platformFeatures.shortcut.errors.registrationFailed'
| 'platformFeatures.shortcut.errors.saveFailed'
> = {
conflict: 'platformFeatures.shortcut.errors.conflict',
'registration-failed':
'platformFeatures.shortcut.errors.registrationFailed',
'save-failed': 'platformFeatures.shortcut.errors.saveFailed'
}
export function PlatformFeaturesSettingsSection({
onMagicNotesEnabledChange,
onNotify
onNotify,
onDirtyChange,
onShortcutSettingsChanged
}: PlatformFeaturesSettingsSectionProps): React.JSX.Element {
const { t } = useTranslation('settingsSections')
const [activeSection, setActiveSection] =
useState<PlatformFeaturesTab>('general')
const [settings, setSettings] = useState<ApplicationSettings>()
const [shortcutSnapshot, setShortcutSnapshot] =
useState<GlobalShortcutSettingsSnapshot>()
const [shortcutDraft, setShortcutDraft] =
useState<GlobalShortcutSettings>()
const [shortcutSaving, setShortcutSaving] = useState(false)
const [shortcutError, setShortcutError] = useState<string | undefined>(
() =>
window.goodbuddy.shortcuts
? undefined
: t('platformFeatures.shortcut.errors.serviceUnavailable')
)
const [saving, setSaving] = useState(false)
const [sourceError, setSourceError] = useState<string>()
const [error, setError] = useState<string | undefined>(() =>
@@ -64,6 +100,139 @@ export function PlatformFeaturesSettingsSection({
}
}, [t])
useEffect(() => {
const shortcuts = window.goodbuddy.shortcuts
let active = true
if (!shortcuts) {
return () => {
active = false
}
}
void shortcuts
.getSettings()
.then((snapshot) => {
if (active) {
setShortcutSnapshot(snapshot)
setShortcutDraft(snapshot.settings)
}
})
.catch(() => {
if (active) {
setShortcutError(
t('platformFeatures.shortcut.errors.readFailed')
)
}
})
return () => {
active = false
}
}, [t])
const shortcutDirty =
shortcutSnapshot !== undefined &&
shortcutDraft !== undefined &&
(shortcutSnapshot.settings.enabled !== shortcutDraft.enabled ||
shortcutSnapshot.settings.accelerator !==
shortcutDraft.accelerator)
useEffect(() => {
onDirtyChange?.(shortcutDirty)
}, [onDirtyChange, shortcutDirty])
const saveShortcut = async (): Promise<void> => {
const shortcuts = window.goodbuddy.shortcuts
if (!shortcuts || !shortcutDraft) {
return
}
let input: GlobalShortcutSettings
try {
input = {
...shortcutDraft,
accelerator: canonicalizeShortcutAccelerator(
shortcutDraft.accelerator
)
}
} catch {
setShortcutError(
t('platformFeatures.shortcut.errors.invalidAccelerator')
)
return
}
setShortcutSaving(true)
setShortcutError(undefined)
try {
setShortcutDraft(input)
const result = await shortcuts.updateSettings(input)
setShortcutSnapshot(result.snapshot)
if (!result.ok) {
setShortcutError(t(shortcutErrorTranslationKeys[result.error]))
return
}
setShortcutDraft(result.snapshot.settings)
onShortcutSettingsChanged?.(result.snapshot)
onNotify?.({
tone: 'success',
message: t('platformFeatures.shortcut.saved'),
dedupeKey: 'global-shortcut-saved'
})
} catch {
setShortcutError(
t('platformFeatures.shortcut.errors.saveFailed')
)
} finally {
setShortcutSaving(false)
}
}
const recordShortcut = (
event: React.KeyboardEvent<HTMLInputElement>
): void => {
if (
['Control', 'Shift', 'Alt', 'Meta'].includes(event.key) ||
event.key === 'Escape' ||
event.key === 'Tab'
) {
return
}
if (
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
) {
return
}
event.preventDefault()
const isMac = shortcutSnapshot?.platform === 'darwin'
const parts = [
event.ctrlKey
? isMac
? 'Control'
: 'CommandOrControl'
: undefined,
event.metaKey
? isMac
? 'Command'
: 'Super'
: undefined,
event.altKey ? 'Alt' : undefined,
event.shiftKey ? 'Shift' : undefined,
event.key === ' ' ? 'Space' : event.key
].filter((part): part is string => Boolean(part))
try {
const accelerator = canonicalizeShortcutAccelerator(
parts.join('+')
)
setShortcutDraft((current) =>
current ? { ...current, accelerator } : current
)
setShortcutError(undefined)
} catch {
setShortcutError(
t('platformFeatures.shortcut.errors.invalidAccelerator')
)
}
}
const changeModelDownloadSource = async (
modelDownloadSource: ModelDownloadSource
): Promise<void> => {
@@ -198,6 +367,112 @@ export function PlatformFeaturesSettingsSection({
id="platform-features-panel-general"
role="tabpanel"
>
<article className="capability-card">
<div className="capability-card__header">
<div>
<strong>{t('platformFeatures.shortcut.title')}</strong>
<small>
{t('platformFeatures.shortcut.description')}
</small>
</div>
</div>
{shortcutDraft && shortcutSnapshot ? (
<>
<label className="toggle-row">
<input
checked={shortcutDraft.enabled}
disabled={shortcutSaving}
onChange={(event) =>
setShortcutDraft({
...shortcutDraft,
enabled: event.target.checked
})
}
role="switch"
type="checkbox"
/>
<span>{t('platformFeatures.shortcut.enabled')}</span>
</label>
<label className="field">
<span>{t('platformFeatures.shortcut.accelerator')}</span>
<input
aria-label={t(
'platformFeatures.shortcut.accelerator'
)}
aria-describedby="global-shortcut-recorder-help"
disabled={!shortcutDraft.enabled || shortcutSaving}
onChange={(event) =>
setShortcutDraft({
...shortcutDraft,
accelerator: event.target.value
})
}
onKeyDown={recordShortcut}
value={shortcutDraft.accelerator}
/>
<small id="global-shortcut-recorder-help">
{t('platformFeatures.shortcut.recorderHelp')}
</small>
</label>
<div className="update-settings__actions">
<button
className="secondary-button"
disabled={shortcutSaving}
onClick={() => {
setShortcutDraft({
...shortcutSnapshot.defaultSettings
})
setShortcutError(undefined)
}}
type="button"
>
<RotateCcw aria-hidden="true" size={13} />
{t('platformFeatures.shortcut.reset')}
</button>
<button
className="primary-button"
disabled={!shortcutDirty || shortcutSaving}
onClick={() => void saveShortcut()}
type="button"
>
<Save aria-hidden="true" size={13} />
{shortcutSaving
? t('platformFeatures.shortcut.saving')
: t('platformFeatures.shortcut.save')}
</button>
</div>
<p
className={
shortcutError
? 'settings-warning'
: 'settings-notice'
}
role={shortcutError ? 'alert' : 'status'}
>
{shortcutError ??
t(
`platformFeatures.shortcut.status.${shortcutSnapshot.status}`,
{
shortcut:
shortcutSnapshot.displayAccelerator
}
)}
</p>
</>
) : (
<p
className={
shortcutError
? 'settings-warning'
: 'settings-notice'
}
role={shortcutError ? 'alert' : 'status'}
>
{shortcutError ??
t('platformFeatures.shortcut.loading')}
</p>
)}
</article>
{settings ? (
<article className="capability-card">
<div className="capability-card__header">
+14 -3
View File
@@ -32,6 +32,7 @@ import {
channelProjectDraft,
ChannelProjectSettingsFields
} from './ChannelProjectSettingsFields'
import { getProjectDisplayText } from './project-display'
type ProjectSwitcherProps = {
projects: AssistantProject[]
@@ -88,6 +89,9 @@ export function ProjectSwitcher({
const activeProject = projects.find(
(project) => project.id === activeProjectId
)
const activeProjectDisplay = activeProject
? getProjectDisplayText(activeProject, t)
: undefined
const userProjects = projects.filter(
(project) => project.kind === 'user'
)
@@ -270,10 +274,13 @@ export function ProjectSwitcher({
}
}}
ref={projectPickerButtonRef}
title={activeProject?.name}
title={activeProjectDisplay?.name}
type="button"
>
<span>{activeProject?.name ?? t('projectSwitcher.selector.empty')}</span>
<span>
{activeProjectDisplay?.name ??
t('projectSwitcher.selector.empty')}
</span>
<ChevronDown aria-hidden="true" size={14} />
</button>
{projectMenuOpen && (
@@ -340,6 +347,10 @@ export function ProjectSwitcher({
{group.projects.map((project) => {
const selected = project.id === activeProjectId
const ProjectIcon = group.icon
const projectDisplay = getProjectDisplayText(
project,
t
)
const detail =
project.kind === 'channel'
? t('projectSwitcher.selector.remoteDetail', {
@@ -372,7 +383,7 @@ export function ProjectSwitcher({
>
<ProjectIcon aria-hidden="true" size={16} />
<span>
<b>{project.name}</b>
<b>{projectDisplay.name}</b>
<small>{detail}</small>
</span>
{selected && (
@@ -1,4 +1,10 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import {
cleanup,
fireEvent,
render,
screen,
waitFor
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
RightAssistantSidebar,
@@ -18,11 +24,17 @@ beforeEach(() => {
function renderSidebar({
tab = 'context',
artifacts = [],
onLoadArtifact = vi.fn(async () => undefined)
onLoadArtifact = vi.fn(async () => undefined),
onClose = vi.fn(),
overlay = false,
restoreFocusRef
}: {
tab?: AssistantSidebarTab
artifacts?: SidebarArtifact[]
onLoadArtifact?: (artifactId: string) => Promise<void>
onClose?: () => void
overlay?: boolean
restoreFocusRef?: { current: HTMLElement | null }
} = {}): HTMLElement {
render(
<RightAssistantSidebar
@@ -35,7 +47,7 @@ function renderSidebar({
tasks={[]}
conversationTitles={new Map()}
projectNames={new Map()}
onClose={vi.fn()}
onClose={onClose}
onCreateCustomTask={vi.fn()}
onImportArtifacts={vi.fn(async () => undefined)}
onListWorkspaceDirectory={vi.fn(async (path: string) => ({
@@ -57,11 +69,13 @@ function renderSidebar({
onStopBrowser={vi.fn(async () => undefined)}
onTabChange={vi.fn()}
open
overlay={overlay}
restoreFocusRef={restoreFocusRef}
tab={tab}
/>
)
return screen.getByRole('complementary', {
return screen.getByRole(overlay ? 'dialog' : 'complementary', {
name: '助手工作栏'
})
}
@@ -166,6 +180,27 @@ describe('RightAssistantSidebar resizing', () => {
).not.toBeInTheDocument()
})
it('treats overlay mode as a focus-trapped dismissible dialog', async () => {
const onClose = vi.fn()
const trigger = document.createElement('button')
document.body.append(trigger)
const sidebar = renderSidebar({
onClose,
overlay: true,
restoreFocusRef: { current: trigger }
})
expect(sidebar).toHaveAttribute('aria-modal', 'true')
await waitFor(() =>
expect(
screen.getByRole('tab', { name: '上下文' })
).toHaveFocus()
)
fireEvent.keyDown(sidebar, { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
trigger.remove()
})
it('keeps the product Task index in the task center', () => {
renderSidebar({ tab: 'tasks' })
@@ -33,6 +33,7 @@ import type {
KnowledgeLibrary
} from '../../shared/contracts'
import { WorkspaceFilesPanel } from './WorkspaceFilesPanel'
import { trapTabFocus } from './dialog-focus'
import { SegmentedControl } from './WorkspacePrimitives'
import {
findTaskSchedule,
@@ -79,6 +80,8 @@ type RightAssistantSidebarProps = {
workspaceChanges?: WorkspaceChanges
workspaceProjectId?: string
browserState?: BrowserLiveState
overlay?: boolean
restoreFocusRef?: { current: HTMLElement | null }
onClose: () => void
onInteractBrowser: () => Promise<void>
onStopBrowser: () => Promise<void>
@@ -156,6 +159,8 @@ export function RightAssistantSidebar({
workspaceChanges,
workspaceProjectId,
browserState,
overlay = false,
restoreFocusRef,
onClose,
onInteractBrowser,
onStopBrowser,
@@ -197,6 +202,8 @@ export function RightAssistantSidebar({
const [sidebarWidth, setSidebarWidth] = useState(defaultSidebarWidth)
const [isResizing, setIsResizing] = useState(false)
const sidebarRef = useRef<HTMLElement>(null)
const wasOpen = useRef(false)
const wasOverlayOpen = useRef(false)
const liveSidebarWidth = useRef(defaultSidebarWidth)
const resizePointerId = useRef<number | undefined>(undefined)
const [selectedArtifactId, setSelectedArtifactId] = useState<string>()
@@ -287,6 +294,24 @@ export function RightAssistantSidebar({
return () => window.removeEventListener('resize', handleViewportResize)
}, [])
useEffect(() => {
if (open && overlay && !wasOverlayOpen.current) {
const focusFrame = requestAnimationFrame(() => {
document
.getElementById(`assistant-sidebar-tab-${tab}`)
?.focus()
})
wasOpen.current = open
wasOverlayOpen.current = true
return () => cancelAnimationFrame(focusFrame)
}
if (!open && wasOpen.current) {
requestAnimationFrame(() => restoreFocusRef?.current?.focus())
}
wasOpen.current = open
wasOverlayOpen.current = open && overlay
}, [open, overlay, restoreFocusRef, tab])
const resizeFromClientX = (
clientX: number,
commit: boolean
@@ -430,12 +455,25 @@ export function RightAssistantSidebar({
ref={sidebarRef}
aria-label={t('sidebar.ariaLabel')}
aria-hidden={!open}
aria-modal={open && overlay ? 'true' : undefined}
className={
open
? `assistant-sidebar assistant-sidebar--open${isResizing && canResize ? ' assistant-sidebar--resizing' : ''}`
: 'assistant-sidebar'
}
inert={!open}
onKeyDown={(event) => {
if (!open || !overlay) {
return
}
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
trapTabFocus(event, sidebarRef.current)
}}
role={open && overlay ? 'dialog' : undefined}
style={
{
'--assistant-sidebar-width': `${sidebarWidth}px`
@@ -33,6 +33,7 @@ type RuntimeCustomizationSectionProps = {
export type RuntimeCustomizationSectionHandle = {
save: () => Promise<boolean>
discard: () => void
}
type RuntimeCustomizationError = {
@@ -534,9 +535,7 @@ export const RuntimeCustomizationSection = forwardRef<
}
}, [settings, settingsDirty, t])
useImperativeHandle(ref, () => ({ save }), [save])
const discardChanges = (): void => {
const discardChanges = useCallback((): void => {
if (!persistedSettings) {
return
}
@@ -547,7 +546,13 @@ export const RuntimeCustomizationSection = forwardRef<
''
)
setError(undefined)
}
}, [persistedSettings])
useImperativeHandle(
ref,
() => ({ save, discard: discardChanges }),
[discardChanges, save]
)
const addPreset = (): void => {
if (
+286 -1
View File
@@ -20,6 +20,9 @@ import type {
CapabilitySnapshot
} from '../../shared/capability-contracts'
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
import type {
GlobalShortcutSettingsSnapshot
} from '../../shared/shortcut'
import type {
EmbeddingDiagnosticResult,
EmbeddingSettingsSnapshot
@@ -27,7 +30,10 @@ import type {
import type { SpeechModelSnapshot } from '../../shared/speech-model-contracts'
import { builtinMcpServers } from '../../shared/builtin-mcp-servers'
import { builtinModelToolGroups } from '../../shared/builtin-model-tools'
import { SettingsPanel } from './SettingsPanel'
import {
SettingsPanel,
type SettingsLeaveRequester
} from './SettingsPanel'
import {
RuntimeCustomizationSection,
type RuntimeCustomizationSectionHandle
@@ -413,6 +419,37 @@ const updateApplicationSettings = vi.fn<
}
return { ...applicationSettings }
})
let shortcutSettingsSnapshot: GlobalShortcutSettingsSnapshot = {
settings: {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
defaultSettings: {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
platform: 'win32',
displayAccelerator: 'Ctrl+Shift+Space',
registered: true,
registeredAccelerator: 'CommandOrControl+Shift+Space',
status: 'registered'
}
const getShortcutSettings = vi.fn(async () => shortcutSettingsSnapshot)
const updateShortcutSettings = vi.fn<
NonNullable<DesktopApi['shortcuts']>['updateSettings']
>(async (input) => {
shortcutSettingsSnapshot = {
...shortcutSettingsSnapshot,
settings: input,
displayAccelerator: input.accelerator,
registered: input.enabled,
registeredAccelerator: input.enabled
? input.accelerator
: undefined,
status: input.enabled ? 'registered' : 'disabled'
}
return { ok: true, snapshot: shortcutSettingsSnapshot }
})
const speechCatalog: SpeechModelSnapshot['catalog'] = [
{
id: 'sensevoice-small-int8',
@@ -592,6 +629,37 @@ describe('SettingsPanel runtime files', () => {
magicNoteCommentFormat: 'combined'
}
speechModelSnapshot = createSpeechModelSnapshot()
shortcutSettingsSnapshot = {
settings: {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
defaultSettings: {
enabled: true,
accelerator: 'CommandOrControl+Shift+Space'
},
platform: 'win32',
displayAccelerator: 'Ctrl+Shift+Space',
registered: true,
registeredAccelerator: 'CommandOrControl+Shift+Space',
status: 'registered'
}
getShortcutSettings.mockImplementation(
async () => shortcutSettingsSnapshot
)
updateShortcutSettings.mockImplementation(async (input) => {
shortcutSettingsSnapshot = {
...shortcutSettingsSnapshot,
settings: input,
displayAccelerator: input.accelerator,
registered: input.enabled,
registeredAccelerator: input.enabled
? input.accelerator
: undefined,
status: input.enabled ? 'registered' : 'disabled'
}
return { ok: true, snapshot: shortcutSettingsSnapshot }
})
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
@@ -668,6 +736,10 @@ describe('SettingsPanel runtime files', () => {
check: vi.fn(),
openReleasePage: vi.fn(),
onResult: vi.fn(() => () => {})
},
shortcuts: {
getSettings: getShortcutSettings,
updateSettings: updateShortcutSettings
}
} as unknown as DesktopApi
})
@@ -1167,6 +1239,12 @@ describe('SettingsPanel runtime files', () => {
expect(
screen.queryByText('当前选择:ModelScope')
).not.toBeInTheDocument()
expect(
await screen.findByText('全局快捷唤起')
).toBeInTheDocument()
expect(screen.getByLabelText('快捷键')).toHaveValue(
'CommandOrControl+Shift+Space'
)
})
it('keeps the confirmed model download source when saving fails', async () => {
@@ -1495,6 +1573,213 @@ describe('SettingsPanel runtime files', () => {
)
})
it('records and saves the global shortcut in General settings', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
expect(
screen.getByRole('tab', { name: '平台功能' })
).toHaveAttribute('aria-selected', 'true')
await waitFor(() =>
expect(getShortcutSettings).toHaveBeenCalled()
)
await waitFor(() =>
expect(
screen.queryByText('正在读取快捷键状态…')
).not.toBeInTheDocument()
)
expect(
await screen.findByText('全局快捷唤起')
).toBeInTheDocument()
expect(
screen.queryByText('读取快捷键设置失败,请重试')
).not.toBeInTheDocument()
const accelerator = await screen.findByLabelText('快捷键')
expect(accelerator).toHaveValue(
'CommandOrControl+Shift+Space'
)
fireEvent.keyDown(accelerator, {
key: 'k',
ctrlKey: true,
altKey: true
})
expect(accelerator).toHaveValue('CommandOrControl+Alt+K')
const platformTab = screen.getByRole('tab', {
name: '平台功能'
})
fireEvent.keyDown(platformTab, { key: 'ArrowRight' })
expect(platformTab).toHaveAttribute('aria-selected', 'true')
fireEvent.click(
screen.getByRole('button', { name: '保存快捷键' })
)
await waitFor(() =>
expect(updateShortcutSettings).toHaveBeenCalledWith({
enabled: true,
accelerator: 'CommandOrControl+Alt+K'
})
)
expect(
await screen.findByText('已注册:CommandOrControl+Alt+K')
).toBeInTheDocument()
})
it('records physical Control separately from Command on macOS', async () => {
shortcutSettingsSnapshot = {
...shortcutSettingsSnapshot,
platform: 'darwin',
displayAccelerator: 'Command+Shift+Space'
}
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const accelerator = await screen.findByLabelText('快捷键')
fireEvent.keyDown(accelerator, {
key: 'k',
ctrlKey: true
})
expect(accelerator).toHaveValue('Control+K')
fireEvent.keyDown(accelerator, {
key: 'k',
ctrlKey: true,
metaKey: true
})
expect(accelerator).toHaveValue('Control+Command+K')
})
it('preserves runtime drafts across navigation and protects them on close', async () => {
const onClose = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={onClose}
onSaved={vi.fn()}
/>
)
const workspace = await screen.findByLabelText('默认工作区目录')
fireEvent.change(workspace, {
target: { value: 'C:\\Unsaved workspace' }
})
const runtimeTab = screen.getByRole('tab', {
name: 'Agent Runtime'
})
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
expect(
screen.getByRole('tab', { name: '外观' })
).toHaveAttribute('aria-selected', 'true')
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }))
expect(onClose).not.toHaveBeenCalled()
expect(
screen.getByRole('alert')
).toHaveTextContent('当前设置有未保存更改')
fireEvent.click(
screen.getByRole('button', { name: '继续编辑' })
)
fireEvent.click(runtimeTab)
expect(await screen.findByLabelText('默认工作区目录')).toHaveValue(
'C:\\Unsaved workspace'
)
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }))
expect(
screen.getByRole('button', { name: '放弃更改并关闭' })
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '放弃更改并关闭' })
)
expect(onClose).toHaveBeenCalledOnce()
})
it('routes external leave requests through the existing dirty confirmation', async () => {
let requestLeave: SettingsLeaveRequester | undefined
const proceed = vi.fn()
const onClose = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
onLeaveRequestReady={(requester) => {
requestLeave = requester
}}
open
onClearLocalData={vi.fn(async () => {})}
onClose={onClose}
onSaved={vi.fn()}
/>
)
fireEvent.change(await screen.findByLabelText('默认工作区目录'), {
target: { value: 'C:\\Pending external navigation' }
})
act(() => requestLeave?.(proceed))
expect(proceed).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByRole('alert')).toHaveTextContent(
'当前设置有未保存更改'
)
fireEvent.click(
screen.getByRole('button', { name: '放弃更改并关闭' })
)
expect(proceed).toHaveBeenCalledOnce()
expect(onClose).not.toHaveBeenCalled()
})
it('keeps shortcut input after a registration conflict', async () => {
updateShortcutSettings.mockResolvedValueOnce({
ok: false,
error: 'conflict',
snapshot: shortcutSettingsSnapshot
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
await waitFor(() =>
expect(getShortcutSettings).toHaveBeenCalled()
)
const accelerator = await screen.findByLabelText('快捷键')
fireEvent.change(accelerator, {
target: { value: 'Control+Alt+K' }
})
fireEvent.click(
screen.getByRole('button', { name: '保存快捷键' })
)
expect(
await screen.findByText(/已被其他应用占用/u)
).toBeInTheDocument()
expect(accelerator).toHaveValue('Control+Alt+K')
})
it('keeps a speech model draft when saving the selection fails', async () => {
selectSpeechModel.mockRejectedValueOnce(
new Error('语音模型切换失败')
+301 -58
View File
@@ -9,7 +9,13 @@ import {
Trash2,
X
} from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import type {
AssistantExpert,
@@ -63,6 +69,8 @@ import type {
EmbeddingConfigurationSummary
} from '../../shared/embedding-contracts'
import { useUiLocale } from './i18n/UiLocaleProvider'
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
import { findPreferredCompatibleModelProfile } from './model-profile-selection'
type ModelType = 'llm' | 'embedding' | 'rerank' | 'speech'
type AgentRuntimeType =
@@ -156,6 +164,8 @@ function normalizeContextCompressionTokenDrafts(
}
}
export type SettingsLeaveRequester = (proceed: () => void) => void
type SettingsPanelProps = {
open: boolean
presentation?: 'modal' | 'page'
@@ -175,6 +185,12 @@ type SettingsPanelProps = {
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
magicNotesEnabled?: boolean
onMagicNotesEnabledChange?: (enabled: boolean) => void
onShortcutSettingsChanged?: (
snapshot: GlobalShortcutSettingsSnapshot
) => void
onLeaveRequestReady?: (
requester: SettingsLeaveRequester | undefined
) => void
}
function settingsErrorMessage(reason: unknown, fallback: string): string {
@@ -525,7 +541,9 @@ export function SettingsPanel({
appearanceTheme = 'system',
onAppearanceThemeChange = () => {},
magicNotesEnabled = false,
onMagicNotesEnabledChange = () => {}
onMagicNotesEnabledChange = () => {},
onShortcutSettingsChanged = () => {},
onLeaveRequestReady = () => {}
}: SettingsPanelProps): React.JSX.Element | null {
const { i18n, t } = useTranslation('settings')
const {
@@ -647,6 +665,15 @@ export function SettingsPanel({
useState<AgentRuntimeType>('opencode')
const [runtimeCustomizationDirty, setRuntimeCustomizationDirty] =
useState(false)
const [platformFeaturesDirty, setPlatformFeaturesDirty] =
useState(false)
const [documentParsingDirty, setDocumentParsingDirty] =
useState(false)
const [pendingLeave, setPendingLeave] = useState<
| { kind: 'close' }
| { kind: 'navigate'; category: SettingsCategoryId }
| { kind: 'external'; proceed: () => void }
>()
const runtimeCustomizationRef =
useRef<RuntimeCustomizationSectionHandle>(null)
const handleRuntimeCustomizationDirtyChange = useCallback(
@@ -725,6 +752,141 @@ export function SettingsPanel({
activeTab === 'mcp' ||
activeTab === 'about'
const savedConfiguredSettings = settings
? configuredRuntimeSettings(settings)
: undefined
const runtimeDraftDirty =
settings !== undefined &&
JSON.stringify({
provider,
modelProfiles,
defaultModelProfileId,
opencodeModelSource,
continueModelSource,
deepseekHarnessModelSource,
opencodeBaseUrl,
opencodeBinaryPath,
opencodeConfigPath,
continueBinaryPath,
continueConfigPath,
continueMode,
knowledgeEmbeddingEnabled,
knowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel,
knowledgeEmbeddingApiKey,
clearKnowledgeEmbeddingApiKey,
knowledgeRerankEnabled,
knowledgeRerankEndpoint,
knowledgeRerankModel,
knowledgeRerankApiKey,
clearKnowledgeRerankApiKey,
workspacePath,
toolApproval,
subagentSmartRoutingEnabled,
contextCompression,
contextCompressionTokenInput
}) !==
JSON.stringify({
provider: settings.provider,
modelProfiles: toModelProfileDrafts(settings),
defaultModelProfileId: settings.defaultModelProfileId,
opencodeModelSource:
savedConfiguredSettings?.opencodeModelSource,
continueModelSource:
savedConfiguredSettings?.continueModelSource,
deepseekHarnessModelSource:
savedConfiguredSettings?.deepseekHarnessModelSource ?? {
kind: 'platform'
},
opencodeBaseUrl: savedConfiguredSettings?.opencodeBaseUrl,
opencodeBinaryPath:
savedConfiguredSettings?.opencodeBinaryPath,
opencodeConfigPath:
savedConfiguredSettings?.opencodeConfigPath,
continueBinaryPath:
savedConfiguredSettings?.continueBinaryPath,
continueConfigPath:
savedConfiguredSettings?.continueConfigPath,
continueMode: settings.continueMode,
knowledgeEmbeddingEnabled:
settings.knowledgeEmbeddingEnabled,
knowledgeEmbeddingBaseUrl:
settings.knowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel: settings.knowledgeEmbeddingModel,
knowledgeEmbeddingApiKey: '',
clearKnowledgeEmbeddingApiKey: false,
knowledgeRerankEnabled:
settings.knowledgeRerankEnabled ??
defaultRuntimeSettings.knowledgeRerankEnabled,
knowledgeRerankEndpoint:
settings.knowledgeRerankEndpoint ??
defaultRuntimeSettings.knowledgeRerankEndpoint,
knowledgeRerankModel:
settings.knowledgeRerankModel ??
defaultRuntimeSettings.knowledgeRerankModel,
knowledgeRerankApiKey: '',
clearKnowledgeRerankApiKey: false,
workspacePath: savedConfiguredSettings?.workspacePath,
toolApproval:
settings.toolApproval === 'policy' ? 'policy' : 'always',
subagentSmartRoutingEnabled:
settings.subagentSmartRoutingEnabled,
contextCompression:
settings.contextCompression ??
defaultContextCompressionSettings,
contextCompressionTokenInput: contextCompressionTokenDrafts(
settings.contextCompression ??
defaultContextCompressionSettings
)
})
const hasUnsavedDrafts =
runtimeDraftDirty ||
speechModelSelectionDirty ||
runtimeCustomizationDirty ||
platformFeaturesDirty ||
documentParsingDirty
const navigationWouldLoseDraft =
runtimeCustomizationDirty ||
platformFeaturesDirty ||
documentParsingDirty
const unsavedCloseMessage =
runtimeCustomizationDirty &&
!runtimeDraftDirty &&
!speechModelSelectionDirty &&
!platformFeaturesDirty &&
!documentParsingDirty
? t('runtime.customization.unsavedClose')
: t('unsaved.close')
useEffect(() => {
if (!open || !hasUnsavedDrafts) {
return
}
const preventUnload = (event: BeforeUnloadEvent): void => {
event.preventDefault()
event.returnValue = ''
}
window.addEventListener('beforeunload', preventUnload)
return () =>
window.removeEventListener('beforeunload', preventUnload)
}, [hasUnsavedDrafts, open])
const requestTabChange = (
category: SettingsCategoryId
): boolean => {
if (category === activeTab) {
return true
}
if (navigationWouldLoseDraft) {
setPendingLeave({ kind: 'navigate', category })
return false
}
setPendingLeave(undefined)
setError(undefined)
setActiveTab(category)
return true
}
const handleTabKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>,
tab: SettingsCategoryId
@@ -747,8 +909,9 @@ export function SettingsPanel({
}
event.preventDefault()
const nextTab = settingsTabs[nextIndex]!
setError(undefined)
setActiveTab(nextTab)
if (!requestTabChange(nextTab)) {
return
}
event.currentTarget.parentElement
?.querySelector<HTMLButtonElement>(
`#settings-tab-${nextTab}`
@@ -770,6 +933,9 @@ export function SettingsPanel({
setSpeechModelDraftId(undefined)
setPersistedSpeechModelId(undefined)
setSpeechModelSelectionDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
setPendingLeave(undefined)
setAgentRuntimeType('opencode')
hydrateSettings(value)
})
@@ -831,10 +997,6 @@ export function SettingsPanel({
}
}, [i18n, open])
if (!open) {
return null
}
const normalizedContextCompression =
normalizeContextCompressionTokenDrafts(
contextCompression,
@@ -852,11 +1014,14 @@ export function SettingsPanel({
}
const close = (): void => {
if (runtimeCustomizationDirty) {
setActiveTab('runtime')
setError(t('runtime.customization.unsavedClose'))
if (hasUnsavedDrafts) {
setPendingLeave({ kind: 'close' })
return
}
closeImmediately()
}
const resetSensitiveDrafts = (): void => {
setModelProfiles((profiles) =>
profiles.map((profile) => ({
...profile,
@@ -871,10 +1036,66 @@ export function SettingsPanel({
setSpeechModelDraftId(undefined)
setPersistedSpeechModelId(undefined)
setSpeechModelSelectionDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
setPendingLeave(undefined)
setError(undefined)
}
const closeImmediately = (): void => {
resetSensitiveDrafts()
onClose()
}
const leaveSettings = (proceed: () => void): void => {
resetSensitiveDrafts()
proceed()
}
const discardAndLeave = (): void => {
if (settings) {
hydrateSettings(settings)
}
setSpeechModelDraftId(persistedSpeechModelId)
setSpeechModelSelectionDirty(false)
runtimeCustomizationRef.current?.discard()
setRuntimeCustomizationDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
const leave = pendingLeave
setPendingLeave(undefined)
setError(undefined)
if (leave?.kind === 'navigate') {
setActiveTab(leave.category)
return
}
if (leave?.kind === 'external') {
setActiveTab('runtime')
leaveSettings(leave.proceed)
return
}
if (leave?.kind === 'close') {
closeImmediately()
}
}
useLayoutEffect(() => {
const requestLeave: SettingsLeaveRequester = (proceed) => {
if (hasUnsavedDrafts) {
setPendingLeave({ kind: 'external', proceed })
return
}
resetSensitiveDrafts()
proceed()
}
onLeaveRequestReady(requestLeave)
return () => onLeaveRequestReady(undefined)
}, [hasUnsavedDrafts, onLeaveRequestReady])
if (!open) {
return null
}
const save = async (
notifySuccess = true
): Promise<RuntimeSettings | undefined> => {
@@ -996,6 +1217,7 @@ export function SettingsPanel({
selectedSpeechModelId = speechSnapshot.selectedModelId
}
hydrateSettings(value, true)
setPendingLeave(undefined)
if (speechModelSelectionDirty) {
setSpeechModelDraftId(selectedSpeechModelId)
setPersistedSpeechModelId(selectedSpeechModelId)
@@ -1215,15 +1437,12 @@ export function SettingsPanel({
(profile) => profile.id === id
)
const remaining = modelProfiles.filter((profile) => profile.id !== id)
const compatibleFallback =
remaining.find(
(profile) =>
profile.id === defaultModelProfileId &&
isAgentRuntimeModelProtocol(profile.protocol)
) ??
remaining.find((profile) =>
const compatibleFallback = findPreferredCompatibleModelProfile(
remaining,
defaultModelProfileId,
(profile) =>
isAgentRuntimeModelProtocol(profile.protocol)
)
)
const runtimeFallback: RuntimeModelSource = compatibleFallback
? { kind: 'profile', profileId: compatibleFallback.id }
: { kind: 'platform' }
@@ -1255,13 +1474,10 @@ export function SettingsPanel({
deepseekHarnessModelSource.kind === 'profile' &&
deepseekHarnessModelSource.profileId === id
) {
const harnessFallback = remaining.find(
(profile) =>
profile.id === defaultModelProfileId &&
profile.protocol === 'openai-chat-completions'
) ?? remaining.find(
(profile) =>
profile.protocol === 'openai-chat-completions'
const harnessFallback = findPreferredCompatibleModelProfile(
remaining,
defaultModelProfileId,
(profile) => profile.protocol === 'openai-chat-completions'
)
setDeepseekHarnessModelSource(
harnessFallback
@@ -1284,13 +1500,12 @@ export function SettingsPanel({
profile: ModelProfileDraft
): void => {
const previousDefaultProfileId = defaultModelProfileId
const compatibleProfile = isAgentRuntimeModelProtocol(
profile.protocol
const compatibleProfile = findPreferredCompatibleModelProfile(
modelProfiles,
profile.id,
(candidate) =>
isAgentRuntimeModelProtocol(candidate.protocol)
)
? profile
: modelProfiles.find((candidate) =>
isAgentRuntimeModelProtocol(candidate.protocol)
)
const nextRuntimeSource: RuntimeModelSource = compatibleProfile
? { kind: 'profile', profileId: compatibleProfile.id }
: { kind: 'platform' }
@@ -1341,21 +1556,18 @@ export function SettingsPanel({
(profile) => profile.id === selectedModelProfileId
) ?? modelProfiles[0]
const defaultTextModelProfile =
modelProfiles.find(
findPreferredCompatibleModelProfile(
modelProfiles,
defaultModelProfileId,
(profile) =>
profile.id === defaultModelProfileId &&
isAgentRuntimeModelProtocol(profile.protocol)
) ??
modelProfiles.find((profile) =>
isAgentRuntimeModelProtocol(profile.protocol)
)
const defaultDeepseekHarnessModelProfile =
modelProfiles.find(
(profile) =>
profile.id === defaultModelProfileId &&
isDeepseekHarnessCompatible(profile)
) ??
modelProfiles.find(isDeepseekHarnessCompatible)
findPreferredCompatibleModelProfile(
modelProfiles,
defaultModelProfileId,
isDeepseekHarnessCompatible
)
const activeRuntimeModelSource =
agentRuntimeType === 'opencode'
? opencodeModelSource
@@ -1440,8 +1652,7 @@ export function SettingsPanel({
id={`settings-tab-${category.id}`}
key={category.id}
onClick={() => {
setError(undefined)
setActiveTab(category.id)
requestTabChange(category.id)
}}
onKeyDown={(event) =>
handleTabKeyDown(event, category.id)
@@ -1469,6 +1680,39 @@ export function SettingsPanel({
ref={settingsBodyRef}
role="tabpanel"
>
{pendingLeave && (
<div
className="settings-warning"
role="alert"
>
<p>
{pendingLeave.kind !== 'navigate'
? unsavedCloseMessage
: t('unsaved.navigation')}
</p>
<div className="update-settings__actions">
<button
className="secondary-button"
onClick={() => {
setPendingLeave(undefined)
setError(undefined)
}}
type="button"
>
{t('unsaved.keepEditing')}
</button>
<button
className="danger-button"
onClick={discardAndLeave}
type="button"
>
{pendingLeave.kind !== 'navigate'
? t('unsaved.discardAndClose')
: t('unsaved.discardAndNavigate')}
</button>
</div>
</div>
)}
{!categoryRendersOwnHeader && (
<SettingsCategoryHeader
actions={
@@ -1609,8 +1853,10 @@ export function SettingsPanel({
)}
{activeTab === 'platform-features' && (
<PlatformFeaturesSettingsSection
onDirtyChange={setPlatformFeaturesDirty}
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
onNotify={onNotify}
onShortcutSettingsChanged={onShortcutSettingsChanged}
/>
)}
{activeTab === 'runtime' && (
@@ -2423,17 +2669,13 @@ export function SettingsPanel({
.value as ModelProfileDraft['protocol']
updateModelProfile(profile.id, { protocol })
const compatibleFallback =
modelProfiles.find(
findPreferredCompatibleModelProfile(
modelProfiles.filter(
(candidate) =>
candidate.id !== profile.id
),
defaultModelProfileId,
(candidate) =>
candidate.id !== profile.id &&
candidate.id === defaultModelProfileId &&
isAgentRuntimeModelProtocol(
candidate.protocol
)
) ??
modelProfiles.find(
(candidate) =>
candidate.id !== profile.id &&
isAgentRuntimeModelProtocol(
candidate.protocol
)
@@ -2926,7 +3168,7 @@ export function SettingsPanel({
<SpeechModelSettingsSection
onNotify={onNotify}
onOpenModelDownloadSourceSettings={() =>
setActiveTab('platform-features')
requestTabChange('platform-features')
}
onSelectedModelIdChange={(modelId, changed) => {
setSpeechModelDraftId(modelId)
@@ -3074,7 +3316,7 @@ export function SettingsPanel({
className="secondary-button"
onClick={() => {
setModelType('llm')
setActiveTab('model')
requestTabChange('model')
}}
type="button"
>
@@ -3124,9 +3366,10 @@ export function SettingsPanel({
{activeTab === 'document-parsing' && (
<DocumentParsingSettingsSection
onDirtyChange={setDocumentParsingDirty}
onNotify={onNotify}
onOpenModelDownloadSourceSettings={() =>
setActiveTab('platform-features')
requestTabChange('platform-features')
}
/>
)}
+124 -7
View File
@@ -15,7 +15,8 @@ import {
PageHeader,
PageShell,
PageTabs,
SegmentedControl
SegmentedControl,
ScopeBadge
} from './WorkspacePrimitives'
const stylesheet = readFileSync(
@@ -92,6 +93,10 @@ describe('WorkspacePrimitives', () => {
)
expect(stylesheet).toMatch(/--font-body:\s*13px/u)
expect(stylesheet).toMatch(/--font-caption:\s*11px/u)
expect(stylesheet).toMatch(
/--text-disabled:\s*#[\da-f]{6};/iu
)
expect(stylesheet).toMatch(/--z-dialog:\s*130;/u)
expect(stylesheet).toMatch(/font-synthesis:\s*style/u)
expect(stylesheet).toContain(
'"Inter Variable", "Segoe UI Variable", "SF Pro Text", "PingFang SC"'
@@ -119,6 +124,76 @@ describe('WorkspacePrimitives', () => {
)
})
it('exposes an unavailable scope explanation to assistive technology', () => {
render(
<ScopeBadge
scope={{
kind: 'unavailable',
explanation: '项目已归档,请选择其他项目。'
}}
/>
)
expect(
screen.getByText('范围不可用')
).toHaveAccessibleDescription('项目已归档,请选择其他项目。')
})
it('labels multi-project scope distinctly from mixed global scope', () => {
const { rerender } = render(
<ScopeBadge
scope={{
kind: 'projects',
projectCount: 2
}}
/>
)
expect(screen.getByLabelText('2 个项目')).toHaveTextContent(
'2 个项目'
)
expect(screen.queryByText(/全局/u)).not.toBeInTheDocument()
rerender(<ScopeBadge scope={{ kind: 'mixed' }} />)
expect(screen.getByLabelText('项目 + 全局')).toBeInTheDocument()
})
it('keeps forced-color and narrow assistant overrides authoritative', () => {
const forcedColorsStart = stylesheet.indexOf(
'@media (forced-colors: active)'
)
const forcedColorsEnd = stylesheet.indexOf(
'@media (prefers-reduced-motion: reduce)',
forcedColorsStart
)
const forcedColors = stylesheet.slice(
forcedColorsStart,
forcedColorsEnd
)
expect(forcedColors).toContain(':root :is(')
expect(forcedColors).not.toContain(':where(')
expect(forcedColors).toMatch(
/html:root\s+:is\(\s*\.assistant-sidebar__tab--active,\s*\.page-tabs__tab--active\s*\),\s*html:root\[data-theme='dark'\]\s+:is\(\s*\.assistant-sidebar__tab--active,\s*\.page-tabs__tab--active\s*\)\s*\{[^}]*border:\s*2px solid Highlight;[^}]*background:\s*Highlight;[^}]*color:\s*HighlightText;[^}]*forced-color-adjust:\s*none;/u
)
expect(forcedColors).toMatch(
/\.task-status-dot--failed,[\s\S]*?forced-color-adjust:\s*none;/u
)
expect(forcedColors).toMatch(
/\.heartbeat-center__meter\s*\{[^}]*forced-color-adjust:\s*none;/u
)
const narrowAssistant = stylesheet.slice(
stylesheet.indexOf('@media (max-width: 719px)'),
stylesheet.indexOf(
'@media (max-width: 720px)',
stylesheet.indexOf('@media (max-width: 719px)')
)
)
expect(narrowAssistant).toMatch(
/\.assistant-sidebar--open\s*\{[^}]*width:\s*calc\(100vw - 16px\);[^}]*flex-basis:\s*calc\(100vw - 16px\);/u
)
})
it('floats Runtime context compaction without shifting the composer', () => {
expect(stylesheet).toMatch(
/\.composer-wrap\s*\{[^}]*var\(--space-2\);[^}]*background:\s*var\(--surface-raised\);/u
@@ -159,6 +234,48 @@ describe('WorkspacePrimitives', () => {
)
})
it('keeps project forms and disabled actions visually consistent', () => {
expect(stylesheet).toMatch(
/\.project-create-card label > span\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.project-create-card input,\s*\.project-create-card textarea,\s*\.project-create-card select\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/\.project-create-card label > small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.project-create-card__error\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.field > span\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.field input,\s*\.field textarea,\s*\.field select\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/\.field small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
)
expect(stylesheet).toMatch(
/\.primary-button:disabled\s*\{[^}]*cursor:\s*not-allowed;/u
)
expect(stylesheet).toMatch(
/\.secondary-button:disabled\s*\{[^}]*cursor:\s*not-allowed;/u
)
expect(stylesheet).toMatch(
/\.danger-button:disabled\s*\{[^}]*cursor:\s*not-allowed;/u
)
expect(stylesheet).toContain(
'.primary-button:hover:not(:disabled)'
)
expect(stylesheet).toContain(
'.danger-button:hover:not(:disabled)'
)
expect(stylesheet).not.toMatch(
/\.primary-button:disabled\s*\{[^}]*cursor:\s*wait;/u
)
})
it('separates Runtime-specific controls from the main composer toolbar', () => {
expect(stylesheet).toMatch(
/\.composer__toolbar--with-runtime-controls\s*\{[^}]*border-radius:\s*0;/u
@@ -434,13 +551,13 @@ describe('WorkspacePrimitives', () => {
const confirmButton = screen.getByRole('button', {
name: '永久删除对象'
})
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(dialog).not.toHaveAttribute('aria-modal')
expect(dialog).toHaveAccessibleDescription('删除此对象?')
expect(cancelButton).toHaveFocus()
fireEvent.keyDown(cancelButton, { key: 'Tab', shiftKey: true })
expect(confirmButton).toHaveFocus()
fireEvent.keyDown(confirmButton, { key: 'Tab' })
expect(
fireEvent.keyDown(cancelButton, { key: 'Tab', shiftKey: true })
).toBe(true)
expect(cancelButton).toHaveFocus()
fireEvent.keyDown(cancelButton, { key: 'Escape' })
@@ -462,7 +579,7 @@ describe('WorkspacePrimitives', () => {
expect(screen.getByRole('button', { name: '删除' })).toHaveFocus()
})
it('keeps focus on the dialog while destructive actions are disabled', () => {
it('keeps focus on the inline confirmation while actions are disabled', () => {
const onCancel = vi.fn()
const { rerender } = render(
<DestructiveConfirmActions
@@ -491,7 +608,7 @@ describe('WorkspacePrimitives', () => {
name: '正在删除'
})
expect(dialog).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Tab' })
expect(fireEvent.keyDown(dialog, { key: 'Tab' })).toBe(true)
expect(dialog).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(onCancel).not.toHaveBeenCalled()
+19 -34
View File
@@ -17,6 +17,7 @@ export type WorkspaceScope =
| { kind: 'global' }
| { kind: 'all-projects' }
| { kind: 'project'; projectName: string }
| { kind: 'projects'; projectCount: number }
| { kind: 'mixed'; projectName?: string }
| { kind: 'unavailable'; explanation: string }
@@ -75,6 +76,7 @@ export function ScopeBadge({
scope: WorkspaceScope
}): React.JSX.Element {
const { t } = useTranslation('workspace')
const explanationId = useId()
const content =
scope.kind === 'global'
? {
@@ -93,7 +95,14 @@ export function ScopeBadge({
projectName: scope.projectName
})
}
: scope.kind === 'mixed'
: scope.kind === 'projects'
? {
icon: <Layers3 size={12} />,
label: t('primitives.scope.projects', {
count: scope.projectCount
})
}
: scope.kind === 'mixed'
? {
icon: <Layers3 size={12} />,
label: scope.projectName
@@ -109,11 +118,20 @@ export function ScopeBadge({
return (
<span
aria-label={content.label}
aria-describedby={
scope.kind === 'unavailable' ? explanationId : undefined
}
className="scope-badge"
title={scope.kind === 'unavailable' ? scope.explanation : undefined}
>
<span aria-hidden="true">{content.icon}</span>
{content.label}
{scope.kind === 'unavailable' && (
<span className="sr-only" id={explanationId}>
{scope.explanation}
</span>
)}
</span>
)
}
@@ -363,7 +381,6 @@ export function DestructiveConfirmActions({
}): React.JSX.Element {
const { t } = useTranslation('workspace')
const cancelRef = useRef<HTMLButtonElement>(null)
const confirmRef = useRef<HTMLButtonElement>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const wasConfirming = useRef(confirming)
@@ -406,43 +423,12 @@ export function DestructiveConfirmActions({
aria-describedby={descriptionId}
aria-labelledby={titleId}
aria-live="assertive"
aria-modal="true"
className="danger-confirm"
onKeyDown={(event) => {
if (event.key === 'Escape' && !disabled) {
event.preventDefault()
onCancel()
return
}
if (event.key !== 'Tab') {
return
}
if (disabled) {
event.preventDefault()
dialogRef.current?.focus()
return
}
const cancelButton = cancelRef.current
const confirmButton = confirmRef.current
if (
!cancelButton ||
!confirmButton ||
cancelButton.disabled ||
confirmButton.disabled
) {
return
}
event.preventDefault()
const nextButton = event.shiftKey
? document.activeElement === cancelButton
? confirmButton
: cancelButton
: document.activeElement === confirmButton
? cancelButton
: confirmButton
nextButton.focus()
}}
ref={dialogRef}
role="alertdialog"
@@ -472,7 +458,6 @@ export function DestructiveConfirmActions({
className="danger-button"
disabled={disabled}
onClick={onConfirm}
ref={confirmRef}
type="button"
>
{confirmLabel}
+41 -7
View File
@@ -42,15 +42,49 @@ export function activateModalFocus(
document.activeElement instanceof HTMLElement
? document.activeElement
: null
const appShell = document.querySelector<HTMLElement>('.app-shell')
const wasInert = appShell?.inert ?? false
if (appShell) {
appShell.inert = true
const focusTarget = initialFocus()
const modalRoot = focusTarget?.closest<HTMLElement>('[aria-modal="true"]')
const isolatedElements: Array<{
element: HTMLElement
wasInert: boolean
}> = []
const isolate = (element: HTMLElement): void => {
if (isolatedElements.some((entry) => entry.element === element)) {
return
}
isolatedElements.push({
element,
wasInert: element.inert ?? false
})
element.inert = true
}
initialFocus()?.focus()
return () => {
if (modalRoot) {
const appShell = document.querySelector<HTMLElement>('.app-shell')
if (appShell && !appShell.contains(modalRoot)) {
isolate(appShell)
}
let activeBranch: HTMLElement = modalRoot
while (activeBranch.parentElement) {
for (const sibling of activeBranch.parentElement.children) {
if (sibling !== activeBranch && sibling instanceof HTMLElement) {
isolate(sibling)
}
}
activeBranch = activeBranch.parentElement
if (activeBranch === document.body) {
break
}
}
} else {
const appShell = document.querySelector<HTMLElement>('.app-shell')
if (appShell) {
appShell.inert = wasInert
isolate(appShell)
}
}
focusTarget?.focus()
return () => {
for (const { element, wasInert } of isolatedElements) {
element.inert = wasInert
}
restoreFocus?.focus()
}
@@ -198,6 +198,7 @@ export const app = {
}
},
chat: {
heading: 'Conversation',
user: 'You',
taskResult: 'Task result: {{title}}',
welcome: {
@@ -437,6 +437,10 @@ export const knowledge = {
interactionHint:
'Drag nodes to arrange them. Drag the canvas to pan, and use the wheel to zoom.',
empty: 'This library does not have any generated entity relations yet.',
chunk: {
loading: 'Loading knowledge graph…',
loadFailed: 'The knowledge graph could not load. Try again.'
},
topologyAriaLabel: 'Graph topology',
visibleRelations: {
title: 'Visible relations',
@@ -447,6 +451,7 @@ export const knowledge = {
},
addEntityPanelAriaLabel: 'Add entity panel',
entityDetailsAriaLabel: 'Entity details',
deleteEntityAriaLabel: 'Delete entity {{name}}',
closeEntityDetailsAriaLabel: 'Close entity details',
noEntityDescription: 'This entity has no additional description.',
aliases: 'Aliases: {{aliases}}',
@@ -459,6 +464,27 @@ export const knowledge = {
targetPlaceholder: 'Select the entity to keep',
actionAriaLabel: 'Merge into target entity'
},
confirm: {
processing: 'Working…',
deleteEntity: {
title: 'Delete entity “{{name}}”?',
description:
'This permanently deletes “{{name}}”, its {{count}} connected relations, and their graph evidence references. It cannot be undone.',
action: 'Delete entity'
},
deleteRelation: {
title: 'Delete relation “{{type}}”?',
description:
'This permanently deletes the “{{type}}” relation from “{{source}}” to “{{target}}” and its graph evidence references. Both entities remain. It cannot be undone.',
action: 'Delete relation'
},
merge: {
title: 'Merge “{{source}}” into “{{target}}”?',
description:
'Relations, aliases, and evidence from “{{source}}” are moved into “{{target}}”, then the source entity is deleted. This cannot be undone.',
action: 'Merge entities'
}
},
evidence: {
title: 'Evidence ({{count}})',
empty: 'This entity and its relations have no linked evidence.'
@@ -31,7 +31,9 @@ export const magicNotes = {
hideAiComments: 'Hide AI comments',
showAiComments: 'Show AI comments',
pinNote: 'Pin note',
unpinNote: 'Unpin note'
unpinNote: 'Unpin note',
continueEditing: 'Continue editing',
discardAndSwitch: 'Discard and switch'
},
status: {
loading: 'Loading',
@@ -116,7 +118,10 @@ export const magicNotes = {
},
confirmations: {
deleteNote: 'Delete “{{title}}” and all of its entries?',
deleteEntry: 'Delete this entry? This action cannot be undone.'
deleteEntry: 'Delete this entry? This action cannot be undone.',
discardDraftTitle: 'Discard this unsaved draft?',
discardDraftDescription:
'Switching will discard the text and attachments in the current entry draft.'
},
comments: {
paneLabel: 'AI comments',
@@ -109,6 +109,15 @@ export const settings = {
clearing: 'Clearing…',
clearLocalData: 'Clear local data'
},
unsaved: {
close:
'These settings have unsaved changes. Save them first, or confirm that you want to discard them before closing Settings.',
navigation:
'These settings have unsaved changes. Save them first, or confirm that you want to discard them before changing categories.',
keepEditing: 'Keep editing',
discardAndClose: 'Discard changes and close',
discardAndNavigate: 'Discard changes and continue'
},
errors: {
readSettings: 'Could not load settings',
detectRuntimes: 'Could not detect Agent Runtimes',
@@ -239,6 +239,41 @@ export const settingsSections = {
general: 'General',
magicNotes: 'Magic Notes'
},
shortcut: {
title: 'Global quick access',
description:
'Show or hide GoodBuddy while another application is active.',
enabled: 'Enable global shortcut',
accelerator: 'Shortcut',
recorderHelp:
'Focus this field and press a key combination, or enter an Electron accelerator such as CommandOrControl+Shift+Space.',
reset: 'Reset to default',
save: 'Save shortcut',
saving: 'Saving…',
saved: 'Global shortcut updated',
loading: 'Loading shortcut status…',
status: {
registered: 'Registered: {{shortcut}}',
disabled: 'The global shortcut is disabled.',
conflict:
'Another application is using this shortcut. Record a different combination and save it.',
failed:
'The system could not register this shortcut. Record another combination or check system shortcut settings.'
},
errors: {
serviceUnavailable:
'Shortcut settings are not available in this version',
readFailed: 'Could not load shortcut settings. Try again.',
invalidAccelerator:
'Enter a valid key combination that includes a modifier.',
conflict:
'Another application is using this shortcut. Record a different combination and try again.',
registrationFailed:
'The system could not register this shortcut. Choose another combination or check system shortcut settings.',
saveFailed:
'The shortcut could not be saved. The previous working shortcut is still active. Try again.'
}
},
modelDownloadSource: {
cardTitle: 'Local models',
cardDescription:
@@ -2,6 +2,10 @@ import type { TranslationShape } from '../../resource-types'
import type { workspace as chineseWorkspace } from '../zh-CN/workspace'
export const workspace = {
builtInDefaultProject: {
name: 'Default project',
description: 'GoodBuddy default workspace'
},
projectSwitcher: {
workModes: {
ask: 'Ask · Read-only',
@@ -70,6 +74,7 @@ export const workspace = {
resizeValue: '{{width}} pixels',
title: 'Workspace',
close: 'Close assistant workspace',
dismissOverlay: 'Dismiss assistant workspace overlay',
categoriesAriaLabel: 'Workspace categories',
tabs: {
tasks: {
@@ -291,8 +296,9 @@ export const workspace = {
global: 'Global',
allProjects: 'All projects',
project: 'Project: {{projectName}}',
projects: '{{count}} projects',
mixedProject: 'Project: {{projectName}} + Global',
mixedCurrent: 'Current project + Global',
mixedCurrent: 'Projects + Global',
unavailable: 'Scope unavailable'
},
destructive: {
@@ -189,6 +189,7 @@ export const app = {
}
},
chat: {
heading: '对话内容',
user: '用户',
taskResult: '任务结果:{{title}}',
welcome: {
@@ -404,6 +404,10 @@ export const knowledge = {
fitView: '显示全部',
interactionHint: '拖动节点调整位置;拖动画布平移,滚轮缩放。',
empty: '当前知识库尚未生成实体关系。',
chunk: {
loading: '正在加载知识图谱…',
loadFailed: '知识图谱未能加载,请重试。'
},
topologyAriaLabel: '图谱拓扑',
visibleRelations: {
title: '可见关系',
@@ -414,6 +418,7 @@ export const knowledge = {
},
addEntityPanelAriaLabel: '新增实体面板',
entityDetailsAriaLabel: '实体详情',
deleteEntityAriaLabel: '删除实体 {{name}}',
closeEntityDetailsAriaLabel: '关闭实体详情',
noEntityDescription: '该实体没有附加描述。',
aliases: '别名:{{aliases}}',
@@ -426,6 +431,27 @@ export const knowledge = {
targetPlaceholder: '选择保留的实体',
actionAriaLabel: '合并到目标实体'
},
confirm: {
processing: '处理中…',
deleteEntity: {
title: '删除实体“{{name}}”?',
description:
'将永久删除实体“{{name}}”及其 {{count}} 条关联关系;相关证据引用也会从图谱中移除,且无法恢复。',
action: '删除实体'
},
deleteRelation: {
title: '删除关系“{{type}}”?',
description:
'将永久删除“{{source}}”到“{{target}}”的“{{type}}”关系及其图谱证据引用,且无法恢复。两个实体本身会保留。',
action: '删除关系'
},
merge: {
title: '将“{{source}}”合并到“{{target}}”?',
description:
'“{{source}}”的关系、别名和证据将并入“{{target}}”,随后删除源实体。此操作无法恢复。',
action: '合并实体'
}
},
evidence: {
title: '证据 ({{count}})',
empty: '此实体和相关关系没有关联证据。'
@@ -27,7 +27,9 @@ export const magicNotes = {
hideAiComments: '隐藏 AI 评论',
showAiComments: '显示 AI 评论',
pinNote: '置顶笔记',
unpinNote: '取消置顶'
unpinNote: '取消置顶',
continueEditing: '继续编辑',
discardAndSwitch: '放弃草稿并切换'
},
status: {
loading: '正在加载',
@@ -108,7 +110,9 @@ export const magicNotes = {
},
confirmations: {
deleteNote: '删除“{{title}}”及其中全部记录?',
deleteEntry: '删除这条记录?此操作不可撤销。'
deleteEntry: '删除这条记录?此操作不可撤销。',
discardDraftTitle: '放弃当前未保存草稿?',
discardDraftDescription: '切换后,当前记录草稿中的文字和附件将被丢弃。'
},
comments: {
paneLabel: 'AI 评论',
@@ -96,6 +96,13 @@ export const settings = {
clearing: '正在清除…',
clearLocalData: '清除本地数据'
},
unsaved: {
close: '当前设置有未保存更改。请先保存,或确认放弃更改后再关闭设置中心。',
navigation: '当前设置有未保存更改。请先保存,或确认放弃更改后再切换分类。',
keepEditing: '继续编辑',
discardAndClose: '放弃更改并关闭',
discardAndNavigate: '放弃更改并切换'
},
errors: {
readSettings: '读取设置失败',
detectRuntimes: 'Runtime 自动检测失败',
@@ -221,6 +221,33 @@ export const settingsSections = {
general: '通用设置',
magicNotes: '魔法笔记'
},
shortcut: {
title: '全局快捷唤起',
description: '在其他应用前台时,也可以显示或隐藏 GoodBuddy。',
enabled: '启用全局快捷键',
accelerator: '快捷键',
recorderHelp:
'聚焦输入框后直接按下组合键,或填写 Electron accelerator(例如 CommandOrControl+Shift+Space)。',
reset: '恢复默认',
save: '保存快捷键',
saving: '保存中…',
saved: '全局快捷键已更新',
loading: '正在读取快捷键状态…',
status: {
registered: '已注册:{{shortcut}}',
disabled: '全局快捷键已停用。',
conflict: '当前快捷键被其他应用占用,请录制其他组合键后保存。',
failed: '系统未能注册当前快捷键,请录制其他组合键或检查系统快捷键设置。'
},
errors: {
serviceUnavailable: '当前版本未提供快捷键设置服务',
readFailed: '读取快捷键设置失败,请重试',
invalidAccelerator: '快捷键无效,请按下包含修饰键的组合键后重试',
conflict: '该快捷键已被其他应用占用,请录制其他组合键后重试',
registrationFailed: '系统未能注册该快捷键,请更换组合键或检查系统快捷键设置',
saveFailed: '快捷键未能保存,原有可用快捷键已保留,请重试'
}
},
modelDownloadSource: {
cardTitle: '本地模型',
cardDescription: '管理 GoodBuddy 托管本地模型的获取方式',
@@ -1,4 +1,8 @@
export const workspace = {
builtInDefaultProject: {
name: '默认项目',
description: 'GoodBuddy 默认工作区'
},
projectSwitcher: {
workModes: {
ask: 'Ask · 只读问答',
@@ -67,6 +71,7 @@ export const workspace = {
resizeValue: '{{width}} 像素',
title: '工作栏',
close: '关闭助手工作栏',
dismissOverlay: '关闭助手工作栏背景',
categoriesAriaLabel: '工作栏分类',
tabs: {
tasks: {
@@ -277,8 +282,9 @@ export const workspace = {
global: '全局',
allProjects: '全部项目',
project: '项目:{{projectName}}',
projects: '{{count}} 个项目',
mixedProject: '项目:{{projectName}} + 全局',
mixedCurrent: '当前项目 + 全局',
mixedCurrent: '项目 + 全局',
unavailable: '范围不可用'
},
destructive: {
+104 -3
View File
@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest'
import {
filterKeepAliveEntries,
pruneKeepAliveEntries,
touchKeepAliveEntry
touchAndPruneKeepAliveEntries,
touchKeepAliveEntry,
type KeepAliveCacheEntry
} from './keep-alive-cache'
describe('keep-alive cache', () => {
@@ -21,6 +24,44 @@ describe('keep-alive cache', () => {
])
})
it('preserves reference equality for no-op touches and filtering', () => {
const entries = [
{ key: 'knowledge', lastVisitedAt: 20 },
{ key: 'chat', lastVisitedAt: 10 }
]
expect(touchKeepAliveEntry(entries, 'chat', 10)).toBe(entries)
expect(
filterKeepAliveEntries(entries, () => true)
).toBe(entries)
expect(
touchAndPruneKeepAliveEntries(entries, 'knowledge', 20, {
expiresAfterMs: 100,
maximumEntries: 2,
protectedKeys: new Set<string>(),
recentEntries: 1
})
).toBe(entries)
})
it('preserves reference equality for a no-op sweep', () => {
const entries = [
{ key: 'knowledge', lastVisitedAt: 20 },
{ key: 'chat', lastVisitedAt: 30 }
]
expect(
pruneKeepAliveEntries(entries, {
currentKey: 'chat',
expiresAfterMs: 100,
maximumEntries: 2,
now: 40,
protectedKeys: new Set(['knowledge']),
recentEntries: 1
})
).toBe(entries)
})
it('keeps recent and protected entries while expiring inactive ones', () => {
expect(
pruneKeepAliveEntries(
@@ -65,12 +106,72 @@ describe('keep-alive cache', () => {
).toEqual([
'conversation-7',
'conversation-6',
'conversation-0',
'conversation-5',
'conversation-4',
'conversation-0'
'conversation-4'
])
})
it('applies the documented priority when protected entries exceed capacity', () => {
const entries = [
{ key: 'newest', lastVisitedAt: 60 },
{ key: 'recent', lastVisitedAt: 50 },
{ key: 'protected-newest', lastVisitedAt: 40 },
{ key: 'protected-older', lastVisitedAt: 30 },
{ key: 'current', lastVisitedAt: 10 }
]
expect(
pruneKeepAliveEntries(entries, {
currentKey: 'current',
expiresAfterMs: 1_000,
maximumEntries: 4,
now: 100,
protectedKeys: new Set([
'protected-newest',
'protected-older',
'current'
]),
recentEntries: 2
}).map((entry) => entry.key)
).toEqual([
'current',
'newest',
'recent',
'protected-newest'
])
})
it.each([
['conversation', 12, 5, 20],
['workspace', 4, 3, 8]
])(
'enforces the %s cap on every rapid touch',
(_kind, maximumEntries, recentEntries, visitCount) => {
const entries = Array.from(
{ length: visitCount },
(_, index) => `entry-${index}`
).reduce<KeepAliveCacheEntry<string>[]>(
(current, key, index) =>
touchAndPruneKeepAliveEntries(current, key, index + 1, {
expiresAfterMs: 60 * 60 * 1_000,
maximumEntries,
protectedKeys: new Set<string>(),
recentEntries
}),
[]
)
expect(entries).toHaveLength(maximumEntries)
expect(entries.map((entry) => entry.key)).toEqual(
Array.from(
{ length: maximumEntries },
(_, index) => `entry-${visitCount - index - 1}`
)
)
}
)
it('expires an unprotected entry after one hour', () => {
const entries = [{ key: 'knowledge', lastVisitedAt: 1_000 }]
const options = {
+130 -39
View File
@@ -3,14 +3,55 @@ export type KeepAliveCacheEntry<Key extends string> = {
lastVisitedAt: number
}
export type KeepAlivePruneOptions<Key extends string> = {
currentKey?: Key
expiresAfterMs: number
maximumEntries: number
now: number
protectedKeys?: ReadonlySet<Key>
recentEntries: number
}
export function filterKeepAliveEntries<Key extends string>(
entries: KeepAliveCacheEntry<Key>[],
predicate: (entry: KeepAliveCacheEntry<Key>) => boolean
): KeepAliveCacheEntry<Key>[]
export function filterKeepAliveEntries<Key extends string>(
entries: readonly KeepAliveCacheEntry<Key>[],
predicate: (entry: KeepAliveCacheEntry<Key>) => boolean
): readonly KeepAliveCacheEntry<Key>[]
export function filterKeepAliveEntries<Key extends string>(
entries: readonly KeepAliveCacheEntry<Key>[],
predicate: (entry: KeepAliveCacheEntry<Key>) => boolean
): readonly KeepAliveCacheEntry<Key>[] {
const filtered = entries.filter(predicate)
return filtered.length === entries.length ? entries : filtered
}
export function touchKeepAliveEntry<Key extends string>(
entries: KeepAliveCacheEntry<Key>[],
key: Key,
visitedAt: number
): KeepAliveCacheEntry<Key>[]
export function touchKeepAliveEntry<Key extends string>(
entries: readonly KeepAliveCacheEntry<Key>[],
key: Key,
visitedAt: number
): KeepAliveCacheEntry<Key>[] {
const existing = entries.find((entry) => entry.key === key)
if (existing?.lastVisitedAt === visitedAt) {
return [...entries]
): readonly KeepAliveCacheEntry<Key>[]
export function touchKeepAliveEntry<Key extends string>(
entries: readonly KeepAliveCacheEntry<Key>[],
key: Key,
visitedAt: number
): readonly KeepAliveCacheEntry<Key>[] {
const existingIndex = entries.findIndex((entry) => entry.key === key)
if (
existingIndex >= 0 &&
entries[existingIndex]?.lastVisitedAt === visitedAt &&
entries.findIndex(
(entry, index) => index !== existingIndex && entry.key === key
) < 0
) {
return entries
}
return [
...entries.filter((entry) => entry.key !== key),
@@ -18,6 +59,51 @@ export function touchKeepAliveEntry<Key extends string>(
]
}
export function touchAndPruneKeepAliveEntries<Key extends string>(
entries: KeepAliveCacheEntry<Key>[],
key: Key,
visitedAt: number,
options: Omit<KeepAlivePruneOptions<Key>, 'currentKey' | 'now'>
): KeepAliveCacheEntry<Key>[]
export function touchAndPruneKeepAliveEntries<Key extends string>(
entries: readonly KeepAliveCacheEntry<Key>[],
key: Key,
visitedAt: number,
options: Omit<KeepAlivePruneOptions<Key>, 'currentKey' | 'now'>
): readonly KeepAliveCacheEntry<Key>[]
export function touchAndPruneKeepAliveEntries<Key extends string>(
entries: readonly KeepAliveCacheEntry<Key>[],
key: Key,
visitedAt: number,
options: Omit<KeepAlivePruneOptions<Key>, 'currentKey' | 'now'>
): readonly KeepAliveCacheEntry<Key>[] {
return pruneKeepAliveEntries(
touchKeepAliveEntry(entries, key, visitedAt),
{
...options,
currentKey: key,
now: visitedAt
}
)
}
export function pruneKeepAliveEntries<Key extends string>(
entries: KeepAliveCacheEntry<Key>[],
options: KeepAlivePruneOptions<Key>
): KeepAliveCacheEntry<Key>[]
export function pruneKeepAliveEntries<Key extends string>(
entries: readonly KeepAliveCacheEntry<Key>[],
options: KeepAlivePruneOptions<Key>
): readonly KeepAliveCacheEntry<Key>[]
/**
* Prunes pane cache membership without modifying the pane data itself.
*
* Capacity is absolute. Candidates are selected in this exact order:
* the current pane, the globally newest `recentEntries`, the newest
* protected panes, then all other unexpired panes by recency. Overlapping
* categories do not consume capacity twice. Equal timestamps retain their
* input order.
*/
export function pruneKeepAliveEntries<Key extends string>(
entries: readonly KeepAliveCacheEntry<Key>[],
{
@@ -27,43 +113,48 @@ export function pruneKeepAliveEntries<Key extends string>(
now,
protectedKeys,
recentEntries
}: {
currentKey?: Key
expiresAfterMs: number
maximumEntries: number
now: number
protectedKeys?: ReadonlySet<Key>
recentEntries: number
}: KeepAlivePruneOptions<Key>
): readonly KeepAliveCacheEntry<Key>[] {
const newestFirst = entries
.map((entry, index) => ({ entry, index }))
.sort(
(left, right) =>
right.entry.lastVisitedAt - left.entry.lastVisitedAt ||
left.index - right.index
)
.map(({ entry }) => entry)
const selected: KeepAliveCacheEntry<Key>[] = []
const selectedKeys = new Set<Key>()
const capacity = Math.max(0, maximumEntries)
const add = (entry: KeepAliveCacheEntry<Key> | undefined): void => {
if (
entry &&
selected.length < capacity &&
!selectedKeys.has(entry.key)
) {
selected.push(entry)
selectedKeys.add(entry.key)
}
}
): KeepAliveCacheEntry<Key>[] {
const newestFirst = [...entries].sort(
(left, right) => right.lastVisitedAt - left.lastVisitedAt
)
const alwaysKeep = new Set(
newestFirst.slice(0, recentEntries).map((entry) => entry.key)
)
if (currentKey) {
alwaysKeep.add(currentKey)
add(newestFirst.find((entry) => entry.key === currentKey))
}
protectedKeys?.forEach((key) => alwaysKeep.add(key))
newestFirst
.slice(0, Math.max(0, recentEntries))
.forEach(add)
newestFirst
.filter((entry) => protectedKeys?.has(entry.key))
.forEach(add)
newestFirst
.filter(
(entry) => now - entry.lastVisitedAt < expiresAfterMs
)
.forEach(add)
const retained = newestFirst.filter(
(entry) =>
alwaysKeep.has(entry.key) ||
now - entry.lastVisitedAt < expiresAfterMs
)
if (retained.length <= maximumEntries) {
return retained
}
const removableOldestFirst = retained
.filter((entry) => !alwaysKeep.has(entry.key))
.sort((left, right) => left.lastVisitedAt - right.lastVisitedAt)
const removeCount = retained.length - maximumEntries
const removedKeys = new Set(
removableOldestFirst
.slice(0, removeCount)
.map((entry) => entry.key)
)
return retained.filter((entry) => !removedKeys.has(entry.key))
const unchanged =
selected.length === entries.length &&
selectedKeys.size === entries.length &&
entries.every((entry) => selectedKeys.has(entry.key))
return unchanged ? entries : selected
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { findPreferredCompatibleModelProfile } from './model-profile-selection'
const profiles = [
{ id: 'image', compatible: false },
{ id: 'first-text', compatible: true },
{ id: 'preferred-text', compatible: true }
]
describe('findPreferredCompatibleModelProfile', () => {
it.each([
['preferred compatible profile', 'preferred-text', 'preferred-text'],
[
'first compatible fallback for an incompatible preference',
'image',
'first-text'
],
[
'first compatible fallback for a missing preference',
'missing',
'first-text'
],
['first compatible fallback without a preference', undefined, 'first-text']
] as const)(
'selects the %s',
(_case, preferredProfileId, expectedId) => {
expect(
findPreferredCompatibleModelProfile(
profiles,
preferredProfileId,
(profile) => profile.compatible
)?.id
).toBe(expectedId)
}
)
it('returns undefined when no profile is compatible', () => {
expect(
findPreferredCompatibleModelProfile(
profiles,
'preferred-text',
() => false
)
).toBeUndefined()
})
})
@@ -0,0 +1,14 @@
export function findPreferredCompatibleModelProfile<
Profile extends { id: string }
>(
profiles: readonly Profile[],
preferredProfileId: string | undefined,
isCompatible: (profile: Profile) => boolean
): Profile | undefined {
return (
profiles.find(
(profile) =>
profile.id === preferredProfileId && isCompatible(profile)
) ?? profiles.find(isCompatible)
)
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import type { AssistantProject } from '../../shared/assistant-contracts'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName
} from '../../shared/assistant-contracts'
import { getProjectDisplayText } from './project-display'
const project: AssistantProject = {
id: '00000000-0000-4000-8000-000000000101',
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription,
rootPath: 'C:\\Workspace',
defaultWorkMode: 'ask',
kind: 'user',
builtInDefault: true,
status: 'active',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
describe('getProjectDisplayText', () => {
it('localizes the untouched seed without changing the project', () => {
expect(
getProjectDisplayText(project, (key) => ({
'builtInDefaultProject.name': 'Default project',
'builtInDefaultProject.description':
'GoodBuddy default workspace'
})[key])
).toEqual({
name: 'Default project',
description: 'GoodBuddy default workspace'
})
expect(project.name).toBe(builtInDefaultProjectSeedName)
expect(project.description).toBe(
builtInDefaultProjectSeedDescription
)
})
it('keeps independently created identical project text raw', () => {
const independentProject = {
...project,
builtInDefault: false
}
expect(
getProjectDisplayText(independentProject, () => 'localized')
).toEqual({
name: builtInDefaultProjectSeedName,
description: builtInDefaultProjectSeedDescription
})
})
it('keeps localization across unrelated project setting changes', () => {
expect(
getProjectDisplayText(
{
...project,
rootPath: 'D:\\Moved',
defaultWorkMode: 'execute',
runtimeSelection: { provider: 'continue' },
updatedAt: '2026-08-01T00:00:01.000Z'
},
(key) =>
key === 'builtInDefaultProject.name'
? 'Default project'
: 'GoodBuddy default workspace'
)
).toEqual({
name: 'Default project',
description: 'GoodBuddy default workspace'
})
})
})
+28
View File
@@ -0,0 +1,28 @@
import type { AssistantProject } from '../../shared/assistant-contracts'
import { isUntouchedBuiltInDefaultProject } from '../../shared/assistant-contracts'
export type ProjectDisplayText = {
name: string
description: string
}
type ProjectDisplayTranslation = (
key:
| 'builtInDefaultProject.name'
| 'builtInDefaultProject.description'
) => string
export function getProjectDisplayText(
project: AssistantProject,
translate: ProjectDisplayTranslation
): ProjectDisplayText {
return isUntouchedBuiltInDefaultProject(project)
? {
name: translate('builtInDefaultProject.name'),
description: translate('builtInDefaultProject.description')
}
: {
name: project.name,
description: project.description
}
}
File diff suppressed because it is too large Load Diff