diff --git a/desktop/src/api/subagents.test.ts b/desktop/src/api/subagents.test.ts new file mode 100644 index 00000000..ee86ab89 --- /dev/null +++ b/desktop/src/api/subagents.test.ts @@ -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', + ) + }) +}) diff --git a/desktop/src/api/subagents.ts b/desktop/src/api/subagents.ts new file mode 100644 index 00000000..74f6db4c --- /dev/null +++ b/desktop/src/api/subagents.ts @@ -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( + `/api/sessions/${encodeURIComponent(sessionId)}/subagents/by-tool/${encodeURIComponent(toolUseId)}`, + ) + }, +} diff --git a/desktop/src/components/activity/SessionActivityButton.tsx b/desktop/src/components/activity/SessionActivityButton.tsx new file mode 100644 index 00000000..1740a81a --- /dev/null +++ b/desktop/src/components/activity/SessionActivityButton.tsx @@ -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 ( + + ) +} diff --git a/desktop/src/components/activity/SessionActivityPanel.test.tsx b/desktop/src/components/activity/SessionActivityPanel.test.tsx new file mode 100644 index 00000000..387b318d --- /dev/null +++ b/desktop/src/components/activity/SessionActivityPanel.test.tsx @@ -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) => { + const translations: Record = { + '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 { + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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() + + 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( + <> + + + , + ) + + 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( + <> + + + , + ) + + 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( + <> + + + , + ) + + 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( + , + ) + + 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() + + 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( + , + ) + + 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() + + expect(screen.queryByRole('dialog', { name: /activity/i })).not.toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/activity/SessionActivityPanel.tsx b/desktop/src/components/activity/SessionActivityPanel.tsx new file mode 100644 index 00000000..48686787 --- /dev/null +++ b/desktop/src/components/activity/SessionActivityPanel.tsx @@ -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 + +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() + + 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 ( + + + ) + } + + if (status === 'in_progress' || status === 'running') { + return ( + + + ) + } + + return ( + + ) +} + +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 ? : null} + + + {label} + + {detail ? ( + + {detail} + + ) : null} + + {isTask ? null : ( + + {getActivityStatusLabel(row.status, t)} + + )} + + ) + + if (row.section === 'team' && row.member && onOpenMember) { + return ( + + ) + } + + if (row.section === 'subagents' && row.openable && row.toolUseId) { + return ( + + ) + } + + if (row.section === 'backgroundTasks' && onOpenBackgroundTask && hasBackgroundTaskDetails(row)) { + return ( + + ) + } + + return ( +
+ {content} +
+ ) +} + +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 ( +
+
+ {t('session.activity.details.title')} +
+
+ {details.map((detail) => ( +
+
+ {detail.label} +
+
+ {detail.value} +
+
+ ))} +
+
+ ) +} + +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(null) + const [selectedBackgroundTaskId, setSelectedBackgroundTaskId] = useState(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 ( +
+
+

{t('session.activity.title')}

+ +
+ +
+ {SECTION_ORDER.map((sectionId) => { + const section = model.sections[sectionId] + const sectionTitle = getSectionTitle(section.id, t) + const sectionEmptyLabel = getSectionEmptyLabel(section.id, t) + + return ( +
+
+
+

+ {sectionTitle} +

+ {section.rows.length > 0 ? ( + + {section.rows.length} + + ) : null} +
+ {section.id === 'backgroundTasks' && finishedBackgroundTaskKeys.length > 0 && onClearFinishedBackgroundTasks ? ( + + ) : null} +
+ {section.rows.length === 0 ? ( +
{sectionEmptyLabel}
+ ) : ( +
+ {section.rows.map((row) => ( +
+ { + setSelectedBackgroundTaskId((current) => ( + current === backgroundRow.id ? null : backgroundRow.id + )) + }} + selected={section.id === 'backgroundTasks' && selectedBackgroundTaskId === row.id} + /> + {section.id === 'backgroundTasks' && selectedBackgroundTaskId === row.id ? ( + + ) : null} +
+ ))} +
+ )} +
+ ) + })} +
+
+ ) +} diff --git a/desktop/src/components/activity/sessionActivityModel.test.ts b/desktop/src/components/activity/sessionActivityModel.test.ts new file mode 100644 index 00000000..873450be --- /dev/null +++ b/desktop/src/components/activity/sessionActivityModel.test.ts @@ -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 => ({ + id: 'task-1', + subject: 'Write tests', + description: '', + status: 'pending', + blocks: [], + blockedBy: [], + taskListId: 'session-1', + ...overrides, +}) + +const background = (overrides: Partial): 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 => ({ + 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\ntotal_tokens: 42' }, + ], + }, + 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) + }) +}) diff --git a/desktop/src/components/activity/sessionActivityModel.ts b/desktop/src/components/activity/sessionActivityModel.ts new file mode 100644 index 00000000..4dfcfc2a --- /dev/null +++ b/desktop/src/components/activity/sessionActivityModel.ts @@ -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 +} + +export type BuildSessionActivityModelInput = { + sessionId: string + messages?: UIMessage[] + tasks: CLITask[] + completedAndDismissed: boolean + backgroundTasks: BackgroundAgentTask[] + dismissedBackgroundTaskKeys?: Set + agentNotifications: AgentTaskNotification[] + teamMembers?: TeamMember[] +} + +const BADGE_STATUSES = new Set(['pending', 'in_progress', 'running', 'failed']) + +const SECTION_META: Record> = { + 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 { + 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): string { + return task.toolUseId ?? task.taskId +} + +function notificationKey(notification: Pick): 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() + + 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, + 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 { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function stringField(value: Record, 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(/[\s\S]*?<\/usage>/g, '') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +function agentToolLabel(toolCall: Extract): 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>() + 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>() + for (const message of messages) { + if (message.type === 'tool_result') { + resultsByToolUseId.set(message.toolUseId, message) + } + } + + const rowsByTaskId = new Map() + 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 | undefined + let latestTodoWrite: Extract | 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() + for (const row of liveRows) { + if (row.taskId || row.id) { + liveRowsById.set(row.taskId ?? row.id, row) + } + } + + const usedLiveIds = new Set() + 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() + const subagentKeyByTaskId = new Map() + const outputRowsByKey = new Map() + const dismissedBackgroundTaskKeys = input.dismissedBackgroundTaskKeys ?? new Set() + const dismissedNotificationKeys = new Set() + const dismissedNotificationTaskIds = new Set() + const visibleBackgroundTaskIds = new Set() + + 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, + } +} diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 89fa169a..cd9eefd1 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -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() + + 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: { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index a5b6a03b..42a4b6d9 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -1960,6 +1960,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { <> {item.kind === 'tool_group' ? ( = { @@ -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) : {} 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 ( diff --git a/desktop/src/components/chat/ToolCallGroup.tsx b/desktop/src/components/chat/ToolCallGroup.tsx index e95a84b8..b0e4bdb3 100644 --- a/desktop/src/components/chat/ToolCallGroup.tsx +++ b/desktop/src/components/chat/ToolCallGroup.tsx @@ -28,10 +28,12 @@ type MemoryToolActivity = { } type Props = { + sessionId?: string | null toolCalls: ToolCall[] resultMap: Map childToolCallsByParent: Map agentTaskNotifications: Record + 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} /> @@ -153,20 +159,24 @@ export const ToolCallGroup = memo(function ToolCallGroup({ return ( ) }) 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 ( ) @@ -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({
@@ -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 childToolCallsByParent: Map 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 (
@@ -572,6 +594,19 @@ function AgentCallCard({ {t('agentStatus.viewResult')} )} + {canOpenRun && ( + + )} {statusLabel} diff --git a/desktop/src/components/layout/ContentRouter.test.tsx b/desktop/src/components/layout/ContentRouter.test.tsx index 3bfc96bf..6f068a2d 100644 --- a/desktop/src/components/layout/ContentRouter.test.tsx +++ b/desktop/src/components/layout/ContentRouter.test.tsx @@ -42,6 +42,12 @@ vi.mock('../../pages/TraceList', () => ({ TraceList: () =>
, })) +vi.mock('../../pages/SubagentRunPage', () => ({ + SubagentRunPage: ({ sourceSessionId, toolUseId, title }: { sourceSessionId: string; toolUseId: string; title: string }) => ( +
{sourceSessionId}:{toolUseId}:{title}
+ ), +})) + vi.mock('../workbench/WorkbenchTab', () => ({ WorkbenchTab: ({ sessionId, tabId }: { sessionId: string; tabId: string }) => (
workbench:{sessionId}:{tabId}
@@ -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() + + 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() + + 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: [{ diff --git a/desktop/src/components/layout/ContentRouter.tsx b/desktop/src/components/layout/ContentRouter.tsx index 2e4845f0..71ca31bd 100644 --- a/desktop/src/components/layout/ContentRouter.tsx +++ b/desktop/src/components/layout/ContentRouter.tsx @@ -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 ? : } else if (activeTabType === 'traces') { page = + } else if (activeTabType === 'subagent') { + const subagentTab = tabs.find((t) => t.sessionId === activeTabId) + page = subagentTab?.sourceSessionId && subagentTab.subagentToolUseId + ? ( + + ) + : } else if (activeTabType === 'workbench') { const workbenchTab = tabs.find((t) => t.sessionId === activeTabId) page = workbenchTab?.workbenchSessionId diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index db60e509..47b09cd9 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -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>) + useChatStore.setState({ + sessions: { + 'session-1': chatSession, + }, + disconnectSession: vi.fn(), + } as Partial>) + + await act(async () => { + render() + }) + + 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() + }) + + 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>) + useChatStore.setState({ + sessions: { + [sessionId]: makeChatSession('idle'), + }, + disconnectSession: vi.fn(), + } as Partial>) + 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() + }) + + 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>) + useChatStore.setState({ + sessions: { + [sessionId]: chatSession, + }, + disconnectSession: vi.fn(), + } as Partial>) + + await act(async () => { + render() + }) + + 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>) + useChatStore.setState({ + sessions: { + 'session-1': makeChatSession('idle'), + }, + disconnectSession: vi.fn(), + } as Partial>) + 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() + }) + + 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>) + + await act(async () => { + render() + }) + + 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>) useWorkspacePanelStore.getState().openPanel('tab-1') useTerminalPanelStore.getState().openPanel('tab-1') + useActivityPanelStore.getState().open('tab-1') await act(async () => { render() @@ -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 () => { diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 3c24c3da..d0399d08 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -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(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() {
+ {isActiveSessionTab && activeTabId && ( + + )} {isDesktopRuntime && isActiveSessionTab && ( )} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index ee0b4e3f..ba090de9 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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.', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 5c2f0c36..3d8782d5 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1691,6 +1691,73 @@ export const jp: Record = { '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} を入力すると作成できます。', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index ac1f5411..182881d5 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1691,6 +1691,73 @@ export const kr: Record = { '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}을(를) 입력하면 만들 수 있습니다.', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 9332b9d9..e1197a84 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1691,6 +1691,73 @@ export const zh: Record = { '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} 即可建立。', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 13bd6d07..4cc5a51a 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1691,6 +1691,73 @@ export const zh: Record = { '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} 即可创建。', diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index 618ac162..6a6df80a 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -23,20 +23,21 @@ vi.mock('../components/chat/ChatInput', () => ({ ), })) -vi.mock('../components/teams/TeamStatusBar', () => ({ - TeamStatusBar: () =>
, -})) - -vi.mock('../components/chat/SessionTaskBar', () => ({ - SessionTaskBar: () =>
, -})) - vi.mock('../components/workbench/WorkbenchPanel', () => ({ WorkbenchPanel: ({ sessionId }: { sessionId: string }) => (
workspace:{sessionId}
), })) +vi.mock('../api/teams', () => ({ + teamsApi: { + getMemberTranscript: vi.fn(() => Promise.resolve({ messages: [] })), + get: vi.fn(), + list: vi.fn(), + sendMemberMessage: vi.fn(), + }, +})) + vi.mock('./TerminalSettings', () => ({ TerminalSettings: ({ active, @@ -78,6 +79,7 @@ import { useTeamStore } from '../stores/teamStore' import { useWorkspacePanelStore } from '../stores/workspacePanelStore' import { WORKSPACE_PANEL_DEFAULT_WIDTH } from '../stores/workspacePanelStore' import { useTerminalPanelStore } from '../stores/terminalPanelStore' +import { useActivityPanelStore } from '../stores/activityPanelStore' import { TERMINAL_PANEL_DEFAULT_HEIGHT, TERMINAL_PANEL_MAX_HEIGHT, @@ -92,90 +94,14 @@ afterEach(() => { useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) useChatStore.setState({ sessions: {} }) useSettingsStore.setState({ locale: 'en' }) + useTeamStore.getState().stopMemberPolling() useTeamStore.setState({ teams: [], activeTeam: null, memberColors: new Map(), error: null }) useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true) useTerminalPanelStore.setState(useTerminalPanelStore.getInitialState(), true) + useActivityPanelStore.setState(useActivityPanelStore.getInitialState(), true) + useCLITaskStore.setState(useCLITaskStore.getInitialState(), true) }) -function renderBackgroundTaskDrawerForLocale(locale: 'jp' | 'kr', sessionId: string) { - useSettingsStore.setState({ locale }) - useSessionStore.setState({ - sessions: [{ - id: sessionId, - title: 'Localized Background Session', - createdAt: '2026-05-07T00:00:00.000Z', - modifiedAt: '2026-05-07T00:00:00.000Z', - messageCount: 1, - projectPath: '/workspace/project', - workDir: '/workspace/project', - workDirExists: true, - }], - activeSessionId: sessionId, - isLoading: false, - error: null, - }) - useTabStore.setState({ - tabs: [{ sessionId, title: 'Localized Background Session', type: 'session', status: 'idle' }], - activeTabId: sessionId, - }) - useChatStore.setState({ - sessions: { - [sessionId]: { - messages: [{ id: 'msg-1', type: 'assistant_text', content: 'tasks finished', timestamp: 1 }], - backgroundAgentTasks: { - 'agent-task-1': { - taskId: 'agent-task-1', - toolUseId: 'agent-tool-1', - status: 'completed', - taskType: 'local_agent', - description: 'Review agent output', - startedAt: 1, - updatedAt: 2, - }, - 'workflow-task-1': { - taskId: 'workflow-task-1', - toolUseId: 'workflow-tool-1', - status: 'completed', - taskType: 'local_workflow', - description: 'Run workflow', - startedAt: 1, - updatedAt: 3, - }, - 'task-1': { - taskId: 'task-1', - toolUseId: 'task-tool-1', - status: 'completed', - taskType: 'other', - description: 'Generic task', - startedAt: 1, - updatedAt: 4, - }, - }, - chatState: 'idle', - connectionState: 'connected', - streamingText: '', - streamingToolInput: '', - activeToolUseId: null, - activeToolName: null, - activeThinkingId: null, - pendingPermission: null, - pendingComputerUsePermission: null, - tokenUsage: { input_tokens: 0, output_tokens: 0 }, - streamingResponseChars: 0, - elapsedSeconds: 0, - statusVerb: '', - slashCommands: [], - agentTaskNotifications: {}, - elapsedTimer: null, - }, - }, - }) - - render() - fireEvent.click(screen.getByTestId('background-tasks-button')) - return screen.getByTestId('background-tasks-drawer') -} - describe('ActiveSession task polling', () => { it('treats a persisted historical session as non-empty before messages finish loading', () => { const sessionId = 'history-loading-session' @@ -461,14 +387,26 @@ describe('ActiveSession task polling', () => { expect(screen.getByTestId('message-list')).toBeInTheDocument() }) - it('renders an official-style background task entry point and drawer', () => { - const sessionId = 'background-agent-visible-session' - useSettingsStore.setState({ locale: 'en' }) + it('keeps persistent activity surfaces out of the composer area', () => { + const sessionId = 'activity-clean-composer-session' + useCLITaskStore.setState({ + sessionId, + tasks: [{ + id: 'task-1', + subject: 'Write tests', + description: '', + status: 'in_progress', + blocks: [], + blockedBy: [], + taskListId: sessionId, + }], + completedAndDismissed: false, + }) useSessionStore.setState({ sessions: [{ id: sessionId, - title: 'Background Agent Session', + title: 'Activity Session', createdAt: '2026-05-07T00:00:00.000Z', modifiedAt: '2026-05-07T00:00:00.000Z', messageCount: 1, @@ -481,136 +419,394 @@ describe('ActiveSession task polling', () => { error: null, }) useTabStore.setState({ - tabs: [{ sessionId, title: 'Background Agent Session', type: 'session', status: 'running' }], + tabs: [{ sessionId, title: 'Activity Session', type: 'session', status: 'idle' }], activeTabId: sessionId, }) useChatStore.setState({ sessions: { [sessionId]: { messages: [], - activeGoal: { - action: 'created', - status: 'active', - objective: 'ship the smoke test', - updatedAt: 1, - }, backgroundAgentTasks: { 'agent-task-1': { taskId: 'agent-task-1', toolUseId: 'agent-tool-1', status: 'running', taskType: 'local_agent', - description: 'Verify the todo app', - summary: 'Running Playwright checks', - usage: { - totalTokens: 1200, - toolUses: 4, - durationMs: 45000, - }, + description: 'Explore code', startedAt: 1, updatedAt: 2, }, + }, + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + render() + + const chatColumn = screen.getByTestId('active-session-chat-column') + expect(chatColumn).toContainElement(screen.getByTestId('chat-input')) + expect(chatColumn).toHaveClass('relative') + expect(screen.queryByTestId('session-task-bar')).not.toBeInTheDocument() + expect(screen.queryByTestId('team-status-bar')).not.toBeInTheDocument() + expect(screen.queryByTestId('background-tasks-bar')).not.toBeInTheDocument() + expect(screen.queryByTestId('background-tasks-button')).not.toBeInTheDocument() + }) + + it('renders the activity panel inside the chat column with session activity rows', () => { + const sessionId = 'activity-panel-open-session' + + useCLITaskStore.setState({ + sessionId, + tasks: [{ + id: 'task-1', + subject: 'Implement panel', + description: 'Move persistent rows', + status: 'in_progress', + blocks: [], + blockedBy: [], + taskListId: sessionId, + }], + completedAndDismissed: false, + }) + useActivityPanelStore.getState().open(sessionId) + useSessionStore.setState({ + sessions: [{ + id: sessionId, + title: 'Activity Panel Session', + createdAt: '2026-05-07T00:00:00.000Z', + modifiedAt: '2026-05-07T00:00:00.000Z', + messageCount: 1, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: sessionId, + isLoading: false, + error: null, + }) + useTabStore.setState({ + tabs: [{ sessionId, title: 'Activity Panel Session', type: 'session', status: 'idle' }], + activeTabId: sessionId, + }) + useChatStore.setState({ + sessions: { + [sessionId]: { + messages: [ + { + id: 'agent-tool-1', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'agent-tool-1', + input: { description: 'Explore repo' }, + timestamp: 1, + }, + { + id: 'agent-result-1', + type: 'tool_result', + toolUseId: 'agent-tool-1', + content: 'Done', + isError: false, + timestamp: 2, + }, + ], + backgroundAgentTasks: { + 'bash-task-1': { + taskId: 'bash-task-1', + toolUseId: 'bash-tool-1', + status: 'running', + taskType: 'local_bash', + description: 'Run smoke checks', + startedAt: 1, + updatedAt: 2, + }, + }, + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: { + 'agent-task-1': { + taskId: 'agent-task-1', + toolUseId: 'agent-tool-1', + status: 'completed', + summary: 'Explore repo', + timestamp: '2026-07-03T00:00:00.000Z', + }, + }, + elapsedTimer: null, + }, + }, + }) + + render() + + const chatColumn = screen.getByTestId('active-session-chat-column') + const panel = screen.getByTestId('session-activity-panel') + expect(chatColumn).not.toContainElement(panel) + expect(panel).toHaveAttribute('data-placement', 'rail') + expect(panel).toHaveAttribute('role', 'dialog') + expect(within(panel).getByText('Implement panel')).toBeInTheDocument() + expect(within(panel).getAllByText('Run smoke checks')).not.toHaveLength(0) + expect(within(panel).getAllByText('Explore repo')).not.toHaveLength(0) + expect(chatColumn).toContainElement(screen.getByTestId('chat-input')) + expect(screen.queryByTestId('session-task-bar')).not.toBeInTheDocument() + expect(screen.queryByTestId('background-tasks-button')).not.toBeInTheDocument() + }) + + it('opens a SubAgent detail tab from the activity panel', () => { + const sessionId = 'activity-subagent-open-session' + + useActivityPanelStore.getState().open(sessionId) + useSessionStore.setState({ + sessions: [{ + id: sessionId, + title: 'SubAgent Activity Session', + createdAt: '2026-05-07T00:00:00.000Z', + modifiedAt: '2026-05-07T00:00:00.000Z', + messageCount: 1, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: sessionId, + isLoading: false, + error: null, + }) + useTabStore.setState({ + tabs: [{ sessionId, title: 'SubAgent Activity Session', type: 'session', status: 'idle' }], + activeTabId: sessionId, + }) + useChatStore.setState({ + sessions: { + [sessionId]: { + messages: [ + { + id: 'agent-tool-1', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'agent-tool-1', + input: { description: 'Review workspace seams' }, + timestamp: 1, + }, + { + id: 'agent-result-1', + type: 'tool_result', + toolUseId: 'agent-tool-1', + content: 'Done', + isError: false, + timestamp: 2, + }, + ], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + backgroundAgentTasks: {}, + agentTaskNotifications: { + 'agent-task-1': { + taskId: 'agent-task-1', + toolUseId: 'agent-tool-1', + status: 'completed', + summary: 'Review workspace seams', + timestamp: '2026-07-03T00:00:00.000Z', + }, + }, + elapsedTimer: null, + }, + }, + }) + + render() + + fireEvent.click(screen.getByRole('button', { name: 'Open run Review workspace seams' })) + + const tab = useTabStore.getState().tabs.find((candidate) => candidate.sessionId === '__subagent__activity-subagent-open-session__agent-tool-1') + expect(tab).toMatchObject({ + sessionId: '__subagent__activity-subagent-open-session__agent-tool-1', + title: 'Review workspace seams', + type: 'subagent', + status: 'idle', + sourceSessionId: sessionId, + subagentToolUseId: 'agent-tool-1', + }) + expect(useTabStore.getState().activeTabId).toBe('__subagent__activity-subagent-open-session__agent-tool-1') + }) + + it('opens a team member session from the activity panel', () => { + const sessionId = 'team-activity-panel-session' + const memberSessionId = 'team-member:security-reviewer@test-team' + + useActivityPanelStore.getState().open(sessionId) + useTeamStore.setState({ + teams: [], + activeTeam: { + name: 'test-team', + leadAgentId: 'team-lead@test-team', + leadSessionId: sessionId, + members: [ + { + agentId: 'team-lead@test-team', + role: 'team-lead', + status: 'running', + }, + { + agentId: 'security-reviewer@test-team', + role: 'security-reviewer', + status: 'running', + currentTask: 'Auditing auth flow', + }, + ], + }, + memberColors: new Map(), + error: null, + }) + useSessionStore.setState({ + sessions: [{ + id: sessionId, + title: 'Team Activity Session', + createdAt: '2026-05-07T00:00:00.000Z', + modifiedAt: '2026-05-07T00:00:00.000Z', + messageCount: 1, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: sessionId, + isLoading: false, + error: null, + }) + useTabStore.setState({ + tabs: [{ sessionId, title: 'Team Activity Session', type: 'session', status: 'idle' }], + activeTabId: sessionId, + }) + useChatStore.setState({ + sessions: { + [sessionId]: { + messages: [{ id: 'msg-1', type: 'assistant_text', content: 'hello', timestamp: 1 }], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + backgroundAgentTasks: {}, + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + render() + + const panel = screen.getByTestId('session-activity-panel') + expect(within(panel).queryByText('team-lead')).not.toBeInTheDocument() + expect(within(panel).getByText('security-reviewer')).toBeInTheDocument() + expect(within(panel).queryByText('Auditing auth flow')).not.toBeInTheDocument() + + fireEvent.click(within(panel).getByRole('button', { name: /open team member security-reviewer/i })) + + expect(useTabStore.getState().activeTabId).toBe(memberSessionId) + expect(useTabStore.getState().tabs).toEqual(expect.arrayContaining([ + expect.objectContaining({ sessionId: memberSessionId, title: 'security-reviewer', type: 'session' }), + ])) + }) + + it('clears finished background tasks in the activity panel without hiding later runs', () => { + const sessionId = 'activity-background-clear-session' + const otherSessionId = 'activity-background-other-session' + + useActivityPanelStore.getState().open(sessionId) + useSessionStore.setState({ + sessions: [ + { + id: sessionId, + title: 'Background Clear Session', + createdAt: '2026-05-07T00:00:00.000Z', + modifiedAt: '2026-05-07T00:00:00.000Z', + messageCount: 1, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }, + { + id: otherSessionId, + title: 'Other Session', + createdAt: '2026-05-07T00:00:00.000Z', + modifiedAt: '2026-05-07T00:00:00.000Z', + messageCount: 1, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }, + ], + activeSessionId: sessionId, + isLoading: false, + error: null, + }) + useTabStore.setState({ + tabs: [ + { sessionId, title: 'Background Clear Session', type: 'session', status: 'idle' }, + { sessionId: otherSessionId, title: 'Other Session', type: 'session', status: 'idle' }, + ], + activeTabId: sessionId, + }) + useChatStore.setState({ + sessions: { + [sessionId]: { + messages: [{ id: 'msg-1', type: 'assistant_text', content: 'hello', timestamp: 1 }], + backgroundAgentTasks: { 'bash-task-1': { taskId: 'bash-task-1', toolUseId: 'bash-tool-1', status: 'completed', taskType: 'local_bash', - description: 'Capture final screenshots', - summary: 'Captured 36 screenshots', - usage: { - durationMs: 120000, - }, - startedAt: 1, - updatedAt: 3, - }, - }, - chatState: 'tool_executing', - connectionState: 'connected', - streamingText: '', - streamingToolInput: '', - activeToolUseId: null, - activeToolName: null, - activeThinkingId: null, - pendingPermission: null, - pendingComputerUsePermission: null, - tokenUsage: { input_tokens: 0, output_tokens: 0 }, - streamingResponseChars: 0, - elapsedSeconds: 0, - statusVerb: '', - slashCommands: [], - agentTaskNotifications: {}, - elapsedTimer: null, - }, - }, - }) - - render() - - expect(screen.queryByText(/Completed in/i)).not.toBeInTheDocument() - const taskButton = screen.getByRole('button', { name: '1 running task' }) - expect(taskButton).toBeInTheDocument() - expect(taskButton).toHaveAttribute('aria-expanded', 'false') - expect(screen.getByTestId('message-list')).toBeInTheDocument() - - fireEvent.click(taskButton) - - expect(taskButton).toHaveAttribute('aria-expanded', 'true') - const drawer = screen.getByTestId('background-tasks-drawer') - expect(drawer).toHaveAttribute('role', 'dialog') - expect(within(drawer).getByRole('heading', { name: 'Background tasks' })).toBeInTheDocument() - expect(within(drawer).getByText('Running')).toBeInTheDocument() - expect(within(drawer).getByText('Verify the todo app')).toBeInTheDocument() - expect(within(drawer).getByText('Agent')).toBeInTheDocument() - expect(within(drawer).getByText('Finished')).toBeInTheDocument() - expect(within(drawer).getByText('Capture final screenshots')).toBeInTheDocument() - expect(within(drawer).getByText('Bash')).toBeInTheDocument() - }) - - it('localizes the background task entry point', () => { - const sessionId = 'background-agent-zh-session' - useSettingsStore.setState({ locale: 'zh' }) - - useSessionStore.setState({ - sessions: [{ - id: sessionId, - title: 'Background Agent Session', - createdAt: '2026-05-07T00:00:00.000Z', - modifiedAt: '2026-05-07T00:00:00.000Z', - messageCount: 1, - projectPath: '/workspace/project', - workDir: '/workspace/project', - workDirExists: true, - }], - activeSessionId: sessionId, - isLoading: false, - error: null, - }) - useTabStore.setState({ - tabs: [{ sessionId, title: 'Background Agent Session', type: 'session', status: 'running' }], - activeTabId: sessionId, - }) - useChatStore.setState({ - sessions: { - [sessionId]: { - messages: [{ id: 'msg-1', type: 'assistant_text', content: 'task started', timestamp: 1 }], - backgroundAgentTasks: { - 'agent-task-1': { - taskId: 'agent-task-1', - toolUseId: 'agent-tool-1', - status: 'running', - taskType: 'local_agent', - description: 'Verify the todo app', - startedAt: 1, - updatedAt: 2, - }, - 'agent-task-2': { - taskId: 'agent-task-2', - toolUseId: 'agent-tool-2', - status: 'running', - taskType: 'remote_agent', - description: 'Review screenshots', - startedAt: 1, - updatedAt: 2, + description: 'Finished smoke run', + startedAt: 1000, + updatedAt: 2000, }, }, chatState: 'idle', @@ -630,366 +826,8 @@ describe('ActiveSession task polling', () => { agentTaskNotifications: {}, elapsedTimer: null, }, - }, - }) - - render() - - expect(screen.getByRole('button', { name: '2 个运行中任务' })).toBeInTheDocument() - }) - - it('localizes background task duration units', () => { - const sessionId = 'background-agent-duration-zh-session' - useSettingsStore.setState({ locale: 'zh' }) - - useSessionStore.setState({ - sessions: [{ - id: sessionId, - title: 'Background Agent Duration Session', - createdAt: '2026-05-07T00:00:00.000Z', - modifiedAt: '2026-05-07T00:00:00.000Z', - messageCount: 1, - projectPath: '/workspace/project', - workDir: '/workspace/project', - workDirExists: true, - }], - activeSessionId: sessionId, - isLoading: false, - error: null, - }) - useTabStore.setState({ - tabs: [{ sessionId, title: 'Background Agent Duration Session', type: 'session', status: 'idle' }], - activeTabId: sessionId, - }) - useChatStore.setState({ - sessions: { - [sessionId]: { - messages: [{ id: 'msg-1', type: 'assistant_text', content: 'task finished', timestamp: 1 }], - backgroundAgentTasks: { - 'agent-task-1': { - taskId: 'agent-task-1', - toolUseId: 'agent-tool-1', - status: 'completed', - taskType: 'local_agent', - description: 'Review screenshots', - usage: { - totalTokens: 94300, - toolUses: 76, - durationMs: 671000, - }, - startedAt: 1, - updatedAt: 2, - }, - }, - chatState: 'idle', - connectionState: 'connected', - streamingText: '', - streamingToolInput: '', - activeToolUseId: null, - activeToolName: null, - activeThinkingId: null, - pendingPermission: null, - pendingComputerUsePermission: null, - tokenUsage: { input_tokens: 0, output_tokens: 0 }, - streamingResponseChars: 0, - elapsedSeconds: 0, - statusVerb: '', - slashCommands: [], - agentTaskNotifications: {}, - elapsedTimer: null, - }, - }, - }) - - render() - - fireEvent.click(screen.getByRole('button', { name: '1 个已完成任务' })) - - expect(within(screen.getByTestId('background-tasks-drawer')).getByText('11 分 11 秒')).toBeInTheDocument() - }) - - it('renders Japanese background task type labels in the drawer', () => { - const drawer = renderBackgroundTaskDrawerForLocale('jp', 'background-agent-jp-label-session') - - expect(within(drawer).getByText('エージェント')).toBeInTheDocument() - expect(within(drawer).getByText('ワークフロー')).toBeInTheDocument() - expect(within(drawer).getByText('タスク')).toBeInTheDocument() - }) - - it('renders Korean background task type labels in the drawer', () => { - const drawer = renderBackgroundTaskDrawerForLocale('kr', 'background-agent-kr-label-session') - - expect(within(drawer).getByText('에이전트')).toBeInTheDocument() - expect(within(drawer).getByText('워크플로')).toBeInTheDocument() - expect(within(drawer).getByText('작업')).toBeInTheDocument() - }) - - it('keeps finished background tasks reachable until cleared', () => { - const sessionId = 'background-agent-finished-session' - useSettingsStore.setState({ locale: 'en' }) - - useSessionStore.setState({ - sessions: [{ - id: sessionId, - title: 'Finished Background Session', - createdAt: '2026-05-07T00:00:00.000Z', - modifiedAt: '2026-05-07T00:00:00.000Z', - messageCount: 1, - projectPath: '/workspace/project', - workDir: '/workspace/project', - workDirExists: true, - }], - activeSessionId: sessionId, - isLoading: false, - error: null, - }) - useTabStore.setState({ - tabs: [{ sessionId, title: 'Finished Background Session', type: 'session', status: 'idle' }], - activeTabId: sessionId, - }) - useChatStore.setState({ - sessions: { - [sessionId]: { - messages: [{ id: 'msg-1', type: 'assistant_text', content: 'task started', timestamp: 1 }], - backgroundAgentTasks: { - 'agent-task-1': { - taskId: 'agent-task-1', - toolUseId: 'agent-tool-1', - status: 'completed', - taskType: 'local_agent', - description: 'Review screenshots', - usage: { - totalTokens: 94300, - toolUses: 76, - durationMs: 671000, - }, - startedAt: 1, - updatedAt: 2, - }, - }, - chatState: 'idle', - connectionState: 'connected', - streamingText: '', - streamingToolInput: '', - activeToolUseId: null, - activeToolName: null, - activeThinkingId: null, - pendingPermission: null, - pendingComputerUsePermission: null, - tokenUsage: { input_tokens: 0, output_tokens: 0 }, - streamingResponseChars: 0, - elapsedSeconds: 0, - statusVerb: '', - slashCommands: [], - agentTaskNotifications: {}, - elapsedTimer: null, - }, - }, - }) - - render() - - const taskButton = screen.getByRole('button', { name: '1 finished task' }) - fireEvent.click(taskButton) - - const drawer = screen.getByTestId('background-tasks-drawer') - expect(within(drawer).getByText('Finished')).toBeInTheDocument() - expect(within(drawer).getByText('1')).toBeInTheDocument() - expect(within(drawer).getByText('Review screenshots')).toBeInTheDocument() - - fireEvent.click(within(drawer).getByRole('button', { name: 'Clear' })) - - expect(screen.queryByTestId('background-tasks-drawer')).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: '1 finished task' })).not.toBeInTheDocument() - }) - - it('does not carry an open background task drawer across sessions', () => { - const firstSessionId = 'background-agent-first-session' - const secondSessionId = 'background-agent-second-session' - useSettingsStore.setState({ locale: 'en' }) - - useSessionStore.setState({ - sessions: [ - { - id: firstSessionId, - title: 'First Background Session', - createdAt: '2026-05-07T00:00:00.000Z', - modifiedAt: '2026-05-07T00:00:00.000Z', - messageCount: 1, - projectPath: '/workspace/project', - workDir: '/workspace/project', - workDirExists: true, - }, - { - id: secondSessionId, - title: 'Second Background Session', - createdAt: '2026-05-07T00:00:00.000Z', - modifiedAt: '2026-05-07T00:00:00.000Z', - messageCount: 1, - projectPath: '/workspace/project', - workDir: '/workspace/project', - workDirExists: true, - }, - ], - activeSessionId: firstSessionId, - isLoading: false, - error: null, - }) - useTabStore.setState({ - tabs: [ - { sessionId: firstSessionId, title: 'First Background Session', type: 'session', status: 'running' }, - { sessionId: secondSessionId, title: 'Second Background Session', type: 'session', status: 'running' }, - ], - activeTabId: firstSessionId, - }) - useChatStore.setState({ - sessions: { - [firstSessionId]: { - messages: [{ id: 'msg-1', type: 'assistant_text', content: 'first', timestamp: 1 }], - backgroundAgentTasks: { - 'agent-task-1': { - taskId: 'agent-task-1', - status: 'running', - taskType: 'local_agent', - description: 'First session task', - startedAt: 1, - updatedAt: 2, - }, - }, - chatState: 'idle', - connectionState: 'connected', - streamingText: '', - streamingToolInput: '', - activeToolUseId: null, - activeToolName: null, - activeThinkingId: null, - pendingPermission: null, - pendingComputerUsePermission: null, - tokenUsage: { input_tokens: 0, output_tokens: 0 }, - streamingResponseChars: 0, - elapsedSeconds: 0, - statusVerb: '', - slashCommands: [], - agentTaskNotifications: {}, - elapsedTimer: null, - }, - [secondSessionId]: { - messages: [{ id: 'msg-2', type: 'assistant_text', content: 'second', timestamp: 1 }], - backgroundAgentTasks: { - 'agent-task-2': { - taskId: 'agent-task-2', - status: 'running', - taskType: 'local_agent', - description: 'Second session task', - startedAt: 1, - updatedAt: 2, - }, - }, - chatState: 'idle', - connectionState: 'connected', - streamingText: '', - streamingToolInput: '', - activeToolUseId: null, - activeToolName: null, - activeThinkingId: null, - pendingPermission: null, - pendingComputerUsePermission: null, - tokenUsage: { input_tokens: 0, output_tokens: 0 }, - streamingResponseChars: 0, - elapsedSeconds: 0, - statusVerb: '', - slashCommands: [], - agentTaskNotifications: {}, - elapsedTimer: null, - }, - }, - }) - - render() - - fireEvent.click(screen.getByRole('button', { name: '1 running task' })) - expect(screen.getByTestId('background-tasks-drawer')).toBeInTheDocument() - - act(() => { - useTabStore.setState({ activeTabId: secondSessionId }) - }) - - expect(screen.queryByTestId('background-tasks-drawer')).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: '1 running task' })).toBeInTheDocument() - }) - - it('keeps cleared finished background tasks dismissed when returning to the same session', () => { - const firstSessionId = 'background-finished-first-session' - const secondSessionId = 'background-finished-second-session' - useSettingsStore.setState({ locale: 'en' }) - - useSessionStore.setState({ - sessions: [ - { - id: firstSessionId, - title: 'Finished First Session', - createdAt: '2026-05-07T00:00:00.000Z', - modifiedAt: '2026-05-07T00:00:00.000Z', - messageCount: 1, - projectPath: '/workspace/project', - workDir: '/workspace/project', - workDirExists: true, - }, - { - id: secondSessionId, - title: 'Second Session', - createdAt: '2026-05-07T00:00:00.000Z', - modifiedAt: '2026-05-07T00:00:00.000Z', - messageCount: 1, - projectPath: '/workspace/project', - workDir: '/workspace/project', - workDirExists: true, - }, - ], - activeSessionId: firstSessionId, - isLoading: false, - error: null, - }) - useTabStore.setState({ - tabs: [ - { sessionId: firstSessionId, title: 'Finished First Session', type: 'session', status: 'idle' }, - { sessionId: secondSessionId, title: 'Second Session', type: 'session', status: 'idle' }, - ], - activeTabId: firstSessionId, - }) - useChatStore.setState({ - sessions: { - [firstSessionId]: { - messages: [{ id: 'msg-1', type: 'assistant_text', content: 'first', timestamp: 1 }], - backgroundAgentTasks: { - 'finished-agent-task': { - taskId: 'finished-agent-task', - status: 'completed', - taskType: 'local_agent', - description: 'Finished review', - startedAt: 1, - updatedAt: 2, - }, - }, - chatState: 'idle', - connectionState: 'connected', - streamingText: '', - streamingToolInput: '', - activeToolUseId: null, - activeToolName: null, - activeThinkingId: null, - pendingPermission: null, - pendingComputerUsePermission: null, - tokenUsage: { input_tokens: 0, output_tokens: 0 }, - streamingResponseChars: 0, - elapsedSeconds: 0, - statusVerb: '', - slashCommands: [], - agentTaskNotifications: {}, - elapsedTimer: null, - }, - [secondSessionId]: { - messages: [{ id: 'msg-2', type: 'assistant_text', content: 'second', timestamp: 1 }], + [otherSessionId]: { + messages: [{ id: 'msg-2', type: 'assistant_text', content: 'other', timestamp: 2 }], backgroundAgentTasks: {}, chatState: 'idle', connectionState: 'connected', @@ -1013,81 +851,20 @@ describe('ActiveSession task polling', () => { render() - fireEvent.click(screen.getByRole('button', { name: '1 finished task' })) - fireEvent.click(within(screen.getByTestId('background-tasks-drawer')).getByRole('button', { name: 'Clear' })) + expect(screen.getByText('Finished smoke run')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /clear finished/i })) + + expect(screen.queryByText('Finished smoke run')).not.toBeInTheDocument() act(() => { - useTabStore.setState({ activeTabId: secondSessionId }) + useTabStore.getState().setActiveTab(otherSessionId) }) act(() => { - useTabStore.setState({ activeTabId: firstSessionId }) + useTabStore.getState().setActiveTab(sessionId) }) - expect(screen.queryByRole('button', { name: '1 finished task' })).not.toBeInTheDocument() - }) - - it('shows a resumed background task after clearing an earlier finish for the same task id', () => { - const sessionId = 'background-finished-resume-session' - useSettingsStore.setState({ locale: 'en' }) - - useSessionStore.setState({ - sessions: [{ - id: sessionId, - title: 'Finished Resume Session', - createdAt: '2026-05-07T00:00:00.000Z', - modifiedAt: '2026-05-07T00:00:00.000Z', - messageCount: 1, - projectPath: '/workspace/project', - workDir: '/workspace/project', - workDirExists: true, - }], - activeSessionId: sessionId, - isLoading: false, - error: null, - }) - useTabStore.setState({ - tabs: [{ sessionId, title: 'Finished Resume Session', type: 'session', status: 'idle' }], - activeTabId: sessionId, - }) - useChatStore.setState({ - sessions: { - [sessionId]: { - messages: [{ id: 'msg-1', type: 'assistant_text', content: 'first', timestamp: 1 }], - backgroundAgentTasks: { - 'reused-agent-task': { - taskId: 'reused-agent-task', - status: 'completed', - taskType: 'local_agent', - description: 'First finished review', - startedAt: 1, - updatedAt: 2, - }, - }, - chatState: 'idle', - connectionState: 'connected', - streamingText: '', - streamingToolInput: '', - activeToolUseId: null, - activeToolName: null, - activeThinkingId: null, - pendingPermission: null, - pendingComputerUsePermission: null, - tokenUsage: { input_tokens: 0, output_tokens: 0 }, - streamingResponseChars: 0, - elapsedSeconds: 0, - statusVerb: '', - slashCommands: [], - agentTaskNotifications: {}, - elapsedTimer: null, - }, - }, - }) - - render() - - fireEvent.click(screen.getByRole('button', { name: '1 finished task' })) - fireEvent.click(within(screen.getByTestId('background-tasks-drawer')).getByRole('button', { name: 'Clear' })) - expect(screen.queryByRole('button', { name: '1 finished task' })).not.toBeInTheDocument() + expect(screen.queryByText('Finished smoke run')).not.toBeInTheDocument() act(() => { useChatStore.setState((state) => ({ @@ -1096,13 +873,14 @@ describe('ActiveSession task polling', () => { [sessionId]: { ...state.sessions[sessionId]!, backgroundAgentTasks: { - 'reused-agent-task': { - taskId: 'reused-agent-task', + 'bash-task-1': { + taskId: 'bash-task-1', + toolUseId: 'bash-tool-2', status: 'completed', - taskType: 'local_agent', - description: 'Duplicate finished review', - startedAt: 1, - updatedAt: 3, + taskType: 'local_bash', + description: 'Finished smoke rerun', + startedAt: 3000, + updatedAt: 4000, }, }, }, @@ -1110,55 +888,7 @@ describe('ActiveSession task polling', () => { })) }) - expect(screen.queryByRole('button', { name: '1 finished task' })).not.toBeInTheDocument() - - act(() => { - useChatStore.setState((state) => ({ - sessions: { - ...state.sessions, - [sessionId]: { - ...state.sessions[sessionId]!, - backgroundAgentTasks: { - 'reused-agent-task': { - taskId: 'reused-agent-task', - status: 'running', - taskType: 'local_agent', - description: 'Resumed review', - startedAt: 4, - updatedAt: 4, - }, - }, - }, - }, - })) - }) - - expect(screen.getByRole('button', { name: '1 running task' })).toBeInTheDocument() - - act(() => { - useChatStore.setState((state) => ({ - sessions: { - ...state.sessions, - [sessionId]: { - ...state.sessions[sessionId]!, - backgroundAgentTasks: { - 'reused-agent-task': { - taskId: 'reused-agent-task', - status: 'completed', - taskType: 'local_agent', - description: 'Second finished review', - startedAt: 4, - updatedAt: 5, - }, - }, - }, - }, - })) - }) - - expect(screen.getByRole('button', { name: '1 finished task' })).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '1 finished task' })) - expect(within(screen.getByTestId('background-tasks-drawer')).getByText('Second finished review')).toBeInTheDocument() + expect(screen.getByText('Finished smoke rerun')).toBeInTheDocument() }) it('keeps the session header active while a background task is still running after the turn completes', () => { @@ -1359,11 +1089,13 @@ describe('ActiveSession task polling', () => { }, }, }) + useActivityPanelStore.getState().open(memberSessionId) const { queryByTestId, unmount } = render() expect(queryByTestId('chat-input')).toBeInTheDocument() expect(queryByTestId('session-task-bar')).not.toBeInTheDocument() + expect(queryByTestId('session-activity-panel')).not.toBeInTheDocument() expect(fetchSessionTasks).not.toHaveBeenCalled() unmount() diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index 7152aa70..d6c32b2e 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -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() +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(null) const activeTabId = useTabStore((s) => s.activeTabId) - const [dismissedBackgroundTaskKeysBySession, setDismissedBackgroundTaskKeysBySession] = useState>>({}) 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() {
{isMemberSession && (
@@ -592,28 +638,17 @@ export function ActiveSession() { )} - {!isMemberSession && } - - - - {!isMemberSession && ( - { - 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) ? ( + closeActivityPanel(activeTabId)} + onOpenSubagent={handleOpenSubagentRun} + onClearFinishedBackgroundTasks={handleClearFinishedBackgroundTasks} + onOpenMember={handleOpenTeamMember} + placement="overlay" /> - )} + ) : null} + {activityModel && !isMobileLayout && !isMemberSession && isSessionTabState(activeTabId, activeTabType) ? ( + closeActivityPanel(activeTabId)} + onOpenSubagent={handleOpenSubagentRun} + onClearFinishedBackgroundTasks={handleClearFinishedBackgroundTasks} + onOpenMember={handleOpenTeamMember} + placement="rail" + /> + ) : null} + {showWorkbench ? ( <> diff --git a/desktop/src/pages/SubagentRunPage.test.tsx b/desktop/src/pages/SubagentRunPage.test.tsx new file mode 100644 index 00000000..e6b2ec50 --- /dev/null +++ b/desktop/src/pages/SubagentRunPage.test.tsx @@ -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 { + 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() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((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() + + 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().promise) + + render() + + 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() + + 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() + + 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() + const second = deferred() + vi.mocked(subagentsApi.getRunByTool).mockImplementation((sessionId) => + sessionId === 'session-a' ? first.promise : second.promise + ) + + const { rerender } = render() + rerender() + + 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() + + 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) + }) +}) diff --git a/desktop/src/pages/SubagentRunPage.tsx b/desktop/src/pages/SubagentRunPage.tsx new file mode 100644 index 00000000..3699bd1a --- /dev/null +++ b/desktop/src/pages/SubagentRunPage.tsx @@ -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 +type ToolResult = Extract +type TranslationFn = ReturnType + +export function SubagentRunPage({ + sourceSessionId, + toolUseId, + title, +}: { + sourceSessionId: string + toolUseId: string + title: string +}) { + const t = useTranslation() + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
+
+
+

{title}

+ {data ? : null} +
+

+ {sourceSessionId} / {toolUseId} +

+
+ +
+ +
+ {loading && !data ? ( +
{t('subagentRun.loading')}
+ ) : null} + {error ? ( +
+ {error} +
+ ) : null} + {data ? ( + + ) : null} +
+
+ ) +} + +function SubagentRunDetails({ data }: { data: SubagentRunResponse }) { + const t = useTranslation() + const agentToolCall = useMemo(() => buildAgentToolCall(data), [data]) + const agentToolResult = useMemo(() => buildAgentToolResult(data, t), [data, t]) + + return ( +
+
+ {t('subagentRun.source')}: {sourceLabel(data.source, t)} + + {t('subagentRun.agent')}: {data.agentId ?? t('subagentRun.unknown')} + {data.taskId ? ( + <> + + {t('subagentRun.task')}: {data.taskId} + + ) : null} + + {t('subagentRun.updated')}: {formatTimestamp(data.updatedAt)} + {data.usage?.totalTokens ? ( + <> + + {t('common.tokens', { count: formatNumber(data.usage.totalTokens) })} + + ) : null} + {data.outputFile ? ( + <> + + {t('subagentRun.output')}: {data.outputFile} + + ) : null} +
+ +
+

{t('subagentRun.parentToolCall')}

+ +
+ + +
+ ) +} + +const EMPTY_AGENT_TASK_NOTIFICATIONS: Record = {} + +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 ( +
+

{t('subagentRun.transcript')}

+
+ {t('subagentRun.noTranscript')} +
+
+ ) + } + + return ( +
+
+

{t('subagentRun.transcript')}

+ {data.truncated ? ( + {t('subagentRun.truncated')} + ) : null} +
+
+ {renderModel.renderItems.map((item) => { + if (item.kind === 'tool_group') { + return ( + + ) + } + + const toolResult = item.message.type === 'tool_use' + ? renderModel.toolResultMap.get(item.message.toolUseId) + : null + + return ( + + ) + })} +
+
+ ) +} + +function StatusBadge({ status, t }: { status: SubagentRunStatus; t: TranslationFn }) { + return ( + + {getSubagentStatusLabel(status, t)} + + ) +} + +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') +} diff --git a/desktop/src/stores/activityPanelStore.ts b/desktop/src/stores/activityPanelStore.ts new file mode 100644 index 00000000..ba3b1c18 --- /dev/null +++ b/desktop/src/stores/activityPanelStore.ts @@ -0,0 +1,90 @@ +import { create } from 'zustand' +import type { ActivitySectionId } from '../components/activity/sessionActivityModel' + +type ActivityPanelStore = { + openSessionId: string | null + selectedSectionBySession: Record + dismissedBackgroundTaskKeysBySession: Record + + 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 + dismissBackgroundTaskKeys: (sessionId: string, taskKeys: Iterable) => void + pruneDismissedBackgroundTaskKeys: (sessionId: string, activeTaskKeys: Iterable) => void +} + +export const useActivityPanelStore = create((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, + } + }), +})) diff --git a/desktop/src/stores/tabStore.test.ts b/desktop/src/stores/tabStore.test.ts index 64135b52..19fe8f0d 100644 --- a/desktop/src/stores/tabStore.test.ts +++ b/desktop/src/stores/tabStore.test.ts @@ -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) => { diff --git a/desktop/src/stores/tabStore.ts b/desktop/src/stores/tabStore.ts index 30e15586..2bfa5216 100644 --- a/desktop/src/stores/tabStore.ts +++ b/desktop/src/stores/tabStore.ts @@ -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((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((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, diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index e5639d94..32402aab 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -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()}`) diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index aa94521a..cd81c489 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -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 diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 2ce3bc26..4496c860 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -2486,6 +2486,21 @@ export class SessionService { ) } + async getSubagentTranscriptMessages( + sessionId: string, + agentId: string, + ): Promise { + 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 { const found = await this.findSessionFile(sessionId) if (!found) return null diff --git a/src/server/services/subagentRunService.test.ts b/src/server/services/subagentRunService.test.ts new file mode 100644 index 00000000..745dec9b --- /dev/null +++ b/src/server/services/subagentRunService.test.ts @@ -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 { + 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[], +): Promise { + 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[], +): Promise { + 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 { + 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 { + return { + type: 'user', + message: { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: toolUseId, + content: [{ + type: 'text', + text: `Finished exploring the repo\nagentId: ${agentId}\ninput_tokens: 7\noutput_tokens: 11\ntotal_tokens: 18`, + }], + }], + }, + 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: '\ntask-1\ntool-1\ncompleted\nAgent completed\nFinished exploring the repo\n/tmp/agent.out\n', + }, + 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() + }) +}) diff --git a/src/server/services/subagentRunService.ts b/src/server/services/subagentRunService.ts new file mode 100644 index 00000000..5a2c251d --- /dev/null +++ b/src/server/services/subagentRunService.ts @@ -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 { + 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(/[\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(/([\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 { + 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 { + 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', + } +}