fix(chat): place agent activity before response
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

This commit is contained in:
2026-11-05 12:27:22 +08:00
parent 7ee844cd88
commit 43eda89d47
7 changed files with 84 additions and 42 deletions
@@ -0,0 +1,10 @@
---
date: 2026-11-05
pr: pending
feature: Agent activity layout
impact: The current agent's thinking state and tool calls appear below its profile header and before its answer. Completed tool calls remain collapsed above the answer; chat events and persistence are unchanged.
---
The active run uses a temporary display-only assistant row until an answer is
available. It uses the session profile avatar and does not create an empty
message bubble or persist a placeholder.
@@ -35,7 +35,7 @@ const JSON_MAX_KEYS_PER_OBJECT = 50;
const JSON_MAX_ITEMS_PER_ARRAY = 50; const JSON_MAX_ITEMS_PER_ARRAY = 50;
const JSON_TRUNCATED_KEY = "__truncated__"; const JSON_TRUNCATED_KEY = "__truncated__";
const props = defineProps<{ message: Message; highlight?: boolean; headingIdPrefix?: string; showForkAction?: boolean }>(); const props = defineProps<{ message: Message; highlight?: boolean; headingIdPrefix?: string; showForkAction?: boolean; activityOnly?: boolean }>();
const { t } = useI18n(); const { t } = useI18n();
const toast = useMessage(); const toast = useMessage();
@@ -1021,7 +1021,8 @@ onBeforeUnmount(() => {
/> />
<span class="msg-profile-name" :title="assistantProfileName">{{ assistantProfileName }}</span> <span class="msg-profile-name" :title="assistantProfileName">{{ assistantProfileName }}</span>
</div> </div>
<div class="msg-content" :class="message.role"> <slot name="agent-activity" />
<div v-if="!activityOnly" class="msg-content" :class="message.role">
<div <div
class="message-bubble" class="message-bubble"
:class="{ :class="{
@@ -120,7 +120,22 @@ const displayMessages = computed(() => {
} }
return true; return true;
}); });
return groupRunMessages(messages, chatStore.isRunActive || !!chatStore.abortState); const grouped = groupRunMessages(messages, chatStore.isRunActive || !!chatStore.abortState);
if (isThinkingIndicatorVisible.value) {
const lastInput = grouped.findLastIndex(m => m.role === 'user' || m.role === 'command');
const currentAssistant = grouped.slice(lastInput + 1).findLast(m => m.role === 'assistant');
if (!currentAssistant) {
grouped.push({ id: `run-activity-${chatStore.activeSessionId}`, role: 'assistant', content: '', timestamp: 0 });
}
}
return grouped;
});
const activeAssistantId = computed(() => {
if (!isThinkingIndicatorVisible.value) return null;
const messages = displayMessages.value;
const lastInput = messages.findLastIndex(m => m.role === 'user' || m.role === 'command');
return messages.slice(lastInput + 1).findLast(m => m.role === 'assistant')?.id ?? null;
}); });
function forkDividerId(sessionId: string): string { function forkDividerId(sessionId: string): string {
@@ -519,18 +534,15 @@ defineExpose({
:message="msg" :message="msg"
:highlight="chatStore.focusMessageId === msg.id" :highlight="chatStore.focusMessageId === msg.id"
:show-fork-action="canForkActiveSession && msg.id === lastForkActionMessageId" :show-fork-action="canForkActiveSession && msg.id === lastForkActionMessageId"
/> :activity-only="msg.id === `run-activity-${chatStore.activeSessionId}`"
</template> >
<template #after> <template #agent-activity>
<Transition name="fade"> <details v-if="msg.toolMessages?.length" class="tool-group">
<div v-if="isThinkingIndicatorVisible" class="streaming-indicator"> <summary>{{ t('chat.toolCallsGroup', { count: msg.toolMessages.length }) }}</summary>
<MessageItem v-for="tool in msg.toolMessages" :key="tool.id" :message="tool" />
</details>
<div v-if="msg.id === activeAssistantId" class="streaming-indicator">
<div class="thinking-status"> <div class="thinking-status">
<img
src="/app-logo.png"
alt=""
aria-hidden="true"
class="thinking-avatar"
>
<div class="thinking-status-copy"> <div class="thinking-status-copy">
<span class="thinking-status-label">{{ t("chat.thinkingInProgress") }}</span> <span class="thinking-status-label">{{ t("chat.thinkingInProgress") }}</span>
<span class="thinking-status-time">{{ formattedThinkingElapsed }}</span> <span class="thinking-status-time">{{ formattedThinkingElapsed }}</span>
@@ -696,7 +708,8 @@ defineExpose({
</details> </details>
</div> </div>
</div> </div>
</Transition> </template>
</MessageItem>
</template> </template>
</VirtualMessageList> </VirtualMessageList>
<button <button
@@ -1475,22 +1488,8 @@ defineExpose({
min-height: 40px; min-height: 40px;
} }
.thinking-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
animation: thinking-avatar-breathe 2.4s ease-in-out infinite;
}
@keyframes thinking-avatar-breathe {
0%, 100% { transform: translateY(0) scale(1); filter: drop-shadow(0 0 2px rgba(var(--accent-primary-rgb), 0.12)); }
50% { transform: translateY(-2px) scale(1.06); filter: drop-shadow(0 0 7px rgba(var(--accent-primary-rgb), 0.4)); }
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.thinking-status .thinking-avatar, .thinking-status .thinking-status-label { animation: none; } .thinking-status .thinking-status-label { animation: none; }
} }
.thinking-status-copy { .thinking-status-copy {
@@ -13,6 +13,7 @@ export function groupRunMessages(messages: Message[], runActive: boolean): Messa
const first = assistants[0] const first = assistants[0]
result.push({ result.push({
...first, ...first,
toolMessages: tools.length ? tools : undefined,
id: assistants[assistants.length - 1].id, id: assistants[assistants.length - 1].id,
content: assistants.map(m => m.content).filter(text => text.trim()).join('\n\n'), content: assistants.map(m => m.content).filter(text => text.trim()).join('\n\n'),
reasoning: assistants.map(m => m.reasoning).filter(Boolean).join('\n\n') || undefined, reasoning: assistants.map(m => m.reasoning).filter(Boolean).join('\n\n') || undefined,
@@ -20,7 +21,7 @@ export function groupRunMessages(messages: Message[], runActive: boolean): Messa
isStreaming: assistants.some(m => m.isStreaming), isStreaming: assistants.some(m => m.isStreaming),
}) })
} }
if (tools.length) { if (tools.length && !assistants.length) {
result.push({ id: `tool-group-${tools[0].id}`, role: 'system', content: '', timestamp: tools[0].timestamp, systemType: 'tool-group', toolMessages: tools }) result.push({ id: `tool-group-${tools[0].id}`, role: 'system', content: '', timestamp: tools[0].timestamp, systemType: 'tool-group', toolMessages: tools })
} }
if (completed && assistants.length) { if (completed && assistants.length) {
+2 -2
View File
@@ -8,8 +8,8 @@ describe('groupRunMessages', () => {
it('groups text across tool calls and marks completion only when the run ends', () => { it('groups text across tool calls and marks completion only when the run ends', () => {
const input = [message('u', 'user', 'question'), message('a', 'assistant', 'first'), message('t', 'tool'), message('b', 'assistant', 'second')] const input = [message('u', 'user', 'question'), message('a', 'assistant', 'first'), message('t', 'tool'), message('b', 'assistant', 'second')]
const active = groupRunMessages(input, true) const active = groupRunMessages(input, true)
expect(active.map(m => m.role)).toEqual(['user', 'assistant', 'system']) expect(active.map(m => m.role)).toEqual(['user', 'assistant'])
expect(active[2]).toMatchObject({ systemType: 'tool-group', toolMessages: [input[2]] }) expect(active[1].toolMessages).toEqual([input[2]])
expect(active[1]).toMatchObject({ id: 'b', content: 'first\n\nsecond' }) expect(active[1]).toMatchObject({ id: 'b', content: 'first\n\nsecond' })
const finished = groupRunMessages(input, false) const finished = groupRunMessages(input, false)
expect(finished.at(-1)?.systemType).toBe('run-end') expect(finished.at(-1)?.systemType).toBe('run-end')
+39 -9
View File
@@ -100,7 +100,8 @@ test('does not render empty assistant bubbles between tool calls', async ({ page
socket.__trigger('tool.completed', { ...common, event: 'tool.completed', tool_call_id: `tool-${i}`, output: 'done' }) socket.__trigger('tool.completed', { ...common, event: 'tool.completed', tool_call_id: `tool-${i}`, output: 'done' })
} }
}, sessionId) }, sessionId)
await expect(page.locator('.message.assistant')).toHaveCount(0) await expect(page.locator('.message.assistant')).toHaveCount(1)
await expect(page.locator('.message.assistant .message-bubble')).toHaveCount(0)
await page.evaluate((sid) => { await page.evaluate((sid) => {
const socket = (window as any).__PW_CHAT_SOCKET__.latest const socket = (window as any).__PW_CHAT_SOCKET__.latest
@@ -138,7 +139,7 @@ test('groups assistant output across tool calls until the run ends', async ({ pa
await expect(page.locator('.run-end')).toHaveText('Run output ended') await expect(page.locator('.run-end')).toHaveText('Run output ended')
}) })
test('animates the application logo while thinking and respects reduced motion', async ({ page }) => { test('shows agent activity under its own avatar before the answer', async ({ page }) => {
await authenticate(page, TEST_ACCESS_KEY, 'research') await authenticate(page, TEST_ACCESS_KEY, 'research')
await mockHermesApi(page) await mockHermesApi(page)
await mockChatSocket(page) await mockChatSocket(page)
@@ -146,15 +147,44 @@ test('animates the application logo while thinking and respects reduced motion',
await sendChatMessage(page, 'think') await sendChatMessage(page, 'think')
const { sessionId } = await waitForRunSessionId(page) const { sessionId } = await waitForRunSessionId(page)
await page.evaluate((sid) => { await page.evaluate((sid) => {
(window as any).__PW_CHAT_SOCKET__.latest.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'thinking-logo' }) const socket = (window as any).__PW_CHAT_SOCKET__.latest
const common = { session_id: sid, run_id: 'activity-layout' }
socket.__trigger('run.started', { ...common, event: 'run.started' })
socket.__trigger('tool.started', { ...common, event: 'tool.started', tool_call_id: 't1', tool: 'terminal' })
}, sessionId) }, sessionId)
const avatar = page.locator('.thinking-avatar') const assistant = page.locator('.message.assistant')
await expect(avatar).toBeVisible() await expect(assistant).toHaveCount(1)
await expect(avatar).toHaveAttribute('src', '/app-logo.png') await expect(assistant.locator('.msg-profile-name')).toHaveText('research')
await expect(avatar).toHaveCSS('animation-name', /^thinking-avatar-breathe/) await expect(assistant.locator('.thinking-status-label')).toBeVisible()
await expect(assistant.locator('.live-tool-group > summary')).toHaveText('Tool calls (1)')
await expect(assistant.locator('.message-bubble')).toHaveCount(0)
await expect(page.locator('.thinking-avatar')).toHaveCount(0)
await page.emulateMedia({ reducedMotion: 'reduce' }) await page.emulateMedia({ reducedMotion: 'reduce' })
await expect(avatar).toHaveCSS('animation-name', 'none') await expect(assistant.locator('.thinking-status-label')).toHaveCSS('animation-name', 'none')
await expect(page.locator('.thinking-status-label')).toHaveCSS('animation-name', 'none') await page.evaluate((sid) => {
const socket = (window as any).__PW_CHAT_SOCKET__.latest
const common = { session_id: sid, run_id: 'activity-layout' }
socket.__trigger('tool.completed', { ...common, event: 'tool.completed', tool_call_id: 't1', output: 'done' })
socket.__trigger('message.delta', { ...common, event: 'message.delta', delta: 'Final answer' })
}, sessionId)
await expect(assistant).toHaveCount(1)
await expect(assistant.locator('.message-bubble')).toContainText('Final answer')
const order = await assistant.evaluate(el => {
const header = el.querySelector('.msg-agent-header')!
const status = el.querySelector('.thinking-status')!
const tool = el.querySelector('.live-tool-group')!
const body = el.querySelector('.message-bubble')!
return !!(header.compareDocumentPosition(status) & Node.DOCUMENT_POSITION_FOLLOWING)
&& !!(status.compareDocumentPosition(tool) & Node.DOCUMENT_POSITION_FOLLOWING)
&& !!(tool.compareDocumentPosition(body) & Node.DOCUMENT_POSITION_FOLLOWING)
})
expect(order).toBe(true)
await page.evaluate((sid) => {
(window as any).__PW_CHAT_SOCKET__.latest.__trigger('run.completed', { event: 'run.completed', session_id: sid, run_id: 'activity-layout' })
}, sessionId)
await expect(assistant.locator('.thinking-status')).toHaveCount(0)
await expect(assistant.locator('.tool-group > summary')).toHaveText('Tool calls (1)')
await expect(assistant.locator('.message-bubble')).toContainText('Final answer')
}) })
test('opens the session model dialog and the session search from the chat header', async ({ page }) => { test('opens the session model dialog and the session search from the chat header', async ({ page }) => {
+1
View File
@@ -22,6 +22,7 @@ test('thinking tool rows fill the chat width and shrink without overflowing', as
}) })
}) })
const panel = page.locator('.tool-calls-panel') const panel = page.locator('.tool-calls-panel')
await panel.locator('.live-tool-group > summary').click()
const row = panel.locator('.tool-call-item').first() const row = panel.locator('.tool-call-item').first()
await expect(row).toBeVisible() await expect(row).toBeVisible()
for (const width of [1920, 900, 390]) { for (const width of [1920, 900, 390]) {