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_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) {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user