feat: show active conversations in sidebar

This commit is contained in:
mesalogo
2026-08-15 20:57:46 +08:00
parent c2b9ce4ff0
commit 1cde0f3385
5 changed files with 80 additions and 0 deletions
+32
View File
@@ -1592,6 +1592,20 @@ describe('App', () => {
if (!request) { if (!request) {
throw new Error('Missing request') throw new Error('Missing request')
} }
const conversationList =
container.querySelector<HTMLElement>('.conversation-list')
if (!conversationList) {
throw new Error('Missing conversation list')
}
const activeIndicator = within(conversationList).getByRole('status', {
name: '会话正在活动'
})
expect(activeIndicator).toHaveClass(
'conversation-activity-indicator'
)
expect(activeIndicator.closest('.conversation-row')).toHaveTextContent(
'离开页面后继续生成'
)
await act( await act(
() => () =>
new Promise<void>((resolve) => new Promise<void>((resolve) =>
@@ -1613,6 +1627,11 @@ describe('App', () => {
fireEvent.click(screen.getByRole('button', { name: '知识库' })) fireEvent.click(screen.getByRole('button', { name: '知识库' }))
expect(chat).toBeInTheDocument() expect(chat).toBeInTheDocument()
expect(chat.closest('[data-route="chat"]')).toHaveAttribute('hidden') expect(chat.closest('[data-route="chat"]')).toHaveAttribute('hidden')
expect(
within(conversationList).getByRole('status', {
name: '会话正在活动'
})
).toBeInTheDocument()
act(() => { act(() => {
agentListener?.({ agentListener?.({
requestId: request.requestId, requestId: request.requestId,
@@ -1620,6 +1639,19 @@ describe('App', () => {
delta: '后台新增的回复内容' delta: '后台新增的回复内容'
}) })
}) })
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'done'
})
})
await waitFor(() =>
expect(
within(conversationList).queryByRole('status', {
name: '会话正在活动'
})
).not.toBeInTheDocument()
)
fireEvent.click(screen.getByRole('button', { name: '对话' })) fireEvent.click(screen.getByRole('button', { name: '对话' }))
expect(await screen.findByText('后台新增的回复内容')).toBeInTheDocument() expect(await screen.findByText('后台新增的回复内容')).toBeInTheDocument()
+33
View File
@@ -2046,6 +2046,26 @@ function App(): React.JSX.Element {
const activityRecordsRef = useRef(activityRecords) const activityRecordsRef = useRef(activityRecords)
const activeRuns = useRef(new Map<string, ActiveRun>()) const activeRuns = useRef(new Map<string, ActiveRun>())
const preparingConversations = useRef(new Set<string>()) const preparingConversations = useRef(new Set<string>())
const [activeConversationIds, setActiveConversationIds] = useState<
ReadonlySet<string>
>(() => new Set())
const setConversationActivity = useCallback(
(conversationId: string, active: boolean): void => {
setActiveConversationIds((current) => {
if (current.has(conversationId) === active) {
return current
}
const next = new Set(current)
if (active) {
next.add(conversationId)
} else {
next.delete(conversationId)
}
return next
})
},
[]
)
const hydratingArtifactIds = useRef(new Set<string>()) const hydratingArtifactIds = useRef(new Set<string>())
const knowledgeScopeInitialized = useRef(false) const knowledgeScopeInitialized = useRef(false)
const inputRef = useRef<HTMLTextAreaElement>(null) const inputRef = useRef<HTMLTextAreaElement>(null)
@@ -3464,6 +3484,7 @@ function App(): React.JSX.Element {
} }
}) })
activeRuns.current.delete(event.requestId) activeRuns.current.delete(event.requestId)
setConversationActivity(run.conversationId, false)
flushConversationPersistenceAfterRenderRef.current = true flushConversationPersistenceAfterRenderRef.current = true
} }
}, },
@@ -3471,6 +3492,7 @@ function App(): React.JSX.Element {
recordActivity, recordActivity,
loadWorkspaceChanges, loadWorkspaceChanges,
refreshTokenUsage, refreshTokenUsage,
setConversationActivity,
updateMessage, updateMessage,
updateRequestActivity updateRequestActivity
] ]
@@ -4651,6 +4673,7 @@ function App(): React.JSX.Element {
delete next[conversationId] delete next[conversationId]
return next return next
}) })
setConversationActivity(conversationId, false)
const remaining = conversations.filter( const remaining = conversations.filter(
(conversation) => conversation.id !== conversationId (conversation) => conversation.id !== conversationId
) )
@@ -4954,6 +4977,7 @@ function App(): React.JSX.Element {
runtime.capability === 'image-generation' ? '' : selectedExpertId runtime.capability === 'image-generation' ? '' : selectedExpertId
const workModeSnapshot = effectiveWorkMode const workModeSnapshot = effectiveWorkMode
preparingConversations.current.add(conversationId) preparingConversations.current.add(conversationId)
setConversationActivity(conversationId, true)
setInput('') setInput('')
updateAttachments([]) updateAttachments([])
const userMessage: Message = { const userMessage: Message = {
@@ -5546,6 +5570,7 @@ function App(): React.JSX.Element {
await window.goodbuddy.agent.cancel(requestId) await window.goodbuddy.agent.cancel(requestId)
} }
activeRuns.current.clear() activeRuns.current.clear()
setActiveConversationIds(new Set())
for (const attachments of attachmentsRef.current.values()) { for (const attachments of attachmentsRef.current.values()) {
for (const attachment of attachments) { for (const attachment of attachments) {
await window.goodbuddy.context.remove(attachment.id) await window.goodbuddy.context.remove(attachment.id)
@@ -5889,6 +5914,14 @@ function App(): React.JSX.Element {
</span> </span>
<small>{formatTime(conversation.updatedAt, locale)}</small> <small>{formatTime(conversation.updatedAt, locale)}</small>
</button> </button>
{activeConversationIds.has(conversation.id) && (
<span
aria-label={t('conversation.active')}
className="conversation-activity-indicator"
role="status"
title={t('conversation.active')}
/>
)}
<button <button
aria-controls={`conversation-actions-${conversation.id}`} aria-controls={`conversation-actions-${conversation.id}`}
aria-expanded={ aria-expanded={
@@ -76,6 +76,7 @@ export const app = {
greeting: greeting:
'Hi, Im GoodBuddy. Ask me a question, add local files, or use your knowledge base. With an Agent Runtime enabled, I can also use tools with your authorization.', 'Hi, Im GoodBuddy. Ask me a question, add local files, or use your knowledge base. With an Agent Runtime enabled, I can also use tools with your authorization.',
interrupted: 'The previous run stopped unexpectedly. You can resend your question.', interrupted: 'The previous run stopped unexpectedly. You can resend your question.',
active: 'Conversation is active',
unread: 'Unread', unread: 'Unread',
unreadRemote: 'Unread remote message', unreadRemote: 'Unread remote message',
noRemote: 'No remote conversations yet', noRemote: 'No remote conversations yet',
@@ -72,6 +72,7 @@ export const app = {
greeting: greeting:
'你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件或使用知识库。启用 Agent Runtime 后,我也可以在你的授权下调用工具。', '你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件或使用知识库。启用 Agent Runtime 后,我也可以在你的授权下调用工具。',
interrupted: '上次运行意外中断,可以重新发送问题', interrupted: '上次运行意外中断,可以重新发送问题',
active: '会话正在活动',
unread: '未读', unread: '未读',
unreadRemote: '未读远程消息', unreadRemote: '未读远程消息',
noRemote: '尚无远程会话', noRemote: '尚无远程会话',
+13
View File
@@ -6962,6 +6962,19 @@ details.settings-section > :not(summary) + :not(summary) {
padding-right: 58px; padding-right: 58px;
} }
.conversation-activity-indicator {
position: absolute;
right: 10px;
width: 8px;
height: 8px;
border-radius: 50%;
animation: pulse 1.1s ease-in-out infinite;
background: var(--success);
box-shadow: 0 0 0 4px
color-mix(in srgb, var(--success) 14%, transparent);
pointer-events: none;
}
.conversation-more, .conversation-more,
.conversation-delete { .conversation-delete {
position: absolute; position: absolute;