feat(desktop): refine activity panel agent interactions

Add generated local Agent mascot PNG assets for SubAgent activity rows and render them through a deterministic AgentMascot component.

Keep tool-call groups collapsed by default and preserve user collapse choices while streaming tool updates arrive.

Tighten the activity panel density while keeping running SubAgents visibly animated without adding runtime image generation.

Tested: cd desktop && bun run test -- src/components/activity/AgentMascot.test.tsx src/components/activity/SessionActivityPanel.test.tsx src/pages/ActiveSession.test.tsx --run

Tested: cd desktop && bun run test -- src/components/activity/AgentMascot.test.tsx src/components/activity/SessionActivityPanel.test.tsx src/components/chat/MessageList.test.tsx src/components/chat/chatBlocks.test.tsx --run

Tested: git diff --check

Tested: bun run check:desktop

Tested: browser smoke at http://127.0.0.1:1420/ with 12 PNG mascot variants loaded at 160x160 and rendered at 30x30.

Confidence: high

Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-07-08 15:55:32 +08:00
parent f269887fe7
commit f6115e91aa
19 changed files with 602 additions and 87 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -0,0 +1,73 @@
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { describe, expect, it } from 'vitest'
import {
AGENT_MASCOT_VARIANTS,
AgentMascot,
resolveAgentMascotSpec,
} from './AgentMascot'
describe('AgentMascot', () => {
it('resolves the same seed to a stable mascot variant across statuses', () => {
const running = resolveAgentMascotSpec({ seed: 'session-1:tool-1', status: 'running' })
const completed = resolveAgentMascotSpec({ seed: 'session-1:tool-1', status: 'completed' })
expect(running.variant).toBe(completed.variant)
expect(running.motion).toBe('active')
expect(completed.motion).toBe('still')
})
it.each([
['running', 'active', 'accent'],
['completed', 'still', 'success'],
['failed', 'still', 'danger'],
['stopped', 'still', 'danger'],
['pending', 'still', 'muted'],
] as const)('renders %s with semantic state metadata', (status, motion, tone) => {
render(<AgentMascot seed="agent-seed" status={status} />)
const mascot = screen.getByTestId('agent-mascot')
expect(mascot).toHaveAttribute('data-agent-mascot-state', status)
expect(mascot).toHaveAttribute('data-agent-mascot-motion', motion)
expect(mascot).toHaveAttribute('data-agent-mascot-tone', tone)
expect(mascot).toHaveAttribute('aria-hidden', 'true')
expect(mascot).toHaveClass('h-[30px]')
expect(mascot).toHaveClass('w-[30px]')
expect(mascot.querySelector('img')).toHaveAttribute('alt', '')
expect(mascot.querySelector('img')).toHaveAttribute('draggable', 'false')
})
it('only renders the motion ring for active agents', () => {
const { rerender } = render(<AgentMascot seed="agent-seed" status="running" />)
expect(screen.getByTestId('agent-mascot-motion-ring')).toBeInTheDocument()
rerender(<AgentMascot seed="agent-seed" status="completed" />)
expect(screen.queryByTestId('agent-mascot-motion-ring')).not.toBeInTheDocument()
})
it('maps every mascot variant to a local generated image asset', () => {
const { rerender } = render(<AgentMascot seed="agent-seed" status="running" />)
for (const variant of AGENT_MASCOT_VARIANTS) {
let seed = ''
for (let index = 0; index < 10000; index += 1) {
const candidate = `variant-${variant}-${index}`
if (resolveAgentMascotSpec({ seed: candidate, status: 'running' }).variant === variant) {
seed = candidate
break
}
}
expect(seed).not.toBe('')
rerender(<AgentMascot seed={seed} status="running" />)
const mascot = screen.getByTestId('agent-mascot')
expect(mascot).toHaveAttribute('data-agent-mascot-variant', variant)
expect(mascot.getAttribute('data-agent-mascot-src')).toContain(`agent-mascot-${variant}`)
expect(mascot.querySelector('img')?.getAttribute('src')).toContain(`agent-mascot-${variant}`)
}
})
})

View File

@ -0,0 +1,123 @@
import type { ActivityStatus } from './sessionActivityModel'
import mascotBuild from '../../assets/agent-mascots/agent-mascot-build.png'
import mascotCheck from '../../assets/agent-mascots/agent-mascot-check.png'
import mascotCode from '../../assets/agent-mascots/agent-mascot-code.png'
import mascotData from '../../assets/agent-mascots/agent-mascot-data.png'
import mascotDesign from '../../assets/agent-mascots/agent-mascot-design.png'
import mascotDocs from '../../assets/agent-mascots/agent-mascot-docs.png'
import mascotFix from '../../assets/agent-mascots/agent-mascot-fix.png'
import mascotPlan from '../../assets/agent-mascots/agent-mascot-plan.png'
import mascotRelease from '../../assets/agent-mascots/agent-mascot-release.png'
import mascotSearch from '../../assets/agent-mascots/agent-mascot-search.png'
import mascotShield from '../../assets/agent-mascots/agent-mascot-shield.png'
import mascotTerminal from '../../assets/agent-mascots/agent-mascot-terminal.png'
export const AGENT_MASCOT_VARIANTS = [
'code',
'check',
'plan',
'shield',
'docs',
'terminal',
'search',
'release',
'build',
'fix',
'design',
'data',
] as const
export type AgentMascotVariant = typeof AGENT_MASCOT_VARIANTS[number]
export type AgentMascotSpec = {
seed: string
variant: AgentMascotVariant
state: ActivityStatus
motion: 'active' | 'still'
tone: 'accent' | 'success' | 'danger' | 'muted'
}
const MASCOT_IMAGE_BY_VARIANT: Record<AgentMascotVariant, string> = {
build: mascotBuild,
check: mascotCheck,
code: mascotCode,
data: mascotData,
design: mascotDesign,
docs: mascotDocs,
fix: mascotFix,
plan: mascotPlan,
release: mascotRelease,
search: mascotSearch,
shield: mascotShield,
terminal: mascotTerminal,
}
export function stableAgentMascotHash(value: string): number {
let hash = 0
for (let index = 0; index < value.length; index += 1) {
hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0
}
return Math.abs(hash)
}
export function resolveAgentMascotSpec({
seed,
status,
}: {
seed: string
status: ActivityStatus
}): AgentMascotSpec {
const normalizedSeed = seed.trim() || 'agent'
const isRunning = status === 'running' || status === 'in_progress'
const isComplete = status === 'completed' || status === 'idle'
const isFailed = status === 'failed' || status === 'error' || status === 'stopped'
return {
seed: normalizedSeed,
variant: AGENT_MASCOT_VARIANTS[stableAgentMascotHash(normalizedSeed) % AGENT_MASCOT_VARIANTS.length] ?? 'code',
state: status,
motion: isRunning ? 'active' : 'still',
tone: isFailed ? 'danger' : isComplete ? 'success' : isRunning ? 'accent' : 'muted',
}
}
function ringClassName(tone: AgentMascotSpec['tone']): string {
if (tone === 'danger') return 'border-[color-mix(in_srgb,var(--color-error)_42%,transparent)]'
if (tone === 'success') return 'border-[color-mix(in_srgb,var(--color-success)_34%,transparent)]'
if (tone === 'muted') return 'border-[color-mix(in_srgb,var(--color-text-tertiary)_24%,transparent)]'
return 'border-[color-mix(in_srgb,var(--color-accent)_42%,transparent)]'
}
export function AgentMascot({ seed, status }: { seed: string; status: ActivityStatus }) {
const spec = resolveAgentMascotSpec({ seed, status })
const isActive = spec.motion === 'active'
const imageSrc = MASCOT_IMAGE_BY_VARIANT[spec.variant]
return (
<span
data-testid="agent-mascot"
data-agent-mascot-seed={spec.seed}
data-agent-mascot-variant={spec.variant}
data-agent-mascot-state={spec.state}
data-agent-mascot-motion={spec.motion}
data-agent-mascot-tone={spec.tone}
data-agent-mascot-src={imageSrc}
className={`relative inline-flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-xl border bg-[var(--color-surface)] shadow-[0_1px_3px_rgba(15,23,42,0.14),inset_0_1px_0_rgba(255,255,255,0.76)] ${ringClassName(spec.tone)}`}
aria-hidden="true"
>
{isActive ? (
<span
data-testid="agent-mascot-motion-ring"
className="absolute -inset-0.5 rounded-[14px] border border-transparent border-t-[var(--color-accent)] opacity-80 motion-safe:animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : null}
<img
src={imageSrc}
alt=""
draggable={false}
className="relative z-[1] h-[34px] w-[34px] max-w-none select-none object-contain"
/>
</span>
)
}

View File

@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SessionActivityPanel } from './SessionActivityPanel'
@ -160,7 +160,8 @@ describe('SessionActivityPanel', () => {
expect(screen.getByLabelText('Task completed')).toBeInTheDocument()
expect(screen.getByLabelText('Task in progress')).toBeInTheDocument()
expect(screen.getByLabelText('Task pending')).toBeInTheDocument()
expect(screen.getByText('Active task').closest('button,div')).toHaveClass('py-3')
expect(screen.getByLabelText('Task in progress').querySelector('svg')).toHaveClass('motion-safe:animate-spin')
expect(screen.getByText('Active task').closest('button,div')).toHaveClass('py-2.5')
expect(screen.queryByText('Completed')).not.toBeInTheDocument()
expect(screen.queryByText('Pending')).not.toBeInTheDocument()
})
@ -290,6 +291,75 @@ describe('SessionActivityPanel', () => {
expect(screen.queryByText('No blocking issue.')).not.toBeInTheDocument()
})
it('animates running SubAgent rows with a compact reduced-motion-safe live marker', () => {
render(<SessionActivityPanel model={model()} open onClose={vi.fn()} onOpenSubagent={vi.fn()} />)
const row = screen.getByRole('button', { name: /open run kuhn.*running/i })
const mascot = within(row).getByTestId('agent-mascot')
const motionRing = within(row).getByTestId('agent-mascot-motion-ring')
expect(row).toHaveTextContent('Running')
expect(mascot).toHaveAttribute('aria-hidden', 'true')
expect(mascot).toHaveAttribute('data-agent-mascot-state', 'running')
expect(mascot).toHaveAttribute('data-agent-mascot-motion', 'active')
expect(mascot).toHaveAttribute('data-agent-mascot-tone', 'accent')
expect(mascot).toHaveAttribute('data-agent-mascot-variant')
expect(motionRing).toBeInTheDocument()
expect(motionRing).toHaveClass('motion-safe:animate-spin')
expect(motionRing).toHaveClass('motion-reduce:animate-none')
expect(row.querySelector('.animate-pulse-dot')).not.toBeInTheDocument()
expect(row.querySelector('.animate-spin')).not.toBeInTheDocument()
expect(row.querySelector('.animate-ping')).not.toBeInTheDocument()
})
it('keeps Agent mascot variants stable for the same SubAgent seed', () => {
const repeatedAgentModel = model({
sections: {
...model().sections,
subagents: {
id: 'subagents',
title: 'SubAgents',
emptyLabel: 'No SubAgents',
rows: [
{
id: 'agent-a-first-render',
section: 'subagents',
label: 'Reviewer A',
status: 'running',
toolUseId: 'stable-agent-tool',
openable: true,
},
{
id: 'agent-a-second-render',
section: 'subagents',
label: 'Reviewer A again',
status: 'completed',
toolUseId: 'stable-agent-tool',
openable: true,
},
],
},
},
})
render(
<SessionActivityPanel
model={repeatedAgentModel}
open
onClose={vi.fn()}
onOpenSubagent={vi.fn()}
/>,
)
const mascots = screen.getAllByTestId('agent-mascot')
expect(mascots).toHaveLength(2)
expect(mascots[0]).toHaveAttribute(
'data-agent-mascot-variant',
mascots[1]?.getAttribute('data-agent-mascot-variant') ?? '',
)
})
it('opens a compact team member row', () => {
const onOpenMember = vi.fn()
const member = {
@ -392,9 +462,9 @@ describe('SessionActivityPanel', () => {
expect(screen.getByTestId('session-activity-panel')).toHaveAttribute('data-placement', 'rail')
expect(screen.getByTestId('session-activity-panel')).toHaveClass('my-4')
expect(screen.getByTestId('session-activity-panel')).toHaveClass('mr-4')
expect(screen.getByTestId('session-activity-panel')).toHaveClass('w-[360px]')
expect(screen.getByTestId('session-activity-panel')).toHaveClass('rounded-[24px]')
expect(screen.getByTestId('session-activity-panel')).toHaveClass('mr-3')
expect(screen.getByTestId('session-activity-panel')).toHaveClass('w-[336px]')
expect(screen.getByTestId('session-activity-panel')).toHaveClass('rounded-[22px]')
expect(screen.getByTestId('session-activity-panel')).toHaveClass('self-start')
expect(screen.getByTestId('session-activity-panel')).toHaveClass('max-h-[min(620px,calc(100vh-72px))]')
expect(screen.getByTestId('session-activity-panel')).not.toHaveClass('h-[calc(100%-24px)]')

View File

@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Bot, Check, ChevronRight, Circle, FileText, LoaderCircle, Terminal, Users, X } from 'lucide-react'
import { Check, ChevronRight, Circle, FileText, LoaderCircle, Terminal, Users, X } from 'lucide-react'
import { AgentMascot } from './AgentMascot'
import { getVisibleActivitySections, type ActivityRow, type ActivitySectionId, type SessionActivityModel } from './sessionActivityModel'
import { useTranslation } from '../../i18n'
import type { BackgroundAgentTask } from '../../types/chat'
@ -164,7 +165,7 @@ function TaskStatusMarker({ status, t }: { status: ActivityRow['status']; t: Tra
aria-label={t('session.activity.task.inProgress')}
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-[var(--color-accent)] bg-[var(--color-surface)] text-[var(--color-accent)]"
>
<LoaderCircle size={13} strokeWidth={2.4} aria-hidden="true" className="animate-spin" />
<LoaderCircle size={13} strokeWidth={2.4} aria-hidden="true" className="motion-safe:animate-spin motion-reduce:animate-none" />
</span>
)
}
@ -184,7 +185,7 @@ function getRowIcon(row: ActivityRow) {
case 'backgroundTasks':
return Terminal
case 'subagents':
return Bot
return Users
case 'sources':
case 'output':
return FileText
@ -206,6 +207,44 @@ function getStatusTone(status: ActivityRow['status']) {
return 'bg-[var(--color-text-tertiary)]'
}
function ActivityRowIcon({ row, sessionId }: { row: ActivityRow; sessionId: string }) {
if (row.section === 'subagents') {
return <AgentMascot seed={`${sessionId}:${row.toolUseId ?? row.taskId ?? row.id}`} status={row.status} />
}
const Icon = getRowIcon(row)
return (
<span className="inline-flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-lg text-[var(--color-text-tertiary)]">
<Icon size={15} strokeWidth={2} aria-hidden="true" />
</span>
)
}
function ActivityStatusIndicator({
status,
label,
animated = true,
}: {
status: ActivityRow['status']
label: string
animated?: boolean
}) {
const isRunning = animated && (status === 'running' || status === 'in_progress')
return (
<span className="inline-flex shrink-0 items-center gap-1 text-[10px] font-medium text-[var(--color-text-tertiary)]">
<span className="relative inline-flex h-1.5 w-1.5" aria-hidden="true">
{isRunning ? (
<span className={`absolute inline-flex h-full w-full rounded-full opacity-35 motion-safe:animate-ping motion-reduce:animate-none ${getStatusTone(status)}`} />
) : null}
<span className={`relative inline-flex h-1.5 w-1.5 rounded-full ${getStatusTone(status)}`} />
</span>
{label}
</span>
)
}
function ActivityRowView({
row,
sessionId,
@ -242,23 +281,18 @@ function ActivityRowView({
{isTask ? (
<TaskStatusMarker status={row.status} t={t} />
) : (
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-lg text-[var(--color-text-tertiary)]">
{(() => {
const Icon = getRowIcon(row)
return <Icon size={17} strokeWidth={2} aria-hidden="true" />
})()}
</span>
<ActivityRowIcon row={row} sessionId={sessionId} />
)}
<span className="min-w-0 flex-1 truncate text-left">
<span
className={`block truncate text-[13px] font-semibold leading-5 ${isTask && row.status === 'completed' ? 'text-[var(--color-text-tertiary)] line-through decoration-[var(--color-text-tertiary)]/60' : 'text-[var(--color-text-primary)]'}`}
className={`block truncate text-[12px] font-semibold leading-4 ${isTask && row.status === 'completed' ? 'text-[var(--color-text-tertiary)] line-through decoration-[var(--color-text-tertiary)]/60' : 'text-[var(--color-text-primary)]'}`}
title={label}
>
{label}
</span>
{detail ? (
<span
className="block truncate text-[11px] leading-4 text-[var(--color-text-tertiary)]"
className="block truncate text-[10px] leading-4 text-[var(--color-text-tertiary)]"
title={detail}
>
{detail}
@ -266,18 +300,19 @@ function ActivityRowView({
) : null}
</span>
{isTask ? null : (
<span className="inline-flex shrink-0 items-center gap-1.5 text-[11px] font-medium text-[var(--color-text-tertiary)]">
<span className={`h-1.5 w-1.5 rounded-full ${getStatusTone(row.status)}`} aria-hidden="true" />
{getActivityStatusLabel(row.status, t)}
</span>
<ActivityStatusIndicator
status={row.status}
label={getActivityStatusLabel(row.status, t)}
animated={row.section !== 'subagents'}
/>
)}
{!isTask && row.openable ? (
<ChevronRight size={14} strokeWidth={2.2} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
<ChevronRight size={13} strokeWidth={2.2} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
) : null}
</>
)
const interactiveRowClassName =
'flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition-[background-color,transform] duration-150 ease-out hover:bg-[var(--color-surface-hover)] active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]'
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2.5 text-left transition-[background-color,transform] duration-150 ease-out hover:bg-[var(--color-surface-hover)] active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]'
if (row.section === 'team' && row.member && onOpenMember) {
return (
@ -293,10 +328,12 @@ function ActivityRowView({
}
if (row.section === 'subagents' && row.openable && row.toolUseId) {
const statusLabel = getActivityStatusLabel(row.status, t)
return (
<button
type="button"
aria-label={t('session.activity.openRun', { name: row.label })}
aria-label={`${t('session.activity.openRun', { name: row.label })} · ${statusLabel}`}
onClick={() => onOpenSubagent({ sessionId, toolUseId: row.toolUseId!, title: row.label })}
className={interactiveRowClassName}
>
@ -320,7 +357,7 @@ function ActivityRowView({
}
return (
<div className="flex items-center gap-3 rounded-xl px-3 py-3">
<div className="flex items-center gap-2.5 rounded-lg px-2.5 py-2.5">
{content}
</div>
)
@ -356,17 +393,17 @@ function BackgroundTaskDetail({ row }: { row: ActivityRow }) {
if (details.length === 0) return null
return (
<div className="mx-3 mb-1.5 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.54)]">
<div className="mb-2 text-[11px] font-semibold text-[var(--color-text-tertiary)]">
<div className="mx-2.5 mb-1.5 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.54)]">
<div className="mb-1.5 text-[10px] font-semibold text-[var(--color-text-tertiary)]">
{t('session.activity.details.title')}
</div>
<dl className="space-y-2">
<dl className="space-y-1.5">
{details.map((detail) => (
<div key={detail.label} className="min-w-0">
<dt className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">
{detail.label}
</dt>
<dd className="max-h-28 overflow-auto whitespace-pre-wrap break-words text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
<dd className="max-h-28 overflow-auto whitespace-pre-wrap break-words text-[10px] leading-relaxed text-[var(--color-text-secondary)]">
{detail.value}
</dd>
</div>
@ -441,8 +478,8 @@ export function SessionActivityPanel({
if (!open) return null
const className = placement === 'rail'
? 'my-4 ml-3 mr-4 flex max-h-[min(620px,calc(100vh-72px))] w-[360px] shrink-0 self-start flex-col overflow-hidden rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[0_26px_80px_-48px_rgba(15,23,42,0.58),0_12px_30px_-22px_rgba(15,23,42,0.34),inset_0_1px_0_rgba(255,255,255,0.82)]'
: 'absolute right-4 top-4 z-40 flex max-h-[calc(100%-80px)] w-[min(360px,calc(100%-32px))] flex-col overflow-hidden rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[0_26px_80px_-48px_rgba(15,23,42,0.58),0_12px_30px_-22px_rgba(15,23,42,0.34),inset_0_1px_0_rgba(255,255,255,0.82)]'
? 'my-4 ml-3 mr-3 flex max-h-[min(620px,calc(100vh-72px))] w-[336px] shrink-0 self-start flex-col overflow-hidden rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[0_24px_72px_-48px_rgba(15,23,42,0.54),0_10px_26px_-22px_rgba(15,23,42,0.32),inset_0_1px_0_rgba(255,255,255,0.82)]'
: 'absolute right-4 top-4 z-40 flex max-h-[calc(100%-80px)] w-[min(336px,calc(100%-32px))] flex-col overflow-hidden rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[0_24px_72px_-48px_rgba(15,23,42,0.54),0_10px_26px_-22px_rgba(15,23,42,0.32),inset_0_1px_0_rgba(255,255,255,0.82)]'
return (
<div
@ -453,21 +490,21 @@ export function SessionActivityPanel({
data-placement={placement}
className={className}
>
<div className="flex items-center justify-between px-5 pb-2 pt-4">
<h2 className="text-[13px] font-semibold text-[var(--color-text-secondary)]">{t('session.activity.title')}</h2>
<div className="flex items-center justify-between px-4 pb-1.5 pt-3.5">
<h2 className="text-[12px] font-semibold text-[var(--color-text-secondary)]">{t('session.activity.title')}</h2>
<button
type="button"
aria-label={t('session.activity.close')}
onClick={onClose}
className="inline-flex h-8 w-8 items-center justify-center rounded-xl text-[var(--color-text-tertiary)] transition-[background-color,color,transform] duration-150 ease-out hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-[var(--color-text-tertiary)] transition-[background-color,color,transform] duration-150 ease-out hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<X size={15} strokeWidth={2.2} aria-hidden="true" />
<X size={14} strokeWidth={2.2} aria-hidden="true" />
</button>
</div>
<div
data-testid="session-activity-scroll"
className={`min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-5 pb-5 pt-1 ${ACTIVITY_SCROLLBAR_CLASS}`}
className={`min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-4 pb-4 pt-0.5 ${ACTIVITY_SCROLLBAR_CLASS}`}
>
{visibleSections.map((section, index) => {
const sectionTitle = getSectionTitle(section.id, t)
@ -476,15 +513,15 @@ export function SessionActivityPanel({
<section
key={section.id}
aria-label={sectionTitle}
className={index > 0 ? 'border-t border-[var(--color-border)] pt-4' : undefined}
className={index > 0 ? 'border-t border-[var(--color-border)] pt-3' : undefined}
>
<div className="mb-2 flex items-center justify-between gap-2 px-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="text-[12px] font-semibold text-[var(--color-text-tertiary)]">
<div className="mb-1.5 flex items-center justify-between gap-2 px-0.5">
<div className="flex min-w-0 items-center gap-1.5">
<h3 className="text-[11px] font-semibold text-[var(--color-text-tertiary)]">
{sectionTitle}
</h3>
{section.rows.length > 0 ? (
<span className="rounded-full bg-[var(--color-surface-container)] px-2 py-0.5 text-[10px] leading-none text-[var(--color-text-tertiary)]">
<span className="rounded-full bg-[var(--color-surface-container)] px-1.5 py-0.5 text-[9px] leading-none text-[var(--color-text-tertiary)]">
{section.rows.length}
</span>
) : null}

View File

@ -951,6 +951,8 @@ describe('MessageList nested tool calls', () => {
const { container } = render(<MessageList />)
expect(screen.getAllByText('Running').length).toBeGreaterThan(0)
expect(screen.queryByText(/Read .*example\.ts.*done/i)).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /dispatched an agent/i }))
expect(screen.getByText(/Read .*example\.ts.*done/i)).toBeTruthy()
expect(container.textContent).toContain('Agent')
})
@ -1107,6 +1109,9 @@ describe('MessageList nested tool calls', () => {
const groupSummary = screen.getByText('TaskUpdate (1), ran a command')
const groupButton = groupSummary.closest('button')
expect(groupButton?.textContent).not.toContain('check_circle')
expect(screen.queryByText('local_bash')).toBeNull()
fireEvent.click(groupButton!)
expect(screen.getByText('local_bash')).toBeTruthy()
})
@ -1237,6 +1242,11 @@ describe('MessageList nested tool calls', () => {
render(<MessageList sessionId={ACTIVE_TAB} />)
expect(screen.getByText('Saved 1 memory item(s)')).toBeTruthy()
expect(screen.queryByText('preferences.md')).toBeNull()
expect(screen.queryByText('Tool details')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Saved 1 memory item/i }))
expect(screen.getByText('preferences.md')).toBeTruthy()
expect(screen.getByText('Tool details')).toBeTruthy()
const memoryCardClassName = screen.getByTestId('memory-tool-activity-card').className
@ -1434,6 +1444,218 @@ describe('MessageList nested tool calls', () => {
])
})
it('honors a manual collapse of an agent group while more SubAgents stream in', async () => {
const initialMessages: UIMessage[] = [
{
id: 'tool-agent-a',
type: 'tool_use',
toolName: 'Agent',
toolUseId: 'agent-a',
input: { description: 'Review renderer' },
timestamp: 1,
},
{
id: 'result-agent-a',
type: 'tool_result',
toolUseId: 'agent-a',
content: 'Async agent launched successfully.',
isError: false,
timestamp: 2,
},
{
id: 'tool-agent-b',
type: 'tool_use',
toolName: 'Agent',
toolUseId: 'agent-b',
input: { description: 'Review stores' },
timestamp: 3,
},
{
id: 'result-agent-b',
type: 'tool_result',
toolUseId: 'agent-b',
content: 'Async agent launched successfully.',
isError: false,
timestamp: 4,
},
]
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({ messages: initialMessages }),
},
})
render(<MessageList />)
const agentGroupButton = screen.getByRole('button', { name: /dispatched 2 agents/i })
expect(screen.queryByText('Review renderer')).toBeNull()
fireEvent.click(agentGroupButton)
expect(screen.getByText('Review renderer')).toBeTruthy()
fireEvent.click(agentGroupButton)
expect(screen.queryByText('Review renderer')).toBeNull()
act(() => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
chatState: 'tool_executing',
messages: [
...initialMessages,
{
id: 'tool-agent-c',
type: 'tool_use',
toolName: 'Agent',
toolUseId: 'agent-c',
input: { description: 'Review coverage' },
timestamp: 5,
},
],
}),
},
})
})
await waitFor(() => {
expect(screen.getByRole('button', { name: /dispatched 3 agents/i })).toBeTruthy()
})
expect(screen.queryByText('Review renderer')).toBeNull()
expect(screen.queryByText('Review coverage')).toBeNull()
})
it('honors a manual collapse of mixed tool groups when nested tool calls arrive', async () => {
const initialMessages: UIMessage[] = [
{
id: 'tool-task-update',
type: 'tool_use',
toolName: 'TaskUpdate',
toolUseId: 'task-update-1',
input: { id: '1', status: 'in_progress' },
timestamp: 1,
},
{
id: 'tool-bash',
type: 'tool_use',
toolName: 'Bash',
toolUseId: 'bash-1',
input: { command: 'git status --short' },
timestamp: 2,
},
]
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
chatState: 'tool_executing',
messages: initialMessages,
}),
},
})
render(<MessageList />)
const mixedGroupButton = screen.getByRole('button', { name: /TaskUpdate \(1\), ran a command/i })
expect(screen.queryByText('git status --short')).toBeNull()
fireEvent.click(mixedGroupButton)
expect(screen.getByText('git status --short')).toBeTruthy()
fireEvent.click(mixedGroupButton)
expect(screen.queryByText('git status --short')).toBeNull()
act(() => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
chatState: 'tool_executing',
messages: [
...initialMessages,
{
id: 'tool-child-read',
type: 'tool_use',
toolName: 'Read',
toolUseId: 'read-1',
input: { file_path: '/workspace/package.json' },
timestamp: 3,
parentToolUseId: 'task-update-1',
},
],
}),
},
})
})
await waitFor(() => {
expect(screen.getByRole('button', { name: /TaskUpdate \(1\), ran a command/i })).toBeTruthy()
})
expect(screen.queryByText('git status --short')).toBeNull()
expect(screen.queryByText('package.json')).toBeNull()
})
it('honors a manual collapse of memory activity when regular tools join the group', async () => {
const initialMessages: UIMessage[] = [
{
id: 'tool-memory-write',
type: 'tool_use',
toolName: 'Write',
toolUseId: 'memory-write-1',
input: {
file_path: '/Users/test/.codex/memory/project-notes.md',
content: 'Persisted context',
},
timestamp: 1,
},
{
id: 'result-memory-write',
type: 'tool_result',
toolUseId: 'memory-write-1',
content: 'Wrote 1 line to /Users/test/.codex/memory/project-notes.md',
isError: false,
timestamp: 2,
},
]
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({ messages: initialMessages }),
},
})
render(<MessageList />)
expect(screen.getByText('Saved 1 memory item(s)')).toBeTruthy()
const memoryActivityButton = screen.getByRole('button', { name: /Saved 1 memory item/i })
expect(screen.queryByText('project-notes.md')).toBeNull()
fireEvent.click(memoryActivityButton)
expect(screen.getByText('project-notes.md')).toBeTruthy()
fireEvent.click(memoryActivityButton)
expect(screen.queryByText('project-notes.md')).toBeNull()
act(() => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
chatState: 'tool_executing',
messages: [
...initialMessages,
{
id: 'tool-bash',
type: 'tool_use',
toolName: 'Bash',
toolUseId: 'bash-1',
input: { command: 'bun test memory.test.ts' },
timestamp: 3,
},
],
}),
},
})
})
await waitFor(() => {
expect(screen.getByText('bun test memory.test.ts')).toBeTruthy()
})
expect(screen.queryByText('project-notes.md')).toBeNull()
})
it('keeps later nested tool calls under their parent after an interleaved user message', () => {
const messages: UIMessage[] = [
{
@ -1541,6 +1763,7 @@ describe('MessageList nested tool calls', () => {
render(<MessageList />)
fireEvent.click(screen.getByRole('button', { name: /dispatched an agent/i }))
expect(screen.getByText('Failed')).toBeTruthy()
expect(screen.getByText('Explore agent unavailable in this session')).toBeTruthy()
})
@ -1584,6 +1807,7 @@ describe('MessageList nested tool calls', () => {
render(<MessageList />)
fireEvent.click(screen.getByRole('button', { name: /dispatched an agent/i }))
expect(screen.getByText('Done')).toBeTruthy()
expect(screen.getByRole('button', { name: 'View result' })).toBeTruthy()
@ -1616,6 +1840,7 @@ describe('MessageList nested tool calls', () => {
render(<MessageList />)
fireEvent.click(screen.getByRole('button', { name: /dispatched an agent/i }))
fireEvent.click(screen.getByRole('button', { name: 'Open run Inspect src/components' }))
const expectedTabId = '__subagent__active-tab__agent-1'
@ -1702,6 +1927,7 @@ describe('MessageList nested tool calls', () => {
render(<MessageList />)
fireEvent.click(screen.getByRole('button', { name: /dispatched an agent/i }))
expect(screen.getByText('Done')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'View result' }))
@ -1756,6 +1982,7 @@ describe('MessageList nested tool calls', () => {
render(<MessageList />)
fireEvent.click(screen.getByRole('button', { name: /dispatched an agent/i }))
expect(screen.getByText(/最终报告应该按 Markdown 展示。/)).toBeTruthy()
expect(screen.queryByText(/raw structured JSON should not be shown/)).toBeNull()
@ -1819,6 +2046,7 @@ describe('MessageList nested tool calls', () => {
render(<MessageList />)
fireEvent.click(screen.getByRole('button', { name: /dispatched an agent/i }))
expect(screen.getByText(/git:v0\.2\.6\.\.v0\.2\.7:0/)).toBeTruthy()
expect(screen.queryByText(/\{"results"/)).toBeNull()

View File

@ -1,4 +1,4 @@
import { memo, useEffect, useState } from 'react'
import { memo, useCallback, useState } from 'react'
import { BookMarked, ChevronDown, ChevronRight, Settings } from 'lucide-react'
import { ToolCallBlock } from './ToolCallBlock'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
@ -27,6 +27,16 @@ type MemoryToolActivity = {
files: MemoryToolFile[]
}
function useExpandableCardState() {
const [expanded, setExpanded] = useState(false)
const toggleExpanded = useCallback(() => {
setExpanded((value) => !value)
}, [])
return { expanded, toggleExpanded }
}
type Props = {
sessionId?: string | null
toolCalls: ToolCall[]
@ -34,7 +44,7 @@ type Props = {
childToolCallsByParent: Map<string, ToolCall[]>
agentTaskNotifications: Record<string, AgentTaskNotification>
showOpenRun?: boolean
/** When true, the last tool is still executing — show expanded */
/** When true, the last tool is still executing. */
isStreaming?: boolean
}
@ -124,16 +134,16 @@ export const ToolCallGroup = memo(function ToolCallGroup({
if (memoryActivity) {
const memoryToolCalls = toolCalls.filter(isMemoryToolCall)
const regularToolCalls = toolCalls.filter((toolCall) => !isMemoryToolCall(toolCall))
if (regularToolCalls.length > 0) {
return (
<div className="mb-2 space-y-2">
<MemoryToolActivityGroup
activity={memoryActivity}
toolCalls={memoryToolCalls}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
isStreaming={isStreaming}
/>
return (
<div className={regularToolCalls.length > 0 ? 'mb-2 space-y-2' : ''}>
<MemoryToolActivityGroup
activity={memoryActivity}
toolCalls={memoryToolCalls}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
isStreaming={isStreaming}
/>
{regularToolCalls.length > 0 ? (
<ToolCallGroupContent
sessionId={sessionId}
toolCalls={regularToolCalls}
@ -143,17 +153,8 @@ export const ToolCallGroup = memo(function ToolCallGroup({
showOpenRun={showOpenRun}
isStreaming={isStreaming}
/>
</div>
)
}
return (
<MemoryToolActivityGroup
activity={memoryActivity}
toolCalls={memoryToolCalls}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
isStreaming={isStreaming}
/>
) : null}
</div>
)
}
@ -231,7 +232,7 @@ function MemoryToolActivityGroup({
childToolCallsByParent: Map<string, ToolCall[]>
isStreaming?: boolean
}) {
const [expanded, setExpanded] = useState(activity.action === 'saved')
const { expanded, toggleExpanded } = useExpandableCardState()
const [detailsExpanded, setDetailsExpanded] = useState(false)
const t = useTranslation()
const titleKey = activity.action === 'saved'
@ -240,10 +241,6 @@ function MemoryToolActivityGroup({
const visibleFiles = activity.files.slice(0, 4)
const hiddenCount = Math.max(0, activity.files.length - visibleFiles.length)
useEffect(() => {
if (isStreaming) setExpanded(true)
}, [isStreaming])
return (
<div className="mb-2">
<div
@ -252,7 +249,7 @@ function MemoryToolActivityGroup({
>
<button
type="button"
onClick={() => setExpanded((value) => !value)}
onClick={toggleExpanded}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]/50"
>
{expanded ? (
@ -347,7 +344,7 @@ function AgentToolGroup({
showOpenRun = true,
isStreaming,
}: Props) {
const [expanded, setExpanded] = useState(true)
const { expanded, toggleExpanded } = useExpandableCardState()
const t = useTranslation()
const statuses = toolCalls.map((toolCall) =>
getAgentStatus({
@ -364,17 +361,11 @@ function AgentToolGroup({
const allComplete = statuses.every((status) => status === 'done')
const anyStopped = statuses.some((status) => status === 'stopped')
useEffect(() => {
if (isStreaming) {
setExpanded(true)
}
}, [isStreaming])
return (
<div className="mb-2">
<button
type="button"
onClick={() => setExpanded((value) => !value)}
onClick={toggleExpanded}
className="flex w-full items-center gap-2 rounded-lg border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-container-high)]"
>
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)]">
@ -432,25 +423,18 @@ function AgentToolGroup({
/** Separated so the useState hook is never called conditionally. */
function ToolCallGroupMulti({ toolCalls, resultMap, childToolCallsByParent, isStreaming }: Props) {
const [expanded, setExpanded] = useState(false)
const { expanded, toggleExpanded } = useExpandableCardState()
const t = useTranslation()
const summary = generateSummary(toolCalls, t)
const errorPresent = groupHasErrors(toolCalls, resultMap, childToolCallsByParent)
const hasUnresolvedTools = hasUnresolvedToolCalls(toolCalls, resultMap, childToolCallsByParent)
const isRunning = !!isStreaming || hasUnresolvedTools
const hasNestedToolCalls = toolCalls.some((tc) => (childToolCallsByParent.get(tc.toolUseId)?.length ?? 0) > 0)
useEffect(() => {
if (isRunning || hasNestedToolCalls) {
setExpanded(true)
}
}, [hasNestedToolCalls, isRunning])
return (
<div className="mb-2">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
onClick={toggleExpanded}
className="flex w-full items-center gap-2 rounded-lg border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-container-high)]"
>
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)]">

View File

@ -928,7 +928,7 @@ describe('ActiveSession task polling', () => {
render(<ActiveSession />)
fireEvent.click(screen.getByRole('button', { name: 'Open run Review workspace seams' }))
fireEvent.click(screen.getByRole('button', { name: /Open run Review workspace seams.*Completed/ }))
const tab = useTabStore.getState().tabs.find((candidate) => candidate.sessionId === '__subagent__activity-subagent-open-session__agent-tool-1')
expect(tab).toMatchObject({