mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
a5b77822cc
commit
56a4be3d14
27
desktop/src/api/subagents.test.ts
Normal file
27
desktop/src/api/subagents.test.ts
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
37
desktop/src/api/subagents.ts
Normal file
37
desktop/src/api/subagents.ts
Normal 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)}`,
|
||||
)
|
||||
},
|
||||
}
|
||||
49
desktop/src/components/activity/SessionActivityButton.tsx
Normal file
49
desktop/src/components/activity/SessionActivityButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
508
desktop/src/components/activity/SessionActivityPanel.test.tsx
Normal file
508
desktop/src/components/activity/SessionActivityPanel.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
488
desktop/src/components/activity/SessionActivityPanel.tsx
Normal file
488
desktop/src/components/activity/SessionActivityPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
625
desktop/src/components/activity/sessionActivityModel.test.ts
Normal file
625
desktop/src/components/activity/sessionActivityModel.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
718
desktop/src/components/activity/sessionActivityModel.ts
Normal file
718
desktop/src/components/activity/sessionActivityModel.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: [{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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} />
|
||||
)}
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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} を入力すると作成できます。',
|
||||
|
||||
@ -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}을(를) 입력하면 만들 수 있습니다.',
|
||||
|
||||
@ -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} 即可建立。',
|
||||
|
||||
@ -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
@ -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} />
|
||||
|
||||
185
desktop/src/pages/SubagentRunPage.test.tsx
Normal file
185
desktop/src/pages/SubagentRunPage.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
302
desktop/src/pages/SubagentRunPage.tsx
Normal file
302
desktop/src/pages/SubagentRunPage.tsx
Normal 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')
|
||||
}
|
||||
90
desktop/src/stores/activityPanelStore.ts
Normal file
90
desktop/src/stores/activityPanelStore.ts
Normal 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,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()}`)
|
||||
|
||||
@ -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 — 获取会话级模型调用 trace(body 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
|
||||
|
||||
@ -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
|
||||
|
||||
313
src/server/services/subagentRunService.test.ts
Normal file
313
src/server/services/subagentRunService.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
333
src/server/services/subagentRunService.ts
Normal file
333
src/server/services/subagentRunService.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user