feat: improve application responsiveness

This commit is contained in:
mesalogo
2026-08-15 18:06:02 +08:00
parent 1329251b5a
commit 79e2511f6f
18 changed files with 3268 additions and 355 deletions
+705 -46
View File
@@ -20,6 +20,26 @@ const speechRecognitionMocks = vi.hoisted(() => ({
startPcmRecording: vi.fn()
}))
const lazyRouteMocks = vi.hoisted(() => {
let pending: Promise<void> | undefined
let releasePending: (() => void) | undefined
return {
suspendKnowledgeRoute(): void {
pending = new Promise((resolve) => {
releasePending = resolve
})
},
releaseKnowledgeRoute(): void {
releasePending?.()
releasePending = undefined
pending = undefined
},
async waitForKnowledgeRoute(): Promise<void> {
await pending
}
}
})
vi.mock('./speech-recognition', async (importOriginal) => ({
...(await importOriginal<
typeof import('./speech-recognition')
@@ -27,6 +47,11 @@ vi.mock('./speech-recognition', async (importOriginal) => ({
startPcmRecording: speechRecognitionMocks.startPcmRecording
}))
vi.mock('./KnowledgeWorkspace', async (importOriginal) => {
await lazyRouteMocks.waitForKnowledgeRoute()
return importOriginal<typeof import('./KnowledgeWorkspace')>()
})
import App from './App'
import { loadActivityRecords } from './activity-store'
import { changeUiLocale } from './i18n'
@@ -41,6 +66,7 @@ let fileSelectionProgressListener:
| undefined
let newConversationListener: (() => void) | undefined
let maximizedChangedListener: ((maximized: boolean) => void) | undefined
let beforeQuitListener: (() => Promise<void>) | undefined
const removeMaximizedChangedListener = vi.fn()
const run = vi.fn<DesktopApi['agent']['run']>()
const modelProfileId = '00000000-0000-4000-8000-000000000001'
@@ -76,6 +102,12 @@ const api: DesktopApi = {
maximizedChangedListener = listener
return removeMaximizedChangedListener
}),
onBeforeQuit: vi.fn((listener) => {
beforeQuitListener = listener
return () => {
beforeQuitListener = undefined
}
}),
clearLocalData: vi.fn(async () => {}),
onNewConversation: vi.fn((listener) => {
newConversationListener = listener
@@ -289,6 +321,8 @@ const api: DesktopApi = {
conversations: {
list: vi.fn(async () => []),
replace: vi.fn(async () => {}),
saveLocal: vi.fn(async () => {}),
deleteLocal: vi.fn(async () => true),
onChanged: vi.fn(() => () => undefined)
},
workspace: {
@@ -664,7 +698,21 @@ describe('App', () => {
delete document.documentElement.dataset.theme
document.documentElement.style.colorScheme = ''
vi.clearAllMocks()
vi.mocked(api.conversations.list).mockReset().mockResolvedValue([])
vi.mocked(api.conversations.replace)
.mockReset()
.mockResolvedValue()
vi.mocked(api.conversations.saveLocal)
.mockReset()
.mockResolvedValue()
vi.mocked(api.conversations.deleteLocal)
.mockReset()
.mockResolvedValue(true)
vi.mocked(api.conversations.onChanged)
.mockReset()
.mockReturnValue(() => undefined)
newConversationListener = undefined
beforeQuitListener = undefined
browserListener = undefined
fileSelectionProgressListener = undefined
maximizedChangedListener = undefined
@@ -774,6 +822,122 @@ describe('App', () => {
expect(screen.getByText('GOODBUDDY 工作台')).toBeInTheDocument()
})
it('shows an accessible fallback while a lazy route loads', async () => {
lazyRouteMocks.suspendKnowledgeRoute()
try {
render(<App />)
fireEvent.click(
await screen.findByRole('button', { name: '知识库' })
)
const loading = screen.getByRole('status', {
name: '正在加载页面…'
})
expect(loading).toHaveAttribute('aria-live', 'polite')
expect(loading).toHaveAttribute('aria-busy', 'true')
await act(async () => lazyRouteMocks.releaseKnowledgeRoute())
expect(
await screen.findByRole('heading', {
level: 1,
name: '知识库'
})
).toBeInTheDocument()
expect(
screen.queryByRole('status', { name: '正在加载页面…' })
).not.toBeInTheDocument()
} finally {
lazyRouteMocks.releaseKnowledgeRoute()
}
})
it('preserves title, message, and project filtering with deferred search', async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000411',
projectId,
title: '标题里的 Alpha',
updatedAt: 1_775_000_000_003,
messages: [
{
id: '00000000-0000-4000-8000-000000000412',
role: 'assistant',
content: '普通正文',
createdAt: 1_775_000_000_003,
state: 'complete'
}
]
},
{
id: '00000000-0000-4000-8000-000000000413',
projectId,
title: '正文命中的会话',
updatedAt: 1_775_000_000_002,
messages: [
{
id: '00000000-0000-4000-8000-000000000414',
role: 'user',
content: '这里包含 Beta Needle',
createdAt: 1_775_000_000_002,
state: 'complete'
}
]
},
{
id: '00000000-0000-4000-8000-000000000415',
projectId: '00000000-0000-4000-8000-000000000999',
title: '其他项目里的 Alpha',
updatedAt: 1_775_000_000_001,
messages: [
{
id: '00000000-0000-4000-8000-000000000416',
role: 'user',
content: 'Beta Needle',
createdAt: 1_775_000_000_001,
state: 'complete'
}
]
}
])
const { container } = render(<App />)
const search = await screen.findByLabelText('搜索对话')
const conversationList =
container.querySelector<HTMLElement>('.conversation-list')
if (!conversationList) {
throw new Error('Missing conversation list')
}
expect(
await within(conversationList).findByText('标题里的 Alpha')
).toBeInTheDocument()
expect(
within(conversationList).queryByText('其他项目里的 Alpha')
).not.toBeInTheDocument()
fireEvent.change(search, { target: { value: 'beta needle' } })
expect(search).toHaveValue('beta needle')
await waitFor(() => {
expect(
within(conversationList).getByText('正文命中的会话')
).toBeInTheDocument()
expect(
within(conversationList).queryByText('标题里的 Alpha')
).not.toBeInTheDocument()
})
fireEvent.change(search, { target: { value: 'ALPHA' } })
await waitFor(() => {
expect(
within(conversationList).getByText('标题里的 Alpha')
).toBeInTheDocument()
expect(
within(conversationList).queryByText('正文命中的会话')
).not.toBeInTheDocument()
expect(
within(conversationList).queryByText('其他项目里的 Alpha')
).not.toBeInTheDocument()
})
})
it('keeps Settings open when the interface language changes', async () => {
api.updates = {
getSettings: vi.fn(async () => ({
@@ -1225,6 +1389,69 @@ describe('App', () => {
).not.toBeInTheDocument()
})
it('renders the latest 80 messages and preserves scroll when revealing earlier messages', async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000421',
projectId,
title: '超长会话',
updatedAt: 1_775_000_000_000,
messages: Array.from({ length: 161 }, (_, index) => ({
id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
role: index % 2 === 0 ? ('user' as const) : ('assistant' as const),
content: `历史消息 ${String(index).padStart(3, '0')}`,
createdAt: 1_775_000_000_000 + index,
state: 'complete' as const
}))
}
])
const { container } = render(<App />)
expect(await screen.findByText('历史消息 160')).toBeInTheDocument()
expect(screen.queryByText('历史消息 080')).not.toBeInTheDocument()
expect(container.querySelectorAll('.message')).toHaveLength(80)
const chat = container.querySelector<HTMLElement>('.chat')
if (!chat) {
throw new Error('Missing chat scroll container')
}
Object.defineProperties(chat, {
clientHeight: { configurable: true, value: 400 },
scrollHeight: {
configurable: true,
get: () => container.querySelectorAll('.message').length * 10
},
scrollTop: {
configurable: true,
writable: true,
value: 125
}
})
fireEvent.click(
screen.getByRole('button', {
name: '加载更早的消息(还剩 81 条)'
})
)
expect(container.querySelectorAll('.message')).toHaveLength(160)
expect(screen.getByText('历史消息 001')).toBeInTheDocument()
expect(screen.queryByText('历史消息 000')).not.toBeInTheDocument()
expect(chat.scrollTop).toBe(925)
fireEvent.click(
screen.getByRole('button', {
name: '加载更早的消息(还剩 1 条)'
})
)
expect(container.querySelectorAll('.message')).toHaveLength(161)
expect(screen.getByText('历史消息 000')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: //u })
).not.toBeInTheDocument()
expect(
screen.getByText('历史消息 000').closest('article')
).toHaveFocus()
})
it('keeps the reader position while a response continues below', async () => {
render(<App />)
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
@@ -1335,6 +1562,68 @@ describe('App', () => {
})
).not.toBeInTheDocument()
)
expect(api.conversations.deleteLocal).toHaveBeenCalledWith(
expect.any(String)
)
})
it('keeps a local conversation visible when deleting its persisted record fails', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000431'
const title = '删除失败时保留的本地会话'
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: conversationId,
projectId,
title,
updatedAt: 1_775_000_000_000,
messages: [
{
id: '00000000-0000-4000-8000-000000000432',
role: 'assistant',
content: '需要保留的消息',
createdAt: 1_775_000_000_000,
state: 'complete'
}
]
}
])
vi.mocked(api.conversations.deleteLocal).mockRejectedValueOnce(
new Error('delete failed')
)
render(<App />)
fireEvent.click(
await screen.findByLabelText(`更多会话操作 ${title}`)
)
fireEvent.click(
screen.getByRole('button', { name: `删除对话 ${title}` })
)
fireEvent.click(
screen.getByRole('button', {
name: `确认永久删除对话 ${title}`
})
)
await waitFor(() =>
expect(api.conversations.deleteLocal).toHaveBeenCalledWith(
conversationId
)
)
expect(
await screen.findByText('删除本地会话失败,已保留当前对话')
).toBeInTheDocument()
expect(screen.getByText('需要保留的消息')).toBeInTheDocument()
const dialog = screen.getByRole('alertdialog', {
name: `确认永久删除对话 ${title}`
})
expect(
dialog
).toBeInTheDocument()
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(
await screen.findByLabelText(`更多会话操作 ${title}`)
).toBeInTheDocument()
})
it('keeps a conversation when cancelling its active task fails', async () => {
@@ -1576,6 +1865,155 @@ describe('App', () => {
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge')
})
it('persists only the changed conversation and streamed assistant message', async () => {
const activeConversationId =
'00000000-0000-4000-8000-000000000441'
const activeMessageId =
'00000000-0000-4000-8000-000000000442'
const unrelatedConversationId =
'00000000-0000-4000-8000-000000000443'
const unrelatedMessageId =
'00000000-0000-4000-8000-000000000444'
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: activeConversationId,
projectId,
title: '增量持久化会话',
updatedAt: 1_775_000_000_002,
messages: [
{
id: activeMessageId,
role: 'assistant',
content: '原有消息',
createdAt: 1_775_000_000_002,
state: 'complete'
}
]
},
{
id: unrelatedConversationId,
projectId,
title: '无关会话',
updatedAt: 1_775_000_000_001,
messages: [
{
id: unrelatedMessageId,
role: 'assistant',
content: '不应重复保存',
createdAt: 1_775_000_000_001,
state: 'complete'
}
]
}
])
render(<App />)
expect(await screen.findByText('原有消息')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '只更新当前会话' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
await waitFor(() =>
expect(api.conversations.saveLocal).toHaveBeenCalled()
)
vi.mocked(api.conversations.saveLocal).mockClear()
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'text',
delta: '流式增量'
})
})
await waitFor(
() => {
const batch = vi
.mocked(api.conversations.saveLocal)
.mock.calls.at(-1)?.[0]
expect(batch).toHaveLength(1)
expect(batch?.[0]?.header.id).toBe(activeConversationId)
expect(batch?.[0]?.messages).toEqual([
expect.objectContaining({
role: 'assistant',
content: '流式增量',
state: 'streaming'
})
])
expect(
batch?.some(
(entry) => entry.header.id === unrelatedConversationId
)
).toBe(false)
expect(
batch?.flatMap((entry) => entry.messages).some(
(message) =>
message.id === activeMessageId ||
message.id === unrelatedMessageId ||
message.role === 'user'
)
).toBe(false)
},
{ timeout: 2_000 }
)
})
it('flushes pending local conversation changes before quit', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000445'
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: conversationId,
projectId,
title: '退出持久化会话',
updatedAt: 1_775_000_000_000,
messages: [
{
id: '00000000-0000-4000-8000-000000000446',
role: 'assistant',
content: '已有内容',
createdAt: 1_775_000_000_000,
state: 'complete'
}
]
}
])
render(<App />)
expect(await screen.findByText('已有内容')).toBeInTheDocument()
vi.mocked(api.conversations.saveLocal).mockClear()
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '退出前必须保存' }
})
fireEvent.click(screen.getByLabelText('发送'))
expect(
await screen.findByText('退出前必须保存')
).toBeInTheDocument()
if (!beforeQuitListener) {
throw new Error('Missing before-quit persistence listener')
}
await act(async () => beforeQuitListener?.())
expect(api.conversations.saveLocal).toHaveBeenCalledWith([
expect.objectContaining({
header: expect.objectContaining({ id: conversationId }),
messages: expect.arrayContaining([
expect.objectContaining({
role: 'user',
content: '退出前必须保存',
state: 'complete'
})
])
})
])
})
it('replaces the direct-model thinking status with real reasoning', async () => {
render(<App />)
@@ -1773,9 +2211,9 @@ describe('App', () => {
await waitFor(
() => {
const persistedMessages = vi
.mocked(api.conversations.replace)
.mock.calls.flatMap(([conversations]) =>
conversations.flatMap((conversation) => conversation.messages)
.mocked(api.conversations.saveLocal)
.mock.calls.flatMap(([batch]) =>
batch.flatMap((conversation) => conversation.messages)
)
const persisted = persistedMessages
.filter((message) => message.role === 'assistant')
@@ -1864,12 +2302,12 @@ describe('App', () => {
expect(screen.getByText('向量模型未配置')).toBeInTheDocument()
await waitFor(() => {
const snapshots = vi
.mocked(api.conversations.replace)
.mock.calls.at(-1)?.[0]
.mocked(api.conversations.saveLocal)
.mock.calls.flatMap(([batch]) => batch)
expect(
snapshots?.some(
(conversation) =>
conversation.knowledgeRetrievalMode === 'always'
conversation.header.knowledgeRetrievalMode === 'always'
)
).toBe(true)
})
@@ -2007,7 +2445,7 @@ describe('App', () => {
expect(anchorClick).toHaveBeenCalledOnce()
await waitFor(
() =>
expect(api.conversations.replace).toHaveBeenCalledWith(
expect(api.conversations.saveLocal).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
messages: expect.arrayContaining([
@@ -2844,14 +3282,14 @@ describe('App', () => {
setTimeout(resolve, 550)
})
)
expect(api.conversations.replace).not.toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
projectId: channelProject.id,
remote: undefined
})
])
)
const savedChannelHeaders = vi
.mocked(api.conversations.saveLocal)
.mock.calls.flatMap(([batch]) => batch.map((entry) => entry.header))
expect(
savedChannelHeaders.some(
(header) => header.projectId === channelProject.id
)
).toBe(false)
expect(screen.getAllByText('尚无远程会话').length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: '打开设置' }))
@@ -3447,10 +3885,238 @@ describe('App', () => {
)
})
it('persists metadata-only retrieval and Runtime changes without rewriting messages', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000451'
const existingMessageId =
'00000000-0000-4000-8000-000000000452'
const libraryId = '11111111-1111-4111-8111-111111111111'
const secondProfileId =
'00000000-0000-4000-8000-000000000453'
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
modelProfiles: [
...settings.modelProfiles,
{
id: secondProfileId,
name: '仅元数据模型',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
imageGenerationQuality: 'auto',
apiKeyConfigured: false,
credentialSource: 'none'
}
]
})
vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce({
libraries: [
{
id: libraryId,
name: '产品知识',
description: '',
storageMode: 'managed',
graphEnabled: false,
graphStrategy: 'rules',
sourceCount: 1,
documentCount: 1,
indexedDocumentCount: 1
}
],
sources: [],
documents: [],
graphNodes: [],
graphRelations: [],
evidence: []
})
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: conversationId,
projectId,
runtimeSelection: {
provider: 'model',
profileId: modelProfileId
},
knowledgeRetrievalMode: 'auto',
title: '元数据会话',
updatedAt: 1_775_000_000_000,
messages: [
{
id: existingMessageId,
role: 'assistant',
content: '现有消息不应重写',
createdAt: 1_775_000_000_000,
state: 'complete'
}
]
}
])
vi.mocked(api.agent.getStatus).mockImplementation(
async (selection) => ({
id: 'model',
label:
selection?.provider === 'model' &&
selection.profileId === secondProfileId
? 'qwen3'
: 'sonnet-5',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})
)
render(<App />)
const knowledgeScope = await screen.findByRole('button', {
name: '选择知识库,本次已启用 1 个'
})
fireEvent.click(knowledgeScope)
fireEvent.click(
within(
screen.getByRole('group', { name: '知识检索方式' })
).getByRole('button', { name: '每次先检索' })
)
await waitFor(
() =>
expect(api.conversations.saveLocal).toHaveBeenCalledWith([
{
header: expect.objectContaining({
id: conversationId,
knowledgeRetrievalMode: 'always'
}),
messages: []
}
]),
{ timeout: 2_000 }
)
vi.mocked(api.conversations.saveLocal).mockClear()
fireEvent.click(
screen.getByRole('button', { name: /sonnet-5/u })
)
fireEvent.click(
screen.getByRole('menuitemradio', {
name: /^.*qwen3$/u
})
)
await waitFor(
() =>
expect(api.conversations.saveLocal).toHaveBeenCalledWith([
{
header: expect.objectContaining({
id: conversationId,
runtimeSelection: {
provider: 'model',
profileId: secondProfileId
},
knowledgeRetrievalMode: 'always'
}),
messages: []
}
]),
{ timeout: 2_000 }
)
const savedMessages = vi
.mocked(api.conversations.saveLocal)
.mock.calls.flatMap(([batch]) =>
batch.flatMap((entry) => entry.messages)
)
expect(
savedMessages.some((message) => message.id === existingMessageId)
).toBe(false)
})
it('migrates legacy startup conversations with replace when SQLite has no local conversation', async () => {
const legacyConversation = {
id: '00000000-0000-4000-8000-000000000461',
title: '待迁移旧会话',
updatedAt: 1_775_000_000_000,
messages: [
{
id: '00000000-0000-4000-8000-000000000462',
role: 'assistant' as const,
content: '旧版浏览器存储消息',
createdAt: 1_775_000_000_000,
state: 'complete' as const
}
]
}
localStorage.setItem(
'goodbuddy.conversations.v1',
JSON.stringify([legacyConversation])
)
render(<App />)
expect(
await screen.findByText('旧版浏览器存储消息')
).toBeInTheDocument()
await waitFor(() =>
expect(api.conversations.replace).toHaveBeenCalledWith([
expect.objectContaining({
...legacyConversation,
projectId
})
])
)
expect(api.conversations.saveLocal).not.toHaveBeenCalled()
})
it('does not let remote rows displace legacy local conversations during migration', async () => {
const legacyConversations = [0, 1].map((index) => ({
id: `00000000-0000-4000-8000-${String(470 + index).padStart(12, '0')}`,
title: `待迁移本地会话 ${index}`,
updatedAt: 1_775_000_000_000 + index,
messages: [
{
id: `00000000-0000-4000-8001-${String(470 + index).padStart(12, '0')}`,
role: 'assistant' as const,
content: `待迁移消息 ${index}`,
createdAt: 1_775_000_000_000 + index,
state: 'complete' as const
}
]
}))
localStorage.setItem(
'goodbuddy.conversations.v1',
JSON.stringify(legacyConversations)
)
const remoteProjectId =
'00000000-0000-4000-8000-000000000499'
vi.mocked(api.conversations.list).mockResolvedValueOnce(
Array.from({ length: 100 }, (_, index) => ({
id: `00000000-0000-4000-8002-${String(index).padStart(12, '0')}`,
projectId: remoteProjectId,
remote: {
channel: 'weixin' as const,
accountDisplay: `远程联系人 ${index}`,
conversationType: 'direct' as const
},
title: `远程会话 ${index}`,
updatedAt: 1_775_000_100_000 + index,
messages: []
}))
)
render(<App />)
await waitFor(() =>
expect(api.conversations.saveLocal).toHaveBeenCalled()
)
expect(api.conversations.replace).not.toHaveBeenCalled()
const migrated =
vi.mocked(api.conversations.saveLocal).mock.calls[0]?.[0] ?? []
expect(migrated.map((conversation) => conversation.header.id)).toEqual(
expect.arrayContaining(
legacyConversations.map((conversation) => conversation.id)
)
)
})
it('preserves a legacy Auto conversation without silently persisting a replacement', async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000020',
projectId,
runtimeSelection: { provider: 'auto' },
title: '旧自动对话',
updatedAt: 1,
@@ -3468,18 +4134,10 @@ describe('App', () => {
render(<App />)
expect(
await screen.findByRole('button', { name: /.*sonnet-5/u })
await screen.findByRole('button', { name: /.*sonnet-5/u })
).toBeInTheDocument()
await waitFor(() =>
expect(api.conversations.replace).toHaveBeenLastCalledWith(
expect.arrayContaining([
expect.objectContaining({
id: '00000000-0000-4000-8000-000000000020',
runtimeSelection: { provider: 'auto' }
})
])
)
)
expect(api.conversations.replace).not.toHaveBeenCalled()
expect(api.conversations.saveLocal).not.toHaveBeenCalled()
})
it('keeps a removed model selection visible until the user replaces it', async () => {
@@ -3488,6 +4146,7 @@ describe('App', () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000022',
projectId,
runtimeSelection: {
provider: 'model',
profileId: removedProfileId
@@ -3507,19 +4166,14 @@ describe('App', () => {
])
render(<App />)
await waitFor(() =>
expect(api.conversations.replace).toHaveBeenLastCalledWith(
expect.arrayContaining([
expect.objectContaining({
id: '00000000-0000-4000-8000-000000000022',
runtimeSelection: {
provider: 'model',
profileId: removedProfileId
}
})
])
)
)
expect(
await screen.findByText('旧消息')
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: //u })
).toBeInTheDocument()
expect(api.conversations.replace).not.toHaveBeenCalled()
expect(api.conversations.saveLocal).not.toHaveBeenCalled()
})
it('keeps model Runtime selection scoped to its conversation', async () => {
@@ -3614,14 +4268,17 @@ describe('App', () => {
await waitFor(
() =>
expect(api.conversations.replace).toHaveBeenLastCalledWith(
expect(api.conversations.saveLocal).toHaveBeenLastCalledWith(
expect.arrayContaining([
expect.objectContaining({
title: '第二模型对话',
runtimeSelection: {
provider: 'model',
profileId: secondProfileId
}
header: expect.objectContaining({
title: '第二模型对话',
runtimeSelection: {
provider: 'model',
profileId: secondProfileId
}
}),
messages: expect.any(Array)
})
])
),
@@ -4855,7 +5512,9 @@ describe('App', () => {
expect(
screen.getByRole('button', { name: '新建笔记' })
).toBeInTheDocument()
expect(api.magicNotes.list).toHaveBeenCalled()
await waitFor(() =>
expect(api.magicNotes.list).toHaveBeenCalled()
)
expect(
screen.queryByLabelText('切换助手工作栏')
).not.toBeInTheDocument()
+639 -114
View File
@@ -38,8 +38,13 @@ import {
X
} from 'lucide-react'
import {
Component,
lazy,
Suspense,
useCallback,
useDeferredValue,
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
@@ -84,10 +89,13 @@ import type {
AssistantExpert,
AssistantTask,
TokenUsageSummary,
ConversationMessage,
ConversationSnapshot,
ConversationAttachment,
ConversationMessageBlock,
ConversationToolActivity,
LocalConversationHeader,
LocalConversationSaveBatch,
ProjectCreateInput,
InteractiveWorkMode,
ProjectChannel,
@@ -109,13 +117,10 @@ import {
upsertActivityRecord,
type ActivityRecord
} from './activity-store'
import { KnowledgeWorkspace } from './KnowledgeWorkspace'
import {
KnowledgeCitationDialog,
type KnowledgeCitationContextView
} from './KnowledgeCitationDialog'
import { HeartbeatCenter } from './HeartbeatCenter'
import { MagicNotesWorkspace } from './MagicNotesWorkspace'
import { MarkdownRenderer } from './MarkdownRenderer'
import {
DestructiveConfirmActions,
@@ -133,7 +138,6 @@ import {
type PendingSidebarApproval,
type SidebarArtifact
} from './RightAssistantSidebar'
import { SettingsPanel } from './SettingsPanel'
import type { SettingsCategoryId } from './settings-categories'
import goodbuddyDarkIcon from './assets/goodbuddy-dark.png'
import goodbuddyLightIcon from './assets/goodbuddy-light.png'
@@ -158,6 +162,30 @@ import type {
import type { ReleaseNotesSnapshot } from '../../shared/release-notes-contracts'
import { ReleaseNotesDialog } from './ReleaseNotesDialog'
const KnowledgeWorkspace = lazy(async () => {
const module = await import('./KnowledgeWorkspace')
return { default: module.KnowledgeWorkspace }
})
const HeartbeatCenter = lazy(async () => {
const module = await import('./HeartbeatCenter')
return { default: module.HeartbeatCenter }
})
const MagicNotesWorkspace = lazy(async () => {
const module = await import('./MagicNotesWorkspace')
return { default: module.MagicNotesWorkspace }
})
const SettingsPanel = lazy(async () => {
const module = await import('./SettingsPanel')
return { default: module.SettingsPanel }
})
const messageRenderBatchSize = 80
const conversationPersistenceIntervalMs = 500
const conversationSearchSnapshotDelayMs = 250
type AppNotification = {
id: string
message: string
@@ -206,6 +234,58 @@ function appNotificationReducer(
return [...errors, ...transient]
}
function RouteLoadingStatus({
label
}: {
label: string
}): React.JSX.Element {
return (
<div
aria-busy="true"
aria-label={label}
aria-live="polite"
className="route-loading-status"
role="status"
>
<LoaderCircle aria-hidden="true" size={20} />
<span>{label}</span>
</div>
)
}
class RouteErrorBoundary extends Component<
{ children: ReactNode; fallback: ReactNode },
{ failed: boolean }
> {
state = { failed: false }
static getDerivedStateFromError(): { failed: boolean } {
return { failed: true }
}
render(): ReactNode {
return this.state.failed ? this.props.fallback : this.props.children
}
}
function RouteLoadError({
message,
reloadLabel
}: {
message: string
reloadLabel: string
}): React.JSX.Element {
return (
<div className="route-load-error" role="alert">
<CircleAlert aria-hidden="true" size={20} />
<strong>{message}</strong>
<button onClick={() => window.location.reload()} type="button">
{reloadLabel}
</button>
</div>
)
}
function AppNotificationItem({
notification,
dispatch
@@ -735,6 +815,14 @@ function loadConversations(
}
}
function hasConversationMigrationStorage(): boolean {
try {
return localStorage.getItem(storageKey) !== null
} catch {
return false
}
}
function loadActiveProjectId(): string | undefined {
try {
return localStorage.getItem(activeProjectStorageKey) || undefined
@@ -803,31 +891,91 @@ function isConversation(value: unknown): value is Conversation {
function toConversationSnapshots(
conversations: Conversation[]
): ConversationSnapshot[] {
return conversations.slice(0, 100).map((conversation) => ({
return conversations
.filter((conversation) => !conversation.remote)
.slice(0, 100)
.map((conversation) => ({
id: conversation.id,
projectId: conversation.projectId,
runtimeSelection: conversation.runtimeSelection,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
title: conversation.title,
updatedAt: conversation.updatedAt,
messages: conversation.messages
.slice(-500)
.map(toConversationMessage)
}))
}
function toConversationMessage(message: Message): ConversationMessage {
return {
id: message.id,
role: message.role,
content: message.content,
reasoning: message.reasoning,
blocks: message.blocks,
createdAt: message.createdAt,
state: message.state,
status: message.status,
tools: message.tools,
sources: message.sources,
sourceReferences: message.sourceReferences,
knowledgeRetrieval: message.knowledgeRetrieval,
artifactIds: message.artifactIds,
attachments: message.attachments
}
}
function toLocalConversationHeader(
conversation: Conversation
): LocalConversationHeader {
return {
id: conversation.id,
projectId: conversation.projectId,
runtimeSelection: conversation.runtimeSelection,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
remote: conversation.remote,
title: conversation.title,
updatedAt: conversation.updatedAt,
messages: conversation.messages.slice(-500).map((message) => ({
id: message.id,
role: message.role,
content: message.content,
reasoning: message.reasoning,
blocks: message.blocks,
createdAt: message.createdAt,
state: message.state,
status: message.status,
tools: message.tools,
sources: message.sources,
sourceReferences: message.sourceReferences,
knowledgeRetrieval: message.knowledgeRetrieval,
artifactIds: message.artifactIds,
attachments: message.attachments
}))
}))
updatedAt: conversation.updatedAt
}
}
function createLocalConversationSaveBatch(
conversations: readonly Conversation[],
persisted: ReadonlyMap<string, Conversation>,
deletingConversationIds: ReadonlySet<string>
): {
batch: LocalConversationSaveBatch
acknowledgements: Conversation[]
} {
const batch: LocalConversationSaveBatch = []
const acknowledgements: Conversation[] = []
for (const conversation of conversations) {
if (
conversation.remote ||
deletingConversationIds.has(conversation.id) ||
persisted.get(conversation.id) === conversation
) {
continue
}
const previous = persisted.get(conversation.id)
const previousMessages = new Map(
previous?.messages.map((message) => [message.id, message]) ?? []
)
batch.push({
header: toLocalConversationHeader(conversation),
messages: conversation.messages
.filter(
(message) => previousMessages.get(message.id) !== message
)
.slice(-500)
.map(toConversationMessage)
})
acknowledgements.push(conversation)
if (batch.length === 100) {
break
}
}
return { batch, acknowledgements }
}
function mergeArtifacts(
@@ -1307,6 +1455,9 @@ function App(): React.JSX.Element {
tRef.current = t
}, [t])
const locale = i18n.resolvedLanguage === 'en-US' ? 'en-US' : 'zh-CN'
const conversationMigrationStoragePresent = useRef(
hasConversationMigrationStorage()
)
const [conversations, setConversations] = useState(() =>
loadConversations(
t('conversation.greeting'),
@@ -1316,6 +1467,14 @@ function App(): React.JSX.Element {
const [activeId, setActiveId] = useState(() => conversations[0]?.id ?? '')
const activeConversationIdRef = useRef(activeId)
const conversationsRef = useRef(conversations)
const persistedLocalConversationsRef = useRef(
new Map<string, Conversation>()
)
const conversationPersistenceQueueRef =
useRef<Promise<void>>(Promise.resolve())
const conversationPersistencePausedRef = useRef(false)
const deletingLocalConversationIdsRef = useRef(new Set<string>())
const flushConversationPersistenceAfterRenderRef = useRef(false)
const [unreadConversationIds, setUnreadConversationIds] = useState<
Set<string>
>(() => new Set())
@@ -1509,6 +1668,9 @@ function App(): React.JSX.Element {
useState<ProjectChannel>()
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const deferredSearchQuery = useDeferredValue(searchQuery)
const [searchConversationSnapshot, setSearchConversationSnapshot] =
useState(conversations)
const [conversationActionsId, setConversationActionsId] = useState('')
const [confirmingConversationId, setConfirmingConversationId] =
useState('')
@@ -1591,6 +1753,7 @@ function App(): React.JSX.Element {
const [activityRecords, setActivityRecords] = useState<ActivityRecord[]>(
loadActivityRecords
)
const activityRecordsRef = useRef(activityRecords)
const activeRuns = useRef(new Map<string, ActiveRun>())
const preparingConversations = useRef(new Set<string>())
const hydratingArtifactIds = useRef(new Set<string>())
@@ -1599,6 +1762,17 @@ function App(): React.JSX.Element {
const scrollRef = useRef<HTMLElement>(null)
const chatPinnedToBottomRef = useRef(true)
const chatScrollContextRef = useRef(`${view}:${activeId}`)
const prependScrollPositionRef = useRef<{
conversationId: string
scrollHeight: number
scrollTop: number
} | undefined>(undefined)
const finalRevealedMessageIdRef = useRef<string | undefined>(undefined)
const messageArticleRefs = useRef(new Map<string, HTMLElement>())
const [visibleMessageWindow, setVisibleMessageWindow] = useState(() => ({
conversationId: activeId,
count: messageRenderBatchSize
}))
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const sidebarRef = useRef<HTMLElement>(null)
const sidebarToggleRef = useRef<HTMLButtonElement>(null)
@@ -1678,10 +1852,21 @@ function App(): React.JSX.Element {
}
}, [closeNarrowSidebar, narrowWindow, sidebarOpen])
useEffect(() => {
useLayoutEffect(() => {
conversationsRef.current = conversations
}, [conversations])
useEffect(() => {
if (!searchQuery.trim()) {
return
}
const timeout = window.setTimeout(
() => setSearchConversationSnapshot(conversations),
conversationSearchSnapshotDelayMs
)
return () => window.clearTimeout(timeout)
}, [conversations, searchQuery])
useEffect(() => {
projectsRef.current = projects
}, [projects])
@@ -1801,6 +1986,70 @@ function App(): React.JSX.Element {
() => conversations.find((conversation) => conversation.id === activeId),
[activeId, conversations]
)
const visibleMessageCount =
visibleMessageWindow.conversationId === activeId
? visibleMessageWindow.count
: messageRenderBatchSize
const visibleMessageStartIndex = Math.max(
0,
(activeConversation?.messages.length ?? 0) - visibleMessageCount
)
const visibleMessages =
activeConversation?.messages.slice(visibleMessageStartIndex) ?? []
const hiddenMessageCount = visibleMessageStartIndex
const revealEarlierMessages = useCallback((): void => {
const scrollContainer = scrollRef.current
if (scrollContainer) {
prependScrollPositionRef.current = {
conversationId: activeId,
scrollHeight: scrollContainer.scrollHeight,
scrollTop: scrollContainer.scrollTop
}
}
const currentCount =
visibleMessageWindow.conversationId === activeId
? visibleMessageWindow.count
: messageRenderBatchSize
if (
activeConversation &&
currentCount + messageRenderBatchSize >=
activeConversation.messages.length
) {
finalRevealedMessageIdRef.current =
activeConversation.messages[0]?.id
}
setVisibleMessageWindow({
conversationId: activeId,
count: currentCount + messageRenderBatchSize
})
}, [activeConversation, activeId, visibleMessageWindow])
useLayoutEffect(() => {
const previous = prependScrollPositionRef.current
if (!previous) {
return
}
prependScrollPositionRef.current = undefined
if (previous.conversationId !== activeId) {
return
}
const scrollContainer = scrollRef.current
if (!scrollContainer) {
return
}
scrollContainer.scrollTop =
previous.scrollTop +
(scrollContainer.scrollHeight - previous.scrollHeight)
const finalRevealedMessageId = finalRevealedMessageIdRef.current
finalRevealedMessageIdRef.current = undefined
if (finalRevealedMessageId) {
messageArticleRefs.current
.get(finalRevealedMessageId)
?.focus({ preventScroll: true })
}
}, [activeId, visibleMessageWindow])
const activeRuntimeSelection = useMemo(
() =>
activeConversation?.runtimeSelection ??
@@ -2050,8 +2299,11 @@ function App(): React.JSX.Element {
[activeProjectId, projects]
)
const filteredConversations = useMemo(() => {
const query = searchQuery.trim().toLocaleLowerCase()
return conversations.filter(
const query = deferredSearchQuery.trim().toLocaleLowerCase()
const candidates = query
? searchConversationSnapshot
: conversations
return candidates.filter(
(conversation) =>
(!activeProjectId ||
conversation.projectId === activeProjectId) &&
@@ -2063,7 +2315,13 @@ function App(): React.JSX.Element {
message.content.toLocaleLowerCase().includes(query)
))
)
}, [activeProject, activeProjectId, conversations, searchQuery])
}, [
activeProject,
activeProjectId,
conversations,
deferredSearchQuery,
searchConversationSnapshot
])
const pendingSidebarApprovals = useMemo<PendingSidebarApproval[]>(
() =>
conversations.flatMap((conversation) =>
@@ -2866,6 +3124,7 @@ function App(): React.JSX.Element {
}
})
activeRuns.current.delete(event.requestId)
flushConversationPersistenceAfterRenderRef.current = true
}
},
[
@@ -2892,25 +3151,86 @@ function App(): React.JSX.Element {
viewRef.current = view
}, [view])
const persistLocalConversationChanges = useCallback((): void => {
const operation = conversationPersistenceQueueRef.current.then(
async () => {
if (conversationPersistencePausedRef.current) {
return
}
const { batch, acknowledgements } =
createLocalConversationSaveBatch(
conversationsRef.current,
persistedLocalConversationsRef.current,
deletingLocalConversationIdsRef.current
)
if (batch.length === 0) {
return
}
await window.goodbuddy.conversations.saveLocal(batch)
for (const conversation of acknowledgements) {
persistedLocalConversationsRef.current.set(
conversation.id,
conversation
)
}
}
)
conversationPersistenceQueueRef.current =
operation.catch(() => undefined)
void operation.catch(() => {
notify({
tone: 'error',
message: tRef.current(
'notices.conversationPersistenceFailed'
),
dedupeKey: 'conversation-persistence'
})
})
}, [])
useEffect(() => {
if (!conversationStoreReady) {
return
}
const timeout = setTimeout(() => {
void window.goodbuddy.conversations
.replace(toConversationSnapshots(conversations))
.catch(() => {
notify({
tone: 'error',
message: tRef.current(
'notices.conversationPersistenceFailed'
),
dedupeKey: 'conversation-persistence'
})
})
}, 500)
return () => clearTimeout(timeout)
}, [conversationStoreReady, conversations])
persistLocalConversationChanges()
const interval = window.setInterval(
persistLocalConversationChanges,
conversationPersistenceIntervalMs
)
return () => {
window.clearInterval(interval)
persistLocalConversationChanges()
}
}, [conversationStoreReady, persistLocalConversationChanges])
useEffect(() => {
if (
!conversationStoreReady ||
!flushConversationPersistenceAfterRenderRef.current
) {
return
}
flushConversationPersistenceAfterRenderRef.current = false
persistLocalConversationChanges()
}, [
conversationStoreReady,
conversations,
persistLocalConversationChanges
])
useEffect(
() =>
window.goodbuddy.app.onBeforeQuit(async () => {
saveActivityRecords(activityRecordsRef.current)
if (!conversationStoreReady) {
return
}
flushConversationPersistenceAfterRenderRef.current = false
persistLocalConversationChanges()
await conversationPersistenceQueueRef.current
}),
[conversationStoreReady, persistLocalConversationChanges]
)
useEffect(() => {
if (!conversationStoreReady) {
@@ -2993,9 +3313,20 @@ function App(): React.JSX.Element {
}, [conversationStoreReady])
useEffect(() => {
saveActivityRecords(activityRecords)
activityRecordsRef.current = activityRecords
const timeout = window.setTimeout(() => {
saveActivityRecords(activityRecords)
}, 250)
return () => window.clearTimeout(timeout)
}, [activityRecords])
useEffect(
() => () => {
saveActivityRecords(activityRecordsRef.current)
},
[]
)
useEffect(() => {
let active = true
void Promise.all([
@@ -3016,14 +3347,34 @@ function App(): React.JSX.Element {
setWorkMode(
normalizeInteractiveWorkMode(project.defaultWorkMode)
)
let nextConversations: Conversation[] =
persistedConversations.length > 0
? persistedConversations
: migrationConversations.current.map((conversation) =>
conversation.projectId || project.kind === 'channel'
? conversation
: { ...conversation, projectId: project.id }
const persistedLocalConversations =
persistedConversations.filter(
(conversation) => !conversation.remote
)
const persistedConversationIds = new Set(
persistedConversations.map((conversation) => conversation.id)
)
const shouldMigrateLocalStorage =
conversationMigrationStoragePresent.current ||
persistedConversations.length === 0
const migratedLocalConversations =
shouldMigrateLocalStorage
? migrationConversations.current
.filter(
(conversation) =>
!conversation.remote &&
!persistedConversationIds.has(conversation.id)
)
.map((conversation) =>
conversation.projectId || project.kind === 'channel'
? conversation
: { ...conversation, projectId: project.id }
)
: []
let nextConversations: Conversation[] = [
...persistedConversations,
...migratedLocalConversations
]
let projectConversation = nextConversations.find(
(conversation) =>
conversation.projectId === project.id &&
@@ -3041,17 +3392,71 @@ function App(): React.JSX.Element {
...nextConversations
]
}
if (persistedConversations.length === 0) {
await window.goodbuddy.conversations.replace(
toConversationSnapshots(nextConversations)
const acknowledgedLocalConversations = new Map(
persistedLocalConversations.map((conversation) => [
conversation.id,
conversation
])
)
if (
nextConversations.some(
(conversation) =>
!conversation.remote &&
acknowledgedLocalConversations.get(conversation.id) !==
conversation
)
) {
if (persistedConversations.length === 0) {
const migratedSnapshots =
toConversationSnapshots(nextConversations)
await window.goodbuddy.conversations.replace(
migratedSnapshots
)
const migratedSnapshotIds = new Set(
migratedSnapshots.map((conversation) => conversation.id)
)
for (const conversation of nextConversations) {
if (migratedSnapshotIds.has(conversation.id)) {
acknowledgedLocalConversations.set(
conversation.id,
conversation
)
}
}
}
while (true) {
const migration = createLocalConversationSaveBatch(
nextConversations,
acknowledgedLocalConversations,
new Set()
)
if (migration.batch.length === 0) {
break
}
await window.goodbuddy.conversations.saveLocal(
migration.batch
)
for (const conversation of migration.acknowledgements) {
acknowledgedLocalConversations.set(
conversation.id,
conversation
)
}
}
}
if (!active) {
return
}
persistedLocalConversationsRef.current =
acknowledgedLocalConversations
setConversations(nextConversations)
setActiveId(projectConversation?.id ?? '')
localStorage.removeItem(storageKey)
try {
localStorage.removeItem(storageKey)
conversationMigrationStoragePresent.current = false
} catch {
// The SQLite migration has already completed successfully.
}
setConversationStoreReady(true)
})
.catch((reason: unknown) => {
@@ -3857,6 +4262,27 @@ function App(): React.JSX.Element {
setDeletingConversationId('')
return
}
const deletingConversation = conversations.find(
(conversation) => conversation.id === conversationId
)
if (deletingConversation && !deletingConversation.remote) {
deletingLocalConversationIdsRef.current.add(conversationId)
try {
await conversationPersistenceQueueRef.current
await window.goodbuddy.conversations.deleteLocal(conversationId)
persistedLocalConversationsRef.current.delete(conversationId)
} catch {
deletingLocalConversationIdsRef.current.delete(conversationId)
notify({
tone: 'error',
message: t(
'notices.deleteConversationPersistenceFailed'
)
})
setDeletingConversationId('')
return
}
}
setConfirmingConversationId('')
setDeletingConversationId('')
if (conversationActionsId === conversationId) {
@@ -3882,6 +4308,8 @@ function App(): React.JSX.Element {
const remaining = conversations.filter(
(conversation) => conversation.id !== conversationId
)
conversationsRef.current = remaining
deletingLocalConversationIdsRef.current.delete(conversationId)
const projectRemaining = remaining.filter(
(conversation) => conversation.projectId === activeProjectId
)
@@ -4726,54 +5154,63 @@ function App(): React.JSX.Element {
}
const clearLocalData = async (): Promise<void> => {
for (const requestId of activeRuns.current.keys()) {
await window.goodbuddy.agent.cancel(requestId)
conversationPersistencePausedRef.current = true
try {
await conversationPersistenceQueueRef.current
for (const requestId of activeRuns.current.keys()) {
await window.goodbuddy.agent.cancel(requestId)
}
activeRuns.current.clear()
for (const attachment of attachments) {
await window.goodbuddy.context.remove(attachment.id)
}
for (const library of knowledgeSnapshot.libraries) {
await window.goodbuddy.knowledge.deleteLibrary(library.id)
}
await window.goodbuddy.app.clearLocalData()
const conversation = createConversation(
activeProjectId || undefined,
runtimeSettings
? getProjectDefaultRuntimeSelection(
activeProject,
runtimeSettings
)
: undefined,
t('conversation.greeting')
)
conversationsRef.current = [conversation]
persistedLocalConversationsRef.current.clear()
setConversations([conversation])
setActiveId(conversation.id)
setActivityRecords([])
setAssistantTasks([])
setTokenUsage(emptyTokenUsage)
setAssistantArtifacts([])
setAssistantMemories([])
setAssistantSchedules([])
setAssistantHeartbeats([])
setHeartbeatEntries([])
setHeartbeatRuns([])
setKnowledgeSnapshot({
libraries: [],
sources: [],
documents: [],
graphNodes: [],
graphRelations: [],
evidence: []
})
setEnabledKnowledgeLibraryIds([])
updateAttachments([])
setInput('')
setView('chat')
notify({
tone: 'success',
message: t('notices.localDataCleared')
})
} finally {
conversationPersistencePausedRef.current = false
persistLocalConversationChanges()
}
activeRuns.current.clear()
for (const attachment of attachments) {
await window.goodbuddy.context.remove(attachment.id)
}
for (const library of knowledgeSnapshot.libraries) {
await window.goodbuddy.knowledge.deleteLibrary(library.id)
}
await window.goodbuddy.app.clearLocalData()
const conversation = createConversation(
activeProjectId || undefined,
runtimeSettings
? getProjectDefaultRuntimeSelection(
activeProject,
runtimeSettings
)
: undefined,
t('conversation.greeting')
)
setConversations([conversation])
setActiveId(conversation.id)
setActivityRecords([])
setAssistantTasks([])
setTokenUsage(emptyTokenUsage)
setAssistantArtifacts([])
setAssistantMemories([])
setAssistantSchedules([])
setAssistantHeartbeats([])
setHeartbeatEntries([])
setHeartbeatRuns([])
setKnowledgeSnapshot({
libraries: [],
sources: [],
documents: [],
graphNodes: [],
graphRelations: [],
evidence: []
})
setEnabledKnowledgeLibraryIds([])
updateAttachments([])
setInput('')
setView('chat')
notify({
tone: 'success',
message: t('notices.localDataCleared')
})
}
const isRunning =
@@ -5161,7 +5598,7 @@ function App(): React.JSX.Element {
{filteredConversations.length === 0 && (
<p className="conversation-empty">
{activeProject?.kind === 'channel' &&
!searchQuery.trim()
!deferredSearchQuery.trim()
? t('conversation.noRemote')
: t('conversation.noMatches')}
</p>
@@ -5382,10 +5819,33 @@ function App(): React.JSX.Element {
)}
<div className="message-list">
{activeConversation?.messages.map((message, messageIndex) => (
<article
{hiddenMessageCount > 0 && (
<button
className="load-earlier-messages"
onClick={revealEarlierMessages}
type="button"
>
{t('chat.loadEarlierMessages', {
count: hiddenMessageCount
})}
</button>
)}
{activeConversation &&
visibleMessages.map((message, visibleMessageIndex) => {
const messageIndex =
visibleMessageStartIndex + visibleMessageIndex
return (
<article
className={`message message--${message.role}`}
key={message.id}
ref={(element) => {
if (element) {
messageArticleRefs.current.set(message.id, element)
} else {
messageArticleRefs.current.delete(message.id)
}
}}
tabIndex={-1}
>
<div className="message__avatar">
{message.role === 'assistant' ? (
@@ -5931,8 +6391,9 @@ function App(): React.JSX.Element {
</button>
)}
</div>
</article>
))}
</article>
)
})}
</div>
</section>
{showScrollToBottom && (
@@ -6624,11 +7085,41 @@ function App(): React.JSX.Element {
</PageShell>
) : view === 'magic-notes' && magicNotesEnabled ? (
<PageShell variant="master-detail">
<MagicNotesWorkspace onNotify={notify} />
<RouteErrorBoundary
key="magic-notes"
fallback={
<RouteLoadError
message={t('route.loadFailed')}
reloadLabel={t('route.reload')}
/>
}
>
<Suspense
fallback={
<RouteLoadingStatus label={t('route.loading')} />
}
>
<MagicNotesWorkspace onNotify={notify} />
</Suspense>
</RouteErrorBoundary>
</PageShell>
) : view === 'knowledge' ? (
<PageShell variant="master-detail">
<KnowledgeWorkspace
<RouteErrorBoundary
key="knowledge"
fallback={
<RouteLoadError
message={t('route.loadFailed')}
reloadLabel={t('route.reload')}
/>
}
>
<Suspense
fallback={
<RouteLoadingStatus label={t('route.loading')} />
}
>
<KnowledgeWorkspace
documents={knowledgeSnapshot.documents}
evidence={knowledgeSnapshot.evidence}
graphNodes={knowledgeSnapshot.graphNodes}
@@ -6908,11 +7399,27 @@ function App(): React.JSX.Element {
selectedLibraryId={knowledgeSnapshot.selectedLibraryId}
sources={knowledgeSnapshot.sources}
tasks={knowledgeSnapshot.tasks}
/>
/>
</Suspense>
</RouteErrorBoundary>
</PageShell>
) : view === 'heartbeat' ? (
<PageShell variant="dashboard">
<HeartbeatCenter
<RouteErrorBoundary
key="heartbeat"
fallback={
<RouteLoadError
message={t('route.loadFailed')}
reloadLabel={t('route.reload')}
/>
}
>
<Suspense
fallback={
<RouteLoadingStatus label={t('route.loading')} />
}
>
<HeartbeatCenter
configs={assistantHeartbeats}
currentProjectName={activeProject?.name}
entries={heartbeatEntries}
@@ -6930,10 +7437,26 @@ function App(): React.JSX.Element {
onUseFollowUpTask={useHeartbeatTask}
runs={heartbeatRuns}
tasks={assistantTasks}
/>
/>
</Suspense>
</RouteErrorBoundary>
</PageShell>
) : view === 'settings' ? (
<SettingsPanel
<RouteErrorBoundary
key="settings"
fallback={
<RouteLoadError
message={t('route.loadFailed')}
reloadLabel={t('route.reload')}
/>
}
>
<Suspense
fallback={
<RouteLoadingStatus label={t('route.loading')} />
}
>
<SettingsPanel
appearanceTheme={appearanceTheme}
heartbeats={assistantHeartbeats}
initialCategory={settingsInitialCategory}
@@ -6972,7 +7495,9 @@ function App(): React.JSX.Element {
onSetHeartbeatPaused={setHeartbeatPaused}
open
presentation="page"
/>
/>
</Suspense>
</RouteErrorBoundary>
) : (
<PageShell variant="dashboard">
<ActivityPanel
+18
View File
@@ -105,6 +105,24 @@ describe('activity-store', () => {
)
})
it('drops the oldest records until the stored JSON fits the load bound', () => {
const records = Array.from(
{ length: MAX_ACTIVITY_RECORDS },
(_, index) => ({
...makeRecord(index),
detail: 'x'.repeat(4_000)
})
)
expect(saveActivityRecords(records)).toBe(true)
const serialized = localStorage.getItem(ACTIVITY_STORAGE_KEY)
expect(serialized?.length).toBeLessThanOrEqual(2_000_000)
const restored = loadActivityRecords()
expect(restored.length).toBeGreaterThan(0)
expect(restored.length).toBeLessThan(MAX_ACTIVITY_RECORDS)
expect(restored[0]?.id).toBe('activity-0')
})
it('reports rejected writes without throwing', () => {
const rejectingStorage = {
setItem: () => {
+20 -6
View File
@@ -278,8 +278,9 @@ export function loadActivityRecords(
}
/**
* Persists at most 500 schema-valid records. Returns false if storage is
* unavailable or rejects the write.
* Persists as many of the newest schema-valid records as fit within the
* bounded storage payload. Returns false if storage is unavailable or rejects
* the write.
*/
export function saveActivityRecords(
records: readonly ActivityRecord[],
@@ -289,19 +290,32 @@ export function saveActivityRecords(
return false
}
const safeRecords: ActivityRecord[] = []
const serializedRecords: string[] = []
let serializedLength = 2
for (const record of records) {
const safeRecord = parseActivityRecord(record)
if (safeRecord) {
safeRecords.push(safeRecord)
const serializedRecord = JSON.stringify(safeRecord)
const nextLength =
serializedLength +
serializedRecord.length +
(serializedRecords.length > 0 ? 1 : 0)
if (nextLength > MAX_STORED_JSON_LENGTH) {
break
}
serializedRecords.push(serializedRecord)
serializedLength = nextLength
}
if (safeRecords.length === MAX_ACTIVITY_RECORDS) {
if (serializedRecords.length === MAX_ACTIVITY_RECORDS) {
break
}
}
try {
storage.setItem(ACTIVITY_STORAGE_KEY, JSON.stringify(safeRecords))
storage.setItem(
ACTIVITY_STORAGE_KEY,
`[${serializedRecords.join(',')}]`
)
return true
} catch {
return false
@@ -49,6 +49,11 @@ export const app = {
activity: 'Tasks & Activity',
pendingSuggestions: '{{count}} pending suggestions'
},
route: {
loading: 'Loading page…',
loadFailed: 'The page component failed to load. Reload the app.',
reload: 'Reload'
},
sidebar: {
label: 'Main sidebar',
newConversation: 'New conversation',
@@ -226,6 +231,7 @@ export const app = {
}
},
retry: 'Edit and send again',
loadEarlierMessages: 'Load earlier messages ({{count}} remaining)',
scrollToBottom: 'Scroll to bottom',
status: {
responseTruncated: 'The response was too long and was truncated locally',
@@ -419,6 +425,8 @@ export const app = {
'Channel conversations are created automatically when the client receives a new message',
deleteConversationCancelFailed:
'Could not stop the running task, so the conversation was not deleted',
deleteConversationPersistenceFailed:
'Could not delete the local conversation, so it was kept',
deletedConversationBrowserCloseFailed:
'Failed to close the browser for the deleted conversation',
conversationCopied: 'Conversation copied to the clipboard',
@@ -45,6 +45,11 @@ export const app = {
activity: '任务与活动',
pendingSuggestions: '{{count}} 条待处理建议'
},
route: {
loading: '正在加载页面…',
loadFailed: '页面组件加载失败,请重新加载应用',
reload: '重新加载'
},
sidebar: {
label: '主侧栏',
newConversation: '新建对话',
@@ -219,6 +224,7 @@ export const app = {
}
},
retry: '重新编辑并发送',
loadEarlierMessages: '加载更早的消息(还剩 {{count}} 条)',
scrollToBottom: '到底部',
status: {
responseTruncated: '回答过长,已在本地截断显示',
@@ -395,6 +401,8 @@ export const app = {
'通道项目的会话由客户端收到新消息后自动创建',
deleteConversationCancelFailed:
'停止会话中的运行任务失败,尚未删除对话',
deleteConversationPersistenceFailed:
'删除本地会话失败,已保留当前对话',
deletedConversationBrowserCloseFailed: '关闭已删除对话的浏览器失败',
conversationCopied: '对话已复制到剪贴板',
clipboardUnavailable: '无法访问剪贴板,请检查系统权限',
+73
View File
@@ -3126,7 +3126,30 @@ button > svg {
gap: 4px;
}
.load-earlier-messages {
align-self: center;
min-height: var(--control-height);
padding: var(--space-1) var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
color: var(--text-secondary);
cursor: pointer;
}
.load-earlier-messages:hover {
border-color: var(--accent-selected);
color: var(--text-primary);
}
.load-earlier-messages:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.message {
content-visibility: auto;
contain-intrinsic-block-size: auto 160px;
display: grid;
min-width: 0;
padding: 17px 8px;
@@ -3138,6 +3161,56 @@ button > svg {
grid-template-columns: minmax(0, 1fr) 30px;
}
.route-loading-status {
display: flex;
min-height: min(360px, 50vh);
align-items: center;
justify-content: center;
gap: var(--space-2);
color: var(--text-secondary);
}
.route-loading-status > svg {
animation: route-loading-spin 1s linear infinite;
}
.route-load-error {
display: flex;
min-height: min(360px, 50vh);
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-5);
gap: var(--space-3);
color: var(--text-secondary);
text-align: center;
}
.route-load-error > svg {
color: var(--danger-solid);
}
.route-load-error > button {
min-height: var(--control-height);
padding: var(--space-1) var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
color: var(--text-primary);
cursor: pointer;
}
.route-load-error > button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@keyframes route-loading-spin {
to {
transform: rotate(360deg);
}
}
.message__avatar {
display: grid;
width: 29px;