feat: add configurable Magic Notes and polish UI
Cross-platform packages / Validate source (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / Publish GitHub Release (push) Has been cancelled

This commit is contained in:
lofyer
2026-08-10 00:24:19 +08:00
parent 1cc969317d
commit b249df116a
29 changed files with 2440 additions and 296 deletions
+47
View File
@@ -21,6 +21,7 @@ function makeRecord(
id: `activity-${index}`,
conversationId: `conversation-${index}`,
requestId: `request-${index}`,
scope: { kind: 'global' },
kind: 'tool',
title: `活动 ${index}`,
detail: `详情 ${index}`,
@@ -150,6 +151,26 @@ describe('ActivityPanel', () => {
).toBeDisabled()
})
it('clears a filter that has no matching activity', () => {
render(
<ActivityPanel
onClear={vi.fn()}
onOpenConversation={vi.fn()}
records={[makeRecord(1)]}
tokenUsage={makeTokenUsage()}
/>
)
fireEvent.click(screen.getByRole('button', { name: '进行中' }))
expect(screen.getByText('没有匹配的活动')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
expect(
screen.getByRole('button', { name: '全部' })
).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByText('对话:活动 1')).toBeInTheDocument()
})
it('labels Subagent activity as child expert work', () => {
render(
<ActivityPanel
@@ -208,6 +229,32 @@ describe('ActivityPanel', () => {
expect(groups[0]!.querySelectorAll('article')).toHaveLength(2)
})
it('shows immutable scope snapshots on groups and records', () => {
const projectRecord: ActivityRecord = {
...makeRecord(1),
scope: {
kind: 'project',
projectId: 'project-1',
projectName: '项目甲'
}
}
const unavailableRecord: ActivityRecord = {
...makeRecord(2),
scope: { kind: 'unavailable' }
}
render(
<ActivityPanel
onClear={vi.fn()}
onOpenConversation={vi.fn()}
records={[projectRecord, unavailableRecord]}
tokenUsage={makeTokenUsage()}
/>
)
expect(screen.getAllByText('项目:项目甲')).toHaveLength(2)
expect(screen.getAllByText('范围不可用')).toHaveLength(2)
})
it('uses the shared page hierarchy and explicit global scope', () => {
render(
<ActivityPanel
+37 -1
View File
@@ -14,7 +14,9 @@ import {
DestructiveConfirmActions,
EmptyState,
PageHeader,
SegmentedControl
ScopeBadge,
SegmentedControl,
type WorkspaceScope
} from './WorkspacePrimitives'
type ActivityFilter = 'all' | 'active' | 'failed'
@@ -136,10 +138,26 @@ type ActivityGroup = {
conversationId: string
title: string
records: ActivityRecord[]
scope: ActivityRecord['scope']
latestAt: number
status: ActivityRecord['status']
}
function activityWorkspaceScope(
scope: ActivityRecord['scope']
): WorkspaceScope {
if (scope.kind === 'project') {
return { kind: 'project', projectName: scope.projectName }
}
if (scope.kind === 'global') {
return { kind: 'global' }
}
return {
kind: 'unavailable',
explanation: '创建此活动记录时未能确定其归属范围。'
}
}
function groupActivityRecords(
records: readonly ActivityRecord[],
allRecords: readonly ActivityRecord[]
@@ -171,6 +189,7 @@ function groupActivityRecords(
request?.title ??
items[0]!.title,
records: items,
scope: items[0]!.scope,
latestAt: Math.max(...items.map((record) => record.createdAt)),
status
}
@@ -361,6 +380,17 @@ export function ActivityPanel({
{filteredRecords.length === 0 ? (
<EmptyState
action={
filter === 'all' ? undefined : (
<button
className="secondary-button"
onClick={() => setFilter('all')}
type="button"
>
</button>
)
}
description={emptyMessage(filter)}
icon={<Activity size={24} />}
level="section"
@@ -379,6 +409,9 @@ export function ActivityPanel({
<span>
<strong>{group.title}</strong>
<small>{group.records.length} </small>
<ScopeBadge
scope={activityWorkspaceScope(group.scope)}
/>
</span>
<span
className={`status-badge activity-item__status activity-item__status--${group.status}`}
@@ -413,6 +446,9 @@ export function ActivityPanel({
{time.display}
</time>
</header>
<ScopeBadge
scope={activityWorkspaceScope(record.scope)}
/>
<h3>{record.title}</h3>
{record.detail.length > 0 && <p>{record.detail}</p>}
<button
+511 -17
View File
@@ -26,6 +26,7 @@ vi.mock('./speech-recognition', async (importOriginal) => ({
}))
import App from './App'
import { loadActivityRecords } from './activity-store'
let agentListener: ((event: AgentEvent) => void) | undefined
let browserListener: ((state: BrowserLiveState) => void) | undefined
@@ -610,9 +611,13 @@ describe('App', () => {
}))
api.updates = {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true
checkUpdatesOnStartup: true,
magicNotesEnabled: true
})),
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true,
magicNotesEnabled: true
})),
updateSettings: vi.fn(async (input) => input),
check,
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {})
@@ -651,9 +656,13 @@ describe('App', () => {
.mockRejectedValueOnce(new Error('offline'))
api.updates = {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true
checkUpdatesOnStartup: true,
magicNotesEnabled: true
})),
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true,
magicNotesEnabled: true
})),
updateSettings: vi.fn(async (input) => input),
check,
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {})
@@ -847,6 +856,114 @@ describe('App', () => {
expect(await screen.findByRole('status')).toBeVisible()
})
it('requires an accessible confirmation before permanently deleting a conversation', async () => {
render(<App />)
const menuTrigger = screen.getByLabelText(
'更多会话操作 新对话'
)
fireEvent.click(menuTrigger)
const deleteTrigger = screen.getByRole('button', {
name: '删除对话 新对话'
})
fireEvent.click(deleteTrigger)
const dialog = screen.getByRole('alertdialog', {
name: '确认永久删除对话 新对话'
})
expect(dialog).toHaveTextContent('将永久删除此会话的全部内容')
expect(dialog).toHaveTextContent(
'如果此会话有正在运行的任务,也会同时停止'
)
expect(dialog).toHaveTextContent('此操作不可恢复')
const cancel = screen.getByRole('button', {
name: '取消删除对话 新对话'
})
const confirm = screen.getByRole('button', {
name: '确认永久删除对话 新对话'
})
expect(cancel).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Tab' })
expect(confirm).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true })
expect(cancel).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() =>
expect(
screen.getByRole('button', { name: '删除对话 新对话' })
).toHaveFocus()
)
expect(
screen.queryByRole('alertdialog', {
name: '确认永久删除对话 新对话'
})
).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '删除对话 新对话' })
)
fireEvent.click(
screen.getByRole('button', {
name: '确认永久删除对话 新对话'
})
)
await waitFor(() =>
expect(
screen.queryByRole('alertdialog', {
name: '确认永久删除对话 新对话'
})
).not.toBeInTheDocument()
)
})
it('keeps a conversation when cancelling its active task fails', async () => {
vi.mocked(api.agent.cancel).mockRejectedValueOnce(
new Error('cancel failed')
)
render(<App />)
const title = '取消失败时保留会话'
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: title }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
fireEvent.click(
await screen.findByLabelText(`更多会话操作 ${title}`)
)
fireEvent.click(
screen.getByRole('button', { name: `删除对话 ${title}` })
)
const confirm = screen.getByRole('button', {
name: `确认永久删除对话 ${title}`
})
fireEvent.click(confirm)
await waitFor(() =>
expect(api.agent.cancel).toHaveBeenCalledOnce()
)
expect(
await screen.findByText('停止会话中的运行任务失败,尚未删除对话')
).toBeInTheDocument()
expect(
screen.getByRole('alertdialog', {
name: `确认永久删除对话 ${title}`
})
).toBeInTheDocument()
await waitFor(() => expect(confirm).toBeEnabled())
vi.mocked(api.agent.cancel).mockResolvedValueOnce()
fireEvent.click(confirm)
await waitFor(() =>
expect(
screen.queryByRole('alertdialog', {
name: `确认永久删除对话 ${title}`
})
).not.toBeInTheDocument()
)
})
it('renders streamed reasoning, text, and tools in event order', async () => {
render(<App />)
@@ -1426,7 +1543,9 @@ describe('App', () => {
expect(composer).toHaveValue('尚未发送的草稿')
expect(
screen.getAllByRole('button', { name: '删除对话 新对话' })
screen.getAllByRole('button', {
name: '更多会话操作 新对话'
})
).toHaveLength(1)
})
@@ -1448,7 +1567,9 @@ describe('App', () => {
})
expect(
screen.getAllByRole('button', { name: /^/u })
screen.getAllByRole('button', {
name: /^/u
})
).toHaveLength(2)
})
@@ -3478,7 +3599,7 @@ describe('App', () => {
await screen.findByRole('heading', { name: '智能心跳' })
).toBeInTheDocument()
expect(
screen.getByRole('tab', { name: '成长概览' })
await screen.findByRole('tab', { name: '成长概览' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '配置智能心跳' })
@@ -3488,27 +3609,400 @@ describe('App', () => {
).not.toBeInTheDocument()
})
it('opens Magic Notes as a scoped first-class workspace', async () => {
it('shows retryable page-local knowledge errors without an empty-state flash', async () => {
vi.mocked(api.knowledge.getSnapshot).mockRejectedValueOnce(
new Error('知识数据库暂时不可用')
)
render(<App />)
fireEvent.click(screen.getByRole('button', { name: '知识库' }))
expect(
await screen.findByText('知识库加载失败')
).toBeInTheDocument()
expect(screen.getAllByText('知识数据库暂时不可用')).toHaveLength(1)
expect(screen.queryByText('建立第一个知识库')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(
await screen.findByText('建立第一个知识库')
).toBeInTheDocument()
})
it('retries the knowledge library whose selection failed', async () => {
const firstLibraryId = '11111111-1111-4111-8111-111111111111'
const secondLibraryId = '22222222-2222-4222-8222-222222222222'
const libraries = [
{
id: firstLibraryId,
name: '产品知识',
description: '',
storageMode: 'managed' as const,
graphEnabled: false,
graphStrategy: 'rules' as const,
sourceCount: 0,
documentCount: 0,
indexedDocumentCount: 0
},
{
id: secondLibraryId,
name: '工程知识',
description: '',
storageMode: 'managed' as const,
graphEnabled: false,
graphStrategy: 'rules' as const,
sourceCount: 0,
documentCount: 0,
indexedDocumentCount: 0
}
]
const snapshot = {
libraries,
sources: [],
documents: [],
graphNodes: [],
graphRelations: [],
evidence: []
}
vi.mocked(api.knowledge.getSnapshot)
.mockResolvedValueOnce({
...snapshot,
selectedLibraryId: firstLibraryId
})
.mockRejectedValueOnce(new Error('工程知识暂时不可用'))
.mockResolvedValueOnce({
...snapshot,
selectedLibraryId: secondLibraryId
})
render(<App />)
fireEvent.click(screen.getByRole('button', { name: '知识库' }))
fireEvent.click(
await screen.findByRole('button', {
name: /^ 0 /u
})
)
expect(
await screen.findByText('知识库刷新失败')
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '重试' }))
await waitFor(() =>
expect(api.knowledge.getSnapshot).toHaveBeenLastCalledWith(
secondLibraryId
)
)
expect(
screen.getByRole('button', {
name: /^ 0 /u
})
).toHaveAttribute('aria-current', 'page')
})
it('shows retryable page-local heartbeat errors without first-time guidance', async () => {
vi.mocked(api.heartbeats.list).mockRejectedValue(
new Error('心跳数据库暂时不可用')
)
render(<App />)
await screen.findByText('项目:默认项目')
fireEvent.click(
screen.getByRole('button', { name: '魔法笔记' })
screen.getByRole('button', { name: '智能心跳' })
)
expect(
await screen.findByText('智能心跳加载失败')
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: '配置智能心跳' })
).not.toBeInTheDocument()
const retry = await screen.findByRole('button', { name: '重试' })
vi.mocked(api.heartbeats.list).mockResolvedValue([])
fireEvent.click(retry)
expect(
await screen.findByRole('button', { name: '配置智能心跳' })
).toBeInTheDocument()
})
it('does not expose the previous project heartbeat after a switch fails', async () => {
const secondProject = {
...project,
id: '00000000-0000-4000-8000-000000000102',
name: '第二项目',
rootPath: 'C:\\Second'
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
secondProject
])
vi.mocked(api.heartbeats.list).mockResolvedValue([
{
id: '00000000-0000-4000-8000-000000000701',
projectId,
name: '旧项目心跳',
timezone: 'Asia/Shanghai',
recurrence: { type: 'daily', localTime: '09:00' },
enabled: true,
lookbackHours: 24,
retentionDays: 30,
nextRunAt: '2026-08-05T01:00:00.000Z',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
])
render(<App />)
fireEvent.click(
screen.getByRole('button', { name: '智能心跳' })
)
fireEvent.click(
await screen.findByRole('tab', { name: '心跳计划' })
)
expect(await screen.findAllByText('旧项目心跳')).not.toHaveLength(0)
vi.mocked(api.heartbeats.list).mockRejectedValue(
new Error('第二项目心跳读取失败')
)
fireEvent.change(screen.getByLabelText('当前项目'), {
target: { value: secondProject.id }
})
fireEvent.click(
screen.getByRole('button', { name: '智能心跳' })
)
expect(
await screen.findByRole('heading', { name: '魔法笔记' })
await screen.findByText('智能心跳加载失败')
).toBeInTheDocument()
expect(screen.getByText('项目:默认项目')).toBeInTheDocument()
expect(screen.queryAllByText('项目心跳')).toHaveLength(0)
expect(
screen.getByRole('button', { name: '新建笔记' })
).toBeInTheDocument()
expect(api.magicNotes.list).toHaveBeenCalled()
expect(
screen.queryByLabelText('切换助手工作栏')
screen.queryByRole('button', { name: '立即运行旧项目心跳' })
).not.toBeInTheDocument()
})
it('automatically snapshots the conversation project on new activity', async () => {
render(<App />)
await screen.findByText('项目:默认项目')
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '记录项目范围' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() =>
expect(
loadActivityRecords().find(
(record) =>
record.kind === 'request' &&
record.title === '记录项目范围'
)?.scope
).toEqual({
kind: 'project',
projectId,
projectName: project.name
})
)
})
it('marks the current primary navigation page and hides decorative icons', async () => {
render(<App />)
const navigation = screen.getByRole('navigation', {
name: '主导航'
})
const chat = within(navigation).getByRole('button', { name: '对话' })
const knowledge = within(navigation).getByRole('button', {
name: '知识库'
})
expect(chat).toHaveAttribute('aria-current', 'page')
expect(knowledge).not.toHaveAttribute('aria-current')
for (const button of within(navigation).getAllByRole('button')) {
expect(button.querySelector('svg')).toHaveAttribute(
'aria-hidden',
'true'
)
}
fireEvent.click(knowledge)
expect(knowledge).toHaveAttribute('aria-current', 'page')
expect(chat).not.toHaveAttribute('aria-current')
})
it('collapses the primary sidebar into an inert narrow-window overlay', async () => {
const originalWidth = window.innerWidth
const { container } = render(<App />)
const sidebar = container.querySelector<HTMLElement>('.sidebar')
const workspace = container.querySelector<HTMLElement>('.workspace')
expect(sidebar).not.toBeNull()
expect(workspace).not.toBeNull()
if (!sidebar || !workspace) {
return
}
try {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 680
})
window.dispatchEvent(new Event('resize'))
await waitFor(() =>
expect(sidebar).toHaveClass('sidebar--closed')
)
expect(sidebar).toHaveAttribute('aria-hidden', 'true')
expect(sidebar).toHaveAttribute('inert')
const toggle = screen.getByRole('button', { name: '切换侧栏' })
fireEvent.click(toggle)
expect(sidebar).not.toHaveClass('sidebar--closed')
expect(sidebar).toHaveAttribute('aria-hidden', 'false')
expect(sidebar).toHaveAttribute('aria-modal', 'true')
expect(sidebar).not.toHaveAttribute('inert')
expect(workspace).toHaveAttribute('aria-hidden', 'true')
expect(workspace).toHaveAttribute('inert')
await waitFor(() =>
expect(
within(sidebar).getByRole('button', { name: '对话' })
).toHaveFocus()
)
fireEvent.click(
screen.getByRole('button', { name: '关闭侧栏' })
)
await waitFor(() => expect(toggle).toHaveFocus())
expect(sidebar).toHaveClass('sidebar--closed')
expect(workspace).not.toHaveAttribute('aria-hidden')
expect(workspace).not.toHaveAttribute('inert')
} finally {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: originalWidth
})
}
})
it('opens Magic Notes as a scoped first-class workspace', async () => {
api.updates = {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
magicNotesEnabled: true
})),
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
magicNotesEnabled: true
})),
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {})
}
try {
render(<App />)
await screen.findByText('项目:默认项目')
const magicNotesEntry = await screen.findByRole('button', {
name: '魔法笔记'
})
fireEvent.click(magicNotesEntry)
expect(
await screen.findByRole('heading', { name: '魔法笔记' })
).toBeInTheDocument()
expect(screen.getByText('项目:默认项目')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '新建笔记' })
).toBeInTheDocument()
expect(api.magicNotes.list).toHaveBeenCalled()
expect(
screen.queryByLabelText('切换助手工作栏')
).not.toBeInTheDocument()
} finally {
delete api.updates
}
})
it('hides Magic Notes when the platform feature is disabled', async () => {
api.updates = {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
magicNotesEnabled: false
})),
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
magicNotesEnabled: false
})),
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {})
}
try {
render(<App />)
await waitFor(() =>
expect(
screen.queryByRole('button', { name: '魔法笔记' })
).not.toBeInTheDocument()
)
} finally {
delete api.updates
}
})
it('hides Magic Notes by default without an explicit setting', () => {
render(<App />)
expect(
screen.queryByRole('button', { name: '魔法笔记' })
).not.toBeInTheDocument()
})
it('keeps platform-feature switches in Settings without navigating', async () => {
let applicationSettings = {
checkUpdatesOnStartup: false,
magicNotesEnabled: false
}
api.updates = {
getSettings: vi.fn(async () => ({ ...applicationSettings })),
updateSettings: vi.fn(async (input) => {
applicationSettings = {
...applicationSettings,
...input
}
return { ...applicationSettings }
}),
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {})
}
try {
const { container } = render(<App />)
fireEvent.click(await screen.findByText('本地工作区'))
fireEvent.click(
screen.getByRole('tab', { name: '平台功能' })
)
const toggle = await screen.findByRole('switch', {
name: '显示魔法笔记入口'
})
fireEvent.click(toggle)
await waitFor(() => expect(toggle).toBeChecked())
expect(
screen.getByRole('heading', { name: '设置中心' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '魔法笔记' })
).toBeInTheDocument()
expect(
container.querySelector('.magic-notes-page')
).not.toBeInTheDocument()
fireEvent.click(toggle)
await waitFor(() => expect(toggle).not.toBeChecked())
expect(
screen.getByRole('heading', { name: '设置中心' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: '魔法笔记' })
).not.toBeInTheDocument()
} finally {
delete api.updates
}
})
it('gives the knowledge workspace the full content width', async () => {
render(<App />)
+335 -84
View File
@@ -104,6 +104,7 @@ import { HeartbeatCenter } from './HeartbeatCenter'
import { MagicNotesWorkspace } from './MagicNotesWorkspace'
import { MarkdownRenderer } from './MarkdownRenderer'
import {
DestructiveConfirmActions,
EmptyState,
PageShell,
ScopeBadge
@@ -609,6 +610,19 @@ function createConversation(
}
}
function displayErrorMessage(reason: unknown, fallback: string): string {
if (
typeof reason === 'object' &&
reason !== null &&
'message' in reason &&
typeof reason.message === 'string' &&
reason.message
) {
return reason.message
}
return fallback
}
function isUnusedConversation(conversation: Conversation): boolean {
return (
conversation.title === '新对话' &&
@@ -1030,6 +1044,7 @@ function App(): React.JSX.Element {
useState(false)
const migrationConversations = useRef(conversations)
const [projects, setProjects] = useState<AssistantProject[]>([])
const projectsRef = useRef(projects)
const [assistantTasks, setAssistantTasks] = useState<AssistantTask[]>([])
const [tokenUsage, setTokenUsage] =
useState<TokenUsageSummary>(emptyTokenUsage)
@@ -1060,6 +1075,8 @@ function App(): React.JSX.Element {
const [heartbeatRuns, setHeartbeatRuns] = useState<
AssistantHeartbeatRun[]
>([])
const [heartbeatLoading, setHeartbeatLoading] = useState(true)
const [heartbeatLoadError, setHeartbeatLoadError] = useState<string>()
const [assistantExperts, setAssistantExperts] = useState<
AssistantExpert[]
>([])
@@ -1110,7 +1127,12 @@ function App(): React.JSX.Element {
? 'ask'
: workMode
const [appInfo, setAppInfo] = useState<AppInfo>()
const [sidebarOpen, setSidebarOpen] = useState(true)
const [narrowWindow, setNarrowWindow] = useState(
() => window.innerWidth < 900
)
const [sidebarOpen, setSidebarOpen] = useState(
() => window.innerWidth >= 900
)
const [assistantSidebarOpen, setAssistantSidebarOpen] = useState(
() => window.innerWidth >= 1280
)
@@ -1120,8 +1142,13 @@ function App(): React.JSX.Element {
Record<string, BrowserLiveState>
>({})
const [view, setView] = useState<WorkspaceView>('chat')
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [conversationActionsId, setConversationActionsId] = useState('')
const [confirmingConversationId, setConfirmingConversationId] =
useState('')
const [deletingConversationId, setDeletingConversationId] =
useState('')
const [renamingConversationId, setRenamingConversationId] = useState('')
const [notifications, notify] = useReducer(
appNotificationReducer,
@@ -1169,6 +1196,11 @@ function App(): React.JSX.Element {
evidence: []
})
const [knowledgeLoading, setKnowledgeLoading] = useState(true)
const [knowledgeLoadError, setKnowledgeLoadError] = useState<string>()
const knowledgeLoadRequestRef = useRef(0)
const failedKnowledgeLibraryIdRef = useRef<string | undefined>(
undefined
)
const [enabledKnowledgeLibraryIds, setEnabledKnowledgeLibraryIds] = useState<
string[]
>([])
@@ -1182,20 +1214,67 @@ function App(): React.JSX.Element {
const knowledgeScopeInitialized = useRef(false)
const inputRef = useRef<HTMLTextAreaElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const sidebarRef = useRef<HTMLElement>(null)
const sidebarToggleRef = useRef<HTMLButtonElement>(null)
const topbarMenuRef = useRef<HTMLDivElement>(null)
const topbarMenuTriggerRef = useRef<HTMLButtonElement>(null)
const conversationActionTriggerRefs = useRef(
new Map<string, HTMLButtonElement>()
)
const closeNarrowSidebar = useCallback((): void => {
setSidebarOpen(false)
requestAnimationFrame(() => sidebarToggleRef.current?.focus())
}, [])
useEffect(() => {
activeConversationIdRef.current = activeId
}, [activeId])
useEffect(() => {
const collapseSidebarAtNarrowWidth = (): void => {
const narrow = window.innerWidth < 900
setNarrowWindow(narrow)
if (narrow) {
setSidebarOpen(false)
}
}
window.addEventListener('resize', collapseSidebarAtNarrowWidth)
return () =>
window.removeEventListener('resize', collapseSidebarAtNarrowWidth)
}, [])
useEffect(() => {
if (!narrowWindow || !sidebarOpen) {
return
}
const focusFrame = requestAnimationFrame(() => {
sidebarRef.current
?.querySelector<HTMLButtonElement>(
'.primary-nav button[aria-current="page"], .primary-nav button'
)
?.focus()
})
const closeOnEscape = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
event.preventDefault()
closeNarrowSidebar()
}
}
document.addEventListener('keydown', closeOnEscape)
return () => {
cancelAnimationFrame(focusFrame)
document.removeEventListener('keydown', closeOnEscape)
}
}, [closeNarrowSidebar, narrowWindow, sidebarOpen])
useEffect(() => {
conversationsRef.current = conversations
}, [conversations])
useEffect(() => {
projectsRef.current = projects
}, [projects])
useEffect(() => {
resizeComposerTextarea(inputRef.current)
}, [input])
@@ -1272,6 +1351,10 @@ function App(): React.JSX.Element {
void updates
.getSettings()
.then(async (settings) => {
setMagicNotesEnabled(settings.magicNotesEnabled)
if (!settings.magicNotesEnabled) {
setView('chat')
}
if (!settings.checkUpdatesOnStartup) {
return
}
@@ -1712,10 +1795,35 @@ function App(): React.JSX.Element {
)
const recordActivity = useCallback(
(record: Omit<ActivityRecord, 'id' | 'createdAt'>): void => {
(
record: Omit<ActivityRecord, 'id' | 'createdAt' | 'scope'>,
scopeOverride?: ActivityRecord['scope']
): void => {
const conversation = conversationsRef.current.find(
(candidate) => candidate.id === record.conversationId
)
const project = conversation?.projectId
? projectsRef.current.find(
(candidate) => candidate.id === conversation.projectId
)
: undefined
const scope: ActivityRecord['scope'] =
scopeOverride ??
(!conversation
? { kind: 'unavailable' }
: !conversation.projectId
? { kind: 'global' }
: project?.id && project.name
? {
kind: 'project',
projectId: project.id.slice(0, 256),
projectName: project.name.slice(0, 120)
}
: { kind: 'unavailable' })
setActivityRecords((current) =>
upsertActivityRecord(current, {
...record,
scope,
id: crypto.randomUUID(),
createdAt: Date.now()
})
@@ -1758,39 +1866,76 @@ function App(): React.JSX.Element {
activity.detail
)
}
recordActivity({
requestId: activity.requestId,
conversationId: activity.conversationId,
callId: activity.callId,
kind: activity.kind,
title: activity.title,
detail: activity.detail,
status: activity.status
})
recordActivity(
{
requestId: activity.requestId,
conversationId: activity.conversationId,
callId: activity.callId,
kind: activity.kind,
title: activity.title,
detail: activity.detail,
status: activity.status
},
{
kind: 'project',
projectId: activity.projectId.slice(0, 256),
projectName: activity.projectName.slice(0, 120)
}
)
})
}, [recordActivity, updateRequestActivity])
const refreshKnowledge = useCallback(
async (libraryId?: string): Promise<KnowledgeSnapshot> => {
const snapshot = await window.goodbuddy.knowledge.getSnapshot(libraryId)
setKnowledgeSnapshot(snapshot)
if (!knowledgeScopeInitialized.current) {
knowledgeScopeInitialized.current = true
setEnabledKnowledgeLibraryIds(
snapshot.libraries.map((library) => library.id)
)
} else {
setEnabledKnowledgeLibraryIds((current) =>
current.filter((id) =>
snapshot.libraries.some((library) => library.id === id)
const requestId = ++knowledgeLoadRequestRef.current
try {
const snapshot =
await window.goodbuddy.knowledge.getSnapshot(libraryId)
if (requestId !== knowledgeLoadRequestRef.current) {
return snapshot
}
failedKnowledgeLibraryIdRef.current = undefined
setKnowledgeSnapshot(snapshot)
setKnowledgeLoadError(undefined)
if (!knowledgeScopeInitialized.current) {
knowledgeScopeInitialized.current = true
setEnabledKnowledgeLibraryIds(
snapshot.libraries.map((library) => library.id)
)
} else {
setEnabledKnowledgeLibraryIds((current) =>
current.filter((id) =>
snapshot.libraries.some((library) => library.id === id)
)
)
}
return snapshot
} catch (reason) {
if (requestId !== knowledgeLoadRequestRef.current) {
throw reason
}
failedKnowledgeLibraryIdRef.current = libraryId
setKnowledgeLoadError(
displayErrorMessage(reason, '本地知识库读取失败')
)
throw reason
}
return snapshot
},
[]
)
const retryKnowledgeLoad = useCallback(async (): Promise<void> => {
setKnowledgeLoading(true)
setKnowledgeLoadError(undefined)
try {
await refreshKnowledge(failedKnowledgeLibraryIdRef.current)
} catch {
// The recoverable page state is set by refreshKnowledge.
} finally {
setKnowledgeLoading(false)
}
}, [refreshKnowledge])
const switchRuntime = useCallback(
async (selection: AgentRuntimeSelection): Promise<void> => {
if (!runtimeSettings || !activeConversation || runtimeSwitching) {
@@ -2610,19 +2755,41 @@ function App(): React.JSX.Element {
useEffect(() => {
const requestId = ++heartbeatLoadRequestRef.current
void loadHeartbeats()
.then((result) => {
if (requestId !== heartbeatLoadRequestRef.current) {
return
}
setAssistantHeartbeats(result.configs)
setHeartbeatRuns(result.runs)
setHeartbeatEntries(result.entries)
})
.catch(() =>
notify({ tone: 'error', message: '智能心跳读取失败' })
)
const timeout = setTimeout(() => {
if (requestId !== heartbeatLoadRequestRef.current) {
return
}
setHeartbeatLoading(true)
setHeartbeatLoadError(undefined)
setAssistantHeartbeats([])
setHeartbeatRuns([])
setHeartbeatEntries([])
void loadHeartbeats()
.then((result) => {
if (requestId !== heartbeatLoadRequestRef.current) {
return
}
setAssistantHeartbeats(result.configs)
setHeartbeatRuns(result.runs)
setHeartbeatEntries(result.entries)
setHeartbeatLoadError(undefined)
})
.catch((reason: unknown) => {
if (requestId !== heartbeatLoadRequestRef.current) {
return
}
setHeartbeatLoadError(
displayErrorMessage(reason, '智能心跳读取失败')
)
})
.finally(() => {
if (requestId === heartbeatLoadRequestRef.current) {
setHeartbeatLoading(false)
}
})
}, 0)
return () => {
clearTimeout(timeout)
if (requestId === heartbeatLoadRequestRef.current) {
heartbeatLoadRequestRef.current += 1
}
@@ -2647,6 +2814,21 @@ function App(): React.JSX.Element {
)
}, [activeProjectId, refreshHeartbeats])
const retryHeartbeatLoad = useCallback(async (): Promise<void> => {
setHeartbeatLoading(true)
setHeartbeatLoadError(undefined)
try {
await refreshHeartbeatCenter()
setHeartbeatLoadError(undefined)
} catch (reason) {
setHeartbeatLoadError(
displayErrorMessage(reason, '智能心跳读取失败')
)
} finally {
setHeartbeatLoading(false)
}
}, [refreshHeartbeatCenter])
const createHeartbeat = useCallback(
async (input: HeartbeatCreateInput): Promise<void> => {
const projectId = activeProjectId
@@ -2706,8 +2888,11 @@ function App(): React.JSX.Element {
}
refreshing = true
void refreshHeartbeatCenter()
.catch(() =>
notify({ tone: 'error', message: '智能心跳刷新失败' })
.then(() => setHeartbeatLoadError(undefined))
.catch((reason: unknown) =>
setHeartbeatLoadError(
displayErrorMessage(reason, '智能心跳刷新失败')
)
)
.finally(() => {
refreshing = false
@@ -2806,14 +2991,8 @@ function App(): React.JSX.Element {
useEffect(() => {
const timeout = setTimeout(() => {
void refreshKnowledge()
.catch((reason: unknown) => {
notify({
tone: 'error',
message:
reason instanceof Error
? reason.message
: '本地知识库读取失败'
})
.catch(() => {
// refreshKnowledge exposes a recoverable page-local error.
})
.finally(() => setKnowledgeLoading(false))
}, 0)
@@ -3137,19 +3316,38 @@ function App(): React.JSX.Element {
)
}
const deleteConversation = (conversationId: string): void => {
const deleteConversation = async (
conversationId: string
): Promise<void> => {
if (deletingConversationId) {
return
}
setDeletingConversationId(conversationId)
const activeRequests = [...activeRuns.current.entries()]
.filter(([, run]) => run.conversationId === conversationId)
.map(([requestId]) => requestId)
try {
await Promise.all(
activeRequests.map((requestId) =>
window.goodbuddy.agent.cancel(requestId)
)
)
} catch {
notify({
tone: 'error',
message: '停止会话中的运行任务失败,尚未删除对话'
})
setDeletingConversationId('')
return
}
setConfirmingConversationId('')
setDeletingConversationId('')
if (conversationActionsId === conversationId) {
setConversationActionsId('')
}
if (renamingConversationId === conversationId) {
setRenamingConversationId('')
}
const activeRequest = [...activeRuns.current.entries()].find(
([, run]) => run.conversationId === conversationId
)?.[0]
if (activeRequest) {
void window.goodbuddy.agent.cancel(activeRequest)
}
const browserStop = window.goodbuddy.browser?.stop(conversationId)
if (browserStop) {
void browserStop.catch(() => {
@@ -3972,7 +4170,15 @@ function App(): React.JSX.Element {
return (
<div className="app-shell">
<aside className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'}>
<aside
aria-label={narrowWindow && sidebarOpen ? '主侧栏' : undefined}
aria-hidden={!sidebarOpen}
aria-modal={narrowWindow && sidebarOpen ? 'true' : undefined}
className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'}
inert={!sidebarOpen}
ref={sidebarRef}
role={narrowWindow && sidebarOpen ? 'dialog' : undefined}
>
<div className="brand">
<div className="brand__mark">
<img
@@ -4028,28 +4234,33 @@ function App(): React.JSX.Element {
<nav className="primary-nav" aria-label="主导航">
<button
aria-current={view === 'chat' ? 'page' : undefined}
className={
view === 'chat' ? 'nav-item nav-item--active' : 'nav-item'
}
onClick={() => setView('chat')}
type="button"
>
<MessageSquare size={17} />
<MessageSquare aria-hidden="true" size={17} />
<span></span>
</button>
{magicNotesEnabled && (
<button
aria-current={view === 'magic-notes' ? 'page' : undefined}
className={
view === 'magic-notes'
? 'nav-item nav-item--active'
: 'nav-item'
}
onClick={() => setView('magic-notes')}
type="button"
>
<Sparkles aria-hidden="true" size={17} />
<span></span>
</button>
)}
<button
className={
view === 'magic-notes'
? 'nav-item nav-item--active'
: 'nav-item'
}
onClick={() => setView('magic-notes')}
type="button"
>
<Sparkles size={17} />
<span></span>
</button>
<button
aria-current={view === 'knowledge' ? 'page' : undefined}
className={
view === 'knowledge'
? 'nav-item nav-item--active'
@@ -4058,10 +4269,11 @@ function App(): React.JSX.Element {
onClick={() => setView('knowledge')}
type="button"
>
<Library size={17} />
<Library aria-hidden="true" size={17} />
<span></span>
</button>
<button
aria-current={view === 'heartbeat' ? 'page' : undefined}
className={
view === 'heartbeat'
? 'nav-item nav-item--active'
@@ -4070,7 +4282,7 @@ function App(): React.JSX.Element {
onClick={() => setView('heartbeat')}
type="button"
>
<HeartPulse size={17} />
<HeartPulse aria-hidden="true" size={17} />
<span></span>
{pendingHeartbeatSuggestionCount > 0 && (
<span
@@ -4082,6 +4294,7 @@ function App(): React.JSX.Element {
)}
</button>
<button
aria-current={view === 'activity' ? 'page' : undefined}
className={
view === 'activity'
? 'nav-item nav-item--active'
@@ -4090,7 +4303,7 @@ function App(): React.JSX.Element {
onClick={() => setView('activity')}
type="button"
>
<TerminalSquare size={17} />
<TerminalSquare aria-hidden="true" size={17} />
<span></span>
</button>
</nav>
@@ -4157,6 +4370,7 @@ function App(): React.JSX.Element {
className="conversation-more"
onClick={() => {
setRenamingConversationId('')
setConfirmingConversationId('')
setConversationActionsId((current) =>
current === conversation.id ? '' : conversation.id
)
@@ -4177,16 +4391,6 @@ function App(): React.JSX.Element {
>
<MoreHorizontal size={14} />
</button>
{!conversation.remote && (
<button
aria-label={`删除对话 ${conversation.title}`}
className="conversation-delete"
onClick={() => deleteConversation(conversation.id)}
type="button"
>
<Trash2 size={14} />
</button>
)}
</div>
{conversationActionsId === conversation.id && (
<div
@@ -4229,6 +4433,30 @@ function App(): React.JSX.Element {
<Download size={14} />
Markdown
</button>
{!conversation.remote && (
<DestructiveConfirmActions
cancelAriaLabel={`取消删除对话 ${conversation.title}`}
confirmAriaLabel={`确认永久删除对话 ${conversation.title}`}
confirmLabel="永久删除对话"
confirming={
confirmingConversationId === conversation.id
}
disabled={
deletingConversationId === conversation.id
}
icon={<Trash2 aria-hidden="true" size={14} />}
message="将永久删除此会话的全部内容;如果此会话有正在运行的任务,也会同时停止。此操作不可恢复。"
onCancel={() => setConfirmingConversationId('')}
onConfirm={() =>
void deleteConversation(conversation.id)
}
onRequestConfirm={() =>
setConfirmingConversationId(conversation.id)
}
triggerAriaLabel={`删除对话 ${conversation.title}`}
triggerLabel="删除对话"
/>
)}
</div>
)}
{!conversation.remote &&
@@ -4304,16 +4532,29 @@ function App(): React.JSX.Element {
</button>
</div>
</aside>
{sidebarOpen && (
<button
aria-label="关闭侧栏"
className="sidebar-backdrop"
onClick={closeNarrowSidebar}
type="button"
/>
)}
<main className="workspace">
<main
aria-hidden={narrowWindow && sidebarOpen ? 'true' : undefined}
className="workspace"
inert={narrowWindow && sidebarOpen}
>
<header className="topbar">
<button
className="icon-button sidebar-toggle"
type="button"
aria-label="切换侧栏"
onClick={() => setSidebarOpen((open) => !open)}
ref={sidebarToggleRef}
>
<PanelLeft size={18} />
<PanelLeft aria-hidden="true" size={18} />
</button>
{view === 'chat' && (
<>
@@ -5496,7 +5737,7 @@ function App(): React.JSX.Element {
)}
</footer>
</PageShell>
) : view === 'magic-notes' ? (
) : view === 'magic-notes' && magicNotesEnabled ? (
<PageShell variant="master-detail">
<MagicNotesWorkspace
key={activeProject?.id ?? 'global'}
@@ -5513,6 +5754,7 @@ function App(): React.JSX.Element {
graphNodes={knowledgeSnapshot.graphNodes}
graphRelations={knowledgeSnapshot.graphRelations}
libraries={knowledgeSnapshot.libraries}
loadError={knowledgeLoadError}
loading={knowledgeLoading}
onCreateLibrary={createKnowledgeLibrary}
onCreateEntity={async (input) => {
@@ -5629,8 +5871,11 @@ function App(): React.JSX.Element {
window.goodbuddy.knowledge.retrySource(sourceId)
)
}
onRetryLoad={retryKnowledgeLoad}
onSelectLibrary={(libraryId) => {
void refreshKnowledge(libraryId)
void refreshKnowledge(libraryId).catch(() => {
// KnowledgeWorkspace renders the recoverable load error.
})
}}
onSyncSource={(sourceId) =>
runKnowledgeSourceAction(() =>
@@ -5660,9 +5905,12 @@ function App(): React.JSX.Element {
configs={assistantHeartbeats}
currentProjectName={activeProject?.name}
entries={heartbeatEntries}
loadError={heartbeatLoadError}
loading={heartbeatLoading}
memories={assistantMemories}
onCreate={createHeartbeat}
onRefresh={refreshHeartbeatCenter}
onRefresh={retryHeartbeatLoad}
onRetryLoad={retryHeartbeatLoad}
onRemove={removeHeartbeat}
onRunNow={runHeartbeat}
onSetMemoryStatus={setMemoryStatus}
@@ -5694,6 +5942,9 @@ function App(): React.JSX.Element {
setSelectedExpertId('')
}
}}
onMagicNotesEnabledChange={(enabled) => {
setMagicNotesEnabled(enabled)
}}
onRemoveHeartbeat={removeHeartbeat}
onRunHeartbeat={runHeartbeat}
onNotify={notify}
+53
View File
@@ -110,6 +110,7 @@ function createProps(
onRemove: vi.fn(async () => {}),
onRunNow: vi.fn(async () => {}),
onRefresh: vi.fn(async () => {}),
onRetryLoad: vi.fn(async () => {}),
onSetMemoryStatus: vi.fn(async () => {}),
onSetTaskStatus: vi.fn(async () => {}),
onUseFollowUpTask: vi.fn(),
@@ -264,4 +265,56 @@ describe('HeartbeatCenter', () => {
screen.getByRole('button', { name: '启用智能心跳' })
).toBeInTheDocument()
})
it('keeps loading and load failure distinct from first-time empty state', () => {
const emptyProps = {
configs: [],
runs: [],
entries: [],
memories: [],
tasks: []
}
const { rerender } = render(
<HeartbeatCenter
{...createProps({
...emptyProps,
loading: true
})}
/>
)
expect(screen.getByText('正在加载智能心跳')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: '配置智能心跳' })
).not.toBeInTheDocument()
rerender(
<HeartbeatCenter
{...createProps({
...emptyProps,
loadError: '数据库暂时不可用'
})}
/>
)
expect(screen.getByText('智能心跳加载失败')).toBeInTheDocument()
expect(screen.getByText('数据库暂时不可用')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: '配置智能心跳' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '重试' })).toBeInTheDocument()
})
it('keeps existing heartbeat data visible when refresh fails', () => {
render(
<HeartbeatCenter
{...createProps({ loadError: '刷新连接失败' })}
/>
)
expect(screen.getByText('智能心跳刷新失败')).toBeInTheDocument()
expect(screen.getByText(config.name)).toBeInTheDocument()
expect(
screen.getByText(entry.summary)
).toBeInTheDocument()
})
})
+63 -5
View File
@@ -54,6 +54,9 @@ export type HeartbeatCenterProps = {
) => Promise<void>
onUseFollowUpTask: (task: AssistantTask) => void
currentProjectName?: string
loading?: boolean
loadError?: string
onRetryLoad: () => void | Promise<void>
}
const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
@@ -143,7 +146,10 @@ export function HeartbeatCenter({
onSetMemoryStatus,
onSetTaskStatus,
onUseFollowUpTask,
currentProjectName = '当前项目'
currentProjectName = '当前项目',
loading = false,
loadError,
onRetryLoad
}: HeartbeatCenterProps): React.JSX.Element {
const [tab, setTab] = useState<HeartbeatCenterTab>('overview')
const [pendingAction, setPendingAction] = useState<string>()
@@ -226,6 +232,10 @@ export function HeartbeatCenter({
entry.followUpTaskIds.length
)
)
const hasHeartbeatData =
configs.length > 0 || runs.length > 0 || entries.length > 0
const initialLoadBlocked =
!hasHeartbeatData && (loading || loadError !== undefined)
const runAction = async (
actionId: string,
@@ -269,11 +279,12 @@ export function HeartbeatCenter({
>
<PageHeader
actions={
<>
initialLoadBlocked ? undefined : (
<>
<button
aria-label="刷新智能心跳"
className="secondary-button"
disabled={pendingAction !== undefined}
disabled={loading || pendingAction !== undefined}
onClick={() => void runAction('refresh', onRefresh)}
type="button"
>
@@ -283,7 +294,7 @@ export function HeartbeatCenter({
{primaryConfig ? (
<button
className="primary-button"
disabled={pendingAction !== undefined}
disabled={loading || pendingAction !== undefined}
onClick={() =>
void runAction(`run:${primaryConfig.id}`, () =>
onRunNow(primaryConfig.id)
@@ -299,13 +310,15 @@ export function HeartbeatCenter({
) : (
<button
className="primary-button"
disabled={loading}
onClick={() => setTab('plans')}
type="button"
>
</button>
)}
</>
</>
)
}
description="定期回顾经历、沉淀记忆、发现问题,并把每次变化转化为可处理的成长建议。"
eyebrow="SMART HEARTBEAT"
@@ -321,6 +334,49 @@ export function HeartbeatCenter({
</p>
)}
{loading && !hasHeartbeatData ? (
<EmptyState
description="正在读取心跳计划、运行记录和成长报告。"
icon={<RefreshCw size={24} />}
level="page"
title="正在加载智能心跳"
/>
) : loadError && !hasHeartbeatData ? (
<EmptyState
action={
<button
className="secondary-button"
onClick={() => void onRetryLoad()}
type="button"
>
<RefreshCw aria-hidden="true" size={14} />
</button>
}
description={loadError}
icon={<XCircle size={24} />}
level="page"
title="智能心跳加载失败"
/>
) : null}
{loadError && hasHeartbeatData && (
<div className="heartbeat-center__error" role="alert">
<strong></strong>
<p>{loadError}</p>
<button
className="secondary-button"
onClick={() => void onRetryLoad()}
type="button"
>
<RefreshCw aria-hidden="true" size={14} />
</button>
</div>
)}
{!initialLoadBlocked && (
<>
<PageTabs
ariaLabel="智能心跳视图"
idPrefix="heartbeat"
@@ -1034,6 +1090,8 @@ export function HeartbeatCenter({
/>
</div>
)}
</>
)}
</section>
)
}
+49 -5
View File
@@ -4,7 +4,8 @@ import {
fireEvent,
render,
screen,
waitFor
waitFor,
within
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
@@ -143,6 +144,7 @@ function createProps(
onCreateRelation: vi.fn(),
onUpdateRelation: vi.fn(),
onDeleteRelation: vi.fn(),
onRetryLoad: vi.fn(),
...overrides
}
}
@@ -283,10 +285,11 @@ describe('KnowledgeWorkspace', () => {
'knowledge-workspace__sidebar'
)
expect(workspace.querySelector('aside')).not.toHaveAttribute('style')
expect(workspace.querySelector('main')).toHaveClass(
'knowledge-workspace__main'
)
expect(workspace.querySelector('main')).toHaveStyle({
const detailRegion = within(workspace).getByRole('region', {
name: '知识库详情'
})
expect(detailRegion).toHaveClass('knowledge-workspace__main')
expect(detailRegion).toHaveStyle({
background: 'var(--surface-raised)'
})
expect(screen.getByText('全局')).toHaveClass('scope-badge')
@@ -308,6 +311,9 @@ describe('KnowledgeWorkspace', () => {
expect(screen.getByLabelText('搜索文档').closest('label')).toHaveClass(
'knowledge-documents__search'
)
expect(screen.getByLabelText('搜索文档')).not.toHaveStyle({
outline: 'none'
})
expect(screen.getByText('本地文件 · 架构说明.md')).toBeInTheDocument()
expect(screen.queryByText('D:\\Private\\架构说明.md')).not
.toBeInTheDocument()
@@ -609,6 +615,44 @@ describe('KnowledgeWorkspace', () => {
).toBeDisabled()
})
it('shows a retryable load error instead of the first-library empty state', () => {
const onRetryLoad = vi.fn()
render(
<KnowledgeWorkspace
{...createProps({
libraries: [],
loadError: '数据库暂时不可用',
onRetryLoad,
selectedLibraryId: undefined
})}
/>
)
expect(screen.getByText('知识库加载失败')).toBeInTheDocument()
expect(screen.getByText('数据库暂时不可用')).toBeInTheDocument()
expect(
screen.queryByText('建立第一个知识库')
).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(onRetryLoad).toHaveBeenCalledOnce()
})
it('keeps existing data and selection visible when refresh fails', () => {
render(
<KnowledgeWorkspace
{...createProps({ loadError: '刷新连接失败' })}
/>
)
expect(screen.getByText('知识库刷新失败')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: library.name }))
.toBeInTheDocument()
expect(screen.getByText('架构说明.md')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /^ 1 /u })
).toHaveAttribute('aria-current', 'page')
})
it('confirms that deleting a managed library removes managed copies', async () => {
const onDeleteLibrary = vi.fn()
render(
+48 -4
View File
@@ -159,6 +159,8 @@ export type KnowledgeWorkspaceProps = {
graphRelations: readonly KnowledgeGraphRelation[]
evidence: readonly KnowledgeEvidence[]
loading?: boolean
loadError?: string
onRetryLoad: () => void | Promise<void>
onSelectLibrary: (libraryId: string) => void
onCreateLibrary: (
input: CreateKnowledgeLibraryInput
@@ -274,7 +276,6 @@ const styles = {
padding: 'var(--space-2) var(--space-3)',
border: '1px solid var(--border-control)',
borderRadius: 'var(--radius-control)',
outline: 'none',
background: 'var(--surface-raised)',
color: 'var(--text-primary)',
font: 'inherit'
@@ -2115,6 +2116,8 @@ export function KnowledgeWorkspace({
graphRelations,
evidence,
loading = false,
loadError,
onRetryLoad,
onSelectLibrary,
onCreateLibrary,
onDeleteLibrary,
@@ -2323,10 +2326,33 @@ export function KnowledgeWorkspace({
</nav>
</aside>
<main
<section
aria-label="知识库详情"
className="knowledge-workspace__main"
style={{ minWidth: 0, background: 'var(--surface-raised)' }}
>
{loadError && libraries.length > 0 && (
<div
role="alert"
style={{
...styles.surface,
margin: 'var(--space-4)',
padding: 'var(--space-3)',
color: 'var(--danger)'
}}
>
<strong></strong>
<p style={{ margin: 'var(--space-2) 0' }}>{loadError}</p>
<button
className="secondary-button"
onClick={() => void onRetryLoad()}
type="button"
>
<RefreshCw aria-hidden="true" size={14} />
</button>
</div>
)}
{selectedLibrary && !creating && !loading && (
<button
className="knowledge-workspace__mobile-back secondary-button"
@@ -2337,13 +2363,31 @@ export function KnowledgeWorkspace({
</button>
)}
{loading ? (
{loading && libraries.length === 0 ? (
<EmptyState
description="正在读取知识库、来源和索引状态。"
icon={<LoaderCircle size={28} />}
level="page"
title="正在加载知识库"
/>
) : loadError && libraries.length === 0 ? (
<EmptyState
action={
<button
className="secondary-button"
onClick={() => void onRetryLoad()}
style={styles.button}
type="button"
>
<RefreshCw aria-hidden="true" size={14} />
</button>
}
description={loadError}
icon={<AlertCircle size={28} />}
level="page"
title="知识库加载失败"
/>
) : creating ? (
<CreateLibraryWizard
onCancel={() => setCreating(false)}
@@ -2502,7 +2546,7 @@ export function KnowledgeWorkspace({
</div>
</>
)}
</main>
</section>
{deletingLibrary && (
<DeleteLibraryDialog
library={deletingLibrary}
+149 -2
View File
@@ -123,6 +123,7 @@ const summaryFromDetail = (
const list = vi.fn<() => Promise<MagicNotesSnapshot>>()
const get = vi.fn<(noteId: string) => Promise<MagicNoteDetail>>()
const listTodos = vi.fn<() => Promise<MagicTodosSnapshot>>()
const remove = vi.fn<DesktopApi['magicNotes']['remove']>()
const createTodo = vi.fn<DesktopApi['magicNotes']['createTodo']>()
const updateTodo = vi.fn<DesktopApi['magicNotes']['updateTodo']>()
const removeTodo = vi.fn<DesktopApi['magicNotes']['removeTodo']>()
@@ -133,6 +134,7 @@ beforeEach(() => {
list.mockResolvedValue({ notes: [detail] })
get.mockResolvedValue(detail)
listTodos.mockResolvedValue({ todos: [noteTodo, manualTodo] })
remove.mockResolvedValue()
createTodo.mockResolvedValue({
...manualTodo,
id: '00000000-0000-4000-8000-000000000606',
@@ -166,6 +168,7 @@ beforeEach(() => {
list,
get,
listTodos,
remove,
createTodo,
updateTodo,
removeTodo,
@@ -181,6 +184,68 @@ afterEach(() => {
})
describe('MagicNotesWorkspace', () => {
it('shows a retryable EmptyState when the initial load fails', async () => {
get.mockRejectedValueOnce(new Error('详情暂时不可用'))
render(
<MagicNotesWorkspace
onNotify={onNotify}
projectId={detail.projectId}
projectName="默认项目"
/>
)
expect(
await screen.findByText('魔法笔记加载失败')
).toBeInTheDocument()
expect(screen.getByText(/详情暂时不可用/)).toBeInTheDocument()
expect(screen.queryByText('还没有笔记')).not.toBeInTheDocument()
expect(screen.queryByText('还没有待办')).not.toBeInTheDocument()
expect(screen.queryByText('还没有选择笔记')).not.toBeInTheDocument()
expect(onNotify).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByText('记录正文')).toBeInTheDocument()
expect(screen.queryByText('魔法笔记加载失败')).not.toBeInTheDocument()
})
it('keeps successful data and selection when a refresh fails', async () => {
render(
<MagicNotesWorkspace
onNotify={onNotify}
projectId={detail.projectId}
projectName="默认项目"
/>
)
await screen.findByText('记录正文')
list.mockRejectedValueOnce(new Error('刷新暂时不可用'))
fireEvent.click(screen.getByRole('button', { name: '删除笔记' }))
fireEvent.click(
screen.getAllByRole('button', { name: '删除笔记' })[1]!
)
expect(
await screen.findByText(/刷新失败,已保留当前内容:刷新暂时不可用/)
).toBeInTheDocument()
expect(screen.getByLabelText('笔记标题')).toHaveValue(detail.title)
expect(
screen.getByRole('button', { name: /发布笔记/ })
).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
expect(screen.getByText('准备演示')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /核对发布材料/ })
).toHaveAttribute('aria-pressed', 'true')
expect(onNotify).not.toHaveBeenCalledWith(
expect.objectContaining({
tone: 'error',
message: '刷新暂时不可用'
})
)
})
it('aggregates note and manual todos without AI-created todo actions', async () => {
render(
<MagicNotesWorkspace
@@ -276,6 +341,53 @@ describe('MagicNotesWorkspace', () => {
)
})
it('does not override a note selected while retrying a refresh', async () => {
const second = alternateDetail(secondNoteId, '第二篇笔记')
list.mockResolvedValue({
notes: [summaryFromDetail(detail), summaryFromDetail(second)]
})
render(
<MagicNotesWorkspace
onNotify={onNotify}
projectId={detail.projectId}
projectName="默认项目"
/>
)
await screen.findByText('记录正文')
list.mockRejectedValueOnce(new Error('刷新暂时不可用'))
fireEvent.click(screen.getByRole('button', { name: '删除笔记' }))
fireEvent.click(
screen.getAllByRole('button', { name: '删除笔记' })[1]!
)
const retry = await screen.findByRole('button', { name: '重试' })
let resolveRefreshDetail:
| ((value: MagicNoteDetail) => void)
| undefined
const delayedRefreshDetail = new Promise<MagicNoteDetail>(
(resolve) => {
resolveRefreshDetail = resolve
}
)
get.mockImplementation((requestedId) =>
requestedId === second.id
? Promise.resolve(second)
: delayedRefreshDetail
)
fireEvent.click(retry)
await waitFor(() => expect(get).toHaveBeenCalledTimes(2))
fireEvent.click(screen.getByText(second.title).closest('button')!)
expect(await screen.findByDisplayValue(second.title)).toBeInTheDocument()
resolveRefreshDetail?.(detail)
await waitFor(() =>
expect(screen.getByLabelText('笔记标题')).toHaveValue(second.title)
)
})
it('creates a manual todo with a dedicated title and details form', async () => {
render(
<MagicNotesWorkspace
@@ -289,7 +401,7 @@ describe('MagicNotesWorkspace', () => {
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
fireEvent.click(screen.getByRole('button', { name: '新建待办' }))
expect(createTodo).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '创建' }))
fireEvent.click(screen.getByRole('button', { name: '创建待办' }))
expect(screen.getByRole('alert')).toHaveTextContent('请输入待办标题')
expect(onNotify).not.toHaveBeenCalled()
@@ -300,7 +412,7 @@ describe('MagicNotesWorkspace', () => {
fireEvent.change(screen.getByLabelText('说明'), {
target: { value: '新增说明' }
})
fireEvent.click(screen.getByRole('button', { name: '创建' }))
fireEvent.click(screen.getByRole('button', { name: '创建待办' }))
await waitFor(() =>
expect(createTodo).toHaveBeenCalledWith({
@@ -335,6 +447,41 @@ describe('MagicNotesWorkspace', () => {
).toBeInTheDocument()
})
it('clears note searches and todo status filters with no results', async () => {
listTodos.mockResolvedValue({
todos: [
{ ...noteTodo, completed: true },
{ ...manualTodo, completed: true }
]
})
render(
<MagicNotesWorkspace
onNotify={onNotify}
projectId={detail.projectId}
projectName="默认项目"
/>
)
await screen.findByText('记录正文')
fireEvent.change(screen.getByRole('searchbox', { name: '搜索当前范围的笔记' }), {
target: { value: '不存在的笔记' }
})
expect(screen.getByText('没有符合条件的笔记')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
expect(screen.getByRole('searchbox', {
name: '搜索当前范围的笔记'
})).toHaveValue('')
expect(screen.getByText('发布笔记')).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
expect(screen.getByText('没有符合条件的待办')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
expect(
screen.getByRole('button', { name: '全部' })
).toHaveAttribute('aria-pressed', 'true')
expect(screen.getAllByText('核对发布材料')).not.toHaveLength(0)
})
it('clears delete confirmation before selecting the next todo', async () => {
render(
<MagicNotesWorkspace
+147 -32
View File
@@ -49,6 +49,7 @@ export type MagicNotesWorkspaceProps = {
type LibraryView = 'notes' | 'todos'
type TodoFilter = 'active' | 'completed' | 'all'
type LoadStatus = 'loading' | 'ready' | 'error'
type ValidationTarget =
| 'create-note'
| 'create-todo'
@@ -150,7 +151,13 @@ export function MagicNotesWorkspace({
const [selectedNoteId, setSelectedNoteId] = useState('')
const [selectedTodoId, setSelectedTodoId] = useState('')
const [detail, setDetail] = useState<MagicNoteDetail>()
const [loading, setLoading] = useState(true)
const [loadStatus, setLoadStatus] = useState<LoadStatus>('loading')
const [loadError, setLoadError] = useState('')
const [refreshError, setRefreshError] = useState('')
const [detailLoadError, setDetailLoadError] = useState<{
message: string
noteId: string
}>()
const [busy, setBusy] = useState('')
const [search, setSearch] = useState('')
const [creating, setCreating] = useState(false)
@@ -172,7 +179,9 @@ export function MagicNotesWorkspace({
message: string
}>()
const detailRequestRef = useRef(0)
const requestedNoteIdRef = useRef('')
const refreshRequestRef = useRef(0)
const hasLoadedRef = useRef(false)
const busyRef = useRef('')
const composerContentRef = useRef<MagicNoteRichContent | undefined>(
undefined
@@ -258,28 +267,40 @@ export function MagicNotesWorkspace({
const loadDetail = useCallback(
async (noteId: string): Promise<void> => {
const requestId = ++detailRequestRef.current
requestedNoteIdRef.current = noteId
setDetailLoadError(undefined)
try {
const nextDetail = await window.goodbuddy.magicNotes.get(noteId)
if (detailRequestRef.current === requestId) {
setSelectedNoteId(noteId)
applyDetail(nextDetail)
}
} catch (loadError) {
if (detailRequestRef.current === requestId) {
notifyError(loadError)
setDetailLoadError({
message: errorMessage(loadError),
noteId
})
}
}
},
[applyDetail, notifyError]
[applyDetail]
)
const refreshNotes = useCallback(
async (preferredId?: string): Promise<void> => {
const requestId = ++refreshRequestRef.current
const detailRequestAtStart = detailRequestRef.current
await Promise.resolve()
if (refreshRequestRef.current !== requestId) {
return
}
setLoading(true)
const isInitialLoad = !hasLoadedRef.current
if (isInitialLoad) {
setLoadStatus('loading')
setLoadError('')
}
setRefreshError('')
try {
const [snapshot, todoSnapshot] = await Promise.all([
window.goodbuddy.magicNotes.list(projectId),
@@ -295,23 +316,37 @@ export function MagicNotesWorkspace({
if (refreshRequestRef.current !== requestId) {
return
}
const requestedNoteId = requestedNoteIdRef.current
const preserveNewerSelection =
detailRequestRef.current !== detailRequestAtStart &&
snapshot.notes.some((note) => note.id === requestedNoteId)
setNotes(snapshot.notes)
setTodos(todoSnapshot.todos)
setSelectedNoteId(nextId)
setSelectedTodoId(todoSnapshot.todos[0]?.id ?? '')
hasLoadedRef.current = true
setLoadStatus('ready')
if (preserveNewerSelection) {
return
}
detailRequestRef.current += 1
requestedNoteIdRef.current = nextId
setSelectedNoteId(nextId)
setDetail(nextDetail)
setTitleDraft(nextDetail?.title ?? '')
setDetailLoadError(undefined)
} catch (loadError) {
if (refreshRequestRef.current === requestId) {
notifyError(loadError)
}
} finally {
if (refreshRequestRef.current === requestId) {
setLoading(false)
const message = errorMessage(loadError)
if (hasLoadedRef.current) {
setRefreshError(message)
} else {
setLoadError(message)
setLoadStatus('error')
}
}
}
},
[notifyError, projectId]
[projectId]
)
useEffect(() => {
@@ -321,6 +356,7 @@ export function MagicNotesWorkspace({
return () => {
window.clearTimeout(timeout)
refreshRequestRef.current += 1
detailRequestRef.current += 1
}
}, [refreshNotes])
@@ -391,6 +427,7 @@ export function MagicNotesWorkspace({
title
})
applyDetail(created)
requestedNoteIdRef.current = created.id
setSelectedNoteId(created.id)
setNewTitle('')
setCreating(false)
@@ -649,7 +686,7 @@ export function MagicNotesWorkspace({
setCreating(true)
}}
>
<Plus size={15} />
<Plus aria-hidden="true" size={15} />
{libraryView === 'notes' ? '新建笔记' : '新建待办'}
</button>
</>
@@ -666,6 +703,36 @@ export function MagicNotesWorkspace({
title="魔法笔记"
/>
{loadStatus === 'error' ? (
<EmptyState
action={
<button
className="secondary-button"
onClick={() => void refreshNotes()}
type="button"
>
</button>
}
description={`无法加载魔法笔记:${loadError}`}
icon={<CircleAlert size={24} />}
level="page"
title="魔法笔记加载失败"
/>
) : (
<>
{refreshError && (
<div className="magic-note-delete-confirmation" role="alert">
<span>{refreshError}</span>
<button
className="secondary-button"
onClick={() => void refreshNotes(selectedNoteId)}
type="button"
>
</button>
</div>
)}
<div
aria-busy={Boolean(busy)}
className={`magic-notes-layout${
@@ -760,18 +827,31 @@ export function MagicNotesWorkspace({
disabled={busy === 'create-note'}
type="submit"
>
</button>
</div>
</form>
)}
<div className="magic-notes-list">
{loading ? (
{loadStatus === 'loading' ? (
<p className="magic-notes-muted"></p>
) : visibleNotes.length === 0 ? (
<p className="magic-notes-muted">
{search ? '没有符合条件的笔记' : '还没有笔记'}
</p>
<>
<p className="magic-notes-muted">
{search.trim()
? '没有符合条件的笔记'
: '还没有笔记'}
</p>
{search.trim() && (
<button
className="secondary-button"
onClick={() => setSearch('')}
type="button"
>
</button>
)}
</>
) : (
visibleNotes.map((note) => (
<button
@@ -785,7 +865,6 @@ export function MagicNotesWorkspace({
type="button"
onClick={() => {
setValidation(undefined)
setSelectedNoteId(note.id)
setDeletingNote(false)
setEditingEntry(undefined)
editingContentRef.current = undefined
@@ -895,22 +974,38 @@ export function MagicNotesWorkspace({
disabled={busy === 'create-todo'}
type="submit"
>
</button>
</div>
</form>
)}
<div className="magic-notes-list">
{loading ? (
{loadStatus === 'loading' ? (
<p className="magic-notes-muted"></p>
) : visibleTodos.length === 0 ? (
<p className="magic-notes-muted">
{todos.length === 0
? '还没有待办'
: search || todoFilter !== 'all'
? '没有符合条件的待办'
: '没有待办'}
</p>
<>
<p className="magic-notes-muted">
{todos.length === 0
? '还没有待办'
: search.trim() || todoFilter !== 'all'
? '没有符合条件的待办'
: '还没有待办'}
</p>
{todos.length > 0 &&
(Boolean(search.trim()) ||
todoFilter !== 'all') && (
<button
className="secondary-button"
onClick={() => {
setSearch('')
setTodoFilter('all')
}}
type="button"
>
</button>
)}
</>
) : (
visibleTodos.map((todo) => (
<button
@@ -955,7 +1050,7 @@ export function MagicNotesWorkspace({
)}
</aside>
<main
<section
aria-label={libraryView === 'notes' ? '笔记记录' : '待办详情'}
className="magic-notes-stream-pane"
>
@@ -964,10 +1059,27 @@ export function MagicNotesWorkspace({
<EmptyState
description="从左侧选择笔记,或新建一篇笔记开始记录。"
icon={<FileText size={24} />}
title={loading ? '正在加载' : '还没有选择笔记'}
title={
loadStatus === 'loading' ? '正在加载' : '还没有选择笔记'
}
/>
) : (
<>
{detailLoadError && (
<div className="magic-note-delete-confirmation" role="alert">
<span>
{detailLoadError.message}
</span>
<button
className="secondary-button"
onClick={() => void loadDetail(detailLoadError.noteId)}
type="button"
>
</button>
</div>
)}
<header className="magic-note-detail-header">
<input
aria-describedby={
@@ -1278,7 +1390,9 @@ export function MagicNotesWorkspace({
<EmptyState
description="从左侧选择待办,或新建一个手动待办。"
icon={<ListTodo size={24} />}
title={loading ? '正在加载' : '还没有选择待办'}
title={
loadStatus === 'loading' ? '正在加载' : '还没有选择待办'
}
/>
) : (
<section className="magic-todo-detail">
@@ -1451,7 +1565,6 @@ export function MagicNotesWorkspace({
className="secondary-button"
onClick={() => {
setLibraryView('notes')
setSelectedNoteId(selectedTodo.noteId!)
void loadDetail(selectedTodo.noteId!).then(() => {
requestAnimationFrame(() =>
document
@@ -1472,7 +1585,7 @@ export function MagicNotesWorkspace({
)}
</section>
)}
</main>
</section>
<aside
aria-label="AI 评论"
@@ -1533,6 +1646,8 @@ export function MagicNotesWorkspace({
)}
</aside>
</div>
</>
)}
</div>
)
}
@@ -0,0 +1,106 @@
import { Sparkles } from 'lucide-react'
import { useEffect, useState } from 'react'
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
type PlatformFeaturesSettingsSectionProps = {
onMagicNotesEnabledChange: (enabled: boolean) => void
}
export function PlatformFeaturesSettingsSection({
onMagicNotesEnabledChange
}: PlatformFeaturesSettingsSectionProps): React.JSX.Element {
const [settings, setSettings] = useState<ApplicationSettings>()
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | undefined>(() =>
window.goodbuddy.updates
? undefined
: '当前版本未提供应用设置服务'
)
useEffect(() => {
const updates = window.goodbuddy.updates
let active = true
if (!updates) {
return () => {
active = false
}
}
void updates
.getSettings()
.then((nextSettings) => {
if (active) {
setSettings(nextSettings)
}
})
.catch(() => {
if (active) {
setError('读取平台功能设置失败')
}
})
return () => {
active = false
}
}, [])
const changeMagicNotes = async (enabled: boolean): Promise<void> => {
const updates = window.goodbuddy.updates
if (!updates || !settings) {
return
}
setSaving(true)
setError(undefined)
try {
const nextSettings = await updates.updateSettings({
magicNotesEnabled: enabled
})
setSettings(nextSettings)
onMagicNotesEnabledChange(nextSettings.magicNotesEnabled)
} catch {
setError('保存魔法笔记设置失败,请重试')
} finally {
setSaving(false)
}
}
return (
<section
aria-labelledby="platform-features-heading"
className="settings-section"
>
<div className="settings-section__title">
<Sparkles aria-hidden="true" size={17} />
<div>
<strong id="platform-features-heading"></strong>
<small> GoodBuddy </small>
</div>
</div>
<article className="capability-card">
<div className="capability-card__header">
<div>
<strong></strong>
<small>
使 AI
</small>
</div>
</div>
<label className="toggle-row">
<input
checked={settings?.magicNotesEnabled ?? false}
disabled={!settings || saving}
onChange={(event) =>
void changeMagicNotes(event.target.checked)
}
role="switch"
type="checkbox"
/>
<span></span>
</label>
</article>
{error && (
<p className="settings-warning" role="alert">
{error}
</p>
)}
</section>
)
}
+113
View File
@@ -325,10 +325,30 @@ const onEmbeddingStatus = vi.fn(
}
}
)
let applicationSettings = {
checkUpdatesOnStartup: true,
magicNotesEnabled: false
}
const getApplicationSettings = vi.fn(async () => ({
...applicationSettings
}))
const updateApplicationSettings = vi.fn<
NonNullable<DesktopApi['updates']>['updateSettings']
>(async (input) => {
applicationSettings = {
...applicationSettings,
...input
}
return { ...applicationSettings }
})
describe('SettingsPanel runtime files', () => {
beforeEach(() => {
vi.clearAllMocks()
applicationSettings = {
checkUpdatesOnStartup: true,
magicNotesEnabled: false
}
embeddingStatusListeners.splice(0)
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
@@ -377,6 +397,13 @@ describe('SettingsPanel runtime files', () => {
rebuild: rebuildEmbeddingIndex,
cancel: cancelEmbeddingIndex,
onStatus: onEmbeddingStatus
},
updates: {
getSettings: getApplicationSettings,
updateSettings: updateApplicationSettings,
check: vi.fn(),
openReleasePage: vi.fn(),
onResult: vi.fn(() => () => {})
}
} as unknown as DesktopApi
})
@@ -408,6 +435,35 @@ describe('SettingsPanel runtime files', () => {
expect(onAppearanceThemeChange).toHaveBeenCalledWith('dark')
})
it('toggles the Magic Notes platform entry setting', async () => {
const onMagicNotesEnabledChange = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const toggle = await screen.findByRole('switch', {
name: '显示魔法笔记入口'
})
expect(toggle).not.toBeChecked()
expect(screen.getByText(/默认关闭/)).toBeInTheDocument()
fireEvent.click(toggle)
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
magicNotesEnabled: true
})
)
expect(onMagicNotesEnabledChange).toHaveBeenCalledWith(true)
})
it('keeps page navigation beside an independently scrollable panel', () => {
render(
<SettingsPanel
@@ -429,6 +485,63 @@ describe('SettingsPanel runtime files', () => {
expect(content).toHaveClass('settings-panel__content')
})
it('uses one first-level heading for the settings page', () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
presentation="page"
/>
)
expect(
screen.getAllByRole('heading', { level: 1 })
).toHaveLength(1)
expect(
screen.getByRole('heading', { level: 1, name: '设置中心' })
).toBeInTheDocument()
})
it('keeps local progress but does not duplicate clear-data success', async () => {
let finishClear: (() => void) | undefined
const onClearLocalData = vi.fn(
() =>
new Promise<void>((resolve) => {
finishClear = resolve
})
)
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={onClearLocalData}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '安全与数据' }))
fireEvent.click(
screen.getByRole('button', { name: '清除本地数据' })
)
fireEvent.click(
screen.getByRole('button', { name: '清除本地数据' })
)
expect(
screen.getByRole('button', { name: '正在清除…' })
).toBeDisabled()
await act(async () => finishClear?.())
await waitFor(() =>
expect(
screen.queryByText('本地数据已清除')
).not.toBeInTheDocument()
)
})
it('supports keyboard navigation between settings tabs', () => {
render(
<SettingsPanel
+58 -20
View File
@@ -34,9 +34,10 @@ import { SkillsSettingsSection } from './SkillsSettingsSection'
import { HeartbeatSettings } from './HeartbeatSettings'
import { ChannelSettingsSection } from './ChannelSettingsSection'
import { UpdateSettingsSection } from './UpdateSettingsSection'
import { PlatformFeaturesSettingsSection } from './PlatformFeaturesSettingsSection'
import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
import { SegmentedControl } from './WorkspacePrimitives'
import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
import type { AppearanceTheme } from './theme'
import type { AppNotificationInput } from './notifications'
import type {
@@ -46,6 +47,7 @@ import type {
type SettingsTab =
| 'appearance'
| 'platform-features'
| 'model'
| 'runtime'
| 'security'
@@ -64,6 +66,7 @@ type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & {
const settingsTabs: readonly SettingsTab[] = [
'appearance',
'platform-features',
'model',
'runtime',
'security',
@@ -93,6 +96,7 @@ type SettingsPanelProps = {
onRunHeartbeat: (heartbeatId: string) => Promise<void>
appearanceTheme?: AppearanceTheme
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
onMagicNotesEnabledChange?: (enabled: boolean) => void
}
const credentialLabels: Record<
@@ -255,7 +259,8 @@ export function SettingsPanel({
onRunHeartbeat,
onExpertsChanged = () => {},
appearanceTheme = 'system',
onAppearanceThemeChange = () => {}
onAppearanceThemeChange = () => {},
onMagicNotesEnabledChange = () => {}
}: SettingsPanelProps): React.JSX.Element | null {
const [settings, setSettings] = useState<RuntimeSettings>()
const [provider, setProvider] =
@@ -328,6 +333,7 @@ export function SettingsPanel({
const [saved, setSaved] = useState(false)
const [connectionResult, setConnectionResult] = useState<string>()
const [confirmingClear, setConfirmingClear] = useState(false)
const [clearingLocalData, setClearingLocalData] = useState(false)
const [detection, setDetection] = useState<AgentRuntimeDetection>()
const [detecting, setDetecting] = useState(false)
const [activeTab, setActiveTab] = useState<SettingsTab>('runtime')
@@ -382,6 +388,7 @@ export function SettingsPanel({
setSaved(false)
setConnectionResult(undefined)
setConfirmingClear(false)
setClearingLocalData(false)
setModelType('llm')
setAgentRuntimeType('opencode')
setSettings(value)
@@ -951,23 +958,26 @@ export function SettingsPanel({
className="settings-panel"
role={presentation === 'modal' ? 'dialog' : 'region'}
>
<header className="settings-panel__header">
<div className="settings-panel__header">
<div>
<p className="eyebrow">SETTINGS</p>
<h2 id="settings-title"></h2>
<p className="settings-panel__description">
Agent Runtime
</p>
<PageHeader
actions={
<button
aria-label="关闭设置"
className="icon-button"
onClick={close}
type="button"
>
<X aria-hidden="true" size={19} />
</button>
}
description="管理模型连接、Agent Runtime、自动化、扩展能力和本地数据。"
eyebrow="SETTINGS"
headingId="settings-title"
title="设置中心"
/>
</div>
<button
aria-label="关闭设置"
className="icon-button"
onClick={close}
type="button"
>
<X size={19} />
</button>
</header>
</div>
<div className="settings-panel__body">
<nav
@@ -992,6 +1002,22 @@ export function SettingsPanel({
<strong></strong>
<small></small>
</button>
<button
aria-controls="settings-panel-platform-features"
aria-label="平台功能"
aria-selected={activeTab === 'platform-features'}
id="settings-tab-platform-features"
onClick={() => setActiveTab('platform-features')}
onKeyDown={(event) =>
handleTabKeyDown(event, 'platform-features')
}
role="tab"
tabIndex={activeTab === 'platform-features' ? 0 : -1}
type="button"
>
<strong></strong>
<small></small>
</button>
<button
aria-controls="settings-panel-model"
aria-label="模型连接"
@@ -1189,6 +1215,11 @@ export function SettingsPanel({
</div>
</div>
)}
{activeTab === 'platform-features' && (
<PlatformFeaturesSettingsSection
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
/>
)}
{activeTab === 'runtime' && (
<>
{settings?.warning && (
@@ -2165,6 +2196,7 @@ export function SettingsPanel({
<div className="danger-actions">
<button
className="secondary-button"
disabled={clearingLocalData}
onClick={() => setConfirmingClear(false)}
type="button"
>
@@ -2172,12 +2204,15 @@ export function SettingsPanel({
</button>
<button
className="danger-button"
disabled={clearingLocalData}
onClick={() => {
setClearingLocalData(true)
setError(undefined)
setSaved(false)
setConnectionResult(undefined)
void onClearLocalData()
.then(() => {
setConfirmingClear(false)
setConnectionResult('本地数据已清除')
setSaved(true)
})
.catch((reason: unknown) => {
setError(
@@ -2187,10 +2222,13 @@ export function SettingsPanel({
)
)
})
.finally(() => setClearingLocalData(false))
}}
type="button"
>
{clearingLocalData
? '正在清除…'
: '清除本地数据'}
</button>
</div>
) : (
@@ -18,7 +18,11 @@ describe('UpdateSettingsSection', () => {
it('checks the official release manifest and updates the startup preference', async () => {
const updateSettings = vi.fn<
NonNullable<DesktopApi['updates']>['updateSettings']
>(async (input) => input)
>(async (input) => ({
checkUpdatesOnStartup:
input.checkUpdatesOnStartup ?? true,
magicNotesEnabled: input.magicNotesEnabled ?? true
}))
const check = vi.fn<
NonNullable<DesktopApi['updates']>['check']
>(async () => ({
@@ -54,7 +58,8 @@ describe('UpdateSettingsSection', () => {
},
updates: {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true
checkUpdatesOnStartup: true,
magicNotesEnabled: true
})),
updateSettings,
check,
@@ -101,9 +106,13 @@ describe('UpdateSettingsSection', () => {
},
updates: {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true
checkUpdatesOnStartup: true,
magicNotesEnabled: true
})),
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true,
magicNotesEnabled: true
})),
updateSettings: vi.fn(async (input) => input),
check: vi.fn(async () => {
throw new Error(
"Error invoking remote method 'application:update:check': TypeError: fetch failed"
+102 -4
View File
@@ -98,6 +98,34 @@ describe('WorkspacePrimitives', () => {
)
})
it('keeps shared controls keyboard and pointer accessible at narrow widths', () => {
expect(stylesheet).toMatch(
/\.window-control\s*>\s*svg,\s*\.icon-button\s*>\s*svg\s*\{[^}]*pointer-events:\s*none;/u
)
expect(stylesheet).toMatch(
/button:focus-visible,\s*input:focus-visible,\s*select:focus-visible,\s*textarea:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--accent\);/u
)
expect(stylesheet).toMatch(
/\.page-tabs\s*\{[^}]*overflow-x:\s*auto;[^}]*flex-wrap:\s*nowrap;/u
)
expect(stylesheet).not.toContain(
'.heartbeat-center > .page-tabs {\n display: grid;'
)
})
it('uses the design-system page gutters at each window width', () => {
expect(stylesheet).toMatch(/--page-gutter:\s*32px;/u)
expect(stylesheet).toMatch(
/@media \(max-width: 1199px\)\s*\{\s*:root\s*\{\s*--page-gutter:\s*24px;/u
)
expect(stylesheet).toMatch(
/@media \(max-width: 959px\)\s*\{\s*:root\s*\{\s*--page-gutter:\s*16px;/u
)
expect(stylesheet).toMatch(
/\.page-shell--master-detail\s*\{[^}]*padding:\s*var\(--page-gutter\);/u
)
})
it('renders a consistent page shell and scoped header', () => {
render(
<PageShell variant="dashboard">
@@ -188,8 +216,10 @@ describe('WorkspacePrimitives', () => {
const onCancel = vi.fn()
const { rerender } = render(
<DestructiveConfirmActions
confirmAriaLabel="永久删除对象"
confirmLabel="确认删除"
confirming={false}
icon={<span data-testid="delete-icon">×</span>}
onCancel={onCancel}
onConfirm={onConfirm}
onRequestConfirm={onRequestConfirm}
@@ -197,11 +227,16 @@ describe('WorkspacePrimitives', () => {
/>
)
expect(screen.getByTestId('delete-icon').parentElement).toHaveAttribute(
'aria-hidden',
'true'
)
fireEvent.click(screen.getByRole('button', { name: '删除' }))
expect(onRequestConfirm).toHaveBeenCalledOnce()
rerender(
<DestructiveConfirmActions
confirmAriaLabel="永久删除对象"
confirmLabel="确认删除"
confirming
message="删除此对象?"
@@ -211,11 +246,74 @@ describe('WorkspacePrimitives', () => {
triggerLabel="删除"
/>
)
expect(screen.getByRole('button', { name: '取消' })).toHaveFocus()
fireEvent.click(screen.getByRole('button', { name: '确认删除' }))
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(onConfirm).toHaveBeenCalledOnce()
const dialog = screen.getByRole('alertdialog', {
name: '永久删除对象'
})
const cancelButton = screen.getByRole('button', { name: '取消' })
const confirmButton = screen.getByRole('button', {
name: '永久删除对象'
})
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(dialog).toHaveAccessibleDescription('删除此对象?')
expect(cancelButton).toHaveFocus()
fireEvent.keyDown(cancelButton, { key: 'Tab', shiftKey: true })
expect(confirmButton).toHaveFocus()
fireEvent.keyDown(confirmButton, { key: 'Tab' })
expect(cancelButton).toHaveFocus()
fireEvent.keyDown(cancelButton, { key: 'Escape' })
expect(onCancel).toHaveBeenCalledOnce()
fireEvent.click(confirmButton)
expect(onConfirm).toHaveBeenCalledOnce()
rerender(
<DestructiveConfirmActions
confirmAriaLabel="永久删除对象"
confirmLabel="确认删除"
confirming={false}
onCancel={onCancel}
onConfirm={onConfirm}
onRequestConfirm={onRequestConfirm}
triggerLabel="删除"
/>
)
expect(screen.getByRole('button', { name: '删除' })).toHaveFocus()
})
it('keeps focus on the dialog while destructive actions are disabled', () => {
const onCancel = vi.fn()
const { rerender } = render(
<DestructiveConfirmActions
confirmLabel="确认删除"
confirming={false}
onCancel={onCancel}
onConfirm={vi.fn()}
onRequestConfirm={vi.fn()}
triggerLabel="删除"
/>
)
rerender(
<DestructiveConfirmActions
confirmLabel="正在删除"
confirming
disabled
onCancel={onCancel}
onConfirm={vi.fn()}
onRequestConfirm={vi.fn()}
triggerLabel="删除"
/>
)
const dialog = screen.getByRole('alertdialog', {
name: '正在删除'
})
expect(dialog).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Tab' })
expect(dialog).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(onCancel).not.toHaveBeenCalled()
})
it.each([
+75 -6
View File
@@ -6,6 +6,7 @@ import {
} from 'lucide-react'
import {
useEffect,
useId,
useRef,
type KeyboardEvent,
type ReactNode
@@ -326,30 +327,97 @@ export function DestructiveConfirmActions({
triggerLabel: string
}): React.JSX.Element {
const cancelRef = useRef<HTMLButtonElement>(null)
const confirmRef = useRef<HTMLButtonElement>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const wasConfirming = useRef(confirming)
const shouldRestoreTrigger = useRef(false)
const titleId = useId()
const descriptionId = useId()
useEffect(() => {
if (confirming && !wasConfirming.current) {
cancelRef.current?.focus()
if (disabled) {
dialogRef.current?.focus()
} else {
cancelRef.current?.focus()
}
} else if (confirming && disabled) {
dialogRef.current?.focus()
} else if (
confirming &&
!disabled &&
document.activeElement === dialogRef.current
) {
cancelRef.current?.focus()
} else if (!confirming && wasConfirming.current) {
shouldRestoreTrigger.current = true
}
if (
!confirming &&
wasConfirming.current &&
shouldRestoreTrigger.current &&
!triggerRef.current?.disabled
) {
triggerRef.current?.focus()
shouldRestoreTrigger.current = false
}
wasConfirming.current = confirming
}, [confirming])
}, [confirming, disabled])
return confirming ? (
<div
aria-label={confirmAriaLabel}
aria-describedby={descriptionId}
aria-labelledby={titleId}
aria-live="assertive"
aria-modal="true"
className="danger-confirm"
onKeyDown={(event) => {
if (event.key === 'Escape' && !disabled) {
event.preventDefault()
onCancel()
return
}
if (event.key !== 'Tab') {
return
}
if (disabled) {
event.preventDefault()
dialogRef.current?.focus()
return
}
const cancelButton = cancelRef.current
const confirmButton = confirmRef.current
if (
!cancelButton ||
!confirmButton ||
cancelButton.disabled ||
confirmButton.disabled
) {
return
}
event.preventDefault()
const nextButton = event.shiftKey
? document.activeElement === cancelButton
? confirmButton
: cancelButton
: document.activeElement === confirmButton
? cancelButton
: confirmButton
nextButton.focus()
}}
ref={dialogRef}
role="alertdialog"
tabIndex={-1}
>
{message && <span>{message}</span>}
<span className="sr-only" id={titleId}>
{confirmAriaLabel ?? confirmLabel}
</span>
<span className={message ? undefined : 'sr-only'} id={descriptionId}>
{message ?? `确认${triggerLabel}操作。`}
</span>
<button
aria-label={cancelAriaLabel}
className="secondary-button"
@@ -365,6 +433,7 @@ export function DestructiveConfirmActions({
className="danger-button"
disabled={disabled}
onClick={onConfirm}
ref={confirmRef}
type="button"
>
{confirmLabel}
@@ -379,7 +448,7 @@ export function DestructiveConfirmActions({
ref={triggerRef}
type="button"
>
{icon}
{icon && <span aria-hidden="true">{icon}</span>}
{triggerLabel}
</button>
)
+58
View File
@@ -15,6 +15,7 @@ function makeRecord(index: number): ActivityRecord {
id: `activity-${index}`,
conversationId: 'conversation-1',
requestId: 'request-1',
scope: { kind: 'global' },
kind: 'tool',
title: `工具调用 ${index}`,
detail: '读取文件',
@@ -55,6 +56,41 @@ describe('activity-store', () => {
expect(loadActivityRecords()).toEqual([validRecord])
})
it('loads legacy records with an explicit unavailable scope', () => {
const { scope, ...legacyRecord } = makeRecord(1)
void scope
localStorage.setItem(
ACTIVITY_STORAGE_KEY,
JSON.stringify([legacyRecord])
)
expect(loadActivityRecords()).toEqual([
expect.objectContaining({
id: legacyRecord.id,
scope: { kind: 'unavailable' }
})
])
})
it('rejects malformed project snapshots from untrusted storage', () => {
const record = makeRecord(1)
localStorage.setItem(
ACTIVITY_STORAGE_KEY,
JSON.stringify([
{
...record,
scope: {
kind: 'project',
projectId: 'project-1',
projectName: 'x'.repeat(121)
}
}
])
)
expect(loadActivityRecords()).toEqual([])
})
it('persists no more than the record limit', () => {
const records = Array.from(
{ length: MAX_ACTIVITY_RECORDS + 1 },
@@ -102,10 +138,32 @@ describe('activity-store', () => {
.toMatchObject({
id: first.id,
createdAt: first.createdAt,
scope: first.scope,
status: 'failed'
})
})
it('keeps the original scope snapshot when a call is updated', () => {
const first: ActivityRecord = {
...makeRecord(1),
callId: 'call-1',
scope: {
kind: 'project',
projectId: 'project-1',
projectName: '原项目名称'
},
status: 'running'
}
expect(
upsertActivityRecord([first], {
...first,
scope: { kind: 'global' },
status: 'completed'
})[0]?.scope
).toEqual(first.scope)
})
it('persists and upserts Subagent state transitions', () => {
const queued: ActivityRecord = {
...makeRecord(1),
+82 -22
View File
@@ -7,6 +7,7 @@ export const MAX_ACTIVITY_DETAIL_LENGTH = 4_000
const MAX_STORED_JSON_LENGTH = 2_000_000
const MAX_ID_LENGTH = 256
const MAX_TITLE_LENGTH = 240
const MAX_PROJECT_NAME_LENGTH = 120
const activityKinds = [
'request',
@@ -30,6 +31,10 @@ export type ActivityRecord = {
conversationId: string
requestId: string
callId?: string
scope:
| { kind: 'global' }
| { kind: 'project'; projectId: string; projectName: string }
| { kind: 'unavailable' }
kind: (typeof activityKinds)[number]
title: string
detail: string
@@ -57,30 +62,82 @@ function isBoundedString(
)
}
function isActivityRecord(value: unknown): value is ActivityRecord {
function parseActivityScope(
value: unknown
): ActivityRecord['scope'] | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false
return undefined
}
const candidate = value as Record<string, unknown>
if (candidate.kind === 'global') {
return { kind: 'global' }
}
if (candidate.kind === 'unavailable') {
return { kind: 'unavailable' }
}
if (
candidate.kind === 'project' &&
isBoundedString(candidate.projectId, MAX_ID_LENGTH) &&
isBoundedString(candidate.projectName, MAX_PROJECT_NAME_LENGTH)
) {
return {
kind: 'project',
projectId: candidate.projectId,
projectName: candidate.projectName
}
}
return undefined
}
function parseActivityRecord(value: unknown): ActivityRecord | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return undefined
}
const candidate = value as Record<string, unknown>
return (
isBoundedString(candidate.id, MAX_ID_LENGTH) &&
isBoundedString(candidate.conversationId, MAX_ID_LENGTH) &&
isBoundedString(candidate.requestId, MAX_ID_LENGTH) &&
(candidate.callId === undefined ||
isBoundedString(candidate.callId, MAX_ID_LENGTH)) &&
activityKinds.some((kind) => kind === candidate.kind) &&
isBoundedString(candidate.title, MAX_TITLE_LENGTH) &&
isBoundedString(
if (
!isBoundedString(candidate.id, MAX_ID_LENGTH) ||
!isBoundedString(candidate.conversationId, MAX_ID_LENGTH) ||
!isBoundedString(candidate.requestId, MAX_ID_LENGTH) ||
(candidate.callId !== undefined &&
!isBoundedString(candidate.callId, MAX_ID_LENGTH)) ||
!activityKinds.some((kind) => kind === candidate.kind) ||
!isBoundedString(candidate.title, MAX_TITLE_LENGTH) ||
!isBoundedString(
candidate.detail,
MAX_ACTIVITY_DETAIL_LENGTH,
true
) &&
activityStatuses.some((status) => status === candidate.status) &&
typeof candidate.createdAt === 'number' &&
Number.isFinite(candidate.createdAt) &&
candidate.createdAt >= 0
)
) ||
!activityStatuses.some((status) => status === candidate.status) ||
typeof candidate.createdAt !== 'number' ||
!Number.isFinite(candidate.createdAt) ||
candidate.createdAt < 0
) {
return undefined
}
const scope =
candidate.scope === undefined
? { kind: 'unavailable' as const }
: parseActivityScope(candidate.scope)
if (!scope) {
return undefined
}
return {
id: candidate.id,
conversationId: candidate.conversationId,
requestId: candidate.requestId,
...(candidate.callId === undefined
? {}
: { callId: candidate.callId }),
scope,
kind: candidate.kind as ActivityRecord['kind'],
title: candidate.title,
detail: candidate.detail,
status: candidate.status as ActivityRecord['status'],
createdAt: candidate.createdAt
}
}
export function upsertActivityRecord(
@@ -109,7 +166,8 @@ export function upsertActivityRecord(
{
...incoming,
id: existing.id,
createdAt: existing.createdAt
createdAt: existing.createdAt,
scope: existing.scope
},
...records.filter((_, index) => index !== existingIndex)
].slice(0, MAX_ACTIVITY_RECORDS)
@@ -200,8 +258,9 @@ export function loadActivityRecords(
const records: ActivityRecord[] = []
for (const candidate of parsed) {
if (isActivityRecord(candidate)) {
records.push(candidate)
const record = parseActivityRecord(candidate)
if (record) {
records.push(record)
}
if (records.length === MAX_ACTIVITY_RECORDS) {
break
@@ -227,8 +286,9 @@ export function saveActivityRecords(
const safeRecords: ActivityRecord[] = []
for (const record of records) {
if (isActivityRecord(record)) {
safeRecords.push(record)
const safeRecord = parseActivityRecord(record)
if (safeRecord) {
safeRecords.push(safeRecord)
}
if (safeRecords.length === MAX_ACTIVITY_RECORDS) {
break
+205 -49
View File
@@ -34,7 +34,7 @@
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--page-gutter: clamp(16px, 2vw, 28px);
--page-gutter: 32px;
--content-reading: 820px;
--content-standard: 960px;
--content-dashboard: 1040px;
@@ -60,6 +60,18 @@
text-rendering: optimizeLegibility;
}
@media (max-width: 1199px) {
:root {
--page-gutter: 24px;
}
}
@media (max-width: 959px) {
:root {
--page-gutter: 16px;
}
}
.magic-notes-page {
display: flex;
min-width: 0;
@@ -785,8 +797,9 @@ button {
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid #1677ff;
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@@ -819,6 +832,10 @@ textarea:focus-visible {
padding-left: 0;
}
.sidebar-backdrop {
display: none;
}
.brand {
display: flex;
align-items: center;
@@ -1330,6 +1347,11 @@ textarea:focus-visible {
outline-offset: -2px;
}
.window-control > svg,
.icon-button > svg {
pointer-events: none;
}
.icon-button {
display: grid;
width: 34px;
@@ -1337,18 +1359,18 @@ textarea:focus-visible {
place-items: center;
border-radius: 9px;
background: transparent;
color: #595959;
color: var(--text-secondary);
cursor: pointer;
}
.icon-button:hover {
background: #e6f4ff;
color: #1677ff;
background: var(--accent-subtle);
color: var(--accent);
}
.icon-button--active {
background: #e6f4ff;
color: #1677ff;
background: var(--accent-subtle);
color: var(--accent);
}
.assistant-sidebar {
@@ -3858,6 +3880,7 @@ textarea:focus-visible {
}
.settings-panel__header {
position: relative;
display: flex;
align-items: center;
padding: 22px 24px 18px;
@@ -3868,6 +3891,25 @@ textarea:focus-visible {
flex: 1;
}
.settings-panel__header .page-header {
position: relative;
width: 100%;
}
.settings-panel__header .page-header__content {
padding-right: 44px;
}
.settings-panel__header
.page-header:not(.page-header--compact)
.page-header__actions {
position: absolute;
top: 0;
right: 0;
width: auto;
justify-content: flex-end;
}
.settings-panel__header .eyebrow {
margin-bottom: 5px;
}
@@ -5050,17 +5092,17 @@ details.settings-section > :not(summary) + :not(summary) {
.capability-card {
display: flex;
flex-direction: column;
padding: 12px;
border: 1px solid #e8e8e8;
border-radius: 8px;
background: #fff;
gap: 8px;
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
gap: var(--space-2);
}
.capability-card__header {
display: flex;
align-items: flex-start;
gap: 12px;
gap: var(--space-3);
}
.capability-card__header > div:first-child {
@@ -5071,30 +5113,30 @@ details.settings-section > :not(summary) + :not(summary) {
}
.capability-card strong {
color: #1f1f1f;
font-size: 11px;
color: var(--text-primary);
font-size: var(--font-body);
}
.capability-card small,
.runtime-assignments small {
color: #8c8c8c;
font-size: 9px;
color: var(--text-muted);
font-size: var(--font-caption);
}
.capability-card p {
margin: 0;
color: #595959;
font-size: 10px;
color: var(--text-secondary);
font-size: var(--font-caption);
line-height: 1.55;
}
.capability-card code {
padding: 7px 8px;
border-radius: 6px;
padding: var(--space-2);
border-radius: var(--radius-control);
overflow: hidden;
background: #f5f5f5;
color: #595959;
font-size: 9px;
background: var(--surface-subtle);
color: var(--text-secondary);
font-size: var(--font-caption);
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -5111,9 +5153,69 @@ details.settings-section > :not(summary) + :not(summary) {
.capability-switch,
.runtime-assignments label {
color: #595959;
font-size: 9px;
gap: 4px;
color: var(--text-secondary);
font-size: var(--font-caption);
gap: var(--space-1);
}
.toggle-row {
display: flex;
min-height: 36px;
align-items: center;
justify-content: space-between;
padding-top: var(--space-2);
border-top: 1px solid var(--border-subtle);
color: var(--text-secondary);
cursor: pointer;
font-size: var(--font-body);
gap: var(--space-3);
}
.toggle-row input {
position: relative;
width: 38px;
height: 22px;
padding: 0;
border: 1px solid var(--border-control);
border-radius: 999px;
appearance: none;
background: var(--surface-muted);
cursor: pointer;
flex: 0 0 auto;
transition:
border-color var(--motion-fast) ease-out,
background-color var(--motion-fast) ease-out;
}
.toggle-row input::after {
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--surface-raised);
box-shadow: 0 1px 2px rgb(0 0 0 / 24%);
content: '';
left: 2px;
top: 2px;
transition: transform var(--motion-fast) ease-out;
}
.toggle-row input:checked {
border-color: var(--accent-solid);
background: var(--accent-solid);
}
.toggle-row input:checked::after {
transform: translateX(16px);
}
.toggle-row input:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.toggle-row:has(input:disabled) {
cursor: not-allowed;
}
.runtime-assignments {
@@ -5770,7 +5872,7 @@ details.settings-section > :not(summary) + :not(summary) {
}
.page-shell--master-detail {
padding: clamp(14px, 2vw, 28px);
padding: var(--page-gutter);
overflow-x: hidden;
container-type: inline-size;
}
@@ -5886,7 +5988,9 @@ details.settings-section > :not(summary) + :not(summary) {
.page-tabs {
display: flex;
flex-wrap: wrap;
max-width: 100%;
overflow-x: auto;
flex-wrap: nowrap;
gap: var(--space-1);
}
@@ -5900,9 +6004,11 @@ details.settings-section > :not(summary) + :not(summary) {
background: transparent;
color: var(--text-secondary);
cursor: pointer;
flex: 0 0 auto;
font-size: 11px;
font-weight: 650;
gap: 6px;
white-space: nowrap;
}
.page-tabs__tab:hover {
@@ -7876,15 +7982,6 @@ details.settings-section > :not(summary) + :not(summary) {
grid-template-columns: 1fr;
}
.heartbeat-center > .page-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
}
.heartbeat-center > .page-tabs .page-tabs__tab {
justify-content: center;
}
.heartbeat-center__trend-row {
grid-template-columns: 70px minmax(0, 1fr) 18px;
}
@@ -7999,6 +8096,32 @@ details.settings-section > :not(summary) + :not(summary) {
}
}
@media (max-width: 899px) {
.sidebar {
position: absolute;
z-index: 40;
top: 0;
bottom: 0;
left: 0;
width: min(278px, calc(100vw - 52px));
box-shadow: 12px 0 30px rgb(0 0 0 / 14%);
}
.sidebar--closed {
width: 0;
box-shadow: none;
}
.sidebar-backdrop {
position: absolute;
z-index: 35;
display: block;
border: 0;
background: var(--overlay-backdrop);
inset: 0;
}
}
@media (max-width: 719px) {
.assistant-sidebar__resize-handle {
display: none;
@@ -8007,7 +8130,49 @@ details.settings-section > :not(summary) + :not(summary) {
@media (max-width: 720px) {
.workspace-panel-scroll {
padding: 20px;
padding: var(--page-gutter);
}
.settings-page .settings-panel__body {
display: flex;
padding: var(--space-4);
overflow: hidden;
flex-direction: column;
gap: var(--space-3);
}
.settings-page .settings-tabs {
display: flex;
width: 100%;
min-height: auto;
padding: var(--space-1);
overflow-x: auto;
overflow-y: hidden;
flex: 0 0 auto;
flex-direction: row;
scrollbar-gutter: auto;
scrollbar-color: var(--border-control) transparent;
scrollbar-width: thin;
}
.settings-page .settings-tabs button {
min-width: max-content;
min-height: 40px;
padding: var(--space-2) var(--space-3);
align-items: center;
text-align: center;
}
.settings-page .settings-tabs button small {
display: none;
}
.settings-page .settings-panel__content {
width: 100%;
min-height: 0;
padding-right: 0;
overflow-y: auto;
flex: 1;
}
.agent-runtime-navigation {
@@ -8072,7 +8237,7 @@ details.settings-section > :not(summary) + :not(summary) {
@media (max-width: 520px) {
.workspace-panel-scroll {
padding: 16px;
padding: var(--page-gutter);
}
.model-connection-add {
@@ -8094,15 +8259,6 @@ details.settings-section > :not(summary) + :not(summary) {
grid-template-columns: 1fr;
}
.heartbeat-center > .page-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
}
.heartbeat-center > .page-tabs .page-tabs__tab {
justify-content: center;
}
.heartbeat-center__trend-row {
grid-template-columns: 70px minmax(0, 1fr) 18px;
}