From f07978f3e1d12eee5276f35de2b05f0c72a03693 Mon Sep 17 00:00:00 2001 From: shanyuhai123 <864299347@qq.com> Date: Tue, 29 Sep 2026 17:00:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(buddy):=20=E4=BC=98=E5=8C=96=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=B4=BB=E5=8A=A8=E7=8A=B6=E6=80=81=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/__tests__/agentInput.e2e.mjs | 82 +++------------- .../__tests__/chatAgentActivities.spec.ts | 44 +++++++++ .../model/transcript/chatActivityLayout.ts | 40 ++++++-- .../model/transcript/chatAgentActivities.ts | 2 + .../transcript/BuddyChatActivityGroup.vue | 41 ++++++-- .../transcript/BuddyChatActivitySpinner.vue | 24 +++++ .../transcript/BuddyChatReasoningRow.vue | 8 +- .../widgets/transcript/BuddyChatToolRow.vue | 12 ++- .../__tests__/BuddyChatActivities.spec.ts | 95 ++++++++++++++++++- 9 files changed, 255 insertions(+), 93 deletions(-) create mode 100644 apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatActivitySpinner.vue diff --git a/.playwright/scripts/__tests__/agentInput.e2e.mjs b/.playwright/scripts/__tests__/agentInput.e2e.mjs index ce1eeb65..6ae63142 100644 --- a/.playwright/scripts/__tests__/agentInput.e2e.mjs +++ b/.playwright/scripts/__tests__/agentInput.e2e.mjs @@ -320,65 +320,46 @@ test('activity groups retain open reasoning across tools, reply phases and canva await page.getByRole('button', { name: '发送消息', exact: true }).click() const activity = page.locator('.buddy-chat-run-activity') const group = page.locator('.buddy-chat-activity-group') + const reasoning = group.locator('.buddy-chat-reasoning-entry__header') await expect(activity).toContainText('正在思考') - await expect(group.locator('.buddy-chat-reasoning-entry__header')).toContainText('正在思考') - await group.locator('.buddy-chat-reasoning-entry__header').click() + await expect(reasoning.locator('.buddy-chat-activity-spinner')).toBeVisible() + await reasoning.click() const originalBody = await group.locator('.buddy-chat-reasoning-entry__body').elementHandle() await expect(group.locator('.buddy-chat-reasoning-entry__body')).toContainText('先检查工作目录') + gates.tools.resolve() await expect(activity).toContainText('准备中') - await expect(group).toHaveAttribute('data-status', 'completed') expect(await originalBody.evaluate(element => element.isConnected)).toBe(true) gates.arguments.resolve() await expect.poll(() => requests.length).toBe(2) - await expect(group).toHaveAttribute('data-status', 'completed') - await expect(group.locator('.buddy-chat-activity-group__header')).toContainText('思考 · 读取 1 个文件 · 搜索 1 次') - await expect(group.locator('.buddy-chat-activity-group__header')).toHaveAttribute('aria-expanded', 'true') - expect(await originalBody.evaluate(element => element.isConnected)).toBe(true) - await expect(activity).toContainText('正在处理') + const groupHeader = group.locator('.buddy-chat-activity-group__header') + await expect(groupHeader).toContainText('思考 · 读取 1 个文件 · 搜索 1 次') + await expect(groupHeader).toHaveAttribute('aria-expanded', 'true') + await expect(group.locator('.buddy-chat-activity-spinner')).toHaveCount(0) await expect(group.locator('.buddy-chat-activity-group__issues')).toContainText('1 项异常') - const failedTool = group.locator('[data-tool-call-id="read-missing"]') - await expectInlineToolStatus(failedTool) - const groupIcon = await group.locator('.buddy-chat-activity-group__header > .buddy-chat-activity-row__icon').elementHandle() - await page.mouse.move(0, 0) - await page.screenshot({ path: path.join(instance.artifactDirectory, 'activity-settled-light.png'), animations: 'disabled' }) - await page.emulateMedia({ reducedMotion: 'no-preference' }) + expect(await originalBody.evaluate(element => element.isConnected)).toBe(true) + gates.thinking.resolve() await expect(group).toHaveAttribute('data-status', 'running') - expect(await groupIcon.evaluate(element => element.isConnected)).toBe(true) - expect(await groupIcon.evaluate(element => getComputedStyle(element).animationName)).toBe('none') - await expect(group.locator('.buddy-chat-activity-group__status')).toHaveText('运行中') - await expect(group.locator('.buddy-shimmer-text--continuous')).toHaveCount(1) - expect(await group.locator('.buddy-shimmer-text--continuous').evaluate(element => getComputedStyle(element).animationDuration)).toBe('3s') - expect(await activity.locator('.buddy-shimmer-text--continuous').evaluate(element => getComputedStyle(element).animationDuration)).toBe('1.8s') - await expect(group.locator('.buddy-chat-reasoning-entry__header').last()).toHaveAttribute('aria-expanded', 'false') - await page.emulateMedia({ reducedMotion: 'reduce' }) - expect(await group.locator('.buddy-shimmer-text--continuous').evaluate(element => getComputedStyle(element).animationName)).toBe('none') - await page.evaluate(() => window.lexoraDesktop.settings.update({ desktop: { theme: 'dark' } })) - await expect(page.locator('.buddy-app')).toHaveClass(/is-dark/) - await page.screenshot({ path: path.join(instance.artifactDirectory, 'activity-running-dark.png'), animations: 'disabled' }) - await group.evaluate(element => element.style.maxWidth = '320px') - expect(await group.evaluate(element => element.scrollWidth <= element.clientWidth + 4)).toBe(true) - await expectInlineToolStatus(failedTool) - await page.screenshot({ path: path.join(instance.artifactDirectory, 'activity-running-narrow.png'), animations: 'disabled' }) - await group.evaluate(element => element.style.removeProperty('max-width')) + await expect(groupHeader).toHaveAttribute('aria-label', /运行中/) + await expect(reasoning.last().locator('.buddy-chat-activity-spinner')).toBeVisible() gates.reply.resolve() await expect(activity).toContainText('正在回复') await expect(group).toHaveAttribute('data-status', 'completed') - expect(await originalBody.evaluate(element => element.isConnected)).toBe(true) + await expect(group.locator('.buddy-chat-activity-spinner')).toHaveCount(0) gates.finish.resolve() await expect.poll(() => completedRuns(instance.home)).toBe(1) await expect(activity).toHaveCount(0) expect(await originalBody.evaluate(element => element.isConnected)).toBe(true) + await page.getByTestId('conversation-canvas-toggle').click() await page.locator('.conversation-node[data-kind="answer"]').click() const detail = page.getByTestId('canvas-node-detail') await expect(detail.locator('.buddy-chat-activity-group__header')).toContainText('思考 · 读取 1 个文件 · 搜索 1 次') await detail.locator('.buddy-chat-activity-group__header').click() - await expectInlineToolStatus(detail.locator('[data-tool-call-id="read-missing"]')) + await expect(detail.locator('.buddy-chat-activity-spinner')).toHaveCount(0) await detail.locator('.buddy-chat-reasoning-entry__header').first().click() await expect(detail.locator('.buddy-chat-reasoning-entry__body')).toContainText('先检查工作目录') - await page.screenshot({ path: path.join(instance.artifactDirectory, 'activity-canvas-dark.png'), animations: 'disabled' }) expect(application.diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) } finally { @@ -390,39 +371,6 @@ test('activity groups retain open reasoning across tools, reply phases and canva } }) -async function expectInlineToolStatus(row) { - const spacing = await row.evaluate((element) => { - const status = element.querySelector('.buddy-chat-tool__status') - const headerBounds = element.querySelector('.buddy-chat-tool__header').getBoundingClientRect() - const iconBounds = element.querySelector('.buddy-chat-activity-row__icon').getBoundingClientRect() - const targetBounds = status.previousElementSibling.getBoundingClientRect() - const statusBounds = status.getBoundingClientRect() - const chevronBounds = element.querySelector('.buddy-chat-activity-row__chevron').getBoundingClientRect() - const textBaselines = [...element.querySelectorAll('.buddy-chat-tool__title, .buddy-chat-tool__summary, .buddy-chat-tool__context, .buddy-chat-tool__status > span')].map((text) => { - const marker = document.createElement('span') - marker.style.cssText = 'display: inline-block; width: 0; height: 0; vertical-align: baseline;' - text.append(marker) - const baseline = marker.getBoundingClientRect().top - marker.remove() - return baseline - }) - return { - targetGap: statusBounds.left - targetBounds.right, - chevronGap: chevronBounds.left - statusBounds.right, - textBaselineOffset: Math.max(...textBaselines) - Math.min(...textBaselines), - iconCenterOffset: Math.abs(iconBounds.y + iconBounds.height / 2 - headerBounds.y - headerBounds.height / 2), - chevronCenterOffset: Math.abs(chevronBounds.y + chevronBounds.height / 2 - headerBounds.y - headerBounds.height / 2), - } - }) - expect(spacing.targetGap).toBeGreaterThanOrEqual(0) - expect(spacing.targetGap).toBeLessThanOrEqual(12) - expect(spacing.chevronGap).toBeGreaterThanOrEqual(0) - expect(spacing.chevronGap).toBeLessThanOrEqual(12) - expect(spacing.textBaselineOffset).toBeLessThan(1) - expect(spacing.iconCenterOffset).toBeLessThan(1) - expect(spacing.chevronCenterOffset).toBeLessThan(1) -} - async function useSyntheticCredentialStorage({ app, page }) { await app.evaluate(({ app, safeStorage }) => { if (app.getName() !== 'Lexora Buddy Test') diff --git a/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatAgentActivities.spec.ts b/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatAgentActivities.spec.ts index 252bd954..c53df99e 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatAgentActivities.spec.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatAgentActivities.spec.ts @@ -74,6 +74,18 @@ describe('activity grouping', () => { expect(summarizeChatActivity(mixed, 'zh-CN')).toMatchObject({ icon: 'activity' }) }) + it.each(['before', 'after'] as const)('uses the aggregate icon with reasoning %s a command, independent of execution status', (position) => { + const command: ChatAgentToolNode = { ...tool('command'), toolName: 'bash', presentation: { card: 'terminal', command: 'pwd', cwd: '.', description: null, output: null, exitCode: null, signal: null, truncated: false } } + for (const status of ['running', 'completed', 'interrupted'] as const) { + const reasoning = thought('thought', status) + const nodes = position === 'before' ? [reasoning, command] : [command, reasoning] + const group = createChatAgentActivityProjector().project(nodes)[0]! + if (group.kind !== 'activity-group') + throw new Error('Expected activity group') + expect(summarizeChatActivity(group, 'zh-CN')).toEqual({ icon: 'activity', label: '思考 · 运行 1 条命令' }) + } + }) + it('does not change the summary when only a large tool output is appended', () => { const call = tool('one', 'running') const projector = createChatAgentActivityProjector() @@ -108,6 +120,38 @@ describe('activity grouping', () => { skill.presentation.path = 'skills/review/SKILL.md' expect(presentChatActivityLayout([skill, tool('file')]).compact.size).toBe(0) }) + + it('merges adjacent reasoning in order without changing source nodes or crossing tool boundaries', () => { + const first = { ...thought('first'), text: '**First**\n\nDetails' } + const second = { ...thought('second'), text: 'Second paragraph' } + const third = { ...thought('third'), text: 'After tools' } + const fourth = { ...thought('fourth'), text: 'Last paragraph' } + const nodes = [first, second, tool('one'), tool('two'), third, fourth] + const original = structuredClone(nodes) + const layout = presentChatActivityLayout(nodes) + expect(layout.entries).toEqual([ + { ...first, text: '**First**\n\nDetails\n\nSecond paragraph' }, + nodes[2], + nodes[3], + { id: 'details:tool:one', kind: 'tool-details', node: nodes[2] }, + { id: 'details:tool:two', kind: 'tool-details', node: nodes[3] }, + { ...third, text: 'After tools\n\nLast paragraph' }, + ]) + expect(nodes).toEqual(original) + expect(presentChatActivityLayout([first]).entries[0]).toBe(first) + }) + + it.each([ + ['completed', 'completed', 'completed'], + ['completed', 'running', 'running'], + ['interrupted', 'running', 'running'], + ['interrupted', 'completed', 'interrupted'], + ['completed', 'interrupted', 'interrupted'], + ] as const)('combines reasoning states %s and %s as %s, including empty active blocks', (first, last, status) => { + const layout = presentChatActivityLayout([thought('first', first), { ...thought('last', last), text: '' }]) + expect(layout.entries).toEqual([{ ...thought('first', status) }]) + }) + it('disambiguates filenames with the shortest unique suffix and retains repeated reads', () => { const nodes = ['src/a/index.ts', 'src/b/index.ts', 'lib/a/index.ts', 'src/a/index.ts', 'src/main.ts'].map((path, index) => { const node = tool(String(index)) diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatActivityLayout.ts b/apps/buddy/src/modules/tasks/model/transcript/chatActivityLayout.ts index e6d9c544..6356af7d 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatActivityLayout.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatActivityLayout.ts @@ -1,5 +1,5 @@ import type { ChatAgentActivityGroup } from './chatAgentActivities' -import type { ChatAgentToolNode } from './chatAgentTurn' +import type { ChatAgentReasoningNode, ChatAgentToolNode } from './chatAgentTurn' import { getChatToolRegistration, isRegisteredChatTool } from './chatToolRegistry' interface ChatToolDetailsEntry { @@ -18,13 +18,14 @@ export function presentChatActivityLayout(nodes: ChatAgentActivityGroup['nodes'] const entries: Array = [] const compact = new Map() let reads: ChatAgentToolNode[] = [] + let reasoning: ChatAgentReasoningNode[] = [] let readIcon: string | null = null function details(node: ChatAgentToolNode): ChatToolDetailsEntry { return { id: `details:${node.id}`, kind: 'tool-details', node } } - function flush() { + function flushReads() { if (reads.length > 1) { const paths = reads.map((node) => { const path = node.presentation.card === 'read' ? node.presentation.path.replaceAll('\\', '/') : '' @@ -42,24 +43,45 @@ export function presentChatActivityLayout(nodes: ChatAgentActivityGroup['nodes'] readIcon = null } + function flushReasoning() { + const first = reasoning[0] + if (!first) + return + entries.push(reasoning.length === 1 + ? first + : { + ...first, + text: reasoning.map(node => node.text).filter(text => text.trim()).join('\n\n'), + status: reasoning.some(node => node.status === 'running') + ? 'running' + : reasoning.some(node => node.status === 'interrupted') ? 'interrupted' : 'completed', + }) + reasoning = [] + } + for (const node of nodes) { - if (node.kind === 'tool' && isRegisteredChatTool(node.toolName) && node.presentation.card === 'read' && node.presentation.path + if (node.kind === 'reasoning') { + flushReads() + reasoning.push(node) + continue + } + flushReasoning() + if (isRegisteredChatTool(node.toolName) && node.presentation.card === 'read' && node.presentation.path && node.status === 'completed' && !node.isError) { const icon = getChatToolRegistration(node).icon if (readIcon !== icon) - flush() + flushReads() readIcon = icon reads.push(node) entries.push(node) } else { - flush() - entries.push(node) - if (node.kind === 'tool') - entries.push(details(node)) + flushReads() + entries.push(node, details(node)) } } - flush() + flushReads() + flushReasoning() return { entries, compact } } diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatAgentActivities.ts b/apps/buddy/src/modules/tasks/model/transcript/chatAgentActivities.ts index bef7c313..8330f904 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatAgentActivities.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatAgentActivities.ts @@ -99,6 +99,8 @@ function summarizeGroup(id: string, nodes: ChatAgentActivityGroup['nodes']): Cha if (isChatToolIssue(node)) issueCount++ } + if (reasoningCount && toolCount) + icon = 'activity' const status = running ? 'running' : preparing ? 'preparing' : approvalCount ? 'awaiting_approval' : interrupted ? 'interrupted' : 'completed' return { id, kind: 'activity-group', nodes, issueCount, toolCount, reasoningCount, approvalCount, status, icon, counts: [...counts].map(([category, count]) => ({ category, count, files: files.get(category)?.size ?? null })) } } diff --git a/apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatActivityGroup.vue b/apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatActivityGroup.vue index 10261a39..5099abde 100644 --- a/apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatActivityGroup.vue +++ b/apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatActivityGroup.vue @@ -8,6 +8,7 @@ import DesktopIcon from '@/shared/ui/icon/DesktopIcon.vue' import { presentChatActivityLayout } from '../../model/transcript/chatActivityLayout' import { summarizeChatActivity, summarizeChatActivityCounts } from '../../model/transcript/chatActivitySummary' import { canExpandChatTool, isChatToolIssue } from '../../model/transcript/chatToolDisplay' +import BuddyChatActivitySpinner from './BuddyChatActivitySpinner.vue' import BuddyChatDisclosure from './BuddyChatDisclosure.vue' import BuddyChatReasoningRow from './BuddyChatReasoningRow.vue' import BuddyChatShimmerText from './BuddyChatShimmerText.vue' @@ -36,8 +37,9 @@ const stateLabel = computed(() => props.group.status === 'running' ? t('desktop.chat.progressPreparing') : props.group.status === 'interrupted' ? t('desktop.chat.processToolInterrupted') : '') const singleTool = computed(() => props.group.nodes.length === 1 && props.group.toolCount === 1) -const singleReasoning = computed(() => props.group.nodes.length === 1 && props.group.nodes[0]?.kind === 'reasoning') const layout = computed(() => presentChatActivityLayout(props.group.nodes)) +const singleReasoning = computed(() => layout.value.entries.length === 1 && layout.value.entries[0]?.kind === 'reasoning') +const showBottomCollapse = computed(() => layout.value.entries.filter(entry => entry.kind !== 'tool-details').length > 8) const issues = computed(() => props.group.nodes.filter(node => node.kind === 'tool' && isChatToolIssue(node))) const fullSummary = computed(() => summarizeChatActivityCounts(props.group, props.language, Infinity)) const summary = computed(() => summarizeChatActivity(props.group, props.language)) @@ -94,8 +96,11 @@ function collapseFromBottom() { {{ open ? fullSummary : summary.label }} - {{ stateLabel }} -