Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 15 additions & 67 deletions .playwright/scripts/__tests__/agentInput.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -18,13 +18,14 @@ export function presentChatActivityLayout(nodes: ChatAgentActivityGroup['nodes']
const entries: Array<ChatAgentActivityGroup['nodes'][number] | ChatToolDetailsEntry> = []
const compact = new Map<string, ChatCompactRead>()
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('\\', '/') : ''
Expand All @@ -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 }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 })) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -94,8 +96,11 @@ function collapseFromBottom() {
<BuddyChatShimmerText class="buddy-chat-activity-row__label" :mode="active ? 'continuous' : 'static'">
{{ open ? fullSummary : summary.label }}
</BuddyChatShimmerText>
<span v-if="stateLabel" class="buddy-chat-activity-group__status">{{ stateLabel }}</span>
<DesktopIcon :component="ChevronRight20Regular" class="buddy-chat-activity-row__chevron" :class="{ 'is-open': open }" aria-hidden="true" />
<span v-if="group.status === 'interrupted'" class="buddy-chat-activity-group__status">{{ stateLabel }}</span>
<span class="buddy-chat-activity-group__toggle" :class="{ 'is-loading': active && !open }" aria-hidden="true">
<BuddyChatActivitySpinner v-if="active && !open" />
<DesktopIcon :component="ChevronRight20Regular" class="buddy-chat-activity-row__chevron" :class="{ 'is-open': open }" />
</span>
</button>
<span v-if="group.approvalCount" class="buddy-chat-activity-group__approval">{{ t('desktop.chat.activityApprovalCount', { count: group.approvalCount }) }}</span>
<button v-if="group.issueCount" class="buddy-chat-activity-group__issues" type="button" @click="revealNextIssue">
Expand All @@ -112,7 +117,7 @@ function collapseFromBottom() {
:compact-target="layout.compact.get(entry.id)?.target"
:has-next="layout.compact.get(entry.id)?.hasNext"
:highlighted="highlightedIssue === entry.id"
:animate="singleTool"
:shimmer="singleTool"
@toggle="toggleEntry(entry.id)"
/>
<BuddyChatDisclosure v-else-if="entry.kind === 'tool-details'">
Expand All @@ -127,12 +132,12 @@ function collapseFromBottom() {
</BuddyChatDisclosure>
<BuddyChatReasoningRow
v-else-if="entry.kind === 'reasoning'"
:animate="singleReasoning"
:shimmer="singleReasoning"
:node="entry" :language="language" :open="openEntries.get(entry.id) === true"
@toggle="toggleEntry(entry.id)"
/>
</template>
<button v-if="!singleTool && group.nodes.length > 8" class="buddy-chat-activity-group__collapse buddy-chat-activity-row" type="button" @click="collapseFromBottom">
<button v-if="showBottomCollapse" class="buddy-chat-activity-group__collapse buddy-chat-activity-row" type="button" @click="collapseFromBottom">
<DesktopIcon :component="ChevronUp20Regular" class="buddy-chat-activity-row__icon" aria-hidden="true" />
{{ t('desktop.chat.activityCollapse') }}
</button>
Expand Down Expand Up @@ -160,6 +165,30 @@ function collapseFromBottom() {

.buddy-chat-activity-group__header { min-width: 0; }

.buddy-chat-activity-group__toggle {
display: grid;
width: var(--buddy-chat-activity-icon-size);
height: var(--buddy-chat-activity-icon-size);
flex: none;
place-items: center;
}

.buddy-chat-activity-group__toggle > * { grid-area: 1 / 1; }

.buddy-chat-activity-group__toggle > .buddy-chat-activity-spinner {
visibility: hidden;
animation-play-state: paused;
}

.buddy-chat-activity-group__header:not(:hover, :focus-visible) .buddy-chat-activity-group__toggle.is-loading {
.buddy-chat-activity-spinner {
visibility: visible;
animation-play-state: running;
}

.buddy-chat-activity-row__chevron { visibility: hidden; }
}

.buddy-chat-activity-group__status,
.buddy-chat-activity-group__approval {
flex: none;
Expand Down
Loading