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
BIN
desktop/src/assets/agent-mascots/agent-mascot-build.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
desktop/src/assets/agent-mascots/agent-mascot-check.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
desktop/src/assets/agent-mascots/agent-mascot-code.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
desktop/src/assets/agent-mascots/agent-mascot-data.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
desktop/src/assets/agent-mascots/agent-mascot-design.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
desktop/src/assets/agent-mascots/agent-mascot-docs.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
desktop/src/assets/agent-mascots/agent-mascot-fix.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
desktop/src/assets/agent-mascots/agent-mascot-plan.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
desktop/src/assets/agent-mascots/agent-mascot-release.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
desktop/src/assets/agent-mascots/agent-mascot-search.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
desktop/src/assets/agent-mascots/agent-mascot-shield.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
desktop/src/assets/agent-mascots/agent-mascot-terminal.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
73
desktop/src/components/activity/AgentMascot.test.tsx
Normal 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}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
123
desktop/src/components/activity/AgentMascot.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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)]')
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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)]">
|
||||
|
||||
@ -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({
|
||||
|
||||