feat(desktop): add session activity panel

Move session tasks, background tasks, subagents, and team activity into a floating right-side activity panel with capped section scrolling.

Add subagent run detail tabs backed by the existing session run data so member activity can be inspected from the main chat.

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

Tested: bun run check:desktop
This commit is contained in:
程序员阿江(Relakkes) 2026-07-03 23:23:51 +08:00
parent a5b77822cc
commit 56a4be3d14
32 changed files with 5154 additions and 741 deletions

View File

@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const apiGetMock = vi.hoisted(() => vi.fn())
vi.mock('./client', () => ({
api: {
get: apiGetMock,
},
}))
import { subagentsApi } from './subagents'
describe('subagentsApi', () => {
afterEach(() => {
apiGetMock.mockReset()
})
it('URL-encodes session and tool ids when fetching a run', () => {
apiGetMock.mockResolvedValue({ ok: true })
subagentsApi.getRunByTool('session/one two?x=1', 'tool/alpha beta?y=2')
expect(apiGetMock).toHaveBeenCalledWith(
'/api/sessions/session%2Fone%20two%3Fx%3D1/subagents/by-tool/tool%2Falpha%20beta%3Fy%3D2',
)
})
})

View File

@ -0,0 +1,37 @@
import { api } from './client'
import type { MessageEntry } from '../types/session'
export type SubagentRunStatus = 'running' | 'completed' | 'failed' | 'stopped' | 'unknown'
export type SubagentRunSource = 'subagent-jsonl' | 'session-history' | 'live-task' | 'none'
export type SubagentRunUsage = {
inputTokens?: number
outputTokens?: number
totalTokens?: number
}
export type SubagentRunResponse = {
sessionId: string
toolUseId: string
agentId: string | null
taskId?: string
status: SubagentRunStatus
description?: string
prompt?: string
summary?: string
result?: string
outputFile?: string
usage?: SubagentRunUsage
messages: MessageEntry[]
truncated: boolean
updatedAt?: string
source: SubagentRunSource
}
export const subagentsApi = {
getRunByTool(sessionId: string, toolUseId: string) {
return api.get<SubagentRunResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/subagents/by-tool/${encodeURIComponent(toolUseId)}`,
)
},
}

View File

@ -0,0 +1,49 @@
import { ListChecks } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useActivityPanelStore } from '../../stores/activityPanelStore'
type SessionActivityButtonProps = {
sessionId: string
badgeCount: number
label?: string
}
export function SessionActivityButton({
sessionId,
badgeCount,
label,
}: SessionActivityButtonProps) {
const t = useTranslation()
const resolvedLabel = label ?? t('session.activity.title')
const isOpen = useActivityPanelStore((state) => state.isOpen(sessionId))
const toggle = useActivityPanelStore((state) => state.toggle)
const visibleBadgeCount = Math.max(0, badgeCount)
return (
<button
type="button"
aria-label={resolvedLabel}
aria-expanded={isOpen}
aria-pressed={isOpen}
title={resolvedLabel}
onClick={() => toggle(sessionId)}
data-active={isOpen ? 'true' : 'false'}
data-session-activity-trigger="true"
className={`relative inline-flex h-8 w-8 items-center justify-center rounded-[10px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
isOpen
? 'bg-[var(--color-surface-hover)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
}`}
>
<ListChecks size={17} strokeWidth={1.9} />
{visibleBadgeCount > 0 && (
<span
data-testid="session-activity-badge"
className="absolute -right-1 -top-1 inline-flex min-w-4 h-4 items-center justify-center rounded-full bg-[var(--color-error)] px-1 text-[10px] font-semibold leading-none text-white"
>
{visibleBadgeCount > 9 ? '9+' : visibleBadgeCount}
</span>
)}
</button>
)
}

View File

@ -0,0 +1,508 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SessionActivityPanel } from './SessionActivityPanel'
import type { SessionActivityModel } from './sessionActivityModel'
vi.mock('../../i18n', () => ({
useTranslation: () => (key: string, params?: Record<string, string | number>) => {
const translations: Record<string, string> = {
'chat.backgroundTasks.type.agent': 'Agent',
'chat.backgroundTasks.type.bash': 'Bash',
'chat.backgroundTasks.type.workflow': 'Workflow',
'chat.backgroundTasks.type.task': 'Task',
'chat.backgroundAgents.status.running': 'running',
'chat.backgroundAgents.status.completed': 'completed',
'chat.backgroundAgents.status.failed': 'failed',
'chat.backgroundAgents.status.stopped': 'stopped',
'chat.backgroundAgents.tokens': '{count} tokens',
'chat.duration.seconds': '{seconds}s',
'chat.duration.minutesSeconds': '{minutes}m {seconds}s',
'session.activity.title': 'Activity',
'session.activity.close': 'Close activity',
'session.activity.clearFinished': 'Clear finished',
'session.activity.openTeamMember': 'Open team member {name}',
'session.activity.openRun': 'Open run {name}',
'session.activity.openBackgroundTask': 'Open background task {name}',
'session.activity.details.title': 'Details',
'session.activity.details.type': 'Type',
'session.activity.details.description': 'Description',
'session.activity.details.summary': 'Summary',
'session.activity.details.outputFile': 'Output file',
'session.activity.details.usage': 'Usage',
'session.activity.section.tasks': 'Tasks',
'session.activity.section.team': 'Team',
'session.activity.section.backgroundTasks': 'Background Tasks',
'session.activity.section.subagents': 'SubAgents',
'session.activity.section.sources': 'Sources',
'session.activity.empty.tasks': 'No tasks',
'session.activity.empty.team': 'No team members',
'session.activity.empty.backgroundTasks': 'No background tasks',
'session.activity.empty.subagents': 'No SubAgents',
'session.activity.empty.sources': 'No sources',
'session.activity.task.completed': 'Task completed',
'session.activity.task.inProgress': 'Task in progress',
'session.activity.task.pending': 'Task pending',
'session.activity.tasks.earlier': 'Earlier tasks',
'session.activity.tasks.earlierSummary': 'Earlier turns: {completed}/{total} completed',
'session.activity.status.pending': 'Pending',
'session.activity.status.inProgress': 'In progress',
'session.activity.status.completed': 'Completed',
'session.activity.status.running': 'Running',
'session.activity.status.failed': 'Failed',
'session.activity.status.stopped': 'Stopped',
'session.activity.status.idle': 'Idle',
'session.activity.status.error': 'Error',
}
let text = translations[key] ?? key
if (params) {
for (const [paramKey, paramValue] of Object.entries(params)) {
text = text.replace(new RegExp(`\\{${paramKey}\\}`, 'g'), String(paramValue))
}
}
return text
},
}))
function model(overrides: Partial<SessionActivityModel> = {}): SessionActivityModel {
return {
sessionId: 'session-1',
badgeCount: 1,
sections: {
output: { id: 'output', title: 'Output', emptyLabel: 'No output', rows: [] },
tasks: {
id: 'tasks',
title: 'Tasks',
emptyLabel: 'No tasks',
rows: [{
id: 'task-1',
section: 'tasks',
label: 'Write tests',
status: 'in_progress',
description: 'Add panel coverage',
openable: false,
}],
},
team: { id: 'team', title: 'Team', emptyLabel: 'No team members', rows: [] },
backgroundTasks: { id: 'backgroundTasks', title: 'Background Tasks', emptyLabel: 'No background tasks', rows: [] },
subagents: {
id: 'subagents',
title: 'SubAgents',
emptyLabel: 'No SubAgents',
rows: [{ id: 'tool-1', section: 'subagents', label: 'Kuhn', status: 'running', toolUseId: 'tool-1', openable: true }],
},
sources: { id: 'sources', title: 'Sources', emptyLabel: 'No sources', rows: [] },
},
...overrides,
}
}
describe('SessionActivityPanel', () => {
afterEach(cleanup)
it('renders sections, rows, and empty states', () => {
render(
<SessionActivityPanel
model={model()}
open
onClose={vi.fn()}
onOpenSubagent={vi.fn()}
/>,
)
expect(screen.getByRole('dialog', { name: /activity/i })).toBeInTheDocument()
expect(screen.queryByText('Output')).not.toBeInTheDocument()
expect(screen.getByText('Tasks')).toBeInTheDocument()
expect(screen.getByText('Write tests')).toHaveAttribute('title', 'Write tests')
expect(screen.getByText('Add panel coverage')).toHaveAttribute('title', 'Add panel coverage')
expect(screen.getByLabelText('Task in progress')).toBeInTheDocument()
expect(screen.queryByText('In progress')).not.toBeInTheDocument()
expect(screen.getByText('Team')).toBeInTheDocument()
expect(screen.getByText('No team members')).toBeInTheDocument()
expect(screen.getByText('Background Tasks')).toBeInTheDocument()
expect(screen.getByText('SubAgents')).toBeInTheDocument()
expect(screen.getByText('Kuhn')).toBeInTheDocument()
expect(screen.getByText('Running')).toBeInTheDocument()
expect(screen.getByText('Sources')).toBeInTheDocument()
expect(screen.getByText('No background tasks')).toBeInTheDocument()
expect(screen.getByText('No sources')).toBeInTheDocument()
})
it('renders task rows as checklist markers instead of status chips', () => {
render(
<SessionActivityPanel
model={model({
sections: {
...model().sections,
tasks: {
id: 'tasks',
title: 'Tasks',
emptyLabel: 'No tasks',
rows: [
{ id: 'done', section: 'tasks', label: 'Finished task', status: 'completed', openable: false },
{ id: 'active', section: 'tasks', label: 'Active task', status: 'in_progress', openable: false },
{ id: 'next', section: 'tasks', label: 'Next task', status: 'pending', openable: false },
],
},
},
})}
open
onClose={vi.fn()}
onOpenSubagent={vi.fn()}
/>,
)
expect(screen.getByLabelText('Task completed')).toBeInTheDocument()
expect(screen.getByLabelText('Task in progress')).toBeInTheDocument()
expect(screen.getByLabelText('Task pending')).toBeInTheDocument()
expect(screen.queryByText('Completed')).not.toBeInTheDocument()
expect(screen.queryByText('Pending')).not.toBeInTheDocument()
})
it('renders earlier task history as a localized compact checklist row', () => {
render(
<SessionActivityPanel
model={model({
sections: {
...model().sections,
tasks: {
id: 'tasks',
title: 'Tasks',
emptyLabel: 'No tasks',
rows: [
{ id: 'current', section: 'tasks', label: 'Implement current turn', status: 'in_progress', openable: false },
{
id: 'history',
section: 'tasks',
label: 'Earlier tasks',
status: 'completed',
taskHistory: { completed: 3, total: 3, turnCount: 1 },
openable: false,
},
],
},
},
})}
open
onClose={vi.fn()}
onOpenSubagent={vi.fn()}
/>,
)
expect(screen.getByText('Earlier tasks')).toBeInTheDocument()
expect(screen.getByText('Earlier turns: 3/3 completed')).toBeInTheDocument()
expect(screen.getAllByLabelText('Task completed')).toHaveLength(1)
})
it('clears visible finished background task keys without showing preview details', () => {
const onClearFinishedBackgroundTasks = vi.fn()
render(
<SessionActivityPanel
model={model({
sections: {
...model().sections,
backgroundTasks: {
id: 'backgroundTasks',
title: 'Background Tasks',
emptyLabel: 'No background tasks',
rows: [{
id: 'bash-tool-1',
section: 'backgroundTasks',
label: 'Run smoke checks',
status: 'completed',
description: '# Markdown preview should stay in details',
summary: 'Task completed with a long markdown report',
toolUseId: 'bash-tool-1',
taskId: 'bash-task-1',
taskType: 'local_bash',
dismissKey: 'bash-task-1:completed:1000',
usage: { totalTokens: 94321, durationMs: 67000 },
openable: true,
}],
},
},
})}
open
onClose={vi.fn()}
onOpenSubagent={vi.fn()}
onClearFinishedBackgroundTasks={onClearFinishedBackgroundTasks}
/>,
)
expect(screen.getByText('Run smoke checks')).toBeInTheDocument()
expect(screen.getByText('Completed')).toBeInTheDocument()
expect(screen.queryByText('# Markdown preview should stay in details')).not.toBeInTheDocument()
expect(screen.queryByText('Task completed with a long markdown report')).not.toBeInTheDocument()
expect(screen.queryByText('94.3k tokens')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /open background task run smoke checks/i }))
expect(screen.getByText('Details')).toBeInTheDocument()
expect(screen.getByText('Description')).toBeInTheDocument()
expect(screen.getByText('# Markdown preview should stay in details')).toBeInTheDocument()
expect(screen.getByText('Summary')).toBeInTheDocument()
expect(screen.getByText('Task completed with a long markdown report')).toBeInTheDocument()
expect(screen.getByText('Usage')).toBeInTheDocument()
expect(screen.getByText('94.3k tokens · 1m 7s')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /clear finished/i }))
expect(onClearFinishedBackgroundTasks).toHaveBeenCalledWith(['bash-task-1:completed:1000'])
})
it('keeps SubAgent rows to name and status instead of result previews', () => {
render(
<SessionActivityPanel
model={model({
sections: {
...model().sections,
subagents: {
id: 'subagents',
title: 'SubAgents',
emptyLabel: 'No SubAgents',
rows: [{
id: 'agent-tool-1',
section: 'subagents',
label: 'Security reviewer',
status: 'completed',
summary: '## Security findings\\nNo blocking issue.',
toolUseId: 'agent-tool-1',
openable: true,
}],
},
},
})}
open
onClose={vi.fn()}
onOpenSubagent={vi.fn()}
/>,
)
expect(screen.getByText('Security reviewer')).toBeInTheDocument()
expect(screen.getByText('Completed')).toBeInTheDocument()
expect(screen.queryByText(/Security findings/)).not.toBeInTheDocument()
expect(screen.queryByText('No blocking issue.')).not.toBeInTheDocument()
})
it('opens a compact team member row', () => {
const onOpenMember = vi.fn()
const member = {
agentId: 'security-reviewer@test-team',
role: 'security-reviewer',
status: 'running' as const,
currentTask: 'Auditing auth flow',
}
render(
<SessionActivityPanel
model={model({
sections: {
...model().sections,
team: {
id: 'team',
title: 'Team',
emptyLabel: 'No team members',
rows: [{
id: member.agentId,
section: 'team',
label: member.role,
status: member.status,
description: member.currentTask,
member,
openable: true,
}],
},
},
})}
open
onClose={vi.fn()}
onOpenSubagent={vi.fn()}
onOpenMember={onOpenMember}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /open team member security-reviewer/i }))
expect(screen.getByText('security-reviewer')).toBeInTheDocument()
expect(screen.queryByText('Auditing auth flow')).not.toBeInTheDocument()
expect(onOpenMember).toHaveBeenCalledWith(member)
})
it('closes from the close button and Escape', () => {
const onClose = vi.fn()
render(<SessionActivityPanel model={model()} open onClose={onClose} onOpenSubagent={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: /close activity/i }))
expect(onClose).toHaveBeenCalledTimes(1)
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(2)
})
it('closes on outside pointerdown', () => {
const onClose = vi.fn()
render(
<>
<button type="button">Outside</button>
<SessionActivityPanel model={model()} open onClose={onClose} onOpenSubagent={vi.fn()} />
</>,
)
fireEvent.pointerDown(screen.getByRole('button', { name: 'Outside' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('keeps open when pointerdown starts from the activity trigger', () => {
const onClose = vi.fn()
render(
<>
<button type="button" data-session-activity-trigger="true">
<span>Activity trigger icon</span>
</button>
<SessionActivityPanel model={model()} open onClose={onClose} onOpenSubagent={vi.fn()} />
</>,
)
fireEvent.pointerDown(screen.getByText('Activity trigger icon'))
expect(onClose).not.toHaveBeenCalled()
})
it('renders as a rail without closing on outside pointerdown', () => {
const onClose = vi.fn()
render(
<>
<button type="button">Outside</button>
<SessionActivityPanel
model={model()}
open
onClose={onClose}
onOpenSubagent={vi.fn()}
placement="rail"
/>
</>,
)
expect(screen.getByTestId('session-activity-panel')).toHaveAttribute('data-placement', 'rail')
expect(screen.getByTestId('session-activity-panel')).toHaveClass('my-3')
expect(screen.getByTestId('session-activity-panel')).toHaveClass('mr-3')
expect(screen.getByTestId('session-activity-panel')).toHaveClass('rounded-xl')
expect(screen.getByTestId('session-activity-panel')).toHaveClass('self-start')
expect(screen.getByTestId('session-activity-panel')).toHaveClass('max-h-[min(480px,calc(100vh-88px))]')
expect(screen.getByTestId('session-activity-panel')).not.toHaveClass('h-[calc(100%-24px)]')
fireEvent.pointerDown(screen.getByRole('button', { name: 'Outside' }))
expect(onClose).not.toHaveBeenCalled()
})
it('caps each populated activity section independently', () => {
const taskRows = Array.from({ length: 12 }, (_, index) => ({
id: `task-${index + 1}`,
section: 'tasks' as const,
label: `Task ${index + 1}`,
status: 'pending' as const,
openable: false,
}))
const teamRows = Array.from({ length: 8 }, (_, index) => ({
id: `member-${index + 1}`,
section: 'team' as const,
label: `Reviewer ${index + 1}`,
status: 'running' as const,
openable: false,
}))
const backgroundRows = Array.from({ length: 8 }, (_, index) => ({
id: `background-${index + 1}`,
section: 'backgroundTasks' as const,
label: `Background ${index + 1}`,
status: 'running' as const,
openable: false,
}))
const subagentRows = Array.from({ length: 8 }, (_, index) => ({
id: `subagent-${index + 1}`,
section: 'subagents' as const,
label: `SubAgent ${index + 1}`,
status: 'running' as const,
openable: false,
}))
render(
<SessionActivityPanel
model={model({
sections: {
...model().sections,
tasks: { id: 'tasks', title: 'Tasks', emptyLabel: 'No tasks', rows: taskRows },
team: { id: 'team', title: 'Team', emptyLabel: 'No team members', rows: teamRows },
backgroundTasks: {
id: 'backgroundTasks',
title: 'Background Tasks',
emptyLabel: 'No background tasks',
rows: backgroundRows,
},
subagents: { id: 'subagents', title: 'SubAgents', emptyLabel: 'No SubAgents', rows: subagentRows },
},
})}
open
onClose={vi.fn()}
onOpenSubagent={vi.fn()}
placement="rail"
/>,
)
const tasksSection = document.querySelector('section[aria-label="Tasks"]')
const teamSection = document.querySelector('section[aria-label="Team"]')
const backgroundSection = document.querySelector('section[aria-label="Background Tasks"]')
const subagentsSection = document.querySelector('section[aria-label="SubAgents"]')
expect(tasksSection?.querySelector('.max-h-40.overflow-y-auto')).toBeInTheDocument()
expect(teamSection?.querySelector('.max-h-32.overflow-y-auto')).toBeInTheDocument()
expect(backgroundSection?.querySelector('.max-h-36.overflow-y-auto')).toBeInTheDocument()
expect(subagentsSection?.querySelector('.max-h-36.overflow-y-auto')).toBeInTheDocument()
})
it('opens a SubAgent row when the row is openable', () => {
const onOpenSubagent = vi.fn()
render(<SessionActivityPanel model={model()} open onClose={vi.fn()} onOpenSubagent={onOpenSubagent} />)
fireEvent.click(screen.getByRole('button', { name: /open run kuhn/i }))
expect(onOpenSubagent).toHaveBeenCalledWith({
sessionId: 'session-1',
toolUseId: 'tool-1',
title: 'Kuhn',
})
})
it('does not open SubAgent rows without a tool use id', () => {
const onOpenSubagent = vi.fn()
render(
<SessionActivityPanel
model={model({
sections: {
...model().sections,
subagents: {
id: 'subagents',
title: 'SubAgents',
emptyLabel: 'No SubAgents',
rows: [{ id: 'agent-no-tool', section: 'subagents', label: 'Local agent', status: 'failed', openable: true }],
},
},
})}
open
onClose={vi.fn()}
onOpenSubagent={onOpenSubagent}
/>,
)
expect(screen.queryByRole('button', { name: /open run local agent/i })).not.toBeInTheDocument()
expect(screen.getByText('Local agent')).toBeInTheDocument()
expect(onOpenSubagent).not.toHaveBeenCalled()
})
it('does not render when closed', () => {
render(<SessionActivityPanel model={model()} open={false} onClose={vi.fn()} onOpenSubagent={vi.fn()} />)
expect(screen.queryByRole('dialog', { name: /activity/i })).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,488 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, LoaderCircle, X } from 'lucide-react'
import type { ActivityRow, ActivitySectionId, SessionActivityModel } from './sessionActivityModel'
import { useTranslation } from '../../i18n'
import type { BackgroundAgentTask } from '../../types/chat'
import type { TeamMember } from '../../types/team'
import { formatTokenCount } from '../../lib/formatTokenCount'
export type OpenSubagentPayload = {
sessionId: string
toolUseId: string
title: string
}
type SessionActivityPanelPlacement = 'overlay' | 'rail'
const SECTION_ORDER = ['tasks', 'team', 'backgroundTasks', 'subagents', 'sources'] as const
type TranslationFn = ReturnType<typeof useTranslation>
function fallbackStatusLabel(status: ActivityRow['status']): string {
const label = String(status).replace(/[_-]/g, ' ').replace(/\s+/g, ' ').trim()
if (!label) return ''
return `${label.charAt(0).toUpperCase()}${label.slice(1)}`
}
function getActivityStatusLabel(status: ActivityRow['status'], t: TranslationFn): string {
switch (status) {
case 'pending':
return t('session.activity.status.pending')
case 'in_progress':
return t('session.activity.status.inProgress')
case 'completed':
return t('session.activity.status.completed')
case 'running':
return t('session.activity.status.running')
case 'failed':
return t('session.activity.status.failed')
case 'stopped':
return t('session.activity.status.stopped')
case 'idle':
return t('session.activity.status.idle')
case 'error':
return t('session.activity.status.error')
default:
return fallbackStatusLabel(status)
}
}
function getSectionTitle(sectionId: ActivitySectionId, t: TranslationFn): string {
switch (sectionId) {
case 'tasks':
return t('session.activity.section.tasks')
case 'team':
return t('session.activity.section.team')
case 'backgroundTasks':
return t('session.activity.section.backgroundTasks')
case 'subagents':
return t('session.activity.section.subagents')
case 'sources':
return t('session.activity.section.sources')
case 'output':
return t('subagentRun.output')
}
}
function getSectionEmptyLabel(sectionId: ActivitySectionId, t: TranslationFn): string {
switch (sectionId) {
case 'tasks':
return t('session.activity.empty.tasks')
case 'team':
return t('session.activity.empty.team')
case 'backgroundTasks':
return t('session.activity.empty.backgroundTasks')
case 'subagents':
return t('session.activity.empty.subagents')
case 'sources':
return t('session.activity.empty.sources')
case 'output':
return ''
}
}
function getSectionRowsClassName(sectionId: ActivitySectionId, rowCount: number): string {
const base = 'space-y-0.5'
if (rowCount === 0) return base
switch (sectionId) {
case 'tasks':
return `${base} max-h-40 overflow-y-auto overscroll-contain pr-1`
case 'team':
return `${base} max-h-32 overflow-y-auto overscroll-contain pr-1`
case 'backgroundTasks':
return `${base} max-h-36 overflow-y-auto overscroll-contain pr-1`
case 'subagents':
return `${base} max-h-36 overflow-y-auto overscroll-contain pr-1`
case 'sources':
return `${base} max-h-24 overflow-y-auto overscroll-contain pr-1`
case 'output':
return `${base} max-h-24 overflow-y-auto overscroll-contain pr-1`
}
}
function getTaskTypeLabel(taskType: BackgroundAgentTask['taskType'] | undefined, t: TranslationFn): string {
if (taskType?.includes('agent')) return t('chat.backgroundTasks.type.agent')
if (taskType === 'local_bash') return t('chat.backgroundTasks.type.bash')
if (taskType === 'local_workflow') return t('chat.backgroundTasks.type.workflow')
return t('chat.backgroundTasks.type.task')
}
function formatBackgroundDuration(ms: number | undefined, t: TranslationFn): string | undefined {
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return undefined
const totalSeconds = Math.max(1, Math.round(ms / 1000))
if (totalSeconds < 60) return t('chat.duration.seconds', { seconds: totalSeconds })
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return t('chat.duration.minutesSeconds', { minutes, seconds })
}
function hasBackgroundTaskDetails(row: ActivityRow): boolean {
return Boolean(
row.description ||
row.summary ||
row.outputFile ||
row.taskType ||
row.workflowName ||
row.usage?.totalTokens ||
row.usage?.durationMs,
)
}
function isActivityTriggerTarget(target: EventTarget | null): boolean {
return target instanceof Element && target.closest('[data-session-activity-trigger="true"]') !== null
}
function isBackgroundTaskStatus(status: ActivityRow['status']): status is BackgroundAgentTask['status'] {
return status === 'running' || status === 'completed' || status === 'failed' || status === 'stopped'
}
function getFinishedBackgroundTaskKeys(model: SessionActivityModel): string[] {
const keys = new Set<string>()
for (const sectionId of ['backgroundTasks', 'subagents'] as const) {
for (const row of model.sections[sectionId].rows) {
if (row.dismissKey && isBackgroundTaskStatus(row.status) && row.status !== 'running') {
keys.add(row.dismissKey)
}
}
}
return Array.from(keys)
}
function TaskStatusMarker({ status, t }: { status: ActivityRow['status']; t: TranslationFn }) {
if (status === 'completed') {
return (
<span
aria-label={t('session.activity.task.completed')}
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded border border-[var(--color-success)] bg-[var(--color-success)] text-white"
>
<Check size={12} strokeWidth={3} aria-hidden="true" />
</span>
)
}
if (status === 'in_progress' || status === 'running') {
return (
<span
aria-label={t('session.activity.task.inProgress')}
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded border border-[var(--color-accent)] text-[var(--color-accent)]"
>
<LoaderCircle size={12} strokeWidth={2.4} aria-hidden="true" className="animate-spin" />
</span>
)
}
return (
<span
aria-label={t('session.activity.task.pending')}
className="inline-flex h-4 w-4 shrink-0 rounded border border-[var(--color-border)] bg-[var(--color-surface)]"
/>
)
}
function ActivityRowView({
row,
sessionId,
onOpenSubagent,
onOpenMember,
onOpenBackgroundTask,
selected,
}: {
row: ActivityRow
sessionId: string
onOpenSubagent: (payload: OpenSubagentPayload) => void
onOpenMember?: (member: TeamMember) => void
onOpenBackgroundTask?: (row: ActivityRow) => void
selected?: boolean
}) {
const t = useTranslation()
const isTask = row.section === 'tasks'
const label = row.taskHistory
? t('session.activity.tasks.earlier')
: row.label
const detail = row.taskHistory
? t('session.activity.tasks.earlierSummary', {
completed: row.taskHistory.completed,
total: row.taskHistory.total,
turns: row.taskHistory.turnCount,
})
: isTask && row.description && row.description !== row.label
? row.description
: isTask && row.summary && row.summary !== row.label
? row.summary
: undefined
const content = (
<>
{isTask ? <TaskStatusMarker status={row.status} t={t} /> : null}
<span className="min-w-0 flex-1 truncate text-left">
<span
className={`block truncate text-xs font-medium ${isTask && row.status === 'completed' ? 'text-[var(--color-text-tertiary)] line-through' : 'text-[var(--color-text-primary)]'}`}
title={label}
>
{label}
</span>
{detail ? (
<span
className="block truncate text-[11px] text-[var(--color-text-tertiary)]"
title={detail}
>
{detail}
</span>
) : null}
</span>
{isTask ? null : (
<span className="shrink-0 rounded bg-[var(--color-surface-container)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-tertiary)]">
{getActivityStatusLabel(row.status, t)}
</span>
)}
</>
)
if (row.section === 'team' && row.member && onOpenMember) {
return (
<button
type="button"
aria-label={t('session.activity.openTeamMember', { name: row.label })}
onClick={() => onOpenMember(row.member!)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
{content}
</button>
)
}
if (row.section === 'subagents' && row.openable && row.toolUseId) {
return (
<button
type="button"
aria-label={t('session.activity.openRun', { name: row.label })}
onClick={() => onOpenSubagent({ sessionId, toolUseId: row.toolUseId!, title: row.label })}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
{content}
</button>
)
}
if (row.section === 'backgroundTasks' && onOpenBackgroundTask && hasBackgroundTaskDetails(row)) {
return (
<button
type="button"
aria-label={t('session.activity.openBackgroundTask', { name: row.label })}
aria-expanded={selected}
onClick={() => onOpenBackgroundTask(row)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${selected ? 'bg-[var(--color-surface-container)]' : ''}`}
>
{content}
</button>
)
}
return (
<div className="flex items-center gap-2 rounded-md px-2 py-1.5">
{content}
</div>
)
}
function BackgroundTaskDetail({ row }: { row: ActivityRow }) {
const t = useTranslation()
const duration = formatBackgroundDuration(row.usage?.durationMs, t)
const usageParts = [
typeof row.usage?.totalTokens === 'number'
? t('chat.backgroundAgents.tokens', { count: formatTokenCount(row.usage.totalTokens) })
: '',
duration,
].filter(Boolean)
const details = [
row.taskType || row.workflowName
? { label: t('session.activity.details.type'), value: getTaskTypeLabel(row.taskType, t) }
: null,
row.description
? { label: t('session.activity.details.description'), value: row.description }
: null,
row.summary
? { label: t('session.activity.details.summary'), value: row.summary }
: null,
row.outputFile
? { label: t('session.activity.details.outputFile'), value: row.outputFile }
: null,
usageParts.length > 0
? { label: t('session.activity.details.usage'), value: usageParts.join(' · ') }
: null,
].filter((item): item is { label: string; value: string } => Boolean(item?.value))
if (details.length === 0) return null
return (
<div className="mx-2 mb-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-2">
<div className="mb-1 text-[11px] font-semibold text-[var(--color-text-tertiary)]">
{t('session.activity.details.title')}
</div>
<dl className="space-y-1.5">
{details.map((detail) => (
<div key={detail.label} className="min-w-0">
<dt className="text-[10px] font-semibold uppercase tracking-normal 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)]">
{detail.value}
</dd>
</div>
))}
</dl>
</div>
)
}
export function SessionActivityPanel({
model,
open,
onClose,
onOpenSubagent,
onClearFinishedBackgroundTasks,
onOpenMember,
placement = 'overlay',
}: {
model: SessionActivityModel
open: boolean
onClose: () => void
onOpenSubagent: (payload: OpenSubagentPayload) => void
onClearFinishedBackgroundTasks?: (taskKeys: string[]) => void
onOpenMember?: (member: TeamMember) => void
placement?: SessionActivityPanelPlacement
}) {
const t = useTranslation()
const panelRef = useRef<HTMLDivElement>(null)
const [selectedBackgroundTaskId, setSelectedBackgroundTaskId] = useState<string | null>(null)
const finishedBackgroundTaskKeys = useMemo(() => getFinishedBackgroundTaskKeys(model), [model])
useEffect(() => {
if (!open) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onClose, open])
useEffect(() => {
if (!open || placement === 'rail') return
const handlePointerDown = (event: PointerEvent) => {
if (isActivityTriggerTarget(event.target)) return
if (panelRef.current?.contains(event.target as Node)) return
onClose()
}
document.addEventListener('pointerdown', handlePointerDown)
return () => document.removeEventListener('pointerdown', handlePointerDown)
}, [onClose, open, placement])
useEffect(() => {
if (!open) {
setSelectedBackgroundTaskId(null)
return
}
if (
selectedBackgroundTaskId &&
!model.sections.backgroundTasks.rows.some((row) => row.id === selectedBackgroundTaskId)
) {
setSelectedBackgroundTaskId(null)
}
}, [model.sections.backgroundTasks.rows, open, selectedBackgroundTaskId])
if (!open) return null
const className = placement === 'rail'
? 'my-3 ml-2 mr-3 flex max-h-[min(480px,calc(100vh-88px))] w-[300px] shrink-0 self-start flex-col overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[0_18px_48px_-30px_rgba(15,23,42,0.42),0_8px_20px_-18px_rgba(15,23,42,0.28),inset_0_1px_0_rgba(255,255,255,0.72)]'
: 'absolute right-3 top-3 z-40 flex max-h-[calc(100%-96px)] w-[min(300px,calc(100%-24px))] flex-col overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-dropdown)]'
return (
<div
ref={panelRef}
role="dialog"
aria-label={t('session.activity.title')}
data-testid="session-activity-panel"
data-placement={placement}
className={className}
>
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2">
<h2 className="text-xs font-semibold text-[var(--color-text-primary)]">{t('session.activity.title')}</h2>
<button
type="button"
aria-label={t('session.activity.close')}
onClick={onClose}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<X size={14} strokeWidth={2.2} aria-hidden="true" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
{SECTION_ORDER.map((sectionId) => {
const section = model.sections[sectionId]
const sectionTitle = getSectionTitle(section.id, t)
const sectionEmptyLabel = getSectionEmptyLabel(section.id, t)
return (
<section key={section.id} aria-label={sectionTitle}>
<div className="mb-1 flex items-center justify-between gap-2 px-1">
<div className="flex min-w-0 items-center gap-1.5">
<h3 className="text-[11px] font-semibold uppercase tracking-normal text-[var(--color-text-tertiary)]">
{sectionTitle}
</h3>
{section.rows.length > 0 ? (
<span className="rounded bg-[var(--color-surface-container)] px-1.5 py-0.5 text-[10px] leading-none text-[var(--color-text-tertiary)]">
{section.rows.length}
</span>
) : null}
</div>
{section.id === 'backgroundTasks' && finishedBackgroundTaskKeys.length > 0 && onClearFinishedBackgroundTasks ? (
<button
type="button"
onClick={() => onClearFinishedBackgroundTasks(finishedBackgroundTaskKeys)}
className="rounded px-1.5 py-0.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
{t('session.activity.clearFinished')}
</button>
) : null}
</div>
{section.rows.length === 0 ? (
<div className="px-2 py-1 text-xs text-[var(--color-text-tertiary)]">{sectionEmptyLabel}</div>
) : (
<div className={getSectionRowsClassName(section.id, section.rows.length)}>
{section.rows.map((row) => (
<div key={row.id}>
<ActivityRowView
row={row}
sessionId={model.sessionId}
onOpenSubagent={onOpenSubagent}
onOpenMember={onOpenMember}
onOpenBackgroundTask={(backgroundRow) => {
setSelectedBackgroundTaskId((current) => (
current === backgroundRow.id ? null : backgroundRow.id
))
}}
selected={section.id === 'backgroundTasks' && selectedBackgroundTaskId === row.id}
/>
{section.id === 'backgroundTasks' && selectedBackgroundTaskId === row.id ? (
<BackgroundTaskDetail row={row} />
) : null}
</div>
))}
</div>
)}
</section>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,625 @@
import { describe, expect, it } from 'vitest'
import { buildSessionActivityModel } from './sessionActivityModel'
import { createBackgroundTaskDismissKey } from '../../lib/backgroundTasks'
import type { BackgroundAgentTask, AgentTaskNotification, UIMessage } from '../../types/chat'
import type { CLITask } from '../../types/cliTask'
const task = (overrides: Partial<CLITask>): CLITask => ({
id: 'task-1',
subject: 'Write tests',
description: '',
status: 'pending',
blocks: [],
blockedBy: [],
taskListId: 'session-1',
...overrides,
})
const background = (overrides: Partial<BackgroundAgentTask>): BackgroundAgentTask => ({
taskId: 'bg-1',
toolUseId: 'tool-1',
status: 'running',
description: 'Explore code',
taskType: 'local_agent',
startedAt: 1000,
updatedAt: 2000,
...overrides,
})
const notification = (overrides: Partial<AgentTaskNotification>): AgentTaskNotification => ({
taskId: 'agent-task-1',
toolUseId: 'tool-1',
status: 'completed',
summary: 'Done',
timestamp: '2026-07-03T00:00:00.000Z',
...overrides,
})
const agentMessages: UIMessage[] = [
{
id: 'agent-tool-1',
type: 'tool_use',
toolName: 'Agent',
toolUseId: 'agent-tool-1',
input: { description: '审查代码结构' },
timestamp: 1000,
},
{
id: 'agent-result-1',
type: 'tool_result',
toolUseId: 'agent-tool-1',
content: {
status: 'completed',
content: [
{ type: 'text', text: '# 审查报告\n\n没有阻塞问题。' },
{ type: 'text', text: 'agentId: child-1\n<usage>total_tokens: 42</usage>' },
],
},
isError: false,
timestamp: 2000,
},
{
id: 'agent-tool-2',
type: 'tool_use',
toolName: 'Agent',
toolUseId: 'agent-tool-2',
input: { description: '运行边界条件方案' },
timestamp: 3000,
},
{
id: 'agent-result-2',
type: 'tool_result',
toolUseId: 'agent-tool-2',
content: "Agent type 'general' not found",
isError: true,
timestamp: 4000,
},
]
describe('buildSessionActivityModel', () => {
it('counts incomplete tasks and running agent rows for the badge', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
tasks: [
task({ id: '1', subject: 'Plan', status: 'completed' }),
task({ id: '2', subject: 'Implement', status: 'in_progress' }),
],
completedAndDismissed: false,
backgroundTasks: [
background({ taskId: 'agent-1', toolUseId: 'tool-1', status: 'running', taskType: 'local_agent' }),
background({ taskId: 'bg-2', toolUseId: 'tool-2', status: 'completed', taskType: 'local_bash' }),
],
agentNotifications: [],
})
expect(model.badgeCount).toBe(2)
expect(model.sections.tasks.rows).toHaveLength(2)
expect(model.sections.subagents.rows).toHaveLength(1)
expect(model.sections.backgroundTasks.rows).toHaveLength(1)
})
it('deduplicates SubAgent rows by toolUseId and keeps notification metadata', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
tasks: [],
completedAndDismissed: false,
backgroundTasks: [
background({ taskId: 'agent-1', toolUseId: 'tool-1', status: 'running', summary: 'Still working' }),
],
agentNotifications: [
notification({ taskId: 'agent-1', toolUseId: 'tool-1', status: 'completed', outputFile: '/tmp/out.md' }),
],
})
expect(model.sections.subagents.rows).toEqual([
expect.objectContaining({
id: 'tool-1',
toolUseId: 'tool-1',
status: 'completed',
summary: 'Done',
outputFile: '/tmp/out.md',
openable: true,
}),
])
expect(model.sections.output.rows).toEqual([
expect.objectContaining({ id: 'output-tool-1', label: '/tmp/out.md' }),
])
})
it('keeps rows without toolUseId readable but not openable', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
tasks: [],
completedAndDismissed: false,
backgroundTasks: [
background({ taskId: 'agent-no-tool', toolUseId: undefined, status: 'failed', taskType: 'local_agent' }),
],
agentNotifications: [],
})
expect(model.sections.subagents.rows).toEqual([
expect.objectContaining({
id: 'agent-no-tool',
toolUseId: undefined,
openable: false,
status: 'failed',
}),
])
expect(model.badgeCount).toBe(1)
})
it('upgrades a taskId-keyed SubAgent row when a notification provides toolUseId', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
tasks: [],
completedAndDismissed: false,
backgroundTasks: [
background({
taskId: 'agent-1',
toolUseId: undefined,
status: 'running',
summary: 'Exploring',
outputFile: '/tmp/background.md',
usage: { totalTokens: 12 },
}),
],
agentNotifications: [
notification({ taskId: 'agent-1', toolUseId: 'tool-1', status: 'completed', result: 'Finished' }),
],
})
expect(model.sections.subagents.rows).toEqual([
expect.objectContaining({
id: 'tool-1',
toolUseId: 'tool-1',
taskId: 'agent-1',
status: 'completed',
summary: 'Done',
outputFile: '/tmp/background.md',
usage: { totalTokens: 12 },
openable: true,
}),
])
})
it('adds Agent tool calls from session messages to the SubAgents section', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
messages: agentMessages,
tasks: [],
completedAndDismissed: false,
backgroundTasks: [],
agentNotifications: [],
})
expect(model.sections.subagents.rows).toEqual([
expect.objectContaining({
id: 'agent-tool-1',
label: '审查代码结构',
status: 'completed',
summary: '# 审查报告 没有阻塞问题。',
openable: true,
}),
expect.objectContaining({
id: 'agent-tool-2',
label: '运行边界条件方案',
status: 'failed',
summary: "Agent type 'general' not found",
openable: true,
}),
])
expect(model.badgeCount).toBe(1)
})
it('restores task rows from the latest TodoWrite message', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
messages: [{
id: 'todo-1',
type: 'tool_use',
toolName: 'TodoWrite',
toolUseId: 'todo-1',
input: {
todos: [
{ content: '审查现有实现', status: 'completed' },
{ content: '补充边界测试', activeForm: '正在补充边界测试', status: 'in_progress' },
],
},
timestamp: 1000,
}],
tasks: [],
completedAndDismissed: false,
backgroundTasks: [],
agentNotifications: [],
})
expect(model.sections.tasks.rows).toEqual([
expect.objectContaining({ label: '审查现有实现', status: 'completed' }),
expect.objectContaining({ label: '补充边界测试', description: '正在补充边界测试', status: 'in_progress' }),
])
expect(model.badgeCount).toBe(1)
})
it('deduplicates repeated TodoWrite task rows from noisy session history', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
messages: [{
id: 'todo-1',
type: 'tool_use',
toolName: 'TodoWrite',
toolUseId: 'todo-1',
input: {
todos: [
{ content: 'Security review', status: 'pending' },
{ content: 'Security review', activeForm: 'Security teammate', status: 'pending' },
{ content: 'Security review', activeForm: 'Security teammate', status: 'pending' },
{ content: 'Performance review', activeForm: 'Performance teammate', status: 'in_progress' },
{ content: 'Performance review', activeForm: 'Performance teammate', status: 'completed' },
],
},
timestamp: 1000,
}],
tasks: [],
completedAndDismissed: false,
backgroundTasks: [],
agentNotifications: [],
})
expect(model.sections.tasks.rows).toEqual([
expect.objectContaining({ label: 'Security review', description: 'Security teammate', status: 'pending' }),
expect.objectContaining({ label: 'Performance review', description: 'Performance teammate', status: 'completed' }),
])
expect(model.badgeCount).toBe(1)
})
it('deduplicates repeated live task rows by title for compact activity display', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
tasks: [
task({ id: 'security-1', subject: 'Security review', description: 'Short', status: 'pending' }),
task({ id: 'security-2', subject: 'Security review', description: 'Longer security review details', status: 'in_progress' }),
task({ id: 'performance-1', subject: 'Performance review', status: 'completed' }),
],
completedAndDismissed: false,
backgroundTasks: [],
agentNotifications: [],
})
expect(model.sections.tasks.rows).toEqual([
expect.objectContaining({
id: 'security-1',
label: 'Security review',
description: 'Longer security review details',
status: 'in_progress',
}),
expect.objectContaining({ id: 'performance-1', label: 'Performance review', status: 'completed' }),
])
expect(model.badgeCount).toBe(1)
})
it('restores task rows from TaskCreate results and TaskUpdate messages', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
messages: [
{
id: 'task-create-1',
type: 'tool_use',
toolName: 'TaskCreate',
toolUseId: 'task-create-call-1',
input: {
subject: '审查现有订单汇总代码与测试',
description: '审查 src/orders.mjs 和 tests/check.mjs',
},
timestamp: 1000,
},
{
id: 'task-create-result-1',
type: 'tool_result',
toolUseId: 'task-create-call-1',
content: 'Task #1 created successfully: 审查现有订单汇总代码与测试',
isError: false,
timestamp: 1001,
},
{
id: 'task-create-2',
type: 'tool_use',
toolName: 'TaskCreate',
toolUseId: 'task-create-call-2',
input: { subject: '补充边界测试' },
timestamp: 1002,
},
{
id: 'task-create-result-2',
type: 'tool_result',
toolUseId: 'task-create-call-2',
content: 'Task #2 created successfully: 补充边界测试',
isError: false,
timestamp: 1003,
},
{
id: 'task-update-1',
type: 'tool_use',
toolName: 'TaskUpdate',
toolUseId: 'task-update-call-1',
input: { taskId: '1', status: 'completed' },
timestamp: 1004,
},
{
id: 'task-update-2',
type: 'tool_use',
toolName: 'TaskUpdate',
toolUseId: 'task-update-call-2',
input: { taskId: '2', status: 'in_progress', activeForm: '正在补充边界测试' },
timestamp: 1005,
},
],
tasks: [],
completedAndDismissed: false,
backgroundTasks: [],
agentNotifications: [],
})
expect(model.sections.tasks.rows).toEqual([
expect.objectContaining({
id: '1',
label: '审查现有订单汇总代码与测试',
description: '审查 src/orders.mjs 和 tests/check.mjs',
status: 'completed',
}),
expect.objectContaining({
id: '2',
label: '补充边界测试',
description: '正在补充边界测试',
status: 'in_progress',
}),
])
expect(model.badgeCount).toBe(1)
})
it('prefers task summary rows over earlier TodoWrite rows', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
messages: [
{
id: 'todo-1',
type: 'tool_use',
toolName: 'TodoWrite',
toolUseId: 'todo-1',
input: { todos: [{ content: '旧任务', status: 'in_progress' }] },
timestamp: 1000,
},
{
id: 'summary-1',
type: 'task_summary',
tasks: [{ id: '1', subject: '最终验收', status: 'completed' }],
timestamp: 2000,
},
],
tasks: [],
completedAndDismissed: false,
backgroundTasks: [],
agentNotifications: [],
})
expect(model.sections.tasks.rows).toEqual([
expect.objectContaining({ label: '最终验收', status: 'completed' }),
])
expect(model.badgeCount).toBe(0)
})
it('keeps current-turn checklist rows separate from earlier completed turns', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
messages: [
{
id: 'user-1',
type: 'user_text',
content: '先做订单功能',
timestamp: 1000,
},
{
id: 'todo-1',
type: 'tool_use',
toolName: 'TodoWrite',
toolUseId: 'todo-1',
input: {
todos: [
{ content: '实现', status: 'completed' },
{ content: '验证', status: 'completed' },
],
},
timestamp: 1100,
},
{
id: 'summary-1',
type: 'task_summary',
tasks: [
{ id: '1', subject: '实现', status: 'completed' },
{ id: '2', subject: '验证', status: 'completed' },
],
timestamp: 1200,
},
{
id: 'user-2',
type: 'user_text',
content: '继续做活动面板',
timestamp: 2000,
},
{
id: 'todo-2',
type: 'tool_use',
toolName: 'TodoWrite',
toolUseId: 'todo-2',
input: {
todos: [
{ content: '实现', activeForm: '实现活动面板', status: 'in_progress' },
{ content: '截图验证', status: 'pending' },
],
},
timestamp: 2100,
},
],
tasks: [],
completedAndDismissed: false,
backgroundTasks: [],
agentNotifications: [],
})
expect(model.sections.tasks.rows).toEqual([
expect.objectContaining({ label: '实现', description: '实现活动面板', status: 'in_progress' }),
expect.objectContaining({ label: '截图验证', status: 'pending' }),
expect.objectContaining({
id: expect.stringContaining('task-history-'),
label: 'Earlier tasks',
status: 'completed',
taskHistory: { completed: 2, total: 2, turnCount: 1 },
}),
])
expect(model.badgeCount).toBe(2)
})
it('does not show orphan non-agent notifications in the SubAgents section', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
tasks: [],
completedAndDismissed: false,
backgroundTasks: [],
agentNotifications: [
notification({
taskId: 'bg-bash-1',
toolUseId: 'bash-tool-1',
status: 'completed',
summary: 'Task completed',
outputFile: '/tmp/bg-test.log',
}),
],
})
expect(model.sections.subagents.rows).toHaveLength(0)
expect(model.sections.output.rows).toEqual([
expect.objectContaining({ id: 'output-bash-tool-1', label: '/tmp/bg-test.log' }),
])
})
it('keeps untyped background command tasks out of the SubAgents section', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
tasks: [],
completedAndDismissed: false,
backgroundTasks: [
background({
taskId: 'bg-command-1',
toolUseId: 'bg-command-tool-1',
taskType: undefined,
status: 'completed',
description: 'Background command "npm test" completed',
summary: 'Task completed',
result: 'check passed',
outputFile: '/tmp/bg-test.log',
}),
],
agentNotifications: [],
})
expect(model.sections.subagents.rows).toHaveLength(0)
expect(model.sections.backgroundTasks.rows).toEqual([
expect.objectContaining({
id: 'bg-command-tool-1',
label: 'Background command "npm test" completed',
}),
])
})
it('does not erase background metadata when matching notification omits optional fields', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
tasks: [],
completedAndDismissed: false,
backgroundTasks: [
background({
taskId: 'agent-1',
toolUseId: 'tool-1',
status: 'running',
summary: 'Still working',
outputFile: '/tmp/background.md',
usage: { totalTokens: 42, toolUses: 3 },
}),
],
agentNotifications: [
{
taskId: 'agent-1',
toolUseId: 'tool-1',
status: 'completed',
},
],
})
expect(model.sections.subagents.rows).toEqual([
expect.objectContaining({
id: 'tool-1',
status: 'completed',
summary: 'Still working',
outputFile: '/tmp/background.md',
usage: { totalTokens: 42, toolUses: 3 },
}),
])
expect(model.sections.output.rows).toEqual([
expect.objectContaining({ id: 'output-tool-1', label: '/tmp/background.md' }),
])
})
it('suppresses dismissed completed task rows from the badge', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
tasks: [task({ id: '1', status: 'completed' })],
completedAndDismissed: true,
backgroundTasks: [],
agentNotifications: [],
})
expect(model.badgeCount).toBe(0)
expect(model.sections.tasks.rows).toHaveLength(1)
})
it('filters dismissed finished background tasks but keeps later runs visible', () => {
const dismissedTask = background({
taskId: 'bg-1',
toolUseId: 'tool-1',
status: 'completed',
taskType: 'local_bash',
startedAt: 1000,
description: 'Dismissed run',
})
const resumedTask = background({
taskId: 'bg-1',
toolUseId: 'tool-2',
status: 'completed',
taskType: 'local_bash',
startedAt: 2000,
description: 'Later run',
})
const runningTask = background({
taskId: 'bg-2',
toolUseId: 'tool-3',
status: 'running',
taskType: 'local_bash',
startedAt: 1000,
description: 'Still running',
})
const model = buildSessionActivityModel({
sessionId: 'session-1',
tasks: [],
completedAndDismissed: false,
backgroundTasks: [dismissedTask, resumedTask, runningTask],
dismissedBackgroundTaskKeys: new Set([createBackgroundTaskDismissKey(dismissedTask)]),
agentNotifications: [],
})
expect(model.sections.backgroundTasks.rows).toEqual([
expect.objectContaining({ label: 'Later run', dismissKey: createBackgroundTaskDismissKey(resumedTask) }),
expect.objectContaining({ label: 'Still running', dismissKey: createBackgroundTaskDismissKey(runningTask) }),
])
expect(model.badgeCount).toBe(1)
})
})

View File

@ -0,0 +1,718 @@
import type { BackgroundAgentTask, AgentTaskNotification, BackgroundAgentTaskUsage } from '../../types/chat'
import type { TaskSummaryItem, UIMessage } from '../../types/chat'
import type { CLITask, TaskStatus } from '../../types/cliTask'
import type { TeamMember } from '../../types/team'
import { createBackgroundTaskDismissKey } from '../../lib/backgroundTasks'
export type ActivityStatus = TaskStatus | BackgroundAgentTask['status'] | TeamMember['status']
export type ActivitySectionId = 'output' | 'tasks' | 'team' | 'backgroundTasks' | 'subagents' | 'sources'
export type ActivityRow = {
id: string
section: ActivitySectionId
label: string
status: ActivityStatus
description?: string
summary?: string
toolUseId?: string
taskId?: string
taskType?: BackgroundAgentTask['taskType']
workflowName?: string
dismissKey?: string
outputFile?: string
usage?: BackgroundAgentTaskUsage
updatedAt?: number | string
member?: TeamMember
taskHistory?: {
completed: number
total: number
turnCount: number
}
openable: boolean
}
export type ActivitySection = {
id: ActivitySectionId
title: string
emptyLabel: string
rows: ActivityRow[]
}
export type SessionActivityModel = {
sessionId: string
badgeCount: number
sections: Record<ActivitySectionId, ActivitySection>
}
export type BuildSessionActivityModelInput = {
sessionId: string
messages?: UIMessage[]
tasks: CLITask[]
completedAndDismissed: boolean
backgroundTasks: BackgroundAgentTask[]
dismissedBackgroundTaskKeys?: Set<string>
agentNotifications: AgentTaskNotification[]
teamMembers?: TeamMember[]
}
const BADGE_STATUSES = new Set<ActivityStatus>(['pending', 'in_progress', 'running', 'failed'])
const SECTION_META: Record<ActivitySectionId, Pick<ActivitySection, 'title' | 'emptyLabel'>> = {
output: { title: 'Output', emptyLabel: 'No output' },
tasks: { title: 'Tasks', emptyLabel: 'No tasks' },
team: { title: 'Team', emptyLabel: 'No team members' },
backgroundTasks: { title: 'Background Tasks', emptyLabel: 'No background tasks' },
subagents: { title: 'SubAgents', emptyLabel: 'No SubAgents' },
sources: { title: 'Sources', emptyLabel: 'No sources' },
}
function createEmptySections(): Record<ActivitySectionId, ActivitySection> {
return {
output: createSection('output'),
tasks: createSection('tasks'),
team: createSection('team'),
backgroundTasks: createSection('backgroundTasks'),
subagents: createSection('subagents'),
sources: createSection('sources'),
}
}
function createSection(id: ActivitySectionId): ActivitySection {
return {
id,
title: SECTION_META[id].title,
emptyLabel: SECTION_META[id].emptyLabel,
rows: [],
}
}
function isBadgeStatus(status: ActivityStatus): boolean {
return BADGE_STATUSES.has(status)
}
function activityKey(task: Pick<BackgroundAgentTask, 'taskId' | 'toolUseId'>): string {
return task.toolUseId ?? task.taskId
}
function notificationKey(notification: Pick<AgentTaskNotification, 'taskId' | 'toolUseId'>): string {
return notification.toolUseId ?? notification.taskId
}
function isAgentLikeBackgroundTask(task: BackgroundAgentTask): boolean {
return Boolean(task.taskType?.includes('agent'))
}
function backgroundLabel(task: BackgroundAgentTask): string {
return task.description || task.workflowName || task.taskId
}
function notificationLabel(notification: AgentTaskNotification): string {
return notification.taskId
}
function buildTaskRow(task: CLITask): ActivityRow {
return {
id: task.id,
section: 'tasks',
label: task.subject,
status: task.status,
description: task.description,
taskId: task.id,
openable: false,
}
}
function buildTaskSummaryRow(task: TaskSummaryItem, index: number): ActivityRow {
return {
id: task.id || `summary-task-${index + 1}`,
section: 'tasks',
label: task.subject || task.activeForm || `Task ${index + 1}`,
status: task.status,
description: task.activeForm && task.activeForm !== task.subject ? task.activeForm : undefined,
taskId: task.id,
openable: false,
}
}
function buildTodoTaskRow(todo: { content?: unknown; status?: unknown; activeForm?: unknown }, index: number): ActivityRow {
const status = todo.status === 'completed' || todo.status === 'in_progress' || todo.status === 'pending'
? todo.status
: 'pending'
const label = typeof todo.content === 'string' && todo.content.trim()
? todo.content.trim()
: typeof todo.activeForm === 'string' && todo.activeForm.trim()
? todo.activeForm.trim()
: `Task ${index + 1}`
const activeForm = typeof todo.activeForm === 'string' && todo.activeForm.trim()
? todo.activeForm.trim()
: ''
return {
id: `todo-${index + 1}`,
section: 'tasks',
label,
status,
description: activeForm && activeForm !== label ? activeForm : undefined,
openable: false,
}
}
function normalizeTaskRowText(value: string | undefined): string {
return (value ?? '').replace(/\s+/g, ' ').trim().toLowerCase()
}
function taskRowDedupeKey(row: ActivityRow): string {
return `text:${normalizeTaskRowText(row.label)}`
}
function mergeTaskRows(existing: ActivityRow, row: ActivityRow): ActivityRow {
const existingDescription = existing.description ?? ''
const nextDescription = row.description ?? ''
return {
...existing,
status: row.status,
description: nextDescription.length > existingDescription.length ? nextDescription : existing.description,
summary: existing.summary || row.summary,
taskId: existing.taskId || row.taskId,
updatedAt: row.updatedAt ?? existing.updatedAt,
}
}
function dedupeTaskRows(rows: ActivityRow[]): ActivityRow[] {
const rowsByKey = new Map<string, ActivityRow>()
for (const row of rows) {
const key = taskRowDedupeKey(row)
const existing = rowsByKey.get(key)
rowsByKey.set(key, existing ? mergeTaskRows(existing, row) : row)
}
return Array.from(rowsByKey.values())
}
type TaskMessageTurn = {
id: string
index: number
messages: UIMessage[]
}
type TaskTurnRows = {
turn: TaskMessageTurn
rows: ActivityRow[]
}
function splitMessagesIntoTurns(messages: UIMessage[]): TaskMessageTurn[] {
const turns: TaskMessageTurn[] = []
let current: TaskMessageTurn = { id: 'turn-0', index: 0, messages: [] }
let nextIndex = 1
for (const message of messages) {
if (message.type === 'user_text') {
if (current.messages.length > 0) {
turns.push(current)
}
current = {
id: message.transcriptMessageId || message.id || `turn-${nextIndex}`,
index: nextIndex,
messages: [message],
}
nextIndex += 1
continue
}
current.messages.push(message)
}
if (current.messages.length > 0) {
turns.push(current)
}
return turns
}
function normalizeTaskStatus(status: unknown): TaskSummaryItem['status'] {
if (status === 'completed' || status === 'in_progress' || status === 'pending') return status
return 'pending'
}
function parseCreatedTaskResult(content: unknown): { id: string; subject?: string } | null {
const text = extractTextContent(content)
const match = text.match(/Task\s+#([^\s:]+)\s+created\s+successfully(?::\s*(.+))?/i)
if (!match?.[1]) return null
return {
id: match[1],
subject: match[2]?.trim(),
}
}
function buildTaskToolRow(
id: string,
input: Record<string, unknown>,
index: number,
result?: { subject?: string } | null,
): ActivityRow {
const subject = stringField(input, 'subject') || result?.subject || `Task #${id || index + 1}`
const description = stringField(input, 'description')
return {
id,
section: 'tasks',
label: subject,
status: 'pending',
description: description && description !== subject ? description : undefined,
taskId: id,
openable: false,
}
}
function buildTeamRow(member: TeamMember): ActivityRow {
return {
id: member.agentId,
section: 'team',
label: member.role || member.name || member.agentId,
status: member.status,
description: member.currentTask,
member,
openable: true,
}
}
function buildBackgroundRow(task: BackgroundAgentTask, section: ActivitySectionId): ActivityRow {
return {
id: activityKey(task),
section,
label: backgroundLabel(task),
status: task.status,
description: task.description,
summary: task.summary,
toolUseId: task.toolUseId,
taskId: task.taskId,
taskType: task.taskType,
workflowName: task.workflowName,
dismissKey: createBackgroundTaskDismissKey(task),
outputFile: task.outputFile,
usage: task.usage,
updatedAt: task.updatedAt,
openable: Boolean(task.toolUseId),
}
}
function buildNotificationRow(notification: AgentTaskNotification): ActivityRow {
return {
id: notificationKey(notification),
section: 'subagents',
label: notificationLabel(notification),
status: notification.status,
summary: notification.summary,
toolUseId: notification.toolUseId,
taskId: notification.taskId,
outputFile: notification.outputFile,
usage: notification.usage,
updatedAt: notification.timestamp,
openable: Boolean(notification.toolUseId),
}
}
function isRecordValue(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function stringField(value: Record<string, unknown>, key: string): string {
const fieldValue = value[key]
return typeof fieldValue === 'string' ? fieldValue.trim() : ''
}
function compactText(value: string, maxLength = 240): string {
const normalized = value.replace(/\s+/g, ' ').trim()
if (normalized.length <= maxLength) return normalized
return `${normalized.slice(0, maxLength - 3).trimEnd()}...`
}
function extractTextContent(value: unknown): string {
if (typeof value === 'string') return value
if (Array.isArray(value)) return value.map(extractTextContent).filter(Boolean).join('\n')
if (!isRecordValue(value)) return ''
const directText = stringField(value, 'text') ||
stringField(value, 'message') ||
stringField(value, 'summary') ||
stringField(value, 'result') ||
stringField(value, 'error')
if (directText) return directText
if ('content' in value) return extractTextContent(value.content)
return ''
}
function stripAgentMetadata(text: string): string {
return text
.replace(/^\s*agentId:.*(?:\r?\n)?/gm, '')
.replace(/<usage>[\s\S]*?<\/usage>/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function agentToolLabel(toolCall: Extract<UIMessage, { type: 'tool_use' }>): string {
const input = isRecordValue(toolCall.input) ? toolCall.input : {}
return compactText(
stringField(input, 'description') ||
stringField(input, 'prompt') ||
stringField(input, 'task') ||
stringField(input, 'subagent_type') ||
'Agent',
120,
)
}
function buildAgentRowsFromMessages(messages: UIMessage[]): ActivityRow[] {
const resultsByToolUseId = new Map<string, Extract<UIMessage, { type: 'tool_result' }>>()
for (const message of messages) {
if (message.type === 'tool_result') {
resultsByToolUseId.set(message.toolUseId, message)
}
}
const rows: ActivityRow[] = []
for (const message of messages) {
if (message.type !== 'tool_use' || message.toolName !== 'Agent') continue
const result = resultsByToolUseId.get(message.toolUseId)
const resultText = result ? stripAgentMetadata(extractTextContent(result.content)) : ''
rows.push({
id: message.toolUseId,
section: 'subagents',
label: agentToolLabel(message),
status: message.status === 'stopped'
? 'stopped'
: result?.isError
? 'failed'
: result
? 'completed'
: 'running',
summary: resultText ? compactText(resultText) : undefined,
toolUseId: message.toolUseId,
taskType: 'local_agent',
updatedAt: result?.timestamp ?? message.timestamp,
openable: true,
})
}
return rows
}
function buildTaskRowsFromTaskTools(messages: UIMessage[]): ActivityRow[] {
const resultsByToolUseId = new Map<string, Extract<UIMessage, { type: 'tool_result' }>>()
for (const message of messages) {
if (message.type === 'tool_result') {
resultsByToolUseId.set(message.toolUseId, message)
}
}
const rowsByTaskId = new Map<string, ActivityRow>()
let createIndex = 0
for (const message of messages) {
if (message.type !== 'tool_use') continue
if (message.toolName === 'TaskCreate') {
const input = isRecordValue(message.input) ? message.input : {}
const result = parseCreatedTaskResult(resultsByToolUseId.get(message.toolUseId)?.content)
const taskId = result?.id || stringField(input, 'taskId') || stringField(input, 'id') || `${createIndex + 1}`
const row = buildTaskToolRow(taskId, input, createIndex, result)
rowsByTaskId.set(taskId, row)
createIndex += 1
continue
}
if (message.toolName === 'TaskUpdate') {
const input = isRecordValue(message.input) ? message.input : {}
const taskId = stringField(input, 'taskId') || stringField(input, 'id')
if (!taskId) continue
const existing = rowsByTaskId.get(taskId)
const activeForm = stringField(input, 'activeForm')
const subject = stringField(input, 'subject')
rowsByTaskId.set(taskId, {
...(existing ?? {
id: taskId,
section: 'tasks',
label: subject || activeForm || `Task #${taskId}`,
taskId,
openable: false,
}),
status: normalizeTaskStatus(input.status),
...(activeForm && activeForm !== (existing?.label ?? subject) ? { description: activeForm } : {}),
})
}
}
return Array.from(rowsByTaskId.values())
}
function buildTaskRowsFromTurnMessages(messages: UIMessage[]): ActivityRow[] {
let latestSummary: Extract<UIMessage, { type: 'task_summary' }> | undefined
let latestTodoWrite: Extract<UIMessage, { type: 'tool_use' }> | undefined
let latestTaskToolTimestamp = -Infinity
for (const message of messages) {
if (message.type === 'task_summary') {
latestSummary = message
} else if (message.type === 'tool_use' && message.toolName === 'TodoWrite') {
latestTodoWrite = message
} else if (message.type === 'tool_use' && (message.toolName === 'TaskCreate' || message.toolName === 'TaskUpdate')) {
latestTaskToolTimestamp = Math.max(latestTaskToolTimestamp, message.timestamp)
}
}
if (latestSummary?.tasks.length) {
return dedupeTaskRows(latestSummary.tasks.map(buildTaskSummaryRow))
}
const input = latestTodoWrite?.input
if (latestTodoWrite && isRecordValue(input) && Array.isArray(input.todos) && latestTodoWrite.timestamp >= latestTaskToolTimestamp) {
return dedupeTaskRows(input.todos.map(buildTodoTaskRow))
}
return buildTaskRowsFromTaskTools(messages)
}
function mergeTaskRowsById(baseRows: ActivityRow[], liveRows: ActivityRow[]): ActivityRow[] {
const liveRowsById = new Map<string, ActivityRow>()
for (const row of liveRows) {
if (row.taskId || row.id) {
liveRowsById.set(row.taskId ?? row.id, row)
}
}
const usedLiveIds = new Set<string>()
const mergedRows = baseRows.map((row) => {
const id = row.taskId ?? row.id
const liveRow = liveRowsById.get(id)
if (!liveRow) return row
usedLiveIds.add(id)
return mergeTaskRows(row, liveRow)
})
for (const row of liveRows) {
const id = row.taskId ?? row.id
if (!usedLiveIds.has(id)) {
mergedRows.push(row)
}
}
return mergedRows
}
function buildHistoricalTasksRow(groups: TaskTurnRows[]): ActivityRow | null {
const rows = groups.flatMap((group) => group.rows)
if (rows.length === 0) return null
const completed = rows.filter((row) => row.status === 'completed').length
const status: TaskStatus = rows.some((row) => row.status === 'in_progress')
? 'in_progress'
: rows.some((row) => row.status === 'pending')
? 'pending'
: 'completed'
return {
id: `task-history-${groups[0]?.turn.id ?? 'turn'}-${groups.length}-${rows.length}`,
section: 'tasks',
label: 'Earlier tasks',
status,
taskHistory: {
completed,
total: rows.length,
turnCount: groups.length,
},
openable: false,
}
}
function buildTaskRowsFromMessages(messages: UIMessage[], liveTasks: CLITask[]): ActivityRow[] {
const liveRows = liveTasks.map(buildTaskRow)
const taskTurnRows = splitMessagesIntoTurns(messages)
.map((turn) => ({ turn, rows: buildTaskRowsFromTurnMessages(turn.messages) }))
.filter((group) => group.rows.length > 0)
if (taskTurnRows.length === 0) {
return dedupeTaskRows(liveRows)
}
const currentGroup = taskTurnRows[taskTurnRows.length - 1]!
const earlierGroups = taskTurnRows.slice(0, -1)
const currentRows = dedupeTaskRows(mergeTaskRowsById(currentGroup.rows, liveRows))
const historicalRow = buildHistoricalTasksRow(earlierGroups)
return historicalRow ? [...currentRows, historicalRow] : currentRows
}
function mergeSubagentRow(existing: ActivityRow | undefined, row: ActivityRow): ActivityRow {
if (!existing) return row
return {
...existing,
id: row.id,
section: 'subagents',
label: existing.label === 'Agent' ? row.label : existing.label,
status: row.status,
description: existing.description ?? row.description,
summary: row.summary ?? existing.summary,
toolUseId: row.toolUseId ?? existing.toolUseId,
taskId: existing.taskId ?? row.taskId,
taskType: existing.taskType ?? row.taskType,
workflowName: existing.workflowName ?? row.workflowName,
dismissKey: existing.dismissKey ?? row.dismissKey,
outputFile: existing.outputFile ?? row.outputFile,
usage: existing.usage ?? row.usage,
updatedAt: row.updatedAt ?? existing.updatedAt,
member: existing.member ?? row.member,
openable: existing.openable || row.openable,
}
}
function mergeNotificationRow(existing: ActivityRow | undefined, notification: AgentTaskNotification): ActivityRow {
const notificationRow = buildNotificationRow(notification)
return {
...existing,
id: notificationRow.id,
section: notificationRow.section,
label: existing?.label || notification.taskId,
status: notification.status,
description: existing?.description,
summary: notification.summary ?? existing?.summary,
toolUseId: notification.toolUseId ?? existing?.toolUseId,
taskId: notification.taskId,
taskType: existing?.taskType,
workflowName: existing?.workflowName,
dismissKey: existing?.dismissKey,
outputFile: notification.outputFile ?? existing?.outputFile,
usage: notification.usage ?? existing?.usage,
updatedAt: notification.timestamp ?? existing?.updatedAt,
openable: Boolean(notification.toolUseId ?? existing?.toolUseId),
}
}
function buildOutputRow(key: string, outputFile: string): ActivityRow {
return {
id: `output-${key}`,
section: 'output',
label: outputFile,
status: 'completed',
outputFile,
openable: true,
}
}
export function buildSessionActivityModel(input: BuildSessionActivityModelInput): SessionActivityModel {
const sections = createEmptySections()
let badgeCount = 0
sections.tasks.rows = buildTaskRowsFromMessages(input.messages ?? [], input.tasks)
for (const row of sections.tasks.rows) {
if (isBadgeStatus(row.status)) {
badgeCount += 1
}
}
for (const member of input.teamMembers ?? []) {
sections.team.rows.push(buildTeamRow(member))
}
const subagentRowsByKey = new Map<string, ActivityRow>()
const subagentKeyByTaskId = new Map<string, string>()
const outputRowsByKey = new Map<string, ActivityRow>()
const dismissedBackgroundTaskKeys = input.dismissedBackgroundTaskKeys ?? new Set<string>()
const dismissedNotificationKeys = new Set<string>()
const dismissedNotificationTaskIds = new Set<string>()
const visibleBackgroundTaskIds = new Set<string>()
for (const row of buildAgentRowsFromMessages(input.messages ?? [])) {
subagentRowsByKey.set(row.id, mergeSubagentRow(subagentRowsByKey.get(row.id), row))
}
for (const task of input.backgroundTasks) {
const dismissKey = createBackgroundTaskDismissKey(task)
if (task.status !== 'running' && dismissedBackgroundTaskKeys.has(dismissKey)) {
const key = activityKey(task)
dismissedNotificationKeys.add(key)
if (!task.toolUseId) {
dismissedNotificationTaskIds.add(task.taskId)
}
continue
}
const key = activityKey(task)
const sectionId: ActivitySectionId = isAgentLikeBackgroundTask(task) ? 'subagents' : 'backgroundTasks'
const row = buildBackgroundRow(task, sectionId)
visibleBackgroundTaskIds.add(task.taskId)
if (sectionId === 'subagents') {
subagentRowsByKey.set(key, mergeSubagentRow(subagentRowsByKey.get(key), row))
subagentKeyByTaskId.set(task.taskId, key)
} else {
sections.backgroundTasks.rows.push(row)
}
if (task.outputFile) {
outputRowsByKey.set(key, buildOutputRow(key, task.outputFile))
}
}
for (const notification of input.agentNotifications) {
const key = notificationKey(notification)
if (
dismissedNotificationKeys.has(key) ||
(!visibleBackgroundTaskIds.has(notification.taskId) && dismissedNotificationTaskIds.has(notification.taskId))
) {
continue
}
const existingKey = subagentRowsByKey.has(key) ? key : subagentKeyByTaskId.get(notification.taskId)
if (!existingKey) {
if (notification.outputFile) {
outputRowsByKey.set(key, buildOutputRow(key, notification.outputFile))
}
continue
}
const mergedRow = mergeNotificationRow(
subagentRowsByKey.get(existingKey),
notification,
)
if (existingKey && existingKey !== key) {
subagentRowsByKey.delete(existingKey)
outputRowsByKey.delete(existingKey)
}
subagentRowsByKey.set(key, mergedRow)
subagentKeyByTaskId.set(notification.taskId, key)
if (mergedRow.outputFile) {
outputRowsByKey.set(key, buildOutputRow(key, mergedRow.outputFile))
}
}
sections.subagents.rows = Array.from(subagentRowsByKey.values())
sections.output.rows = Array.from(outputRowsByKey.values())
for (const row of sections.subagents.rows) {
if (isBadgeStatus(row.status)) {
badgeCount += 1
}
}
for (const row of sections.backgroundTasks.rows) {
if (isBadgeStatus(row.status)) {
badgeCount += 1
}
}
return {
sessionId: input.sessionId,
badgeCount,
sections,
}
}

View File

@ -1596,6 +1596,38 @@ describe('MessageList nested tool calls', () => {
expect(screen.getByRole('button', { name: 'Close dialog' })).toBeTruthy()
})
it('opens the SubAgent run tab from an agent tool card', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'tool-agent',
type: 'tool_use',
toolName: 'Agent',
toolUseId: 'agent-1',
input: { description: 'Inspect src/components' },
timestamp: 1,
},
],
}),
},
})
render(<MessageList />)
fireEvent.click(screen.getByRole('button', { name: 'Open run Inspect src/components' }))
const expectedTabId = '__subagent__active-tab__agent-1'
expect(useTabStore.getState().activeTabId).toBe(expectedTabId)
expect(useTabStore.getState().tabs.find((tab) => tab.sessionId === expectedTabId)).toMatchObject({
title: 'Inspect src/components',
type: 'subagent',
sourceSessionId: ACTIVE_TAB,
subagentToolUseId: 'agent-1',
})
})
it('keeps async launched agents in running state until a terminal notification arrives', () => {
useChatStore.setState({
sessions: {

View File

@ -1960,6 +1960,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
<>
{item.kind === 'tool_group' ? (
<ToolCallGroup
sessionId={resolvedSessionId}
toolCalls={item.toolCalls}
resultMap={toolResultMap}
childToolCallsByParent={childToolCallsByParent}

View File

@ -24,6 +24,7 @@ type Props = {
isPending?: boolean
status?: 'stopped'
partialInput?: string
defaultExpanded?: boolean
}
const TOOL_ICONS: Record<string, string> = {
@ -50,10 +51,10 @@ type ContentStats = {
windowed?: boolean
}
export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, status, partialInput }: Props) {
export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, status, partialInput, defaultExpanded = false }: Props) {
const isExitPlanTool = isExitPlanModeTool(toolName)
const isEnterPlanTool = isEnterPlanModeTool(toolName)
const [expanded, setExpanded] = useState(isExitPlanTool)
const [expanded, setExpanded] = useState(defaultExpanded || isExitPlanTool)
const t = useTranslation()
const obj = input && typeof input === 'object' ? (input as Record<string, unknown>) : {}
const icon = TOOL_ICONS[toolName] || 'build'
@ -82,7 +83,12 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
const hasResultDetails = Boolean(result && extractTextContent(result.content))
const hasEditPreview = toolName === 'Edit' && typeof obj.old_string === 'string' && typeof obj.new_string === 'string'
const hasWritePreview = toolName === 'Write' && typeof obj.content === 'string'
const expandable = hasEditPreview || hasWritePreview || hasResultDetails || Boolean(isPending && partialInput)
const hasAgentInputDetails = toolName === 'Agent' && (
typeof obj.description === 'string' ||
typeof obj.prompt === 'string' ||
typeof obj.subagent_type === 'string'
)
const expandable = hasEditPreview || hasWritePreview || hasResultDetails || hasAgentInputDetails || Boolean(isPending && partialInput)
if (isEnterPlanTool) {
return (

View File

@ -28,10 +28,12 @@ type MemoryToolActivity = {
}
type Props = {
sessionId?: string | null
toolCalls: ToolCall[]
resultMap: Map<string, ToolResult>
childToolCallsByParent: Map<string, ToolCall[]>
agentTaskNotifications: Record<string, AgentTaskNotification>
showOpenRun?: boolean
/** When true, the last tool is still executing — show expanded */
isStreaming?: boolean
}
@ -110,10 +112,12 @@ function hasUnresolvedToolCalls(
}
export const ToolCallGroup = memo(function ToolCallGroup({
sessionId,
toolCalls,
resultMap,
childToolCallsByParent,
agentTaskNotifications,
showOpenRun = true,
isStreaming,
}: Props) {
const memoryActivity = getMemoryToolActivity(toolCalls, resultMap)
@ -131,10 +135,12 @@ export const ToolCallGroup = memo(function ToolCallGroup({
isStreaming={isStreaming}
/>
<ToolCallGroupContent
sessionId={sessionId}
toolCalls={regularToolCalls}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
showOpenRun={showOpenRun}
isStreaming={isStreaming}
/>
</div>
@ -153,20 +159,24 @@ export const ToolCallGroup = memo(function ToolCallGroup({
return (
<ToolCallGroupContent
sessionId={sessionId}
toolCalls={toolCalls}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
showOpenRun={showOpenRun}
isStreaming={isStreaming}
/>
)
})
function ToolCallGroupContent({
sessionId,
toolCalls,
resultMap,
childToolCallsByParent,
agentTaskNotifications,
showOpenRun = true,
isStreaming,
}: Props) {
const allAgents = toolCalls.every((toolCall) => toolCall.toolName === 'Agent')
@ -174,10 +184,12 @@ function ToolCallGroupContent({
if (allAgents) {
return (
<AgentToolGroup
sessionId={sessionId}
toolCalls={toolCalls}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
showOpenRun={showOpenRun}
isStreaming={isStreaming}
/>
)
@ -327,10 +339,12 @@ function MemoryToolActivityGroup({
}
function AgentToolGroup({
sessionId,
toolCalls,
resultMap,
childToolCallsByParent,
agentTaskNotifications,
showOpenRun = true,
isStreaming,
}: Props) {
const [expanded, setExpanded] = useState(true)
@ -399,10 +413,12 @@ function AgentToolGroup({
<div className="absolute left-[8px] top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full border border-[var(--color-border)]/65 bg-[var(--color-surface-container-lowest)] shadow-[0_0_0_2px_var(--color-surface)]" />
</div>
<AgentCallCard
sessionId={sessionId}
toolCall={toolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotification={agentTaskNotifications[toolCall.toolUseId]}
showOpenRun={showOpenRun}
isStreaming={isStreaming && !resultMap.has(toolCall.toolUseId)}
/>
</div>
@ -474,16 +490,20 @@ function ToolCallGroupMulti({ toolCalls, resultMap, childToolCallsByParent, isSt
}
function AgentCallCard({
sessionId,
toolCall,
resultMap,
childToolCallsByParent,
agentTaskNotification,
showOpenRun = true,
isStreaming = false,
}: {
sessionId?: string | null
toolCall: ToolCall
resultMap: Map<string, ToolResult>
childToolCallsByParent: Map<string, ToolCall[]>
agentTaskNotification?: AgentTaskNotification
showOpenRun?: boolean
isStreaming?: boolean
}) {
const [expanded, setExpanded] = useState(false)
@ -523,6 +543,8 @@ function AgentCallCard({
const previewText = terminalTaskReport || fullOutputText || terminalTaskSummary
const outputSummary = previewText ? getAgentOutputSummary(previewText) : ''
const description = typeof input.description === 'string' ? input.description : ''
const openRunTitle = description.trim() || 'Agent'
const canOpenRun = showOpenRun && !!sessionId && !!toolCall.toolUseId
return (
<div className="overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-container-lowest)]">
@ -572,6 +594,19 @@ function AgentCallCard({
{t('agentStatus.viewResult')}
</button>
)}
{canOpenRun && (
<button
type="button"
aria-label={`Open run ${openRunTitle}`}
onClick={(event) => {
event.stopPropagation()
useTabStore.getState().openSubagentTab(sessionId, toolCall.toolUseId, openRunTitle)
}}
className="shrink-0 rounded-md border border-[var(--color-border)] px-2.5 py-1 text-[11px] font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
Open run
</button>
)}
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${statusClassName}`}>
{statusLabel}
</span>

View File

@ -42,6 +42,12 @@ vi.mock('../../pages/TraceList', () => ({
TraceList: () => <div data-testid="trace-list" />,
}))
vi.mock('../../pages/SubagentRunPage', () => ({
SubagentRunPage: ({ sourceSessionId, toolUseId, title }: { sourceSessionId: string; toolUseId: string; title: string }) => (
<div data-testid="subagent-run-page">{sourceSessionId}:{toolUseId}:{title}</div>
),
}))
vi.mock('../workbench/WorkbenchTab', () => ({
WorkbenchTab: ({ sessionId, tabId }: { sessionId: string; tabId: string }) => (
<div data-testid="workbench-tab">workbench:{sessionId}:{tabId}</div>
@ -154,6 +160,42 @@ describe('ContentRouter tab surfaces', () => {
expect(screen.queryByTestId('active-session')).not.toBeInTheDocument()
})
it('renders SubAgent run tabs', () => {
useTabStore.setState({
tabs: [{
sessionId: '__subagent__session-1__tool-1',
title: 'Kuhn',
type: 'subagent',
status: 'idle',
sourceSessionId: 'session-1',
subagentToolUseId: 'tool-1',
}],
activeTabId: '__subagent__session-1__tool-1',
})
render(<ContentRouter />)
expect(screen.getByTestId('subagent-run-page')).toHaveTextContent('session-1:tool-1:Kuhn')
expect(screen.queryByTestId('active-session')).not.toBeInTheDocument()
})
it('falls back to the empty session for malformed SubAgent tab metadata', () => {
useTabStore.setState({
tabs: [{
sessionId: '__subagent__session-1__tool-1',
title: 'Kuhn',
type: 'subagent',
status: 'idle',
}],
activeTabId: '__subagent__session-1__tool-1',
})
render(<ContentRouter />)
expect(screen.getByTestId('empty-session')).toBeInTheDocument()
expect(screen.queryByTestId('subagent-run-page')).not.toBeInTheDocument()
})
it('renders workbench tabs as main content instead of mounting the chat session surface', () => {
useTabStore.setState({
tabs: [{

View File

@ -7,6 +7,7 @@ import { Settings } from '../../pages/Settings'
import { TerminalSettings } from '../../pages/TerminalSettings'
import { TraceList } from '../../pages/TraceList'
import { TraceSession } from '../../pages/TraceSession'
import { SubagentRunPage } from '../../pages/SubagentRunPage'
import { WorkbenchTab } from '../workbench/WorkbenchTab'
import { previewBridge } from '../../lib/previewBridge'
@ -33,6 +34,17 @@ export function ContentRouter() {
page = traceSessionId ? <TraceSession sessionId={traceSessionId} /> : <EmptySession />
} else if (activeTabType === 'traces') {
page = <TraceList />
} else if (activeTabType === 'subagent') {
const subagentTab = tabs.find((t) => t.sessionId === activeTabId)
page = subagentTab?.sourceSessionId && subagentTab.subagentToolUseId
? (
<SubagentRunPage
sourceSessionId={subagentTab.sourceSessionId}
toolUseId={subagentTab.subagentToolUseId}
title={subagentTab.title}
/>
)
: <EmptySession />
} else if (activeTabType === 'workbench') {
const workbenchTab = tabs.find((t) => t.sessionId === activeTabId)
page = workbenchTab?.workbenchSessionId

View File

@ -87,6 +87,7 @@ vi.mock('../../i18n', () => ({
'openProject.openIn': 'Open in {target}',
'openProject.openFailed': 'Could not open project',
'common.cancel': 'Cancel',
'session.activity.title': 'Activity',
}
let text = translations[key] ?? key
@ -179,6 +180,8 @@ describe('TabBar', () => {
const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore')
const { useTerminalPanelStore } = await import('../../stores/terminalPanelStore')
const { useBrowserPanelStore } = await import('../../stores/browserPanelStore')
const { useActivityPanelStore } = await import('../../stores/activityPanelStore')
const { useCLITaskStore } = await import('../../stores/cliTaskStore')
useTabStore.setState({ tabs: [], activeTabId: null })
useChatStore.setState({
@ -195,11 +198,232 @@ describe('TabBar', () => {
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
useTerminalPanelStore.setState(useTerminalPanelStore.getInitialState(), true)
useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
useActivityPanelStore.setState(useActivityPanelStore.getInitialState(), true)
useCLITaskStore.setState(useCLITaskStore.getInitialState(), true)
Reflect.deleteProperty(window, 'desktopHost')
Reflect.deleteProperty(window, '__TAURI__')
})
it('shows the activity button for active session tabs', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const { useSessionStore } = await import('../../stores/sessionStore')
const { useActivityPanelStore } = await import('../../stores/activityPanelStore')
const chatSession = makeChatSession('idle')
chatSession.backgroundAgentTasks = {
'agent-1': {
taskId: 'agent-1',
toolUseId: 'tool-1',
status: 'running',
taskType: 'local_agent',
description: 'Explore',
startedAt: 1,
updatedAt: 2,
},
}
useTabStore.setState({
tabs: [{ sessionId: 'session-1', title: 'Chat', type: 'session', status: 'idle' }],
activeTabId: 'session-1',
})
useSessionStore.setState({
sessions: [{ id: 'session-1', title: 'Chat', workDir: '/tmp/project', workDirExists: true }],
} as Partial<ReturnType<typeof useSessionStore.getState>>)
useChatStore.setState({
sessions: {
'session-1': chatSession,
},
disconnectSession: vi.fn(),
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
const button = screen.getByRole('button', { name: /activity/i })
expect(button).toBeInTheDocument()
expect(screen.getByTestId('session-activity-badge')).toHaveTextContent('1')
expect(useActivityPanelStore.getState().isOpen('session-1')).toBe(false)
expect(button).toHaveAttribute('aria-expanded', 'false')
expect(button).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(button)
expect(button).toHaveAttribute('data-active', 'true')
expect(button).toHaveAttribute('aria-expanded', 'true')
expect(button).toHaveAttribute('aria-pressed', 'true')
expect(useActivityPanelStore.getState().isOpen('session-1')).toBe(true)
})
it('does not show the activity button for settings tabs', async () => {
const { TabBar } = await import('./TabBar')
const { SETTINGS_TAB_ID, useTabStore } = await import('../../stores/tabStore')
useTabStore.setState({
tabs: [{ sessionId: SETTINGS_TAB_ID, title: 'Settings', type: 'settings', status: 'idle' }],
activeTabId: SETTINGS_TAB_ID,
})
await act(async () => {
render(<TabBar />)
})
expect(screen.queryByRole('button', { name: /activity/i })).not.toBeInTheDocument()
})
it('includes current-session CLI tasks in the activity badge', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const { useSessionStore } = await import('../../stores/sessionStore')
const { useCLITaskStore } = await import('../../stores/cliTaskStore')
const sessionId = 'session-1'
useTabStore.setState({
tabs: [{ sessionId, title: 'Chat', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useSessionStore.setState({
sessions: [{ id: sessionId, title: 'Chat', workDir: '/tmp/project', workDirExists: true }],
} as Partial<ReturnType<typeof useSessionStore.getState>>)
useChatStore.setState({
sessions: {
[sessionId]: makeChatSession('idle'),
},
disconnectSession: vi.fn(),
} as Partial<ReturnType<typeof useChatStore.getState>>)
useCLITaskStore.setState({
sessionId,
tasks: [
{
id: 'task-1',
subject: 'Plan work',
description: '',
status: 'pending',
blocks: [],
blockedBy: [],
taskListId: sessionId,
},
{
id: 'task-2',
subject: 'Ship work',
description: '',
status: 'in_progress',
blocks: [],
blockedBy: [],
taskListId: sessionId,
},
],
completedAndDismissed: false,
})
await act(async () => {
render(<TabBar />)
})
expect(screen.getByTestId('session-activity-badge')).toHaveTextContent('2')
})
it('excludes dismissed failed background tasks from the activity badge while keeping running tasks', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const { useSessionStore } = await import('../../stores/sessionStore')
const { useActivityPanelStore } = await import('../../stores/activityPanelStore')
const { createBackgroundTaskDismissKey } = await import('../../lib/backgroundTasks')
const sessionId = 'session-1'
const failedTask = {
taskId: 'failed-task-1',
toolUseId: 'failed-tool-1',
status: 'failed' as const,
taskType: 'local_bash',
description: 'Failed run',
startedAt: 1000,
updatedAt: 2000,
}
const runningTask = {
taskId: 'running-task-1',
toolUseId: 'running-tool-1',
status: 'running' as const,
taskType: 'local_bash',
description: 'Running run',
startedAt: 1000,
updatedAt: 2000,
}
const chatSession = makeChatSession('idle')
chatSession.backgroundAgentTasks = {
[failedTask.taskId]: failedTask,
[runningTask.taskId]: runningTask,
}
useActivityPanelStore.getState().dismissBackgroundTaskKeys(sessionId, [
createBackgroundTaskDismissKey(failedTask),
])
useTabStore.setState({
tabs: [{ sessionId, title: 'Chat', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useSessionStore.setState({
sessions: [{ id: sessionId, title: 'Chat', workDir: '/tmp/project', workDirExists: true }],
} as Partial<ReturnType<typeof useSessionStore.getState>>)
useChatStore.setState({
sessions: {
[sessionId]: chatSession,
},
disconnectSession: vi.fn(),
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
expect(screen.getByTestId('session-activity-badge')).toHaveTextContent('1')
})
it('ignores CLI tasks from a different session in the activity badge', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const { useSessionStore } = await import('../../stores/sessionStore')
const { useCLITaskStore } = await import('../../stores/cliTaskStore')
useTabStore.setState({
tabs: [{ sessionId: 'session-1', title: 'Chat', type: 'session', status: 'idle' }],
activeTabId: 'session-1',
})
useSessionStore.setState({
sessions: [{ id: 'session-1', title: 'Chat', workDir: '/tmp/project', workDirExists: true }],
} as Partial<ReturnType<typeof useSessionStore.getState>>)
useChatStore.setState({
sessions: {
'session-1': makeChatSession('idle'),
},
disconnectSession: vi.fn(),
} as Partial<ReturnType<typeof useChatStore.getState>>)
useCLITaskStore.setState({
sessionId: 'session-2',
tasks: [{
id: 'task-1',
subject: 'Other session work',
description: '',
status: 'in_progress',
blocks: [],
blockedBy: [],
taskListId: 'session-2',
}],
completedAndDismissed: false,
})
await act(async () => {
render(<TabBar />)
})
expect(screen.getByRole('button', { name: /activity/i })).toBeInTheDocument()
expect(screen.queryByTestId('session-activity-badge')).not.toBeInTheDocument()
})
it('scrolls the active tab into view when the active tab changes', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
@ -901,12 +1125,54 @@ describe('TabBar', () => {
expect(screen.queryByRole('button', { name: 'Show Workspace' })).not.toBeInTheDocument()
})
it('treats active SubAgent tabs as non-session tabs for toolbar state', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore')
const { useTerminalPanelStore } = await import('../../stores/terminalPanelStore')
const { useActivityPanelStore } = await import('../../stores/activityPanelStore')
const tabId = '__subagent__session-1__tool-1'
useTabStore.setState({
tabs: [{
sessionId: tabId,
title: 'Kuhn',
type: 'subagent',
status: 'idle',
sourceSessionId: 'session-1',
subagentToolUseId: 'tool-1',
}],
activeTabId: tabId,
})
useChatStore.setState({
sessions: {},
disconnectSession: vi.fn(),
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
expect(screen.queryByRole('button', { name: /activity/i })).not.toBeInTheDocument()
expect(screen.queryByTestId('open-project-menu')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Show Workspace' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Open Terminal' }))
expect(useTabStore.getState().tabs.some((tab) => tab.type === 'terminal')).toBe(true)
expect(useWorkspacePanelStore.getState().panelBySession[tabId]).toBeUndefined()
expect(useTerminalPanelStore.getState().panelBySession[tabId]).toBeUndefined()
expect(useActivityPanelStore.getState().isOpen(tabId)).toBe(false)
})
it('clears session panel state when closing a session tab', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore')
const { useTerminalPanelStore } = await import('../../stores/terminalPanelStore')
const { useActivityPanelStore } = await import('../../stores/activityPanelStore')
useTabStore.setState({
tabs: [
@ -920,6 +1186,7 @@ describe('TabBar', () => {
} as Partial<ReturnType<typeof useChatStore.getState>>)
useWorkspacePanelStore.getState().openPanel('tab-1')
useTerminalPanelStore.getState().openPanel('tab-1')
useActivityPanelStore.getState().open('tab-1')
await act(async () => {
render(<TabBar />)
@ -929,6 +1196,7 @@ describe('TabBar', () => {
expect(useWorkspacePanelStore.getState().panelBySession['tab-1']).toBeUndefined()
expect(useTerminalPanelStore.getState().panelBySession['tab-1']).toBeUndefined()
expect(useActivityPanelStore.getState().isOpen('tab-1')).toBe(false)
})
it('asks before stopping running sessions when closing all tabs', async () => {

View File

@ -3,6 +3,7 @@ import { useShallow } from 'zustand/react/shallow'
import {
SCHEDULED_TAB_ID,
SETTINGS_TAB_ID,
SUBAGENT_TAB_PREFIX,
TERMINAL_TAB_PREFIX,
TRACE_LIST_TAB_ID,
TRACE_TAB_PREFIX,
@ -15,6 +16,7 @@ import { useSessionStore } from '../../stores/sessionStore'
import { isPlaceholderSessionTitle } from '../../lib/sessionTitle'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { useTerminalPanelStore } from '../../stores/terminalPanelStore'
import { useCLITaskStore } from '../../stores/cliTaskStore'
import { useTranslation } from '../../i18n'
import { getDesktopHost } from '../../lib/desktopHost'
import { hasRunningBackgroundTasks } from '../../lib/backgroundTasks'
@ -22,11 +24,15 @@ import { WindowControls, showWindowControls } from './WindowControls'
import { OpenProjectMenu } from './OpenProjectMenu'
import { Folder, FolderOpen, SquareTerminal } from 'lucide-react'
import { ActionDialog } from '../shared/ActionDialog'
import { buildSessionActivityModel } from '../activity/sessionActivityModel'
import { SessionActivityButton } from '../activity/SessionActivityButton'
import { useActivityPanelStore } from '../../stores/activityPanelStore'
const TAB_WIDTH = 180
const DRAG_START_THRESHOLD = 4
const desktopHost = getDesktopHost()
const isDesktopRuntime = desktopHost.isDesktop
const EMPTY_DISMISSED_BACKGROUND_TASK_KEYS: readonly string[] = []
type PendingCloseRequest = {
tabs: Tab[]
@ -48,7 +54,8 @@ function isSessionTabId(tabId: string | null) {
tabId !== TRACE_LIST_TAB_ID &&
!tabId.startsWith(TERMINAL_TAB_PREFIX) &&
!tabId.startsWith(TRACE_TAB_PREFIX) &&
!tabId.startsWith(WORKBENCH_TAB_PREFIX)
!tabId.startsWith(WORKBENCH_TAB_PREFIX) &&
!tabId.startsWith(SUBAGENT_TAB_PREFIX)
}
export function TabBar() {
@ -89,6 +96,33 @@ export function TabBar() {
const isTerminalPanelOpen = useTerminalPanelStore((state) =>
activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false,
)
const cliTasks = useCLITaskStore((state) => state.tasks)
const cliTasksSessionId = useCLITaskStore((state) => state.sessionId)
const cliTasksCompletedAndDismissed = useCLITaskStore((state) => state.completedAndDismissed)
const dismissedBackgroundTaskKeyList = useActivityPanelStore((state) =>
activeTabId
? state.dismissedBackgroundTaskKeysBySession[activeTabId] ?? EMPTY_DISMISSED_BACKGROUND_TASK_KEYS
: EMPTY_DISMISSED_BACKGROUND_TASK_KEYS,
)
const dismissedBackgroundTaskKeys = useMemo(
() => new Set(dismissedBackgroundTaskKeyList),
[dismissedBackgroundTaskKeyList],
)
const activityBadgeCount = useChatStore((state) => {
if (!activeTabId || !isActiveSessionTab) return 0
const sessionState = state.sessions[activeTabId]
const includeCliTasks = cliTasksSessionId === activeTabId
return buildSessionActivityModel({
sessionId: activeTabId,
messages: sessionState?.messages ?? [],
tasks: includeCliTasks ? cliTasks : [],
completedAndDismissed: includeCliTasks ? cliTasksCompletedAndDismissed : false,
backgroundTasks: Object.values(sessionState?.backgroundAgentTasks ?? {}),
dismissedBackgroundTaskKeys,
agentNotifications: Object.values(sessionState?.agentTaskNotifications ?? {}),
}).badgeCount
})
const moveTab = useTabStore((s) => s.moveTab)
const scrollRef = useRef<HTMLDivElement>(null)
@ -167,6 +201,7 @@ export function TabBar() {
if (isSessionTab(tab)) {
useWorkspacePanelStore.getState().clearSession(tab.sessionId)
useTerminalPanelStore.getState().clearSession(tab.sessionId)
useActivityPanelStore.getState().close(tab.sessionId)
}
closeTab(tab.sessionId)
}, [closeTab])
@ -378,6 +413,9 @@ export function TabBar() {
</div>
<div className="flex shrink-0 items-center gap-1 border-l border-[var(--color-border)]/70 px-2">
{isActiveSessionTab && activeTabId && (
<SessionActivityButton sessionId={activeTabId} badgeCount={activityBadgeCount} />
)}
{isDesktopRuntime && isActiveSessionTab && (
<OpenProjectMenu path={openProjectPath} />
)}

View File

@ -1689,6 +1689,73 @@ export const en = {
'teams.memberPlaceholder': 'Message this teammate directly...',
'teams.memberSessionHint': 'Messages here go straight to this teammate while you watch its transcript update.',
// ─── Session Activity ──────────────────────────────────────
'session.activity.title': 'Activity',
'session.activity.close': 'Close activity',
'session.activity.clearFinished': 'Clear finished',
'session.activity.openTeamMember': 'Open team member {name}',
'session.activity.openRun': 'Open run {name}',
'session.activity.openBackgroundTask': 'Open background task {name}',
'session.activity.details.title': 'Details',
'session.activity.details.type': 'Type',
'session.activity.details.description': 'Description',
'session.activity.details.summary': 'Summary',
'session.activity.details.outputFile': 'Output file',
'session.activity.details.usage': 'Usage',
'session.activity.section.tasks': 'Tasks',
'session.activity.section.team': 'Team',
'session.activity.section.backgroundTasks': 'Background Tasks',
'session.activity.section.subagents': 'SubAgents',
'session.activity.section.sources': 'Sources',
'session.activity.empty.tasks': 'No tasks',
'session.activity.empty.team': 'No team members',
'session.activity.empty.backgroundTasks': 'No background tasks',
'session.activity.empty.subagents': 'No SubAgents',
'session.activity.empty.sources': 'No sources',
'session.activity.task.completed': 'Task completed',
'session.activity.task.inProgress': 'Task in progress',
'session.activity.task.pending': 'Task pending',
'session.activity.tasks.earlier': 'Earlier tasks',
'session.activity.tasks.earlierSummary': 'Earlier turns: {completed}/{total} completed',
'session.activity.status.pending': 'Pending',
'session.activity.status.inProgress': 'In progress',
'session.activity.status.completed': 'Completed',
'session.activity.status.running': 'Running',
'session.activity.status.failed': 'Failed',
'session.activity.status.stopped': 'Stopped',
'session.activity.status.idle': 'Idle',
'session.activity.status.error': 'Error',
'session.activity.status.unknown': 'Unknown',
// ─── SubAgent Run ──────────────────────────────────────
'subagentRun.refresh': 'Refresh SubAgent run',
'subagentRun.loading': 'Loading SubAgent run...',
'subagentRun.source': 'Source',
'subagentRun.agent': 'Agent',
'subagentRun.unknown': 'Unknown',
'subagentRun.task': 'Task',
'subagentRun.updated': 'Updated',
'subagentRun.output': 'Output',
'subagentRun.agentInvocation': 'Agent invocation',
'subagentRun.parentToolInput': 'Parent Agent tool input',
'subagentRun.parentToolCall': 'Parent Agent Tool Call',
'subagentRun.transcript': 'Transcript',
'subagentRun.noTranscript': 'No local transcript messages captured for this SubAgent.',
'subagentRun.truncated': 'Showing first 50 and latest 950 entries',
'subagentRun.source.transcript': 'Transcript',
'subagentRun.source.sessionHistory': 'Session history',
'subagentRun.source.liveTask': 'Live task',
'subagentRun.source.none': 'None',
'subagentRun.status.completed': 'Completed',
'subagentRun.status.failed': 'Failed',
'subagentRun.status.stopped': 'Stopped',
'subagentRun.status.running': 'Running',
'subagentRun.status.unknown': 'Unknown',
'subagentRun.result.completedFallback': 'SubAgent completed without a result payload.',
'subagentRun.result.failedFallback': 'SubAgent failed without an error payload.',
'subagentRun.result.stoppedFallback': 'SubAgent stopped without a result payload.',
'subagentRun.result.noneFallback': 'No result available.',
// ─── Scheduled Tasks Pages ──────────────────────────────────────
'scheduledPage.title': 'Scheduled tasks',
'scheduledPage.subtitle': 'Run tasks on a schedule or whenever you need them. Type {code} in any existing session to create one.',

View File

@ -1691,6 +1691,73 @@ export const jp: Record<TranslationKey, string> = {
'teams.memberPlaceholder': 'このメンバーに直接メッセージを送信...',
'teams.memberSessionHint': 'ここでのメッセージはこのメンバーに直接届き、記録の更新を見ながらやり取りできます。',
// ─── Session Activity ──────────────────────────────────────
'session.activity.title': 'アクティビティ',
'session.activity.close': 'アクティビティを閉じる',
'session.activity.clearFinished': '完了済みをクリア',
'session.activity.openTeamMember': 'チームメンバー {name} を開く',
'session.activity.openRun': '実行 {name} を開く',
'session.activity.openBackgroundTask': 'バックグラウンドタスク {name} を開く',
'session.activity.details.title': '詳細',
'session.activity.details.type': '種類',
'session.activity.details.description': '説明',
'session.activity.details.summary': '要約',
'session.activity.details.outputFile': '出力ファイル',
'session.activity.details.usage': '使用量',
'session.activity.section.tasks': 'タスク',
'session.activity.section.team': 'チーム',
'session.activity.section.backgroundTasks': 'バックグラウンドタスク',
'session.activity.section.subagents': 'SubAgent',
'session.activity.section.sources': 'ソース',
'session.activity.empty.tasks': 'タスクはありません',
'session.activity.empty.team': 'チームメンバーはいません',
'session.activity.empty.backgroundTasks': 'バックグラウンドタスクはありません',
'session.activity.empty.subagents': 'SubAgent はありません',
'session.activity.empty.sources': 'ソースはありません',
'session.activity.task.completed': 'タスク完了',
'session.activity.task.inProgress': 'タスク実行中',
'session.activity.task.pending': 'タスク待機中',
'session.activity.tasks.earlier': '過去のタスク',
'session.activity.tasks.earlierSummary': '過去のターン: {completed}/{total} 完了',
'session.activity.status.pending': '待機中',
'session.activity.status.inProgress': '進行中',
'session.activity.status.completed': '完了',
'session.activity.status.running': '実行中',
'session.activity.status.failed': '失敗',
'session.activity.status.stopped': '停止済み',
'session.activity.status.idle': 'アイドル',
'session.activity.status.error': 'エラー',
'session.activity.status.unknown': '不明',
// ─── SubAgent Run ──────────────────────────────────────
'subagentRun.refresh': 'SubAgent 実行を更新',
'subagentRun.loading': 'SubAgent 実行を読み込み中...',
'subagentRun.source': 'ソース',
'subagentRun.agent': 'Agent',
'subagentRun.unknown': '不明',
'subagentRun.task': 'タスク',
'subagentRun.updated': '更新日時',
'subagentRun.output': '出力',
'subagentRun.agentInvocation': 'Agent 呼び出し',
'subagentRun.parentToolInput': '親 Agent のツール入力',
'subagentRun.parentToolCall': '親 Agent のツール呼び出し',
'subagentRun.transcript': '実行記録',
'subagentRun.noTranscript': 'この SubAgent のローカル実行記録は取得されていません。',
'subagentRun.truncated': '先頭 50 件と最新 950 件を表示中',
'subagentRun.source.transcript': '実行記録',
'subagentRun.source.sessionHistory': 'セッション履歴',
'subagentRun.source.liveTask': 'ライブタスク',
'subagentRun.source.none': 'なし',
'subagentRun.status.completed': '完了',
'subagentRun.status.failed': '失敗',
'subagentRun.status.stopped': '停止済み',
'subagentRun.status.running': '実行中',
'subagentRun.status.unknown': '不明',
'subagentRun.result.completedFallback': 'SubAgent は完了しましたが、結果ペイロードはありません。',
'subagentRun.result.failedFallback': 'SubAgent は失敗しましたが、エラーペイロードはありません。',
'subagentRun.result.stoppedFallback': 'SubAgent は停止しましたが、結果ペイロードはありません。',
'subagentRun.result.noneFallback': '結果はありません。',
// ─── Scheduled Tasks Pages ──────────────────────────────────────
'scheduledPage.title': 'スケジュールタスク',
'scheduledPage.subtitle': 'タスクをスケジュール実行したり、必要なときに実行したりします。既存のセッションで {code} を入力すると作成できます。',

View File

@ -1691,6 +1691,73 @@ export const kr: Record<TranslationKey, string> = {
'teams.memberPlaceholder': '이 팀원에게 직접 메시지 보내기...',
'teams.memberSessionHint': '여기서 보내는 메시지는 이 팀원에게 바로 전달되며, 기록이 업데이트되는 것을 보면서 주고받을 수 있습니다.',
// ─── Session Activity ──────────────────────────────────────
'session.activity.title': '활동',
'session.activity.close': '활동 닫기',
'session.activity.clearFinished': '완료 항목 지우기',
'session.activity.openTeamMember': '팀 멤버 {name} 열기',
'session.activity.openRun': '실행 {name} 열기',
'session.activity.openBackgroundTask': '백그라운드 작업 {name} 열기',
'session.activity.details.title': '세부 정보',
'session.activity.details.type': '유형',
'session.activity.details.description': '설명',
'session.activity.details.summary': '요약',
'session.activity.details.outputFile': '출력 파일',
'session.activity.details.usage': '사용량',
'session.activity.section.tasks': '작업',
'session.activity.section.team': '팀',
'session.activity.section.backgroundTasks': '백그라운드 작업',
'session.activity.section.subagents': 'SubAgent',
'session.activity.section.sources': '소스',
'session.activity.empty.tasks': '작업이 없습니다',
'session.activity.empty.team': '팀 멤버가 없습니다',
'session.activity.empty.backgroundTasks': '백그라운드 작업이 없습니다',
'session.activity.empty.subagents': 'SubAgent가 없습니다',
'session.activity.empty.sources': '소스가 없습니다',
'session.activity.task.completed': '작업 완료됨',
'session.activity.task.inProgress': '작업 진행 중',
'session.activity.task.pending': '작업 대기 중',
'session.activity.tasks.earlier': '이전 작업',
'session.activity.tasks.earlierSummary': '이전 턴: {completed}/{total} 완료',
'session.activity.status.pending': '대기 중',
'session.activity.status.inProgress': '진행 중',
'session.activity.status.completed': '완료됨',
'session.activity.status.running': '실행 중',
'session.activity.status.failed': '실패',
'session.activity.status.stopped': '중지됨',
'session.activity.status.idle': '유휴',
'session.activity.status.error': '오류',
'session.activity.status.unknown': '알 수 없음',
// ─── SubAgent Run ──────────────────────────────────────
'subagentRun.refresh': 'SubAgent 실행 새로고침',
'subagentRun.loading': 'SubAgent 실행을 불러오는 중...',
'subagentRun.source': '소스',
'subagentRun.agent': 'Agent',
'subagentRun.unknown': '알 수 없음',
'subagentRun.task': '작업',
'subagentRun.updated': '업데이트됨',
'subagentRun.output': '출력',
'subagentRun.agentInvocation': 'Agent 호출',
'subagentRun.parentToolInput': '상위 Agent 도구 입력',
'subagentRun.parentToolCall': '상위 Agent 도구 호출',
'subagentRun.transcript': '실행 기록',
'subagentRun.noTranscript': '이 SubAgent의 로컬 실행 기록이 캡처되지 않았습니다.',
'subagentRun.truncated': '처음 50개와 최신 950개 항목 표시 중',
'subagentRun.source.transcript': '실행 기록',
'subagentRun.source.sessionHistory': '세션 기록',
'subagentRun.source.liveTask': '실시간 작업',
'subagentRun.source.none': '없음',
'subagentRun.status.completed': '완료됨',
'subagentRun.status.failed': '실패',
'subagentRun.status.stopped': '중지됨',
'subagentRun.status.running': '실행 중',
'subagentRun.status.unknown': '알 수 없음',
'subagentRun.result.completedFallback': 'SubAgent가 완료되었지만 결과 페이로드가 없습니다.',
'subagentRun.result.failedFallback': 'SubAgent가 실패했지만 오류 페이로드가 없습니다.',
'subagentRun.result.stoppedFallback': 'SubAgent가 중지되었지만 결과 페이로드가 없습니다.',
'subagentRun.result.noneFallback': '사용 가능한 결과가 없습니다.',
// ─── Scheduled Tasks Pages ──────────────────────────────────────
'scheduledPage.title': '예약 작업',
'scheduledPage.subtitle': '작업을 예약 실행하거나 필요할 때 실행하세요. 기존 세션에서 {code}을(를) 입력하면 만들 수 있습니다.',

View File

@ -1691,6 +1691,73 @@ export const zh: Record<TranslationKey, string> = {
'teams.memberPlaceholder': '直接給這個 Agent 發訊息...',
'teams.memberSessionHint': '這裡傳送的訊息會直接投遞給該 Agent同時繼續重新整理它的 transcript。',
// ─── Session Activity ──────────────────────────────────────
'session.activity.title': '活動',
'session.activity.close': '關閉活動',
'session.activity.clearFinished': '清除已完成',
'session.activity.openTeamMember': '開啟團隊成員 {name}',
'session.activity.openRun': '開啟執行 {name}',
'session.activity.openBackgroundTask': '開啟後台任務 {name}',
'session.activity.details.title': '詳情',
'session.activity.details.type': '類型',
'session.activity.details.description': '描述',
'session.activity.details.summary': '摘要',
'session.activity.details.outputFile': '輸出檔案',
'session.activity.details.usage': '用量',
'session.activity.section.tasks': '任務',
'session.activity.section.team': '團隊',
'session.activity.section.backgroundTasks': '後台任務',
'session.activity.section.subagents': 'SubAgent',
'session.activity.section.sources': '來源',
'session.activity.empty.tasks': '暫無任務',
'session.activity.empty.team': '暫無團隊成員',
'session.activity.empty.backgroundTasks': '暫無後台任務',
'session.activity.empty.subagents': '暫無 SubAgent',
'session.activity.empty.sources': '暫無來源',
'session.activity.task.completed': '任務已完成',
'session.activity.task.inProgress': '任務進行中',
'session.activity.task.pending': '任務待處理',
'session.activity.tasks.earlier': '歷史任務',
'session.activity.tasks.earlierSummary': '歷史輪次:{completed}/{total} 已完成',
'session.activity.status.pending': '待處理',
'session.activity.status.inProgress': '進行中',
'session.activity.status.completed': '已完成',
'session.activity.status.running': '執行中',
'session.activity.status.failed': '失敗',
'session.activity.status.stopped': '已停止',
'session.activity.status.idle': '閒置',
'session.activity.status.error': '錯誤',
'session.activity.status.unknown': '未知',
// ─── SubAgent Run ──────────────────────────────────────
'subagentRun.refresh': '重新整理 SubAgent 執行',
'subagentRun.loading': '正在載入 SubAgent 執行...',
'subagentRun.source': '來源',
'subagentRun.agent': 'Agent',
'subagentRun.unknown': '未知',
'subagentRun.task': '任務',
'subagentRun.updated': '更新時間',
'subagentRun.output': '輸出',
'subagentRun.agentInvocation': 'Agent 呼叫',
'subagentRun.parentToolInput': '父級 Agent 工具輸入',
'subagentRun.parentToolCall': '父級 Agent 工具呼叫',
'subagentRun.transcript': '執行記錄',
'subagentRun.noTranscript': '沒有擷取到這個 SubAgent 的本地執行記錄。',
'subagentRun.truncated': '顯示前 50 筆和最新 950 筆記錄',
'subagentRun.source.transcript': '執行記錄',
'subagentRun.source.sessionHistory': '會話歷史',
'subagentRun.source.liveTask': '即時任務',
'subagentRun.source.none': '無',
'subagentRun.status.completed': '已完成',
'subagentRun.status.failed': '失敗',
'subagentRun.status.stopped': '已停止',
'subagentRun.status.running': '執行中',
'subagentRun.status.unknown': '未知',
'subagentRun.result.completedFallback': 'SubAgent 已完成,但沒有結果內容。',
'subagentRun.result.failedFallback': 'SubAgent 已失敗,但沒有錯誤內容。',
'subagentRun.result.stoppedFallback': 'SubAgent 已停止,但沒有結果內容。',
'subagentRun.result.noneFallback': '暫無結果。',
// ─── Scheduled Tasks Pages ──────────────────────────────────────
'scheduledPage.title': '定時任務',
'scheduledPage.subtitle': '按計劃或在需要時執行任務。在任意會話中輸入 {code} 即可建立。',

View File

@ -1691,6 +1691,73 @@ export const zh: Record<TranslationKey, string> = {
'teams.memberPlaceholder': '直接给这个 Agent 发消息...',
'teams.memberSessionHint': '这里发送的消息会直接投递给该 Agent同时继续刷新它的 transcript。',
// ─── Session Activity ──────────────────────────────────────
'session.activity.title': '活动',
'session.activity.close': '关闭活动',
'session.activity.clearFinished': '清除已完成',
'session.activity.openTeamMember': '打开团队成员 {name}',
'session.activity.openRun': '打开运行 {name}',
'session.activity.openBackgroundTask': '打开后台任务 {name}',
'session.activity.details.title': '详情',
'session.activity.details.type': '类型',
'session.activity.details.description': '描述',
'session.activity.details.summary': '摘要',
'session.activity.details.outputFile': '输出文件',
'session.activity.details.usage': '用量',
'session.activity.section.tasks': '任务',
'session.activity.section.team': '团队',
'session.activity.section.backgroundTasks': '后台任务',
'session.activity.section.subagents': 'SubAgent',
'session.activity.section.sources': '来源',
'session.activity.empty.tasks': '暂无任务',
'session.activity.empty.team': '暂无团队成员',
'session.activity.empty.backgroundTasks': '暂无后台任务',
'session.activity.empty.subagents': '暂无 SubAgent',
'session.activity.empty.sources': '暂无来源',
'session.activity.task.completed': '任务已完成',
'session.activity.task.inProgress': '任务进行中',
'session.activity.task.pending': '任务待处理',
'session.activity.tasks.earlier': '历史任务',
'session.activity.tasks.earlierSummary': '历史轮次:{completed}/{total} 已完成',
'session.activity.status.pending': '待处理',
'session.activity.status.inProgress': '进行中',
'session.activity.status.completed': '已完成',
'session.activity.status.running': '运行中',
'session.activity.status.failed': '失败',
'session.activity.status.stopped': '已停止',
'session.activity.status.idle': '空闲',
'session.activity.status.error': '错误',
'session.activity.status.unknown': '未知',
// ─── SubAgent Run ──────────────────────────────────────
'subagentRun.refresh': '刷新 SubAgent 运行',
'subagentRun.loading': '正在加载 SubAgent 运行...',
'subagentRun.source': '来源',
'subagentRun.agent': 'Agent',
'subagentRun.unknown': '未知',
'subagentRun.task': '任务',
'subagentRun.updated': '更新时间',
'subagentRun.output': '输出',
'subagentRun.agentInvocation': 'Agent 调用',
'subagentRun.parentToolInput': '父级 Agent 工具输入',
'subagentRun.parentToolCall': '父级 Agent 工具调用',
'subagentRun.transcript': '运行记录',
'subagentRun.noTranscript': '没有捕获到这个 SubAgent 的本地运行记录。',
'subagentRun.truncated': '显示前 50 条和最新 950 条记录',
'subagentRun.source.transcript': '运行记录',
'subagentRun.source.sessionHistory': '会话历史',
'subagentRun.source.liveTask': '实时任务',
'subagentRun.source.none': '无',
'subagentRun.status.completed': '已完成',
'subagentRun.status.failed': '失败',
'subagentRun.status.stopped': '已停止',
'subagentRun.status.running': '运行中',
'subagentRun.status.unknown': '未知',
'subagentRun.result.completedFallback': 'SubAgent 已完成,但没有结果内容。',
'subagentRun.result.failedFallback': 'SubAgent 已失败,但没有错误内容。',
'subagentRun.result.stoppedFallback': 'SubAgent 已停止,但没有结果内容。',
'subagentRun.result.noneFallback': '暂无结果。',
// ─── Scheduled Tasks Pages ──────────────────────────────────────
'scheduledPage.title': '定时任务',
'scheduledPage.subtitle': '按计划或在需要时运行任务。在任意会话中输入 {code} 即可创建。',

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { RefObject } from 'react'
import { Target } from 'lucide-react'
import {
@ -25,13 +25,13 @@ import { useTranslation } from '../i18n'
import { MessageList } from '../components/chat/MessageList'
import { ChatInput } from '../components/chat/ChatInput'
import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal'
import { SessionTaskBar } from '../components/chat/SessionTaskBar'
import { BackgroundTasksBar } from '../components/chat/BackgroundTasksBar'
import { WorkbenchPanel } from '../components/workbench/WorkbenchPanel'
import { TeamStatusBar } from '../components/teams/TeamStatusBar'
import { SessionActivityPanel } from '../components/activity/SessionActivityPanel'
import { buildSessionActivityModel } from '../components/activity/sessionActivityModel'
import { TerminalSettings } from './TerminalSettings'
import type { SessionListItem } from '../types/session'
import type { ActiveGoalState, TokenUsage } from '../types/chat'
import type { TeamMember } from '../types/team'
import { useMobileViewport } from '../hooks/useMobileViewport'
import { isDesktopRuntime } from '../lib/desktopRuntime'
import { formatTokenCount } from '../lib/formatTokenCount'
@ -40,13 +40,14 @@ import {
createBackgroundTaskDismissKey,
hasRunningBackgroundTasks as hasAnyRunningBackgroundTasks,
} from '../lib/backgroundTasks'
import { useActivityPanelStore } from '../stores/activityPanelStore'
const TASK_POLL_INTERVAL_MS = 1000
const WORKSPACE_RESIZE_STEP = 32
const TERMINAL_RESIZE_STEP = 24
const CHAT_COLUMN_WITH_WORKSPACE_CLASS =
'min-w-[320px] flex-1 border-r border-[var(--color-border)] bg-[var(--color-surface)]'
const EMPTY_DISMISSED_BACKGROUND_TASK_KEYS = new Set<string>()
const EMPTY_DISMISSED_BACKGROUND_TASK_KEYS: readonly string[] = []
function isSessionTabState(activeTabId: string | null, activeTabType: TabType | null | undefined) {
if (!activeTabId) return false
@ -289,7 +290,6 @@ export function ActiveSession() {
const isMobileLayout = useMobileViewport() && !isDesktopRuntime()
const workbenchPanelRef = useRef<HTMLElement>(null)
const activeTabId = useTabStore((s) => s.activeTabId)
const [dismissedBackgroundTaskKeysBySession, setDismissedBackgroundTaskKeysBySession] = useState<Record<string, Set<string>>>({})
const activeTabType = useTabStore((s) => s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type ?? null)
const sessions = useSessionStore((s) => s.sessions)
const connectToSession = useChatStore((s) => s.connectToSession)
@ -297,8 +297,19 @@ export function ActiveSession() {
const pendingComputerUsePermission = sessionState?.pendingComputerUsePermission ?? null
const fetchSessionTasks = useCLITaskStore((s) => s.fetchSessionTasks)
const trackedTaskSessionId = useCLITaskStore((s) => s.sessionId)
const hasIncompleteTasks = useCLITaskStore((s) => s.tasks.some((task) => task.status !== 'completed'))
const hasRunningTasks = useCLITaskStore((s) => s.tasks.some((task) => task.status === 'in_progress'))
const cliTasks = useCLITaskStore((s) => s.tasks)
const cliTasksCompletedAndDismissed = useCLITaskStore((s) => s.completedAndDismissed)
const hasIncompleteTasks = cliTasks.some((task) => task.status !== 'completed')
const hasRunningTasks = cliTasks.some((task) => task.status === 'in_progress')
const isActivityPanelOpen = useActivityPanelStore((state) => activeTabId ? state.isOpen(activeTabId) : false)
const closeActivityPanel = useActivityPanelStore((state) => state.close)
const dismissBackgroundTaskKeys = useActivityPanelStore((state) => state.dismissBackgroundTaskKeys)
const pruneDismissedBackgroundTaskKeys = useActivityPanelStore((state) => state.pruneDismissedBackgroundTaskKeys)
const dismissedBackgroundTaskKeyList = useActivityPanelStore((state) =>
activeTabId
? state.dismissedBackgroundTaskKeysBySession[activeTabId] ?? EMPTY_DISMISSED_BACKGROUND_TASK_KEYS
: EMPTY_DISMISSED_BACKGROUND_TASK_KEYS,
)
const chatState = sessionState?.chatState ?? 'idle'
const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 }
const hasRunningBackgroundTasks = hasAnyRunningBackgroundTasks(sessionState?.backgroundAgentTasks)
@ -364,9 +375,11 @@ export function ActiveSession() {
() => Object.values(sessionState?.backgroundAgentTasks ?? {}),
[sessionState?.backgroundAgentTasks],
)
const dismissedBackgroundTaskKeys = activeTabId
? dismissedBackgroundTaskKeysBySession[activeTabId] ?? EMPTY_DISMISSED_BACKGROUND_TASK_KEYS
: EMPTY_DISMISSED_BACKGROUND_TASK_KEYS
const dismissedBackgroundTaskKeys = useMemo(
() => new Set(dismissedBackgroundTaskKeyList),
[dismissedBackgroundTaskKeyList],
)
const agentTaskNotifications = sessionState?.agentTaskNotifications ?? {}
const activeGoal = sessionState?.activeGoal ?? null
const isEmpty = messages.length === 0 && !streamingText && (session?.messageCount ?? 0) === 0
const compactEmptyHero = isEmpty && showTerminalPanel
@ -388,6 +401,56 @@ export function ActiveSession() {
(trackedTaskSessionId === activeTabId && hasRunningTasks) ||
hasRunningBackgroundTasks
const totalTokens = getTokenUsageTotal(tokenUsage)
const activityTeamMembers = useMemo(() => {
if (!activeTeam) return []
return activeTeam.members.filter((member) =>
!activeTeam.leadAgentId || member.agentId !== activeTeam.leadAgentId
)
}, [activeTeam])
useEffect(() => {
if (!activeTabId) return
pruneDismissedBackgroundTaskKeys(
activeTabId,
backgroundTasks.map((task) => createBackgroundTaskDismissKey(task)),
)
}, [activeTabId, backgroundTasks, pruneDismissedBackgroundTaskKeys])
const activityModel = useMemo(() => {
if (!activeTabId) return null
const includeCliTasks = trackedTaskSessionId === activeTabId
return buildSessionActivityModel({
sessionId: activeTabId,
messages,
tasks: includeCliTasks ? cliTasks : [],
completedAndDismissed: includeCliTasks ? cliTasksCompletedAndDismissed : false,
backgroundTasks,
dismissedBackgroundTaskKeys,
agentNotifications: Object.values(agentTaskNotifications),
teamMembers: activityTeamMembers,
})
}, [
activeTabId,
activityTeamMembers,
agentTaskNotifications,
backgroundTasks,
cliTasks,
cliTasksCompletedAndDismissed,
dismissedBackgroundTaskKeys,
messages,
trackedTaskSessionId,
])
const handleOpenSubagentRun = useCallback((payload: { sessionId: string; toolUseId: string; title: string }) => {
useTabStore.getState().openSubagentTab(payload.sessionId, payload.toolUseId, payload.title)
}, [])
const handleOpenTeamMember = useCallback((member: TeamMember) => {
useTeamStore.getState().openMemberSession(member)
}, [])
const handleClearFinishedBackgroundTasks = useCallback((taskKeys: string[]) => {
if (!activeTabId || taskKeys.length === 0) return
dismissBackgroundTaskKeys(activeTabId, taskKeys)
}, [activeTabId, dismissBackgroundTaskKeys])
const lastUpdated = useMemo(() => {
if (!session?.modifiedAt) return ''
@ -398,23 +461,6 @@ export function ActiveSession() {
return t('session.timeDays', { n: Math.floor(diff / 86400000) })
}, [session?.modifiedAt, t])
useEffect(() => {
if (!activeTabId || dismissedBackgroundTaskKeys.size === 0) return
const currentTaskKeys = new Set(backgroundTasks.map(createBackgroundTaskDismissKey))
const nextDismissed = new Set([...dismissedBackgroundTaskKeys].filter((taskKey) => currentTaskKeys.has(taskKey)))
if (nextDismissed.size === dismissedBackgroundTaskKeys.size) return
setDismissedBackgroundTaskKeysBySession((current) => {
const next = { ...current }
if (nextDismissed.size === 0) {
delete next[activeTabId]
} else {
next[activeTabId] = nextDismissed
}
return next
})
}, [activeTabId, backgroundTasks, dismissedBackgroundTaskKeys])
if (!activeTabId) return null
return (
@ -422,7 +468,7 @@ export function ActiveSession() {
<div data-testid="active-session-content-row" className="flex min-h-0 min-w-0 flex-1">
<div
data-testid="active-session-chat-column"
className={`flex min-h-0 flex-col ${showRightPanel ? CHAT_COLUMN_WITH_WORKSPACE_CLASS : isMobileLayout ? 'min-w-0 flex-1' : 'min-w-[360px] flex-1'}`}
className={`relative flex min-h-0 min-w-0 flex-col overflow-hidden ${showRightPanel ? CHAT_COLUMN_WITH_WORKSPACE_CLASS : isMobileLayout ? 'flex-1' : 'min-w-[360px] flex-1'}`}
>
{isMemberSession && (
<div className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface-container)]">
@ -592,28 +638,17 @@ export function ActiveSession() {
</>
)}
{!isMemberSession && <SessionTaskBar />}
<TeamStatusBar />
{!isMemberSession && (
<BackgroundTasksBar
key={activeTabId}
tasks={backgroundTasks}
compact={showRightPanel}
dismissedFinishedTaskKeys={dismissedBackgroundTaskKeys}
onClearFinished={(taskKeys) => {
if (!activeTabId || taskKeys.length === 0) return
setDismissedBackgroundTaskKeysBySession((current) => ({
...current,
[activeTabId]: new Set([
...(current[activeTabId] ?? EMPTY_DISMISSED_BACKGROUND_TASK_KEYS),
...taskKeys,
]),
}))
}}
{activityModel && isMobileLayout && !isMemberSession && isSessionTabState(activeTabId, activeTabType) ? (
<SessionActivityPanel
model={activityModel}
open={isActivityPanelOpen}
onClose={() => closeActivityPanel(activeTabId)}
onOpenSubagent={handleOpenSubagentRun}
onClearFinishedBackgroundTasks={handleClearFinishedBackgroundTasks}
onOpenMember={handleOpenTeamMember}
placement="overlay"
/>
)}
) : null}
<ChatInput
variant={isEmpty && !isMemberSession && !showRightPanel ? 'hero' : 'default'}
@ -648,6 +683,18 @@ export function ActiveSession() {
) : null}
</div>
{activityModel && !isMobileLayout && !isMemberSession && isSessionTabState(activeTabId, activeTabType) ? (
<SessionActivityPanel
model={activityModel}
open={isActivityPanelOpen}
onClose={() => closeActivityPanel(activeTabId)}
onOpenSubagent={handleOpenSubagentRun}
onClearFinishedBackgroundTasks={handleClearFinishedBackgroundTasks}
onOpenMember={handleOpenTeamMember}
placement="rail"
/>
) : null}
{showWorkbench ? (
<>
<WorkspaceResizeHandle panelRef={workbenchPanelRef} />

View File

@ -0,0 +1,185 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SubagentRunResponse } from '../api/subagents'
import { useSettingsStore } from '../stores/settingsStore'
vi.mock('../api/subagents', () => ({
subagentsApi: {
getRunByTool: vi.fn(),
},
}))
import { subagentsApi } from '../api/subagents'
import { SubagentRunPage } from './SubagentRunPage'
const TRANSCRIPT_TIMESTAMP = '2026-07-03T10:20:11.000Z'
function subagentRun(overrides: Partial<SubagentRunResponse> = {}): SubagentRunResponse {
return {
sessionId: 'session-1',
toolUseId: 'tool-1',
agentId: 'abc123',
status: 'completed',
description: 'Explore repo',
prompt: 'Read files',
summary: 'Found layout seam',
messages: [
{
id: 'msg-user',
type: 'user',
content: 'Read files',
timestamp: TRANSCRIPT_TIMESTAMP,
},
{
id: 'msg-assistant',
type: 'assistant',
content: [{ type: 'text', text: 'Finding' }],
timestamp: TRANSCRIPT_TIMESTAMP,
},
],
truncated: false,
source: 'subagent-jsonl',
...overrides,
}
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, reject, resolve }
}
describe('SubagentRunPage', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
})
afterEach(() => {
cleanup()
vi.mocked(subagentsApi.getRunByTool).mockReset()
})
it('renders SubAgent run details', async () => {
vi.mocked(subagentsApi.getRunByTool).mockResolvedValue(subagentRun({
outputFile: '/tmp/result.md',
}))
render(<SubagentRunPage sourceSessionId="session-1" toolUseId="tool-1" title="Kuhn" />)
expect(await screen.findByText('Kuhn')).toBeInTheDocument()
expect(screen.getAllByText('Agent').length).toBeGreaterThan(0)
expect(screen.getAllByText('Explore repo').length).toBeGreaterThan(0)
expect(screen.getAllByText('Found layout seam').length).toBeGreaterThan(0)
expect(screen.getByText('Output: /tmp/result.md')).toBeInTheDocument()
expect(screen.getByText('Parent Agent Tool Call')).toBeInTheDocument()
expect(document.body).toHaveTextContent('"prompt": "Read files"')
expect(screen.queryByText(/Dispatched an agent|派遣了一个代理/)).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Open run/ })).not.toBeInTheDocument()
const transcript = screen.getByTestId('subagent-transcript')
expect(transcript).toHaveTextContent('Read files')
expect(transcript).toHaveTextContent('Finding')
expect(transcript).not.toHaveTextContent('assistant_text')
})
it('renders a loading state while the run is loading', () => {
vi.mocked(subagentsApi.getRunByTool).mockReturnValue(deferred<SubagentRunResponse>().promise)
render(<SubagentRunPage sourceSessionId="session-1" toolUseId="tool-1" title="Kuhn" />)
expect(screen.getByRole('status')).toHaveTextContent('Loading SubAgent run...')
expect(screen.getByRole('button', { name: 'Refresh SubAgent run' })).toBeDisabled()
})
it('renders a missing transcript fallback', async () => {
vi.mocked(subagentsApi.getRunByTool).mockResolvedValue(subagentRun({
agentId: null,
status: 'unknown',
summary: 'Only summary available',
messages: [],
source: 'none',
}))
render(<SubagentRunPage sourceSessionId="session-1" toolUseId="tool-1" title="SubAgent" />)
expect(await screen.findByText('No local transcript messages captured for this SubAgent.')).toBeInTheDocument()
expect(screen.getAllByText('Only summary available').length).toBeGreaterThan(0)
})
it('keeps the tab open on API errors', async () => {
vi.mocked(subagentsApi.getRunByTool).mockRejectedValue(new Error('boom'))
render(<SubagentRunPage sourceSessionId="session-1" toolUseId="tool-1" title="SubAgent" />)
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('boom'))
expect(screen.getByRole('button', { name: 'Refresh SubAgent run' })).toBeInTheDocument()
})
it('ignores stale responses when the selected SubAgent changes before the first request resolves', async () => {
const first = deferred<SubagentRunResponse>()
const second = deferred<SubagentRunResponse>()
vi.mocked(subagentsApi.getRunByTool).mockImplementation((sessionId) =>
sessionId === 'session-a' ? first.promise : second.promise
)
const { rerender } = render(<SubagentRunPage sourceSessionId="session-a" toolUseId="tool-a" title="First Agent" />)
rerender(<SubagentRunPage sourceSessionId="session-b" toolUseId="tool-b" title="Second Agent" />)
await act(async () => {
second.resolve(subagentRun({
sessionId: 'session-b',
toolUseId: 'tool-b',
summary: 'Second result',
messages: [{
id: 'second-finding',
type: 'assistant',
content: [{ type: 'text', text: 'Second finding' }],
timestamp: TRANSCRIPT_TIMESTAMP,
}],
}))
await second.promise
})
expect((await screen.findAllByText('Second result')).length).toBeGreaterThan(0)
expect(screen.getByText(/Second finding/)).toBeInTheDocument()
await act(async () => {
first.resolve(subagentRun({
sessionId: 'session-a',
toolUseId: 'tool-a',
summary: 'Stale first result',
messages: [{
id: 'stale-finding',
type: 'assistant',
content: [{ type: 'text', text: 'Stale finding' }],
timestamp: TRANSCRIPT_TIMESTAMP,
}],
}))
await first.promise
})
expect(screen.getAllByText('Second result').length).toBeGreaterThan(0)
expect(screen.queryByText('Stale first result')).not.toBeInTheDocument()
expect(screen.queryByText(/Stale finding/)).not.toBeInTheDocument()
})
it('keeps existing details visible when refresh fails', async () => {
vi.mocked(subagentsApi.getRunByTool)
.mockResolvedValueOnce(subagentRun({ summary: 'Initial result' }))
.mockRejectedValueOnce(new Error('refresh failed'))
render(<SubagentRunPage sourceSessionId="session-1" toolUseId="tool-1" title="SubAgent" />)
expect((await screen.findAllByText('Initial result')).length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: 'Refresh SubAgent run' }))
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('refresh failed'))
expect(screen.getAllByText('Initial result').length).toBeGreaterThan(0)
})
})

View File

@ -0,0 +1,302 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { RefreshCw } from 'lucide-react'
import {
subagentsApi,
type SubagentRunResponse,
type SubagentRunStatus,
} from '../api/subagents'
import { buildRenderModel, MessageBlock } from '../components/chat/MessageList'
import { ToolCallBlock } from '../components/chat/ToolCallBlock'
import { ToolCallGroup } from '../components/chat/ToolCallGroup'
import { useTranslation } from '../i18n'
import { mapHistoryMessagesToUiMessages } from '../stores/chatStore'
import type { AgentTaskNotification, UIMessage } from '../types/chat'
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
type TranslationFn = ReturnType<typeof useTranslation>
export function SubagentRunPage({
sourceSessionId,
toolUseId,
title,
}: {
sourceSessionId: string
toolUseId: string
title: string
}) {
const t = useTranslation()
const [data, setData] = useState<SubagentRunResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const requestIdRef = useRef(0)
const load = useCallback(async (options?: { resetData?: boolean }) => {
const requestId = requestIdRef.current + 1
requestIdRef.current = requestId
setLoading(true)
setError(null)
if (options?.resetData) setData(null)
try {
const nextData = await subagentsApi.getRunByTool(sourceSessionId, toolUseId)
if (requestIdRef.current !== requestId) return
setData(nextData)
} catch (err) {
if (requestIdRef.current !== requestId) return
setError(err instanceof Error ? err.message : String(err))
} finally {
if (requestIdRef.current !== requestId) return
setLoading(false)
}
}, [sourceSessionId, toolUseId])
useEffect(() => {
void load({ resetData: true })
}, [load])
return (
<div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)] text-[var(--color-text-primary)]">
<header className="flex shrink-0 items-start justify-between gap-4 border-b border-[var(--color-border)] px-5 py-3">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h1 className="min-w-0 truncate text-sm font-semibold text-[var(--color-text-primary)]">{title}</h1>
{data ? <StatusBadge status={data.status} t={t} /> : null}
</div>
<p className="mt-1 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
{sourceSessionId} / {toolUseId}
</p>
</div>
<button
type="button"
aria-label={t('subagentRun.refresh')}
onClick={() => void load()}
disabled={loading}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] disabled:cursor-not-allowed disabled:opacity-60"
>
<RefreshCw size={15} strokeWidth={2.2} aria-hidden="true" className={loading ? 'animate-spin' : undefined} />
</button>
</header>
<main className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{loading && !data ? (
<div role="status" className="text-sm text-[var(--color-text-tertiary)]">{t('subagentRun.loading')}</div>
) : null}
{error ? (
<div role="alert" className="rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error)]/5 px-3 py-2 text-sm text-[var(--color-error)]">
{error}
</div>
) : null}
{data ? (
<SubagentRunDetails data={data} />
) : null}
</main>
</div>
)
}
function SubagentRunDetails({ data }: { data: SubagentRunResponse }) {
const t = useTranslation()
const agentToolCall = useMemo(() => buildAgentToolCall(data), [data])
const agentToolResult = useMemo(() => buildAgentToolResult(data, t), [data, t])
return (
<div className="mx-auto flex max-w-4xl flex-col gap-5">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--color-text-tertiary)]">
<span>{t('subagentRun.source')}: {sourceLabel(data.source, t)}</span>
<span aria-hidden="true">/</span>
<span>{t('subagentRun.agent')}: {data.agentId ?? t('subagentRun.unknown')}</span>
{data.taskId ? (
<>
<span aria-hidden="true">/</span>
<span>{t('subagentRun.task')}: {data.taskId}</span>
</>
) : null}
<span aria-hidden="true">/</span>
<span>{t('subagentRun.updated')}: {formatTimestamp(data.updatedAt)}</span>
{data.usage?.totalTokens ? (
<>
<span aria-hidden="true">/</span>
<span>{t('common.tokens', { count: formatNumber(data.usage.totalTokens) })}</span>
</>
) : null}
{data.outputFile ? (
<>
<span aria-hidden="true">/</span>
<span className="min-w-0 truncate font-mono" title={data.outputFile}>{t('subagentRun.output')}: {data.outputFile}</span>
</>
) : null}
</div>
<section aria-label={t('subagentRun.parentToolInput')}>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-normal text-[var(--color-text-tertiary)]">{t('subagentRun.parentToolCall')}</h2>
<ToolCallBlock
toolName="Agent"
input={agentToolCall.input}
result={agentToolResult}
isPending={data.status === 'running' && !agentToolResult}
defaultExpanded
/>
</section>
<TranscriptSection data={data} />
</div>
)
}
const EMPTY_AGENT_TASK_NOTIFICATIONS: Record<string, AgentTaskNotification> = {}
function TranscriptSection({ data }: { data: SubagentRunResponse }) {
const t = useTranslation()
const transcriptMessages = useMemo(() => (
mapHistoryMessagesToUiMessages(data.messages, { includeTeammateMessages: true })
), [data.messages])
const renderModel = useMemo(() => buildRenderModel(transcriptMessages), [transcriptMessages])
if (renderModel.renderItems.length === 0) {
return (
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-normal text-[var(--color-text-tertiary)]">{t('subagentRun.transcript')}</h2>
<div className="rounded-lg border border-dashed border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-text-tertiary)]">
{t('subagentRun.noTranscript')}
</div>
</section>
)
}
return (
<section>
<div className="mb-2 flex items-center justify-between gap-3">
<h2 className="text-xs font-semibold uppercase tracking-normal text-[var(--color-text-tertiary)]">{t('subagentRun.transcript')}</h2>
{data.truncated ? (
<span className="text-[11px] text-[var(--color-text-tertiary)]">{t('subagentRun.truncated')}</span>
) : null}
</div>
<div data-testid="subagent-transcript" className="space-y-3">
{renderModel.renderItems.map((item) => {
if (item.kind === 'tool_group') {
return (
<ToolCallGroup
key={item.id}
toolCalls={item.toolCalls}
resultMap={renderModel.toolResultMap}
childToolCallsByParent={renderModel.childToolCallsByParent}
agentTaskNotifications={EMPTY_AGENT_TASK_NOTIFICATIONS}
showOpenRun={false}
isStreaming={false}
/>
)
}
const toolResult = item.message.type === 'tool_use'
? renderModel.toolResultMap.get(item.message.toolUseId)
: null
return (
<MessageBlock
key={item.message.id}
message={item.message}
activeThinkingId={null}
agentTaskNotifications={EMPTY_AGENT_TASK_NOTIFICATIONS}
toolResult={toolResult}
/>
)
})}
</div>
</section>
)
}
function StatusBadge({ status, t }: { status: SubagentRunStatus; t: TranslationFn }) {
return (
<span className={`rounded-[var(--radius-sm)] border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-normal ${statusToneClass(status)}`}>
{getSubagentStatusLabel(status, t)}
</span>
)
}
function statusToneClass(status: SubagentRunStatus) {
if (status === 'completed') {
return 'border-[var(--color-success)]/25 bg-[var(--color-success)]/10 text-[var(--color-success)]'
}
if (status === 'failed' || status === 'stopped') {
return 'border-[var(--color-error)]/30 bg-[var(--color-error)]/5 text-[var(--color-error)]'
}
if (status === 'running') {
return 'border-[var(--color-brand)]/25 bg-[var(--color-brand)]/10 text-[var(--color-brand)]'
}
return 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)]'
}
function sourceLabel(source: SubagentRunResponse['source'], t: TranslationFn) {
if (source === 'subagent-jsonl') return t('subagentRun.source.transcript')
if (source === 'session-history') return t('subagentRun.source.sessionHistory')
if (source === 'live-task') return t('subagentRun.source.liveTask')
return t('subagentRun.source.none')
}
function getSubagentStatusLabel(status: SubagentRunStatus, t: TranslationFn) {
switch (status) {
case 'completed':
return t('subagentRun.status.completed')
case 'failed':
return t('subagentRun.status.failed')
case 'stopped':
return t('subagentRun.status.stopped')
case 'running':
return t('subagentRun.status.running')
case 'unknown':
return t('subagentRun.status.unknown')
}
}
function formatNumber(value: number | undefined) {
return typeof value === 'number' && Number.isFinite(value) ? value.toLocaleString() : '-'
}
function formatTimestamp(value: string | undefined) {
if (!value) return '-'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
}
function timestampMs(value: string | undefined) {
if (!value) return Date.now()
const time = Date.parse(value)
return Number.isFinite(time) ? time : Date.now()
}
function buildAgentToolCall(data: SubagentRunResponse): ToolCall {
return {
id: `subagent-tool-${data.toolUseId}`,
type: 'tool_use',
toolName: 'Agent',
toolUseId: data.toolUseId,
input: {
...(data.description ? { description: data.description } : {}),
...(data.prompt ? { prompt: data.prompt } : {}),
},
timestamp: timestampMs(data.updatedAt),
}
}
function buildAgentToolResult(data: SubagentRunResponse, t: TranslationFn): ToolResult | null {
const resultText = data.result || data.summary
if (!resultText && data.status === 'running') return null
if (!resultText && data.status === 'unknown') return null
return {
id: `subagent-result-${data.toolUseId}`,
type: 'tool_result',
toolUseId: data.toolUseId,
content: resultText || statusFallbackResult(data.status, t),
isError: data.status === 'failed' || data.status === 'stopped',
timestamp: timestampMs(data.updatedAt),
}
}
function statusFallbackResult(status: SubagentRunStatus, t: TranslationFn) {
if (status === 'completed') return t('subagentRun.result.completedFallback')
if (status === 'failed') return t('subagentRun.result.failedFallback')
if (status === 'stopped') return t('subagentRun.result.stoppedFallback')
return t('subagentRun.result.noneFallback')
}

View File

@ -0,0 +1,90 @@
import { create } from 'zustand'
import type { ActivitySectionId } from '../components/activity/sessionActivityModel'
type ActivityPanelStore = {
openSessionId: string | null
selectedSectionBySession: Record<string, ActivitySectionId | undefined>
dismissedBackgroundTaskKeysBySession: Record<string, string[] | undefined>
isOpen: (sessionId: string) => boolean
toggle: (sessionId: string) => void
open: (sessionId: string, section?: ActivitySectionId) => void
close: (sessionId?: string) => void
getSelectedSection: (sessionId: string) => ActivitySectionId | undefined
getDismissedBackgroundTaskKeys: (sessionId: string) => Set<string>
dismissBackgroundTaskKeys: (sessionId: string, taskKeys: Iterable<string>) => void
pruneDismissedBackgroundTaskKeys: (sessionId: string, activeTaskKeys: Iterable<string>) => void
}
export const useActivityPanelStore = create<ActivityPanelStore>((set, get) => ({
openSessionId: null,
selectedSectionBySession: {},
dismissedBackgroundTaskKeysBySession: {},
isOpen: (sessionId) => get().openSessionId === sessionId,
toggle: (sessionId) =>
set((state) => ({
openSessionId: state.openSessionId === sessionId ? null : sessionId,
})),
open: (sessionId, section) =>
set((state) => ({
openSessionId: sessionId,
selectedSectionBySession: section
? {
...state.selectedSectionBySession,
[sessionId]: section,
}
: state.selectedSectionBySession,
})),
close: (sessionId) =>
set((state) => {
if (sessionId && state.openSessionId !== sessionId) return state
return { openSessionId: null }
}),
getSelectedSection: (sessionId) => get().selectedSectionBySession[sessionId],
getDismissedBackgroundTaskKeys: (sessionId) =>
new Set(get().dismissedBackgroundTaskKeysBySession[sessionId] ?? []),
dismissBackgroundTaskKeys: (sessionId, taskKeys) =>
set((state) => {
const keys = Array.from(new Set(taskKeys)).filter((key) => key.length > 0)
if (keys.length === 0) return state
const existing = state.dismissedBackgroundTaskKeysBySession[sessionId] ?? []
const merged = Array.from(new Set([...existing, ...keys]))
if (merged.length === existing.length) return state
return {
dismissedBackgroundTaskKeysBySession: {
...state.dismissedBackgroundTaskKeysBySession,
[sessionId]: merged,
},
}
}),
pruneDismissedBackgroundTaskKeys: (sessionId, activeTaskKeys) =>
set((state) => {
const existing = state.dismissedBackgroundTaskKeysBySession[sessionId]
if (!existing || existing.length === 0) return state
const activeSet = new Set(activeTaskKeys)
const pruned = existing.filter((key) => activeSet.has(key))
if (pruned.length === existing.length) return state
const nextBySession = { ...state.dismissedBackgroundTaskKeysBySession }
if (pruned.length > 0) {
nextBySession[sessionId] = pruned
} else {
delete nextBySession[sessionId]
}
return {
dismissedBackgroundTaskKeysBySession: nextBySession,
}
}),
}))

View File

@ -66,6 +66,29 @@ describe('tabStore', () => {
}))
})
it('opens one ephemeral SubAgent tab per source session and tool use', () => {
const tabId = useTabStore.getState().openSubagentTab('session-1', 'tool-1', 'Kuhn')
const sameTabId = useTabStore.getState().openSubagentTab('session-1', 'tool-1', 'Kuhn updated')
expect(tabId).toBe('__subagent__session-1__tool-1')
expect(sameTabId).toBe(tabId)
expect(useTabStore.getState().tabs).toEqual([
{
sessionId: '__subagent__session-1__tool-1',
title: 'Kuhn updated',
type: 'subagent',
status: 'idle',
sourceSessionId: 'session-1',
subagentToolUseId: 'tool-1',
},
])
expect(useTabStore.getState().activeTabId).toBe('__subagent__session-1__tool-1')
expect(localStorage.getItem('cc-haha-open-tabs')).toBe(JSON.stringify({
openTabs: [],
activeTabId: null,
}))
})
it('does not let async tab restore overwrite tabs opened while restore is in flight', async () => {
let resolveSessions: (value: unknown) => void = () => {}
vi.mocked(sessionsApi.list).mockReturnValueOnce(new Promise((resolve) => {

View File

@ -11,8 +11,9 @@ export const TRACE_LIST_TAB_ID = '__traces__'
export const TERMINAL_TAB_PREFIX = '__terminal__'
export const TRACE_TAB_PREFIX = '__trace__'
export const WORKBENCH_TAB_PREFIX = '__workbench__'
export const SUBAGENT_TAB_PREFIX = '__subagent__'
export type TabType = 'session' | 'settings' | 'scheduled' | 'terminal' | 'trace' | 'traces' | 'workbench'
export type TabType = 'session' | 'settings' | 'scheduled' | 'terminal' | 'trace' | 'traces' | 'workbench' | 'subagent'
export type Tab = {
sessionId: string
@ -23,6 +24,8 @@ export type Tab = {
terminalRuntimeId?: string
traceSessionId?: string
workbenchSessionId?: string
sourceSessionId?: string
subagentToolUseId?: string
}
type TabPersistence = {
@ -39,6 +42,7 @@ type TabStore = {
openTraceTab: (sessionId: string, title?: string) => string
openTerminalTab: (cwd?: string, terminalRuntimeId?: string) => string
openWorkbenchTab: (sessionId: string, title?: string) => string
openSubagentTab: (sourceSessionId: string, toolUseId: string, title?: string) => string
closeTab: (sessionId: string) => void
setActiveTab: (sessionId: string) => void
updateTabTitle: (sessionId: string, title: string) => void
@ -171,6 +175,29 @@ export const useTabStore = create<TabStore>((set, get) => ({
return tabId
},
openSubagentTab: (sourceSessionId, toolUseId, title = 'SubAgent') => {
const tabId = `${SUBAGENT_TAB_PREFIX}${sourceSessionId}__${toolUseId}`
const { tabs } = get()
const existing = tabs.find((tab) => tab.sessionId === tabId)
const tab: Tab = {
sessionId: tabId,
title,
type: 'subagent',
status: 'idle',
sourceSessionId,
subagentToolUseId: toolUseId,
}
set({
tabs: existing
? tabs.map((current) => current.sessionId === tabId ? tab : current)
: [...tabs, tab],
activeTabId: tabId,
})
get().saveTabs()
return tabId
},
closeTab: (sessionId) => {
const { tabs, activeTabId } = get()
const index = tabs.findIndex((t) => t.sessionId === sessionId)
@ -240,7 +267,7 @@ export const useTabStore = create<TabStore>((set, get) => ({
saveTabs: () => {
const { tabs, activeTabId } = get()
const persistableTabs = tabs.filter((tab) => tab.type !== 'terminal' && tab.type !== 'workbench')
const persistableTabs = tabs.filter((tab) => tab.type !== 'terminal' && tab.type !== 'workbench' && tab.type !== 'subagent')
const data: TabPersistence = {
openTabs: persistableTabs.map((t) => ({
sessionId: t.sessionId,

View File

@ -2421,6 +2421,99 @@ describe('Sessions API', () => {
])
})
it('GET /api/sessions/:id/subagents/by-tool/:toolUseId should return a resolved run', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const projectDir = '-tmp-api-subagent-run'
const agentId = 'abc123'
await writeSessionFile(projectDir, sessionId, [
makeSnapshotEntry(),
makeAssistantToolUseEntry([
{
id: 'tool-1',
name: 'Agent',
input: { description: 'Inspect server seam', prompt: 'Read session routes' },
},
]),
makeToolResultUserEntry('tool-1', `server summary\nagentId: ${agentId}`),
])
await writeSubagentTranscriptFile(projectDir, sessionId, agentId, [
{
type: 'user',
message: { role: 'user', content: 'Read session routes' },
uuid: crypto.randomUUID(),
timestamp: '2026-01-01T00:00:04.000Z',
},
{
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'Found sessions.ts' }] },
uuid: crypto.randomUUID(),
timestamp: '2026-01-01T00:00:05.000Z',
},
])
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/subagents/by-tool/tool-1`)
expect(res.status).toBe(200)
const body = (await res.json()) as {
sessionId: string
toolUseId: string
agentId: string | null
description?: string
prompt?: string
messages: unknown[]
source: string
}
expect(body).toMatchObject({
sessionId,
toolUseId: 'tool-1',
agentId,
description: 'Inspect server seam',
prompt: 'Read session routes',
source: 'subagent-jsonl',
})
expect(body.messages).toHaveLength(2)
})
it('POST /api/sessions/:id/subagents/by-tool/:toolUseId should return 405', async () => {
const res = await fetch(
`${baseUrl}/api/sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/subagents/by-tool/tool-1`,
{ method: 'POST' },
)
expect(res.status).toBe(405)
})
it('GET /api/sessions/:id/subagents/by-tool/:toolUseId/extra should return 404', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const agentId = 'abc123'
await writeSessionFile('-tmp-api-subagent-run-extra', sessionId, [
makeSnapshotEntry(),
makeAssistantToolUseEntry([
{
id: 'tool-1',
name: 'Agent',
input: { description: 'Inspect server seam', prompt: 'Read session routes' },
},
]),
makeToolResultUserEntry('tool-1', `server summary\nagentId: ${agentId}`),
])
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/subagents/by-tool/tool-1/extra`)
expect(res.status).toBe(404)
})
it('GET /api/sessions/:id/subagents/by-tool/:toolUseId should return 404 for malformed encoding', async () => {
const { handleSessionsApi } = await import('../api/sessions.js')
const res = await handleSessionsApi(
new Request(`${baseUrl}/api/sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/subagents/by-tool/%25E0%25A4%25A`),
new URL(`${baseUrl}/api/sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/subagents/by-tool/%25E0%25A4%25A`),
['api', 'sessions', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', 'subagents', 'by-tool', '%E0%A4%A'],
)
expect(res.status).toBe(404)
})
it('GET /api/sessions/:id/git-info should prefer the active CLI workDir', async () => {
const workDir = await createCleanGitRepo(tmpDir)
const activeWorktree = path.join(tmpDir, `active-feature-rail-${Date.now()}`)

View File

@ -7,6 +7,7 @@
* GET /api/sessions
* GET /api/sessions/:id
* GET /api/sessions/:id/messages
* GET /api/sessions/:id/subagents/by-tool/:toolUseId SubAgent
* GET /api/sessions/:id/trace tracebody preview
* GET /api/sessions/:id/trace/calls/:callId trace
* GET /api/sessions/:id/turn-checkpoints checkpoint
@ -43,6 +44,7 @@ import {
import { registerChangedFileAccessRoot, registerFilesystemAccessRoot } from '../services/filesystemAccessRoots.js'
import { findGitRoot } from '../../utils/git.js'
import { traceCaptureService, trimTraceCallPreviews } from '../services/traceCaptureService.js'
import { getSubagentRunByTool } from '../services/subagentRunService.js'
const DEFAULT_GIT_INFO_COMMAND_TIMEOUT_MS = 3_000
@ -200,6 +202,36 @@ export async function handleSessionsApi(
return await handleSessionWorkspaceRoute(sessionId, url, segments[4])
}
if (subResource === 'subagents') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
)
}
if (segments[4] !== 'by-tool' || !segments[5] || segments.length !== 6) {
return Response.json(
{ error: 'NOT_FOUND', message: 'SubAgent route not found' },
{ status: 404 }
)
}
let toolUseId: string
try {
toolUseId = decodeURIComponent(segments[5])
} catch {
return Response.json(
{ error: 'NOT_FOUND', message: 'SubAgent route not found' },
{ status: 404 }
)
}
const result = await getSubagentRunByTool(sessionId, toolUseId)
if (!result) {
throw ApiError.notFound(`SubAgent run not found: ${toolUseId}`)
}
return Response.json(result)
}
// Route to conversations handler if sub-resource is 'chat'
if (subResource === 'chat') {
// This is handled by the conversations API, but in case the router

View File

@ -2486,6 +2486,21 @@ export class SessionService {
)
}
async getSubagentTranscriptMessages(
sessionId: string,
agentId: string,
): Promise<MessageEntry[]> {
const found = await this.findSessionFile(sessionId)
if (!found) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
const entries = await this.readJsonlFile(
this.subagentTranscriptPath(found.projectDir, sessionId, agentId),
)
return this.entriesToMessages(entries)
}
async getSessionMessagesSignature(sessionId: string): Promise<string | null> {
const found = await this.findSessionFile(sessionId)
if (!found) return null

View File

@ -0,0 +1,313 @@
import { afterEach, describe, expect, it } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import {
getSubagentRunByTool,
resolveSubagentRunFromMessages,
truncateSubagentMessages,
} from './subagentRunService.js'
import type { MessageEntry } from './sessionService.js'
let tmpDir: string | null = null
async function setupTmpConfigDir(): Promise<string> {
tmpDir = path.join(os.tmpdir(), `subagent-run-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
process.env.CLAUDE_CONFIG_DIR = tmpDir
return tmpDir
}
async function writeSessionFile(
projectDir: string,
sessionId: string,
entries: Record<string, unknown>[],
): Promise<void> {
if (!tmpDir) throw new Error('tmpDir not initialized')
const dir = path.join(tmpDir, 'projects', projectDir)
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(
path.join(dir, `${sessionId}.jsonl`),
`${entries.map((entry) => JSON.stringify(entry)).join('\n')}\n`,
'utf-8',
)
}
async function writeSubagentTranscriptFile(
projectDir: string,
sessionId: string,
agentId: string,
entries: Record<string, unknown>[],
): Promise<void> {
if (!tmpDir) throw new Error('tmpDir not initialized')
const dir = path.join(tmpDir, 'projects', projectDir, sessionId, 'subagents')
await fs.mkdir(dir, { recursive: true })
const normalizedAgentId = agentId.startsWith('agent-') ? agentId : `agent-${agentId}`
await fs.writeFile(
path.join(dir, `${normalizedAgentId}.jsonl`),
`${entries.map((entry) => JSON.stringify(entry)).join('\n')}\n`,
'utf-8',
)
}
function makeAgentToolUseEntry(toolUseId: string): Record<string, unknown> {
return {
type: 'assistant',
message: {
role: 'assistant',
content: [{
type: 'tool_use',
id: toolUseId,
name: 'Agent',
input: { description: 'Explore repo', prompt: 'Read files' },
}],
},
uuid: 'assistant-agent-use',
timestamp: '2026-01-01T00:00:01.000Z',
}
}
function makeAgentToolResultEntry(toolUseId: string, agentId: string): Record<string, unknown> {
return {
type: 'user',
message: {
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: toolUseId,
content: [{
type: 'text',
text: `Finished exploring the repo\nagentId: ${agentId}\n<usage>input_tokens: 7\noutput_tokens: 11\ntotal_tokens: 18</usage>`,
}],
}],
},
uuid: 'user-agent-result',
timestamp: '2026-01-01T00:00:03.000Z',
}
}
describe('subagentRunService helpers', () => {
it('resolves agentId, description, and prompt from parent Agent messages by toolUseId', () => {
const messages = [
{
id: 'assistant-agent-use',
type: 'tool_use',
content: [{
type: 'tool_use',
id: 'tool-1',
name: 'Agent',
input: { description: 'Explore repo', prompt: 'Read files' },
}],
timestamp: '2026-01-01T00:00:01.000Z',
},
{
id: 'user-agent-result',
type: 'tool_result',
content: [{
type: 'tool_result',
tool_use_id: 'tool-1',
content: [{ type: 'text', text: 'agentId: abc123\nStarted' }],
}],
timestamp: '2026-01-01T00:00:02.000Z',
},
] as MessageEntry[]
expect(resolveSubagentRunFromMessages(messages, 'tool-1')).toMatchObject({
agentId: 'abc123',
description: 'Explore repo',
prompt: 'Read files',
})
})
it('does not truncate transcripts with at most 1000 messages', () => {
const messages = Array.from({ length: 1000 }, (_, index) => ({ id: String(index) }))
const result = truncateSubagentMessages(messages)
expect(result).toEqual({ messages, truncated: false })
})
it('truncates long transcripts to first 50 and latest 950 entries', () => {
const messages = Array.from({ length: 1200 }, (_, index) => ({ id: String(index) }))
const result = truncateSubagentMessages(messages)
expect(result.truncated).toBe(true)
expect(result.messages).toHaveLength(1000)
expect(result.messages[0]).toEqual({ id: '0' })
expect(result.messages[49]).toEqual({ id: '49' })
expect(result.messages[50]).toEqual({ id: '250' })
expect(result.messages[999]).toEqual({ id: '1199' })
})
})
describe('getSubagentRunByTool', () => {
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true })
tmpDir = null
}
delete process.env.CLAUDE_CONFIG_DIR
})
it('returns parent metadata and visible persisted subagent transcript messages', async () => {
await setupTmpConfigDir()
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const projectDir = '-tmp-subagent-run'
const toolUseId = 'tool-1'
const agentId = 'abc123'
await writeSessionFile(projectDir, sessionId, [
makeAgentToolUseEntry(toolUseId),
makeAgentToolResultEntry(toolUseId, agentId),
{
type: 'user',
message: {
role: 'user',
content: '<task-notification>\n<task-id>task-1</task-id>\n<tool-use-id>tool-1</tool-use-id>\n<status>completed</status>\n<summary>Agent completed</summary>\n<result>Finished exploring the repo</result>\n<output-file>/tmp/agent.out</output-file>\n</task-notification>',
},
uuid: 'task-notification',
timestamp: '2026-01-01T00:00:04.000Z',
},
])
await writeSubagentTranscriptFile(projectDir, sessionId, agentId, [
{
type: 'user',
message: { role: 'user', content: 'Read the source' },
uuid: 'subagent-user',
timestamp: '2026-01-01T00:00:05.000Z',
},
{
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Found the service seam' }],
usage: { input_tokens: 13, output_tokens: 17 },
},
uuid: 'subagent-assistant',
timestamp: '2026-01-01T00:00:06.000Z',
},
])
const result = await getSubagentRunByTool(sessionId, toolUseId)
expect(result).toMatchObject({
sessionId,
toolUseId,
agentId,
taskId: 'task-1',
status: 'completed',
description: 'Explore repo',
prompt: 'Read files',
summary: 'Agent completed',
result: 'Finished exploring the repo',
outputFile: '/tmp/agent.out',
usage: { inputTokens: 7, outputTokens: 11, totalTokens: 18 },
truncated: false,
updatedAt: '2026-01-01T00:00:06.000Z',
source: 'subagent-jsonl',
})
expect(result?.messages).toHaveLength(2)
expect(result?.messages[0]).toMatchObject({
type: 'user',
content: 'Read the source',
isSidechain: undefined,
})
expect(result?.messages[1]).toMatchObject({
type: 'assistant',
content: [{ type: 'text', text: 'Found the service seam' }],
usage: { input_tokens: 13, output_tokens: 17 },
})
})
it('does not report usage when parent and transcript usage are unknown', async () => {
await setupTmpConfigDir()
const sessionId = 'cccccccc-bbbb-cccc-dddd-eeeeeeeeeeee'
const projectDir = '-tmp-subagent-run'
const toolUseId = 'tool-1'
const agentId = 'abc123'
await writeSessionFile(projectDir, sessionId, [
makeAgentToolUseEntry(toolUseId),
{
type: 'user',
message: {
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: toolUseId,
content: `Finished exploring the repo\nagentId: ${agentId}`,
}],
},
uuid: 'user-agent-result-without-usage',
timestamp: '2026-01-01T00:00:03.000Z',
},
])
await writeSubagentTranscriptFile(projectDir, sessionId, agentId, [
{
type: 'user',
message: { role: 'user', content: 'Read the source' },
uuid: 'subagent-user',
timestamp: '2026-01-01T00:00:05.000Z',
},
{
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Found the service seam' }],
},
uuid: 'subagent-assistant',
timestamp: '2026-01-01T00:00:06.000Z',
},
])
const result = await getSubagentRunByTool(sessionId, toolUseId)
expect(result?.usage).toBeUndefined()
})
it('marks parent Agent tool errors as failed when no task notification overrides them', async () => {
await setupTmpConfigDir()
const sessionId = 'dddddddd-bbbb-cccc-dddd-eeeeeeeeeeee'
const projectDir = '-tmp-subagent-run'
const toolUseId = 'tool-1'
await writeSessionFile(projectDir, sessionId, [
makeAgentToolUseEntry(toolUseId),
{
type: 'user',
message: {
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: toolUseId,
content: "Agent type 'general' not found",
is_error: true,
}],
},
uuid: 'user-agent-error-result',
timestamp: '2026-01-01T00:00:03.000Z',
},
])
const result = await getSubagentRunByTool(sessionId, toolUseId)
expect(result).toMatchObject({
sessionId,
toolUseId,
status: 'failed',
result: "Agent type 'general' not found",
source: 'session-history',
})
})
it('returns null when the parent Agent tool use is not present', async () => {
await setupTmpConfigDir()
const sessionId = 'bbbbbbbb-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-subagent-run', sessionId, [
makeAgentToolResultEntry('tool-1', 'abc123'),
])
await expect(getSubagentRunByTool(sessionId, 'tool-1')).resolves.toBeNull()
})
})

View File

@ -0,0 +1,333 @@
import {
sessionService,
type MessageEntry,
type MessageUsage,
type SessionTaskNotification,
} from './sessionService.js'
export type SubagentRunStatus = 'running' | 'completed' | 'failed' | 'stopped' | 'unknown'
export type SubagentRunSource = 'subagent-jsonl' | 'session-history' | 'live-task' | 'none'
export type SubagentRunUsage = {
inputTokens?: number
outputTokens?: number
totalTokens?: number
}
export type SubagentRunResponse = {
sessionId: string
toolUseId: string
agentId: string | null
taskId?: string
status: SubagentRunStatus
description?: string
prompt?: string
summary?: string
result?: string
outputFile?: string
usage?: SubagentRunUsage
messages: MessageEntry[]
truncated: boolean
updatedAt?: string
source: SubagentRunSource
}
export type SubagentRunResolution = {
agentId: string | null
description?: string
prompt?: string
result?: string
usage?: SubagentRunUsage
updatedAt?: string
hasResult: boolean
isError: boolean
}
type ContentBlock = {
type?: unknown
id?: unknown
name?: unknown
input?: unknown
tool_use_id?: unknown
content?: unknown
text?: unknown
is_error?: unknown
}
type MessageEntryLike = {
type?: string
content?: unknown
timestamp?: string
usage?: MessageUsage
message?: {
role?: string
content?: unknown
usage?: MessageUsage
}
}
type TruncateResult = {
messages: MessageEntry[]
truncated: boolean
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function contentFromMessage(entry: MessageEntryLike): unknown {
return entry.content ?? entry.message?.content
}
function contentBlocks(content: unknown): ContentBlock[] {
if (!Array.isArray(content)) return []
return content.filter(isRecord) as ContentBlock[]
}
function textFromContent(content: unknown): string {
if (typeof content === 'string') return content
if (!Array.isArray(content)) return ''
return content
.map((block) => {
if (typeof block === 'string') return block
if (!isRecord(block)) return ''
if (typeof block.text === 'string') return block.text
if ('content' in block) return textFromContent(block.content)
return ''
})
.filter(Boolean)
.join('\n')
}
function extractAgentId(text: string): string | null {
return text.match(/(?:^|\n)\s*agentId:\s*([A-Za-z0-9_-]+)/)?.[1] ?? null
}
function cleanedAgentResultText(text: string): string | undefined {
const cleaned = text
.replace(/<usage>[\s\S]*?<\/usage>/gi, '')
.split('\n')
.filter((line) => !/^\s*agentId:\s*[A-Za-z0-9_-]+/.test(line))
.join('\n')
.trim()
return cleaned || undefined
}
function readNumberValue(text: string, keys: string[]): number | undefined {
for (const key of keys) {
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const tagMatch = text.match(new RegExp(`<${escapedKey}>\\s*(\\d+)\\s*<\\/${escapedKey}>`, 'i'))
if (tagMatch?.[1]) return Number.parseInt(tagMatch[1], 10)
const lineMatch = text.match(new RegExp(`(?:^|\\n)\\s*${escapedKey}\\s*[:=]\\s*(\\d+)`, 'i'))
if (lineMatch?.[1]) return Number.parseInt(lineMatch[1], 10)
}
return undefined
}
function normalizeUsage(usage: SubagentRunUsage): SubagentRunUsage | undefined {
const normalized: SubagentRunUsage = {}
if (typeof usage.inputTokens === 'number' && Number.isFinite(usage.inputTokens)) {
normalized.inputTokens = usage.inputTokens
}
if (typeof usage.outputTokens === 'number' && Number.isFinite(usage.outputTokens)) {
normalized.outputTokens = usage.outputTokens
}
if (typeof usage.totalTokens === 'number' && Number.isFinite(usage.totalTokens)) {
normalized.totalTokens = usage.totalTokens
} else if (
typeof normalized.inputTokens === 'number' &&
typeof normalized.outputTokens === 'number'
) {
normalized.totalTokens = normalized.inputTokens + normalized.outputTokens
}
return Object.keys(normalized).length > 0 ? normalized : undefined
}
function extractUsage(text: string): SubagentRunUsage | undefined {
const usageText = text.match(/<usage>([\s\S]*?)<\/usage>/i)?.[1] ?? text
return normalizeUsage({
inputTokens: readNumberValue(usageText, ['input_tokens', 'inputTokens']),
outputTokens: readNumberValue(usageText, ['output_tokens', 'outputTokens']),
totalTokens: readNumberValue(usageText, ['total_tokens', 'totalTokens']),
})
}
function mergeUsage(
preferred: SubagentRunUsage | undefined,
fallback: SubagentRunUsage | undefined,
): SubagentRunUsage | undefined {
if (!preferred) return fallback
if (!fallback) return preferred
return normalizeUsage({
inputTokens: preferred.inputTokens ?? fallback.inputTokens,
outputTokens: preferred.outputTokens ?? fallback.outputTokens,
totalTokens: preferred.totalTokens ?? fallback.totalTokens,
})
}
function usageFromTranscriptMessages(messages: unknown[]): SubagentRunUsage | undefined {
let inputTokens: number | undefined
let outputTokens: number | undefined
for (const message of messages) {
if (!isRecord(message) || !isRecord(message.usage)) continue
if (typeof message.usage.input_tokens === 'number') {
inputTokens = (inputTokens ?? 0) + message.usage.input_tokens
}
if (typeof message.usage.output_tokens === 'number') {
outputTokens = (outputTokens ?? 0) + message.usage.output_tokens
}
}
if (inputTokens === undefined && outputTokens === undefined) return undefined
return normalizeUsage({ inputTokens, outputTokens })
}
function latestTimestamp(...values: Array<string | undefined>): string | undefined {
let latest: string | undefined
let latestMs = Number.NEGATIVE_INFINITY
for (const value of values) {
if (!value) continue
const time = Date.parse(value)
if (!Number.isFinite(time) || time < latestMs) continue
latest = value
latestMs = time
}
return latest
}
function stringField(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined
}
export function resolveSubagentRunFromMessages(
messages: MessageEntryLike[],
toolUseId: string,
): SubagentRunResolution | null {
let foundAgentToolUse = false
let description: string | undefined
let prompt: string | undefined
let agentId: string | null = null
let result: string | undefined
let usage: SubagentRunUsage | undefined
let updatedAt: string | undefined
let hasResult = false
let isError = false
for (const entry of messages) {
for (const block of contentBlocks(contentFromMessage(entry))) {
if (
block.type === 'tool_use' &&
block.name === 'Agent' &&
block.id === toolUseId
) {
foundAgentToolUse = true
updatedAt = latestTimestamp(updatedAt, entry.timestamp)
const input = isRecord(block.input) ? block.input : {}
description = stringField(input.description) ?? description
prompt = stringField(input.prompt) ?? prompt
}
if (block.type === 'tool_result' && block.tool_use_id === toolUseId) {
hasResult = true
isError = block.is_error === true || isError
updatedAt = latestTimestamp(updatedAt, entry.timestamp)
const text = textFromContent(block.content)
agentId = extractAgentId(text) ?? agentId
result = cleanedAgentResultText(text) ?? result
usage = mergeUsage(extractUsage(text), usage)
}
}
}
if (!foundAgentToolUse) return null
return {
agentId,
...(description ? { description } : {}),
...(prompt ? { prompt } : {}),
...(result ? { result } : {}),
...(usage ? { usage } : {}),
...(updatedAt ? { updatedAt } : {}),
hasResult,
isError,
}
}
export function truncateSubagentMessages(messages: MessageEntry[]): TruncateResult {
if (messages.length <= 1000) {
return { messages, truncated: false }
}
return {
messages: [...messages.slice(0, 50), ...messages.slice(-950)],
truncated: true,
}
}
function statusFromResolution(
resolution: SubagentRunResolution,
notification: SessionTaskNotification | undefined,
): SubagentRunStatus {
if (resolution.isError) return 'failed'
if (notification?.status) return notification.status
if (resolution.hasResult) return 'completed'
return 'running'
}
export async function getSubagentRunByTool(
sessionId: string,
toolUseId: string,
): Promise<SubagentRunResponse | null> {
const [parentMessages, taskNotifications] = await Promise.all([
sessionService.getSessionMessages(sessionId),
sessionService.getSessionTaskNotifications(sessionId),
])
const resolution = resolveSubagentRunFromMessages(parentMessages, toolUseId)
if (!resolution) return null
const notification = taskNotifications.find((candidate) => candidate.toolUseId === toolUseId)
const transcriptMessages = resolution.agentId
? await sessionService.getSubagentTranscriptMessages(sessionId, resolution.agentId)
: []
const truncated = truncateSubagentMessages(transcriptMessages)
const transcriptUsage = usageFromTranscriptMessages(transcriptMessages)
const usage = mergeUsage(resolution.usage, transcriptUsage)
const latestTranscriptTimestamp = latestTimestamp(
...transcriptMessages.map((message) => (
isRecord(message) && typeof message.timestamp === 'string'
? message.timestamp
: undefined
)),
)
return {
sessionId,
toolUseId,
agentId: resolution.agentId,
...(notification?.taskId ? { taskId: notification.taskId } : {}),
status: statusFromResolution(resolution, notification),
...(resolution.description ? { description: resolution.description } : {}),
...(resolution.prompt ? { prompt: resolution.prompt } : {}),
...(notification?.summary ? { summary: notification.summary } : {}),
...(notification?.result || resolution.result
? { result: notification?.result || resolution.result }
: {}),
...(notification?.outputFile ? { outputFile: notification.outputFile } : {}),
...(usage ? { usage } : {}),
messages: truncated.messages,
truncated: truncated.truncated,
...(latestTimestamp(resolution.updatedAt, notification?.timestamp, latestTranscriptTimestamp)
? { updatedAt: latestTimestamp(resolution.updatedAt, notification?.timestamp, latestTranscriptTimestamp) }
: {}),
source: transcriptMessages.length > 0 ? 'subagent-jsonl' : 'session-history',
}
}