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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 () => {})
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -30,7 +30,7 @@ type ChartKnowledgeGraphRelation = Omit<
|
||||
evidenceIds?: readonly string[]
|
||||
}
|
||||
|
||||
type KnowledgeGraphChartProps = {
|
||||
export type KnowledgeGraphChartProps = {
|
||||
nodes: readonly ChartKnowledgeGraphNode[]
|
||||
relations: readonly ChartKnowledgeGraphRelation[]
|
||||
selectedNodeId?: string
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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('语音模型切换失败')
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+841
-20
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user