fix(chat): place agent activity before response
This commit is contained in:
@@ -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_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 toast = useMessage();
|
||||
|
||||
@@ -1021,7 +1021,8 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
<span class="msg-profile-name" :title="assistantProfileName">{{ assistantProfileName }}</span>
|
||||
</div>
|
||||
<div class="msg-content" :class="message.role">
|
||||
<slot name="agent-activity" />
|
||||
<div v-if="!activityOnly" class="msg-content" :class="message.role">
|
||||
<div
|
||||
class="message-bubble"
|
||||
:class="{
|
||||
|
||||
@@ -120,7 +120,22 @@ const displayMessages = computed(() => {
|
||||
}
|
||||
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 {
|
||||
@@ -519,18 +534,15 @@ defineExpose({
|
||||
:message="msg"
|
||||
:highlight="chatStore.focusMessageId === msg.id"
|
||||
:show-fork-action="canForkActiveSession && msg.id === lastForkActionMessageId"
|
||||
/>
|
||||
</template>
|
||||
<template #after>
|
||||
<Transition name="fade">
|
||||
<div v-if="isThinkingIndicatorVisible" class="streaming-indicator">
|
||||
<div class="thinking-status">
|
||||
<img
|
||||
src="/app-logo.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="thinking-avatar"
|
||||
:activity-only="msg.id === `run-activity-${chatStore.activeSessionId}`"
|
||||
>
|
||||
<template #agent-activity>
|
||||
<details v-if="msg.toolMessages?.length" class="tool-group">
|
||||
<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-copy">
|
||||
<span class="thinking-status-label">{{ t("chat.thinkingInProgress") }}</span>
|
||||
<span class="thinking-status-time">{{ formattedThinkingElapsed }}</span>
|
||||
@@ -696,7 +708,8 @@ defineExpose({
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
</MessageItem>
|
||||
</template>
|
||||
</VirtualMessageList>
|
||||
<button
|
||||
@@ -1475,22 +1488,8 @@ defineExpose({
|
||||
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) {
|
||||
.thinking-status .thinking-avatar, .thinking-status .thinking-status-label { animation: none; }
|
||||
.thinking-status .thinking-status-label { animation: none; }
|
||||
}
|
||||
|
||||
.thinking-status-copy {
|
||||
|
||||
@@ -13,6 +13,7 @@ export function groupRunMessages(messages: Message[], runActive: boolean): Messa
|
||||
const first = assistants[0]
|
||||
result.push({
|
||||
...first,
|
||||
toolMessages: tools.length ? tools : undefined,
|
||||
id: assistants[assistants.length - 1].id,
|
||||
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,
|
||||
@@ -20,7 +21,7 @@ export function groupRunMessages(messages: Message[], runActive: boolean): Messa
|
||||
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 })
|
||||
}
|
||||
if (completed && assistants.length) {
|
||||
|
||||
@@ -8,8 +8,8 @@ describe('groupRunMessages', () => {
|
||||
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 active = groupRunMessages(input, true)
|
||||
expect(active.map(m => m.role)).toEqual(['user', 'assistant', 'system'])
|
||||
expect(active[2]).toMatchObject({ systemType: 'tool-group', toolMessages: [input[2]] })
|
||||
expect(active.map(m => m.role)).toEqual(['user', 'assistant'])
|
||||
expect(active[1].toolMessages).toEqual([input[2]])
|
||||
expect(active[1]).toMatchObject({ id: 'b', content: 'first\n\nsecond' })
|
||||
const finished = groupRunMessages(input, false)
|
||||
expect(finished.at(-1)?.systemType).toBe('run-end')
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
}, 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) => {
|
||||
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')
|
||||
})
|
||||
|
||||
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 mockHermesApi(page)
|
||||
await mockChatSocket(page)
|
||||
@@ -146,15 +147,44 @@ test('animates the application logo while thinking and respects reduced motion',
|
||||
await sendChatMessage(page, 'think')
|
||||
const { sessionId } = await waitForRunSessionId(page)
|
||||
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)
|
||||
const avatar = page.locator('.thinking-avatar')
|
||||
await expect(avatar).toBeVisible()
|
||||
await expect(avatar).toHaveAttribute('src', '/app-logo.png')
|
||||
await expect(avatar).toHaveCSS('animation-name', /^thinking-avatar-breathe/)
|
||||
const assistant = page.locator('.message.assistant')
|
||||
await expect(assistant).toHaveCount(1)
|
||||
await expect(assistant.locator('.msg-profile-name')).toHaveText('research')
|
||||
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 expect(avatar).toHaveCSS('animation-name', 'none')
|
||||
await expect(page.locator('.thinking-status-label')).toHaveCSS('animation-name', 'none')
|
||||
await expect(assistant.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 }) => {
|
||||
|
||||
@@ -22,6 +22,7 @@ test('thinking tool rows fill the chat width and shrink without overflowing', as
|
||||
})
|
||||
})
|
||||
const panel = page.locator('.tool-calls-panel')
|
||||
await panel.locator('.live-tool-group > summary').click()
|
||||
const row = panel.locator('.tool-call-item').first()
|
||||
await expect(row).toBeVisible()
|
||||
for (const width of [1920, 900, 390]) {
|
||||
|
||||
Reference in New Issue
Block a user