diff --git a/desktop/src/api/subagents.ts b/desktop/src/api/subagents.ts index 96164880..f4ad10be 100644 --- a/desktop/src/api/subagents.ts +++ b/desktop/src/api/subagents.ts @@ -35,4 +35,12 @@ export const subagentsApi = { `/api/sessions/${encodeURIComponent(sessionId)}/subagents/by-tool/${encodeURIComponent(toolUseId)}${query}`, ) }, + + sendMessage(sessionId: string, toolUseId: string, content: string, taskId?: string) { + const query = taskId ? `?taskId=${encodeURIComponent(taskId)}` : '' + return api.post<{ ok: true; delivery?: 'queued' | 'resumed'; agent_id?: string }>( + `/api/sessions/${encodeURIComponent(sessionId)}/subagents/by-tool/${encodeURIComponent(toolUseId)}/messages${query}`, + { content }, + ) + }, } diff --git a/desktop/src/api/teams.ts b/desktop/src/api/teams.ts index ec7c668b..fbcdf747 100644 --- a/desktop/src/api/teams.ts +++ b/desktop/src/api/teams.ts @@ -1,5 +1,10 @@ import { api } from './client' -import type { TeamSummary, TeamDetail } from '../types/team' +import type { + TeamSummary, + TeamDetail, + TeamWorkbenchSnapshot, + TeamWorkbenchSessionTimeline, +} from '../types/team' type TeamsResponse = { teams: TeamSummary[] } @@ -24,6 +29,7 @@ type TranscriptOptions = { signature?: string cursor?: string afterOrdinal?: number + leadSessionId?: string } export type { TranscriptMessage } @@ -37,6 +43,18 @@ export const teamsApi = { return api.get(`/api/teams/${encodeURIComponent(name)}`) }, + getWorkbench(name: string) { + return api.get( + `/api/teams/${encodeURIComponent(name)}/workbench`, + ) + }, + + getWorkbenchForSession(sessionId: string) { + return api.get( + `/api/teams/session/${encodeURIComponent(sessionId)}/workbench`, + ) + }, + getMemberTranscript( teamName: string, agentId: string, @@ -45,6 +63,7 @@ export const teamsApi = { const params = new URLSearchParams() if (options) { params.set('incremental', 'true') + if (options.leadSessionId) params.set('leadSessionId', options.leadSessionId) if (options.signature) params.set('signature', options.signature) if (options.cursor) params.set('cursor', options.cursor) if (options.afterOrdinal !== undefined) { diff --git a/desktop/src/assets/agent-teams/data-analyst.png b/desktop/src/assets/agent-teams/data-analyst.png new file mode 100644 index 00000000..1a8a98e2 Binary files /dev/null and b/desktop/src/assets/agent-teams/data-analyst.png differ diff --git a/desktop/src/assets/agent-teams/docs-coordinator.png b/desktop/src/assets/agent-teams/docs-coordinator.png new file mode 100644 index 00000000..1b728ee6 Binary files /dev/null and b/desktop/src/assets/agent-teams/docs-coordinator.png differ diff --git a/desktop/src/assets/agent-teams/qa-engineer.png b/desktop/src/assets/agent-teams/qa-engineer.png new file mode 100644 index 00000000..7f9f6885 Binary files /dev/null and b/desktop/src/assets/agent-teams/qa-engineer.png differ diff --git a/desktop/src/assets/agent-teams/release-engineer.png b/desktop/src/assets/agent-teams/release-engineer.png new file mode 100644 index 00000000..4d8e20ca Binary files /dev/null and b/desktop/src/assets/agent-teams/release-engineer.png differ diff --git a/desktop/src/assets/agent-teams/security-reviewer.png b/desktop/src/assets/agent-teams/security-reviewer.png new file mode 100644 index 00000000..45df2e62 Binary files /dev/null and b/desktop/src/assets/agent-teams/security-reviewer.png differ diff --git a/desktop/src/assets/agent-teams/server-engineer.png b/desktop/src/assets/agent-teams/server-engineer.png new file mode 100644 index 00000000..12df48af Binary files /dev/null and b/desktop/src/assets/agent-teams/server-engineer.png differ diff --git a/desktop/src/assets/agent-teams/team-lead.png b/desktop/src/assets/agent-teams/team-lead.png new file mode 100644 index 00000000..ed21ec53 Binary files /dev/null and b/desktop/src/assets/agent-teams/team-lead.png differ diff --git a/desktop/src/assets/agent-teams/ui-designer.png b/desktop/src/assets/agent-teams/ui-designer.png new file mode 100644 index 00000000..2b3d7952 Binary files /dev/null and b/desktop/src/assets/agent-teams/ui-designer.png differ diff --git a/desktop/src/components/agentTeams/AgentTeamsWorkbench.test.tsx b/desktop/src/components/agentTeams/AgentTeamsWorkbench.test.tsx new file mode 100644 index 00000000..79e12313 --- /dev/null +++ b/desktop/src/components/agentTeams/AgentTeamsWorkbench.test.tsx @@ -0,0 +1,193 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSettingsStore } from '../../stores/settingsStore' +import { useTeamStore } from '../../stores/teamStore' +import { useTabStore } from '../../stores/tabStore' +import type { TeamWorkbenchSnapshot, TeamWorkbenchTask } from '../../types/team' +import { AgentTeamsWorkbench } from './AgentTeamsWorkbench' + +const { getWorkbenchForSessionMock, getWorkbenchMock } = vi.hoisted(() => ({ + getWorkbenchForSessionMock: vi.fn(), + getWorkbenchMock: vi.fn(), +})) + +vi.mock('../../api/teams', () => ({ + teamsApi: { + list: vi.fn(), + get: vi.fn(), + getWorkbenchForSession: getWorkbenchForSessionMock, + getWorkbench: getWorkbenchMock, + getMemberTranscript: vi.fn(), + sendMemberMessage: vi.fn(), + delete: vi.fn(), + }, +})) + +function task( + id: string, + status: TeamWorkbenchTask['status'], + blockedBy: string[] = [], + owner?: string, +): TeamWorkbenchTask { + return { + id, + subject: `Task ${id}`, + description: `Task ${id} detail`, + activeForm: status === 'in_progress' ? `Working on task ${id}` : undefined, + owner, + status, + blocks: [], + blockedBy, + taskListId: 'visual-team', + } +} + +function workbench( + version: string, + statuses: [TeamWorkbenchTask['status'], TeamWorkbenchTask['status'], TeamWorkbenchTask['status']], +): TeamWorkbenchSnapshot { + return { + version, + generatedAt: `2026-08-08T00:00:0${version.slice(-1)}.000Z`, + team: { + name: 'visual-team', + leadAgentId: 'team-lead@visual-team', + leadSessionId: 'lead-session', + members: [ + { agentId: 'team-lead@visual-team', name: 'team-lead', role: 'lead', status: 'running' }, + { agentId: 'builder@visual-team', name: 'builder', role: 'frontend', status: 'running' }, + { agentId: 'reviewer@visual-team', name: 'reviewer', role: 'reviewer', status: 'idle' }, + ], + }, + tasks: [ + task('1', statuses[0], [], 'builder'), + task('2', statuses[1], ['1'], 'reviewer'), + task('3', statuses[2], ['1', '2']), + ], + messages: [{ + id: `message-${version}`, + from: 'builder', + to: 'reviewer', + recipients: ['reviewer'], + kind: 'direct', + text: `Snapshot ${version} ready`, + timestamp: `2026-08-08T00:00:0${version.slice(-1)}.000Z`, + }], + } +} + +describe('AgentTeamsWorkbench', () => { + beforeEach(() => { + getWorkbenchMock.mockReset() + getWorkbenchForSessionMock.mockReset() + useTeamStore.getState().clearTeam() + useTabStore.setState({ tabs: [], activeTabId: null }) + useSettingsStore.setState({ locale: 'en' }) + }) + + it('renders a multi-dependency team and keeps history fixed while live updates continue', async () => { + getWorkbenchMock + .mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending'])) + .mockResolvedValueOnce(workbench('v2', ['completed', 'completed', 'in_progress'])) + .mockResolvedValueOnce(workbench('v3', ['completed', 'completed', 'completed'])) + + await act(async () => { + await useTeamStore.getState().fetchWorkbench('visual-team') + await useTeamStore.getState().fetchWorkbench('visual-team') + }) + const { container } = render() + + expect(screen.getByTestId('agent-teams-task-2').getAttribute('data-state')).toBe('completed') + expect(screen.getByTestId('agent-teams-task-3').getAttribute('data-state')).toBe('running') + expect(screen.getByText('Snapshot v2 ready')).toBeTruthy() + expect(screen.getByTestId('agent-teams-member-team-lead@visual-team').getAttribute('data-avatar-key')).toBe('team-lead') + expect(screen.getByTestId('agent-teams-member-builder@visual-team').getAttribute('data-avatar-key')).toBe('ui-designer') + expect(screen.getByTestId('agent-teams-member-builder@visual-team').querySelector('img')).toBeTruthy() + expect(container.querySelector('[data-layout-role="leader-root"]')?.getAttribute('data-center-x')).toBe('302') + expect(container.querySelectorAll('[data-edge-kind="leader-root"]')).toHaveLength(1) + expect(container.querySelectorAll('[data-edge-kind="dependency-primary"]')).toHaveLength(2) + expect(container.querySelectorAll('[data-edge-kind="dependency-secondary"]')).toHaveLength(1) + expect(screen.getByTestId('agent-teams-task-2').querySelector('img')).toBeTruthy() + expect(screen.getByTestId('agent-teams-member-builder@visual-team').className).toContain('cursor-pointer') + + fireEvent.mouseEnter(screen.getByTestId('agent-teams-task-3')) + expect(container.querySelectorAll('[data-edge-active="true"]')).toHaveLength(2) + fireEvent.mouseLeave(screen.getByTestId('agent-teams-task-3')) + expect(container.querySelectorAll('[data-edge-active="true"]')).toHaveLength(0) + + fireEvent.click(screen.getByRole('button', { name: 'Review history' })) + expect(screen.getByTestId('agent-teams-task-2').getAttribute('data-state')).toBe('running') + expect(screen.getByTestId('agent-teams-task-3').getAttribute('data-state')).toBe('blocked') + + await act(async () => { + await useTeamStore.getState().fetchWorkbench('visual-team') + }) + expect(screen.getByTestId('agent-teams-task-2').getAttribute('data-state')).toBe('running') + expect(screen.getByText('Snapshot v1 ready')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Back to live' })) + await waitFor(() => { + expect(screen.getByTestId('agent-teams-task-3').getAttribute('data-state')).toBe('completed') + }) + expect(screen.getByText('Snapshot v3 ready')).toBeTruthy() + }) + + it('opens teammate details from the moving figure without replacing the workbench', async () => { + getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending'])) + await act(async () => { + await useTeamStore.getState().fetchWorkbench('visual-team') + }) + render() + + fireEvent.click(screen.getByTestId('agent-teams-member-reviewer@visual-team')) + + expect(screen.getByRole('dialog', { name: 'reviewer details' })).toBeTruthy() + expect(screen.getByText('OWNED TASKS')).toBeTruthy() + expect(screen.getByText('Working on task 2')).toBeTruthy() + expect(screen.getByTestId('agent-teams-office')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Talk directly to reviewer' })) + expect(useTabStore.getState().activeTabId).toBe('team-member:reviewer@visual-team') + }) + + it('closes the workbench without discarding its archived timeline', async () => { + getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'completed', 'completed'])) + await act(async () => { + await useTeamStore.getState().fetchWorkbench('visual-team') + }) + render() + + fireEvent.click(screen.getByRole('button', { name: /Close Agent Teams workbench/i })) + + expect(useTeamStore.getState().workbenchOpenBySession['lead-session']).toBe(false) + expect(useTeamStore.getState().workbenchesBySession['lead-session']?.snapshots[0]?.version).toBe('v1') + }) + + it('keeps every archived teammate character visible inside the organization tree', async () => { + const archived = workbench('v1', ['completed', 'completed', 'completed']) + archived.deletedAt = '2026-08-08T00:10:00.000Z' + archived.team.members = archived.team.members.map((member) => ({ + ...member, + status: 'completed', + })) + getWorkbenchMock.mockResolvedValueOnce(archived) + await act(async () => { + await useTeamStore.getState().fetchWorkbench('visual-team') + }) + + render() + + for (const member of archived.team.members) { + const figure = screen.getByTestId(`agent-teams-member-${member.agentId}`) + expect(figure.getAttribute('data-member-state')).toBe('exited') + expect(figure.querySelector('img')).toBeTruthy() + expect(figure.getAttribute('style') ?? '').not.toContain('opacity: 0') + } + + fireEvent.click(screen.getByTestId('agent-teams-member-reviewer@visual-team')) + const executionButton = screen.getByRole('button', { name: 'View reviewer execution' }) + expect((executionButton as HTMLButtonElement).disabled).toBe(false) + fireEvent.click(executionButton) + expect(useTabStore.getState().activeTabId).toBe('team-member:reviewer@visual-team') + }) +}) diff --git a/desktop/src/components/agentTeams/AgentTeamsWorkbench.tsx b/desktop/src/components/agentTeams/AgentTeamsWorkbench.tsx new file mode 100644 index 00000000..31f2d081 --- /dev/null +++ b/desktop/src/components/agentTeams/AgentTeamsWorkbench.tsx @@ -0,0 +1,1005 @@ +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { ChevronLeft, ChevronRight, Radio, X } from 'lucide-react' +import { Badge, StatusDot, type Tone } from '@/components/ui/Badge' +import { Button } from '@/components/ui/Button' +import { IconButton } from '@/components/ui/IconButton' +import { Progress } from '@/components/ui/Progress' +import dataAnalystAvatar from '../../assets/agent-teams/data-analyst.png' +import docsCoordinatorAvatar from '../../assets/agent-teams/docs-coordinator.png' +import qaEngineerAvatar from '../../assets/agent-teams/qa-engineer.png' +import releaseEngineerAvatar from '../../assets/agent-teams/release-engineer.png' +import securityReviewerAvatar from '../../assets/agent-teams/security-reviewer.png' +import serverEngineerAvatar from '../../assets/agent-teams/server-engineer.png' +import teamLeadAvatar from '../../assets/agent-teams/team-lead.png' +import uiDesignerAvatar from '../../assets/agent-teams/ui-designer.png' +import { useTranslation, type TranslationKey } from '../../i18n' +import { useTeamStore } from '../../stores/teamStore' +import type { + TeamMember, + TeamWorkbenchMessage, + TeamWorkbenchSnapshot, +} from '../../types/team' +import { + getWorkbenchPhase, + getWorkbenchProgress, + getMemberAvatarKey, + layoutWorkbenchTasks, + runningTaskForMember, + taskOwnedByMember, + WORKBENCH_TASK_HEIGHT, + WORKBENCH_TASK_WIDTH, + type PositionedWorkbenchTask, + type MemberAvatarKey, + type WorkbenchPhase, + type WorkbenchTaskState, +} from './agentTeamsModel' + +const MEMBER_AVATARS: Record = { + 'team-lead': teamLeadAvatar, + 'server-engineer': serverEngineerAvatar, + 'ui-designer': uiDesignerAvatar, + 'qa-engineer': qaEngineerAvatar, + 'security-reviewer': securityReviewerAvatar, + 'data-analyst': dataAnalystAvatar, + 'release-engineer': releaseEngineerAvatar, + 'docs-coordinator': docsCoordinatorAvatar, +} + +const MEMBER_ACCENTS = [ + 'var(--color-brand)', + 'var(--color-warning)', + 'var(--color-success)', + 'var(--color-info)', + 'var(--color-text-secondary)', + 'var(--color-error)', +] as const + +type MemberWorkState = 'working' | 'idle' | 'stopped' | 'exited' | 'error' + +type BotPosition = { + x: number + y: number + opacity: number + state: MemberWorkState +} + +type TranslationFn = ReturnType + +function phaseTone(phase: WorkbenchPhase): Tone { + if (phase === 'forming') return 'warning' + if (phase === 'running') return 'brand' + if (phase === 'finishing') return 'info' + return 'success' +} + +function phaseLabel(phase: WorkbenchPhase, t: TranslationFn): string { + return t(`agentTeams.phase.${phase}` as TranslationKey) +} + +function taskStateLabel(state: WorkbenchTaskState, t: TranslationFn): string { + return t(`agentTeams.task.${state}` as TranslationKey) +} + +function taskTone(state: WorkbenchTaskState): Tone { + if (state === 'running') return 'brand' + if (state === 'completed') return 'success' + if (state === 'open') return 'warning' + return 'neutral' +} + +function taskBorder(state: WorkbenchTaskState): string { + if (state === 'running') return 'var(--color-brand)' + if (state === 'completed') return 'var(--color-success)' + if (state === 'open') return 'var(--color-warning)' + return 'var(--color-border)' +} + +function memberState( + member: TeamMember, + snapshot: TeamWorkbenchSnapshot, + isLead: boolean, +): MemberWorkState { + if (snapshot.deletedAt || member.status === 'completed') return 'exited' + if (member.status === 'error') return 'error' + if (runningTaskForMember(snapshot.tasks, member) || isLead) return 'working' + if (member.status === 'idle') return 'idle' + return 'idle' +} + +function memberStateLabel(state: MemberWorkState, t: TranslationFn): string { + return t(`agentTeams.member.${state}` as TranslationKey) +} + +function memberAccent(member: TeamMember, index: number): string { + const colorIndex = member.color + ? ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan'].indexOf(member.color) + : -1 + return MEMBER_ACCENTS[(colorIndex >= 0 ? colorIndex : index) % MEMBER_ACCENTS.length]! +} + +function memberName(member: TeamMember): string { + return member.name || member.role || member.agentId.split('@')[0] || member.agentId +} + +function memberMatchesIdentity(member: TeamMember, identity: string): boolean { + return [member.agentId, member.agentId.split('@')[0], member.name, member.role] + .filter(Boolean) + .includes(identity) +} + +function messageText(message: TeamWorkbenchMessage, t: TranslationFn): string { + if (message.protocolType === 'task_assignment') { + return t('agentTeams.communication.taskAssignment', { + task: message.taskId ? `#${message.taskId}` : '', + subject: message.text, + }) + } + if (message.protocolType === 'shutdown_request') { + return t('agentTeams.communication.shutdownRequest', { reason: message.text }) + } + if (message.protocolType === 'shutdown_response') { + return t('agentTeams.communication.shutdownResponse') + } + if (message.protocolType === 'idle_notification') { + return t('agentTeams.communication.idle') + } + return message.text +} + +function messageKindLabel(message: TeamWorkbenchMessage, t: TranslationFn): string { + if (message.kind === 'direct') return t('agentTeams.communication.direct') + if (message.kind === 'broadcast') return t('agentTeams.communication.broadcast') + return t('agentTeams.communication.system') +} + +function messageTone(message: TeamWorkbenchMessage): string { + if (message.kind === 'direct') return 'var(--color-brand)' + if (message.kind === 'broadcast') return 'var(--color-warning)' + return 'var(--color-text-tertiary)' +} + +function formatSnapshotTime(snapshot: TeamWorkbenchSnapshot): string { + const time = new Date(snapshot.generatedAt) + return Number.isNaN(time.getTime()) + ? snapshot.generatedAt + : time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) +} + +export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) { + const t = useTranslation() + const timeline = useTeamStore((state) => state.workbenchesBySession[sessionId]) + const historyIndex = useTeamStore((state) => state.workbenchHistoryIndexBySession[sessionId] ?? null) + const setHistoryIndex = useTeamStore((state) => state.setWorkbenchHistoryIndex) + const setWorkbenchOpen = useTeamStore((state) => state.setWorkbenchOpen) + const openMemberSession = useTeamStore((state) => state.openMemberSession) + const officeViewportRef = useRef(null) + const [officeWidth, setOfficeWidth] = useState(604) + const [selectedMemberId, setSelectedMemberId] = useState(null) + const [focusedTaskId, setFocusedTaskId] = useState(null) + + const snapshots = timeline?.snapshots ?? [] + const latestIndex = snapshots.length - 1 + const selectedIndex = historyIndex === null ? latestIndex : Math.min(historyIndex, latestIndex) + const snapshot = selectedIndex >= 0 ? snapshots[selectedIndex] : undefined + const previousSnapshot = selectedIndex > 0 ? snapshots[selectedIndex - 1] : undefined + + useEffect(() => { + const element = officeViewportRef.current + if (!element || typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver((entries) => { + const width = entries[0]?.contentRect.width ?? 604 + setOfficeWidth(Math.max(360, Math.min(760, width - 32))) + }) + observer.observe(element) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (!selectedMemberId || !snapshot) return + if (!snapshot.team.members.some((member) => member.agentId === selectedMemberId)) { + setSelectedMemberId(null) + } + }, [selectedMemberId, snapshot]) + + const layout = useMemo( + () => layoutWorkbenchTasks(snapshot?.tasks ?? [], officeWidth), + [officeWidth, snapshot?.tasks], + ) + + if (!snapshot) { + return ( +
+ {timeline?.error || t('agentTeams.loading')} +
+ ) + } + + const phase = getWorkbenchPhase(snapshot) + const progress = getWorkbenchProgress(snapshot) + const leadId = snapshot.team.leadAgentId + const members = snapshot.team.members + const leadMember = members.find((member) => member.agentId === leadId) ?? members[0] + const workerMembers = members.filter((member) => member.agentId !== leadId) + const primaryTaskByMemberId = new Map() + for (const member of workerMembers) { + const firstOwnedTask = snapshot.tasks.find((task) => taskOwnedByMember(task, member)) + if (firstOwnedTask) primaryTaskByMemberId.set(member.agentId, firstOwnedTask.id) + } + const unassignedMembers = workerMembers.filter((member) => !primaryTaskByMemberId.has(member.agentId)) + const rootTasks = layout.tasks.filter(({ task }) => + task.blockedBy.every((dependencyId) => !layout.byId.has(dependencyId)), + ) + const memberPositions = new Map() + + members.forEach((member) => { + const isLead = member.agentId === leadId + const state = memberState(member, snapshot, isLead) + const taskPosition = isLead + ? undefined + : layout.byId.get(primaryTaskByMemberId.get(member.agentId) ?? '') + if (isLead) { + memberPositions.set(member.agentId, { x: layout.width / 2, y: 66, opacity: 1, state }) + } else if (taskPosition) { + memberPositions.set(member.agentId, { + x: taskPosition.x + 22, + y: taskPosition.y + WORKBENCH_TASK_HEIGHT / 2, + opacity: 1, + state, + }) + } else { + const unassignedIndex = unassignedMembers.findIndex((worker) => worker.agentId === member.agentId) + const pitch = Math.min(62, (layout.width - 48) / Math.max(unassignedMembers.length, 1)) + const rowWidth = Math.max(0, (unassignedMembers.length - 1) * pitch) + memberPositions.set(member.agentId, { + x: layout.width / 2 - rowWidth / 2 + Math.max(0, unassignedIndex) * pitch, + y: 147, + opacity: 1, + state, + }) + } + }) + + const latestMessage = snapshot.messages.at(-1) + const hasNewMessage = Boolean( + previousSnapshot && latestMessage && previousSnapshot.messages.at(-1)?.id !== latestMessage.id, + ) + const selectedMember = selectedMemberId + ? members.find((member) => member.agentId === selectedMemberId) ?? null + : null + + return ( +
+
+ + {snapshot.team.name} + + {phaseLabel(phase, t)} + + + {progress.completed}/{progress.total} + + + {t('agentTeams.dependencyLegend')} + +
+ {historyIndex === null ? ( + <> + + + {t('agentTeams.followingLive')} + + + + ) : ( + <> +
+
+ +
+
+ + + {leadMember ? ( + setSelectedMemberId((current) => current === leadMember.agentId ? null : leadMember.agentId)} + t={t} + /> + ) : null} + + {unassignedMembers.map((member) => { + const position = memberPositions.get(member.agentId) + if (!position) return null + return ( + setSelectedMemberId((current) => current === member.agentId ? null : member.agentId)} + t={t} + /> + ) + })} + + {layout.tasks.length === 0 ? ( +
+ {t('agentTeams.emptyTasks')} +
+ ) : null} + + {layout.tasks.map((positioned) => ( + setSelectedMemberId((current) => current === member.agentId ? null : member.agentId)} + onFocusTask={setFocusedTaskId} + t={t} + /> + ))} +
+
+ + + + {selectedMember ? ( + setSelectedMemberId(null)} + onOpenConversation={() => openMemberSession(selectedMember, snapshot.team)} + t={t} + /> + ) : null} +
+ ) +} + +function MemberFigure({ + member, + state, + accent, + isLead = false, + isMessageSender = false, + showName = false, + testId, + className, + onSelect, + t, +}: { + member: TeamMember + state: MemberWorkState + accent: string + isLead?: boolean + isMessageSender?: boolean + showName?: boolean + testId?: string + className: string + onSelect: () => void + t: TranslationFn +}) { + const name = memberName(member) + const avatarKey = getMemberAvatarKey(member, isLead) + const motionClass = state === 'working' + ? 'agent-teams-character-working' + : state === 'idle' + ? 'agent-teams-character-idle' + : '' + return ( + + ) +} + +function LeaderNode({ + member, + state, + canvasWidth, + accent, + isMessageSender, + onSelect, + t, +}: { + member: TeamMember + state: MemberWorkState + canvasWidth: number + accent: string + isMessageSender: boolean + onSelect: () => void + t: TranslationFn +}) { + const name = memberName(member) + return ( +
+ + ) +} + +function UnassignedMemberNode({ + member, + state, + position, + accent, + isMessageSender, + onSelect, + t, +}: { + member: TeamMember + state: MemberWorkState + position: BotPosition + accent: string + isMessageSender: boolean + onSelect: () => void + t: TranslationFn +}) { + return ( +
+ +
+ ) +} + +function LeaderEdge({ + canvasWidth, + to, + active, +}: { + canvasWidth: number + to: PositionedWorkbenchTask + active: boolean +}) { + const x1 = canvasWidth / 2 + const y1 = 132 + const x2 = to.x + WORKBENCH_TASK_WIDTH / 2 + const y2 = to.y + return ( + + ) +} + +function DependencyEdge({ + from, + to, + secondary, + focused, + dimmed, +}: { + from: PositionedWorkbenchTask + to: PositionedWorkbenchTask + secondary: boolean + focused: boolean + dimmed: boolean +}) { + const x1 = from.x + WORKBENCH_TASK_WIDTH / 2 + const y1 = from.y + WORKBENCH_TASK_HEIGHT + const x2 = to.x + WORKBENCH_TASK_WIDTH / 2 + const y2 = to.y + const isSatisfied = from.task.status === 'completed' + const isFlowing = isSatisfied && to.state === 'running' + const stroke = focused || isFlowing + ? 'var(--color-brand)' + : secondary + ? 'var(--color-border-separator)' + : 'var(--color-outline)' + const opacity = dimmed + ? 0.06 + : focused + ? 0.92 + : secondary + ? 0.16 + : 0.5 + return ( + + ) +} + +function MessageFlight({ + message, + positions, + members, + width, +}: { + message: TeamWorkbenchMessage + positions: Map + members: TeamMember[] + width: number +}) { + const findMember = (identity: string) => members.find((member) => memberMatchesIdentity(member, identity)) + const sender = findMember(message.from) + const senderPosition = sender ? positions.get(sender.agentId) : undefined + if (!senderPosition || message.kind === 'system') return null + const color = message.kind === 'broadcast' ? 'var(--color-warning)' : 'var(--color-brand)' + if (message.kind === 'broadcast') { + return ( + <> + + + + ) + } + const recipient = findMember(message.to) + const recipientPosition = recipient ? positions.get(recipient.agentId) : undefined + if (!recipientPosition) return null + const x1 = senderPosition.x + const y1 = senderPosition.y + const x2 = recipientPosition.x + const y2 = recipientPosition.y + return ( + <> + + + + ) +} + +function TaskCard({ + positioned, + members, + snapshot, + primaryTaskByMemberId, + latestMessage, + onSelectMember, + onFocusTask, + t, +}: { + positioned: PositionedWorkbenchTask + members: TeamMember[] + snapshot: TeamWorkbenchSnapshot + primaryTaskByMemberId: Map + latestMessage?: TeamWorkbenchMessage + onSelectMember: (member: TeamMember) => void + onFocusTask: (taskId: string | null) => void + t: TranslationFn +}) { + const { task, state, x, y } = positioned + const owner = task.owner + ? members.find((member) => taskOwnedByMember(task, member)) + : undefined + const dependencyLabel = task.blockedBy.map((dependency) => `#${dependency}`).join(' ') + const ownerState = owner + ? memberState(owner, snapshot, owner.agentId === snapshot.team.leadAgentId) + : undefined + const isPrimaryOwnerFigure = Boolean( + owner + && owner.agentId !== snapshot.team.leadAgentId + && primaryTaskByMemberId.get(owner.agentId) === task.id, + ) + return ( +
onFocusTask(task.id)} + onMouseLeave={() => onFocusTask(null)} + onFocus={() => onFocusTask(task.id)} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) onFocusTask(null) + }} + className={`agent-teams-task absolute h-[94px] w-[216px] rounded-[var(--radius-xl)] border bg-[var(--color-surface-container-lowest)] py-[10px] pr-3 shadow-[var(--shadow-card)] outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${owner ? 'pl-[58px]' : 'pl-3'}`} + style={{ + left: x, + top: y, + borderColor: taskBorder(state), + }} + > + {owner && ownerState ? ( +
+ onSelectMember(owner)} + t={t} + /> +
+ ) : null} +
+ + #{task.id} + + + + + {taskStateLabel(state, t)} + +
+
+ {task.subject} +
+
+ {owner ? ( + + {memberName(owner)} + + ) : ( + + {state === 'blocked' + ? t('agentTeams.task.dependencies', { dependencies: dependencyLabel }) + : t('agentTeams.task.unclaimed')} + + )} +
+
+ ) +} + +function CommunicationFeed({ + snapshot, + selectedIndex, + t, +}: { + snapshot: TeamWorkbenchSnapshot + selectedIndex: number + t: TranslationFn +}) { + const messages = [...snapshot.messages].reverse() + return ( +
+
+

{t('agentTeams.communication.title')}

+ + {t('agentTeams.communication.count', { count: messages.length })} + + {snapshot.deletedAt ? ( + + {t('agentTeams.disbanded')} + + ) : null} +
+
+ {messages.length === 0 ? ( +
+ {t('agentTeams.communication.empty')} +
+ ) : messages.map((message, index) => ( +
+
+ + {messageKindLabel(message, t)} + + {message.kind !== 'system' ? ( + + {message.from} → {message.kind === 'broadcast' ? t('agentTeams.communication.everyone') : message.to} + + ) : null} + {message.taskId ? ( + #{message.taskId} + ) : null} + + T+{selectedIndex} + +
+
+ {messageText(message, t)} +
+
+ ))} +
+
+ ) +} + +function MemberDrawer({ + member, + snapshot, + avatarKey, + accent, + onClose, + onOpenConversation, + t, +}: { + member: TeamMember + snapshot: TeamWorkbenchSnapshot + avatarKey: MemberAvatarKey + accent: string + onClose: () => void + onOpenConversation: () => void + t: TranslationFn +}) { + const isLead = member.agentId === snapshot.team.leadAgentId + const state = memberState(member, snapshot, isLead) + const tasks = snapshot.tasks.filter((task) => taskOwnedByMember(task, member)) + const messages = snapshot.messages + .filter((message) => message.from === memberName(member) || message.to === memberName(member) || message.recipients.includes(memberName(member))) + .slice(-5) + .reverse() + const currentTask = runningTaskForMember(snapshot.tasks, member) + const name = memberName(member) + return ( + + ) +} + +function DrawerSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ) +} diff --git a/desktop/src/components/agentTeams/agentTeamsModel.test.ts b/desktop/src/components/agentTeams/agentTeamsModel.test.ts new file mode 100644 index 00000000..361f009c --- /dev/null +++ b/desktop/src/components/agentTeams/agentTeamsModel.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest' +import type { TeamMember, TeamWorkbenchSnapshot, TeamWorkbenchTask } from '../../types/team' +import { + getWorkbenchPhase, + getWorkbenchProgress, + getWorkbenchTaskState, + getMemberAvatarKey, + layoutWorkbenchTasks, + runningTaskForMember, + taskOwnedByMember, + WORKBENCH_TASK_WIDTH, +} from './agentTeamsModel' + +function task( + id: string, + status: TeamWorkbenchTask['status'], + blockedBy: string[] = [], + owner?: string, +): TeamWorkbenchTask { + return { + id, + subject: `Task ${id}`, + description: `Description ${id}`, + status, + owner, + blocks: [], + blockedBy, + taskListId: 'team-a', + } +} + +function snapshot(tasks: TeamWorkbenchTask[]): TeamWorkbenchSnapshot { + return { + version: 'v1', + generatedAt: '2026-08-08T00:00:00.000Z', + team: { + name: 'team-a', + leadAgentId: 'lead@team-a', + leadSessionId: 'session-a', + members: [], + }, + tasks, + messages: [], + } +} + +describe('Agent Teams workbench model', () => { + it('lays out a multi-parent DAG after every dependency and keeps edge states honest', () => { + const tasks = [ + task('1', 'completed'), + task('2', 'in_progress'), + task('3', 'pending', ['1', '2']), + task('4', 'pending', ['missing']), + ] + const layout = layoutWorkbenchTasks(tasks, 604) + const byId = new Map(tasks.map((entry) => [entry.id, entry])) + + expect(layout.columns).toBe(2) + expect(layout.tasks.every((entry) => entry.x >= 0 && entry.x + WORKBENCH_TASK_WIDTH <= layout.width)).toBe(true) + expect(layout.byId.get('3')!.y).toBeGreaterThan(layout.byId.get('1')!.y) + expect(layout.byId.get('3')!.y).toBeGreaterThan(layout.byId.get('2')!.y) + expect(getWorkbenchTaskState(tasks[2]!, byId)).toBe('blocked') + expect(getWorkbenchTaskState(tasks[3]!, byId)).toBe('blocked') + + tasks[1] = task('2', 'completed') + const completedParents = new Map(tasks.map((entry) => [entry.id, entry])) + expect(getWorkbenchTaskState(tasks[2]!, completedParents)).toBe('open') + }) + + it('falls back deterministically for cyclic dependencies instead of recursing forever', () => { + const tasks = [ + task('a', 'pending', ['b']), + task('b', 'pending', ['a']), + ] + + const layout = layoutWorkbenchTasks(tasks, 440) + + expect(layout.tasks.map((entry) => entry.task.id).sort()).toEqual(['a', 'b']) + expect(layout.columns).toBe(1) + expect(layout.tasks.every((entry) => entry.x >= 0 && entry.x + WORKBENCH_TASK_WIDTH <= layout.width)).toBe(true) + expect(layout.height).toBeGreaterThan(0) + }) + + it('uses a three-column organization tree only when complete person nodes fit', () => { + const tasks = [task('1', 'pending'), task('2', 'pending'), task('3', 'pending')] + + const layout = layoutWorkbenchTasks(tasks, 760) + + expect(layout.columns).toBe(3) + expect(layout.tasks.every((entry) => entry.x >= 0 && entry.x + WORKBENCH_TASK_WIDTH <= layout.width)).toBe(true) + }) + + it('normalizes bare owner names and full agent ids for active work', () => { + const member: TeamMember = { + agentId: 'reviewer@team-a', + name: 'reviewer', + role: 'security-reviewer', + status: 'running', + } + const bareOwner = task('1', 'in_progress', [], 'reviewer') + const fullOwner = task('2', 'pending', [], 'reviewer@team-a') + + expect(taskOwnedByMember(bareOwner, member)).toBe(true) + expect(taskOwnedByMember(fullOwner, member)).toBe(true) + expect(runningTaskForMember([fullOwner, bareOwner], member)?.id).toBe('1') + }) + + it('assigns generated occupational characters and preserves unknown-member identity', () => { + const member = (agentId: string, role: string): TeamMember => ({ + agentId, + name: agentId.split('@')[0], + role, + status: 'running', + }) + + expect(getMemberAvatarKey(member('lead@team-a', 'orchestrator'), true)).toBe('team-lead') + expect(getMemberAvatarKey(member('watcher-runtime@team-a', 'backend'))).toBe('server-engineer') + expect(getMemberAvatarKey(member('desktop-workbench@team-a', 'frontend'))).toBe('ui-designer') + expect(getMemberAvatarKey(member('test-engineer@team-a', 'quality'))).toBe('qa-engineer') + expect(getMemberAvatarKey(member('security-reviewer@team-a', 'reviewer'))).toBe('security-reviewer') + expect(getMemberAvatarKey(member('release-auditor@team-a', 'release'))).toBe('release-engineer') + expect(getMemberAvatarKey(member('unknown-specialist@team-a', 'general-purpose'))) + .toBe(getMemberAvatarKey(member('unknown-specialist@team-a', 'general-purpose'))) + }) + + it('derives forming, running, finishing, and completed phases from real transitions', () => { + expect(getWorkbenchPhase(snapshot([]))).toBe('forming') + expect(getWorkbenchPhase(snapshot([task('1', 'in_progress')]))).toBe('running') + + const finished = snapshot([task('1', 'completed'), task('2', 'completed')]) + expect(getWorkbenchPhase(finished)).toBe('finishing') + expect(getWorkbenchProgress(finished)).toEqual({ completed: 2, total: 2, percent: 100 }) + + expect(getWorkbenchPhase({ + ...finished, + deletedAt: '2026-08-08T00:01:00.000Z', + })).toBe('completed') + }) +}) diff --git a/desktop/src/components/agentTeams/agentTeamsModel.ts b/desktop/src/components/agentTeams/agentTeamsModel.ts new file mode 100644 index 00000000..9a1b3087 --- /dev/null +++ b/desktop/src/components/agentTeams/agentTeamsModel.ts @@ -0,0 +1,212 @@ +import type { + TeamMember, + TeamWorkbenchSnapshot, + TeamWorkbenchTask, +} from '../../types/team' + +export type WorkbenchTaskState = 'blocked' | 'open' | 'running' | 'completed' +export type WorkbenchPhase = 'forming' | 'running' | 'finishing' | 'completed' +export type MemberAvatarKey = + | 'team-lead' + | 'server-engineer' + | 'ui-designer' + | 'qa-engineer' + | 'security-reviewer' + | 'data-analyst' + | 'release-engineer' + | 'docs-coordinator' + +export type PositionedWorkbenchTask = { + task: TeamWorkbenchTask + state: WorkbenchTaskState + x: number + y: number +} + +export type WorkbenchLayout = { + tasks: PositionedWorkbenchTask[] + byId: Map + width: number + height: number + columns: number +} + +const TASK_WIDTH = 216 +const TASK_HEIGHT = 94 +const HORIZONTAL_GAP = 32 +const ROW_HEIGHT = 154 +const DAG_TOP = 196 + +const WORKER_AVATARS: Array<{ + key: Exclude + matches: RegExp +}> = [ + { key: 'release-engineer', matches: /release|\bbuild\b|package|deploy|\bops\b|\bci\b|ship/ }, + { key: 'security-reviewer', matches: /security|secure|audit|risk|threat|review/ }, + { key: 'qa-engineer', matches: /\bqa\b|test|quality|verify|verification/ }, + { key: 'ui-designer', matches: /\bui\b|\bux\b|frontend|desktop|design|theme|accessib/ }, + { key: 'server-engineer', matches: /server|backend|\bapi\b|runtime|watcher|service|contract/ }, + { key: 'data-analyst', matches: /data|research|analyst|replay|state|investigat|explor/ }, + { key: 'docs-coordinator', matches: /docs?|product|spec|writer|coordinat/ }, +] + +function stableHash(value: string): number { + let hash = 0 + for (let index = 0; index < value.length; index += 1) { + hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0 + } + return Math.abs(hash) +} + +/** + * Keeps a teammate's visual identity stable across snapshots while still + * assigning the purpose-built occupational art when its role is known. + */ +export function getMemberAvatarKey(member: TeamMember, isLead = false): MemberAvatarKey { + if (isLead) return 'team-lead' + const identity = [member.name, member.role, member.agentId, member.currentTask] + .filter(Boolean) + .join(' ') + .toLowerCase() + const matched = WORKER_AVATARS.find(({ matches }) => matches.test(identity)) + if (matched) return matched.key + return WORKER_AVATARS[stableHash(member.agentId) % WORKER_AVATARS.length]!.key +} + +export function getWorkbenchTaskState( + task: TeamWorkbenchTask, + tasksById: Map, +): WorkbenchTaskState { + if (task.status === 'completed') return 'completed' + if (task.status === 'in_progress') return 'running' + const hasOpenDependency = task.blockedBy.some( + (dependencyId) => tasksById.get(dependencyId)?.status !== 'completed', + ) + return hasOpenDependency ? 'blocked' : 'open' +} + +/** Longest dependency path with deterministic fallbacks for missing nodes/cycles. */ +function taskDepths(tasks: TeamWorkbenchTask[]): Map { + const byId = new Map(tasks.map((task) => [task.id, task])) + const depths = new Map() + const visiting = new Set() + + const depthOf = (taskId: string): number => { + const cached = depths.get(taskId) + if (cached !== undefined) return cached + if (visiting.has(taskId)) return 0 + const task = byId.get(taskId) + if (!task) return 0 + + visiting.add(taskId) + const dependencies = task.blockedBy.filter((dependencyId) => byId.has(dependencyId)) + const depth = dependencies.length === 0 + ? 0 + : 1 + Math.max(...dependencies.map(depthOf)) + visiting.delete(taskId) + depths.set(taskId, depth) + return depth + } + + for (const task of tasks) depthOf(task.id) + return depths +} + +export function layoutWorkbenchTasks( + tasks: TeamWorkbenchTask[], + requestedWidth: number, +): WorkbenchLayout { + const width = Math.max(360, Math.min(760, Math.round(requestedWidth || 604))) + const columns = width >= 704 ? 3 : width >= 480 ? 2 : 1 + const depths = taskDepths(tasks) + const byLayer = new Map() + + for (const task of tasks) { + const depth = depths.get(task.id) ?? 0 + const layer = byLayer.get(depth) + if (layer) layer.push(task) + else byLayer.set(depth, [task]) + } + + const tasksById = new Map(tasks.map((task) => [task.id, task])) + const positioned: PositionedWorkbenchTask[] = [] + let row = 0 + for (const depth of Array.from(byLayer.keys()).sort((left, right) => left - right)) { + const layer = byLayer.get(depth)! + for (let offset = 0; offset < layer.length; offset += columns) { + const chunk = layer.slice(offset, offset + columns) + const chunkWidth = chunk.length * TASK_WIDTH + (chunk.length - 1) * HORIZONTAL_GAP + const startX = Math.round((width - chunkWidth) / 2) + chunk.forEach((task, index) => { + positioned.push({ + task, + state: getWorkbenchTaskState(task, tasksById), + x: startX + index * (TASK_WIDTH + HORIZONTAL_GAP), + y: DAG_TOP + row * ROW_HEIGHT, + }) + }) + row += 1 + } + } + + return { + tasks: positioned, + byId: new Map(positioned.map((task) => [task.task.id, task])), + width, + height: DAG_TOP + Math.max(0, row - 1) * ROW_HEIGHT + TASK_HEIGHT + 54, + columns, + } +} + +function memberNames(member: TeamMember): string[] { + return [ + member.agentId, + member.agentId.split('@')[0] ?? '', + member.name ?? '', + member.role, + ].filter(Boolean) +} + +export function taskOwnedByMember( + task: TeamWorkbenchTask, + member: TeamMember, +): boolean { + if (!task.owner) return false + return memberNames(member).includes(task.owner) +} + +export function runningTaskForMember( + tasks: TeamWorkbenchTask[], + member: TeamMember, +): TeamWorkbenchTask | undefined { + return tasks.find((task) => task.status === 'in_progress' && taskOwnedByMember(task, member)) +} + +export function getWorkbenchPhase(snapshot: TeamWorkbenchSnapshot): WorkbenchPhase { + if (snapshot.deletedAt) return 'completed' + if (snapshot.tasks.length === 0) return 'forming' + if (snapshot.tasks.every((task) => task.status === 'completed')) return 'finishing' + return 'running' +} + +export function getWorkbenchProgress(snapshot: TeamWorkbenchSnapshot) { + const total = snapshot.tasks.length + const completed = snapshot.tasks.filter((task) => task.status === 'completed').length + return { + completed, + total, + percent: total === 0 ? 0 : Math.round((completed / total) * 100), + } +} + +export function memberInitials(member: TeamMember): string { + const source = member.name || member.role || member.agentId.split('@')[0] || 'AG' + const words = source.split(/[^\p{L}\p{N}]+/u).filter(Boolean) + return (words.length > 1 + ? words.slice(0, 2).map((word) => word[0]).join('') + : source.slice(0, 2) + ).toUpperCase() +} + +export const WORKBENCH_TASK_WIDTH = TASK_WIDTH +export const WORKBENCH_TASK_HEIGHT = TASK_HEIGHT diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 72586e80..4e1c4af5 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -188,6 +188,9 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro setPreparingTurn, } = useChatStore() const activeTabId = useTabStore((s) => s.activeTabId) + const activeTabType = useTabStore((s) => + s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type, + ) const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) const repositoryLaunchDraft = sessionState?.repositoryLaunchDraft const launchWorkDir = repositoryLaunchDraft?.workDir ?? '' @@ -254,7 +257,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro pasteGenerationRef.current += 1 }, []) - const isMemberSession = !!memberInfo + const isMemberSession = !!memberInfo || activeTabType === 'subagent' const isActive = chatState !== 'idle' const hasRunningSubagents = hasRunningSubagentTasks(sessionState?.backgroundAgentTasks) const workspaceState = getSessionWorkspaceState(activeSession) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 9cd52e34..0ba5437a 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -6059,6 +6059,42 @@ describe('MessageList nested tool calls', () => { expect(screen.queryByText('first.ts')).toBeNull() }) + it('does not call parent-session checkpoint APIs for a completed SubAgent conversation', async () => { + const subagentTabId = '__subagent__parent-session__agent-tool-1' + const getTurnCheckpoints = vi.spyOn(sessionsApi, 'getTurnCheckpoints') + .mockRejectedValue(new Error(`Session not found: ${subagentTabId}`)) + useTabStore.setState({ + activeTabId: subagentTabId, + tabs: [{ + sessionId: subagentTabId, + title: 'Completed reviewer', + type: 'subagent', + status: 'idle', + sourceSessionId: 'parent-session', + subagentToolUseId: 'agent-tool-1', + }], + }) + useChatStore.setState({ + sessions: { + [subagentTabId]: makeSessionState({ + messages: [ + { id: 'user-1', type: 'user_text', content: 'Review the patch', timestamp: 1 }, + { id: 'assistant-1', type: 'assistant_text', content: 'Review complete', timestamp: 2 }, + ], + }), + }, + }) + + render() + + await act(async () => { + await Promise.resolve() + }) + + expect(getTurnCheckpoints).not.toHaveBeenCalled() + expect(screen.queryByText(`Session not found: ${subagentTabId}`)).toBeNull() + }) + it('confirms before rewinding to an earlier turn from a historical change card', async () => { vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({ checkpoints: [ @@ -6749,3 +6785,81 @@ describe('workspace panel origin visibility', () => { expect(isRenderItemFullyVisibleInChatScroller(item)).toBe(false) }) }) + +describe('Agent Teams chat projection', () => { + function sendMessageRun( + id: string, + result: unknown, + isError = false, + input: unknown = { to: 'worker', message: 'Review task #2' }, + ): UIMessage[] { + return [ + { + id: `tool-${id}`, + type: 'tool_use', + toolName: 'SendMessage', + toolUseId: id, + input, + timestamp: 1, + }, + { + id: `result-${id}`, + type: 'tool_result', + toolUseId: id, + content: result, + isError, + timestamp: 2, + }, + ] + } + + it('hides a successful routed teammate message only in a lead workbench session', () => { + const messages = sendMessageRun('team-message', { + routing: { + sender: 'team-lead', + target: 'worker', + content: 'Review task #2', + }, + }) + + expect(buildRenderModel(messages).renderItems).toHaveLength(1) + expect(buildRenderModel(messages, null, { + hideTeamCoordinationTools: true, + teamMemberNames: new Set(['worker']), + }).renderItems).toHaveLength(0) + }) + + it('hides successful team messages from their real input shape when the transport omits routing', () => { + const direct = sendMessageRun('direct', 'Message sent to worker inbox') + const broadcast = sendMessageRun('broadcast', 'Message broadcast to 4 teammates', false, { + to: '*', + message: 'Start the dependency graph', + }) + const messages = [...direct, ...broadcast] + + expect(buildRenderModel(messages, null, { + hideTeamCoordinationTools: true, + teamMemberNames: new Set(['worker']), + }).renderItems).toHaveLength(0) + }) + + it('keeps ordinary agent continuation and failed team SendMessage calls visible', () => { + const ordinary = sendMessageRun( + 'ordinary', + 'Message queued to async agent a1', + false, + { to: 'async-agent-a1', message: 'Continue' }, + ) + const failed = sendMessageRun('failed', { + routing: { sender: 'team-lead', target: 'missing-worker' }, + }, true) + + expect(buildRenderModel(ordinary, null, { + hideTeamCoordinationTools: true, + teamMemberNames: new Set(['worker']), + }).renderItems).toHaveLength(1) + expect(buildRenderModel(failed, null, { + hideTeamCoordinationTools: true, + }).renderItems).toHaveLength(1) + }) +}) diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index ec336361..c32fc348 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -629,7 +629,56 @@ function appendChildToolCall( } } -export function buildRenderModel(messages: UIMessage[], activeAskUserQuestionToolUseId?: string | null): RenderModel { +function hasTeamMessageRouting(value: unknown): boolean { + if (typeof value === 'string') { + try { + return hasTeamMessageRouting(JSON.parse(value)) + } catch { + return false + } + } + if (Array.isArray(value)) return value.some(hasTeamMessageRouting) + if (!value || typeof value !== 'object') return false + const record = value as Record + const routing = record.routing + if (routing && typeof routing === 'object') { + const route = routing as Record + if (typeof route.sender === 'string' && typeof route.target === 'string') return true + } + return 'content' in record && hasTeamMessageRouting(record.content) +} + +function getSendMessageTarget(value: unknown): string | null { + if (typeof value === 'string') { + try { + return getSendMessageTarget(JSON.parse(value)) + } catch { + return null + } + } + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const target = (value as Record).to + return typeof target === 'string' ? target : null +} + +function isTeamCoordinationSendMessage( + input: unknown, + result: unknown, + teamMemberNames: ReadonlySet | undefined, +): boolean { + if (hasTeamMessageRouting(result)) return true + const target = getSendMessageTarget(input) + return target === '*' || Boolean(target && teamMemberNames?.has(target)) +} + +export function buildRenderModel( + messages: UIMessage[], + activeAskUserQuestionToolUseId?: string | null, + options: { + hideTeamCoordinationTools?: boolean + teamMemberNames?: ReadonlySet + } = {}, +): RenderModel { const items: RenderItem[] = [] const toolResultMap = new Map() const childToolCallsByParent = new Map() @@ -726,6 +775,15 @@ export function buildRenderModel(messages: UIMessage[], activeAskUserQuestionToo appendChildToolCall(childToolCallsByParent, msg.parentToolUseId, msg) continue } + const toolResult = toolResultMap.get(msg.toolUseId) + if ( + options.hideTeamCoordinationTools && + msg.toolName === 'SendMessage' && + toolResult?.isError === false && + isTeamCoordinationSendMessage(msg.input, toolResult.content, options.teamMemberNames) + ) { + continue + } if (msg.toolName === 'AskUserQuestion') { const isResolved = toolResultMap.has(msg.toolUseId) const lastUnresolvedIndex = lastUnresolvedAskUserQuestionIndexByToolUseId.get(msg.toolUseId) @@ -1687,6 +1745,21 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false } const isMemberSession = useTeamStore((s) => resolvedSessionId ? Boolean(s.getMemberBySessionId(resolvedSessionId)) : false, ) + const isSubagentSession = useTabStore((s) => s.tabs.some((tab) => ( + tab.sessionId === resolvedSessionId && tab.type === 'subagent' + ))) + const isDirectAgentSession = isMemberSession || isSubagentSession + const teamWorkbench = useTeamStore((s) => + resolvedSessionId ? s.workbenchesBySession[resolvedSessionId] : undefined, + ) + const isTeamLeadSession = Boolean(teamWorkbench?.snapshots.length) + const teamMemberNames = useMemo(() => { + const snapshots = teamWorkbench?.snapshots + if (!snapshots?.length) return undefined + return new Set(snapshots[snapshots.length - 1]!.team.members.flatMap((member) => + member.name ? [member.name] : [], + )) + }, [teamWorkbench?.snapshots]) const addToast = useUIStore((s) => s.addToast) const messages = sessionState?.messages ?? EMPTY_MESSAGES const chatState = sessionState?.chatState ?? 'idle' @@ -1775,7 +1848,7 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false } const conversationFindMatchesRef = useRef([]) const [messageListWidth, setMessageListWidth] = useState(null) const branchActionsDisabled = - isMemberSession || + isDirectAgentSession || isPreparingTurn || chatState !== 'idle' || hasRunningBackgroundTasks || @@ -2199,8 +2272,11 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false } }, [requestLiveFollow]) const { toolResultMap, childToolCallsByParent, renderItems } = useMemo( - () => buildRenderModel(messages, activeAskUserQuestionToolUseId), - [activeAskUserQuestionToolUseId, messages], + () => buildRenderModel(messages, activeAskUserQuestionToolUseId, { + hideTeamCoordinationTools: isTeamLeadSession, + teamMemberNames, + }), + [activeAskUserQuestionToolUseId, isTeamLeadSession, messages, teamMemberNames], ) // Defer the per-message branchable / completed-turn computations so the first // commit on tab switch can render the virtualization window without doing two @@ -2362,7 +2438,7 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false } }, [renderItemKeys]) useEffect(() => { - if (!resolvedSessionId || completedTurnTargets.length === 0 || isMemberSession) { + if (!resolvedSessionId || completedTurnTargets.length === 0 || isDirectAgentSession) { setTurnChangeCards([]) setTurnChangeLoadError(null) setIsLoadingTurnChangeCards(false) @@ -2429,7 +2505,7 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false } return () => { cancelled = true } - }, [chatState, completedTurnTargets, hasRunningBackgroundTasks, historyMutationEpoch, isMemberSession, latestCompletedTurnId, resolvedSessionId]) + }, [chatState, completedTurnTargets, hasRunningBackgroundTasks, historyMutationEpoch, isDirectAgentSession, latestCompletedTurnId, resolvedSessionId]) const handleUndoCurrentTurn = useCallback(async (mode: SessionRewindMode = 'both') => { if (!resolvedSessionId || !confirmTurnCard || rewindingTurnId || hasRunningBackgroundTasks) return diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index f75b0707..ffc999a7 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import '@testing-library/jest-dom' import type { PerSessionState } from '../../stores/chatStore' import type { ChatState, UIMessage } from '../../types/chat' +import type { TeamWorkbenchTimeline } from '../../types/team' import { browserHost } from '../../lib/desktopHost/browserHost' type ToolUseMessage = Extract @@ -93,6 +94,29 @@ const completedTodoWriteMessage = (overrides: Partial = {}): UIM ...overrides, }) +function teamWorkbenchTimeline(sessionId: string): TeamWorkbenchTimeline { + return { + teamName: 'review-team', + loading: false, + error: null, + snapshots: [{ + version: 'v1', + generatedAt: '2026-08-08T00:00:00.000Z', + team: { + name: 'review-team', + leadAgentId: 'lead', + leadSessionId: sessionId, + members: [ + { agentId: 'lead', role: 'Lead', status: 'running' }, + { agentId: 'security', role: 'Security reviewer', status: 'running' }, + ], + }, + tasks: [], + messages: [], + }], + } +} + vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: getCurrentWindowMock, })) @@ -129,6 +153,8 @@ vi.mock('../../i18n', () => ({ 'tabs.hideWorkspace': 'Hide Workspace', 'tabs.showBrowser': 'Show Browser', 'tabs.hideBrowser': 'Hide Browser', + 'agentTeams.showWorkbench': 'Show Agent Teams Workbench', + 'agentTeams.hideWorkbench': 'Hide Agent Teams Workbench', 'tabs.scrollLeft': 'Scroll tabs left', 'tabs.scrollRight': 'Scroll tabs right', 'tabs.closeTab': 'Close {title}', @@ -259,12 +285,7 @@ describe('TabBar', () => { useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true) useActivityPanelStore.setState(useActivityPanelStore.getInitialState(), true) useCLITaskStore.setState(useCLITaskStore.getInitialState(), true) - useTeamStore.setState({ - teams: [], - activeTeam: null, - memberColors: new Map(), - error: null, - }) + useTeamStore.setState(useTeamStore.getInitialState(), true) Reflect.deleteProperty(window, 'desktopHost') Reflect.deleteProperty(window, '__TAURI__') @@ -437,7 +458,7 @@ describe('TabBar', () => { expect(useActivityPanelStore.getState().isOpen(sessionId)).toBe(true) }) - it('shows the activity button for team members associated with the active session', async () => { + it('moves team activity out of the activity rail and reserves the workspace slot', async () => { const { TabBar } = await import('./TabBar') const { useTabStore } = await import('../../stores/tabStore') const { useChatStore } = await import('../../stores/chatStore') @@ -468,14 +489,31 @@ describe('TabBar', () => { { agentId: 'security', role: 'Security reviewer', status: 'running' }, ], }, + workbenchesBySession: { + [sessionId]: teamWorkbenchTimeline(sessionId), + }, } as Partial>) await act(async () => { render() }) - expect(screen.getByRole('button', { name: /activity/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /activity/i })).not.toBeInTheDocument() expect(screen.queryByTestId('session-activity-badge')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Show Workspace' })).not.toBeInTheDocument() + const workbenchButton = screen.getByRole('button', { name: 'Hide Agent Teams Workbench' }) + expect(workbenchButton).toHaveAttribute('aria-pressed', 'true') + + fireEvent.click(workbenchButton) + + expect(useTeamStore.getState().workbenchOpenBySession[sessionId]).toBe(false) + expect(screen.getByRole('button', { name: 'Show Agent Teams Workbench' })).toHaveAttribute( + 'aria-pressed', + 'false', + ) + + fireEvent.click(screen.getByRole('button', { name: 'Show Agent Teams Workbench' })) + expect(useTeamStore.getState().workbenchOpenBySession[sessionId]).toBe(true) }) it('hides team-only activity when the active team belongs to another session', async () => { @@ -518,7 +556,7 @@ describe('TabBar', () => { expect(screen.queryByTestId('session-activity-badge')).not.toBeInTheDocument() }) - it('shows the activity button without a badge when team activity arrives after initial render', async () => { + it('keeps the activity rail absent when a workbench arrives after initial render', async () => { const { TabBar } = await import('./TabBar') const { useTabStore } = await import('../../stores/tabStore') const { useChatStore } = await import('../../stores/chatStore') @@ -556,11 +594,15 @@ describe('TabBar', () => { { agentId: 'security', role: 'Security reviewer', status: 'error' }, ], }, + workbenchesBySession: { + [sessionId]: teamWorkbenchTimeline(sessionId), + }, } as Partial>) }) - expect(screen.getByRole('button', { name: /activity/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /activity/i })).not.toBeInTheDocument() expect(screen.queryByTestId('session-activity-badge')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Show Workspace' })).not.toBeInTheDocument() }) it('does not show the activity button for settings tabs', async () => { diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 13a6291d..9f67998b 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -28,7 +28,7 @@ import { getDesktopHost } from '../../lib/desktopHost' import { hasRunningBackgroundTasks } from '../../lib/backgroundTasks' import { WindowControls, showWindowControls } from './WindowControls' import { OpenProjectMenu } from './OpenProjectMenu' -import { Folder, FolderOpen, SquareTerminal } from 'lucide-react' +import { Folder, FolderOpen, SquareTerminal, UsersRound } from 'lucide-react' import { ActionDialog } from '@/components/ui/ActionDialog' import { buildSessionActivityModel, hasVisibleSessionActivity } from '../activity/sessionActivityModel' import { SessionActivityButton } from '../activity/SessionActivityButton' @@ -131,7 +131,15 @@ export function TabBar() { const workbenchMode = useWorkspacePanelStore((state) => activeTabId && isActiveSessionTab ? state.getMode(activeTabId) : 'workspace', ) - const isWorkspacePanelOpen = isWorkbenchOpen && workbenchMode === 'workspace' + const hasAgentTeamsWorkbench = useTeamStore((state) => Boolean( + activeTabId && state.workbenchesBySession[activeTabId]?.snapshots.length, + )) + const isAgentTeamsWorkbenchOpen = useTeamStore((state) => Boolean( + activeTabId && + state.workbenchesBySession[activeTabId]?.snapshots.length && + (state.workbenchOpenBySession[activeTabId] ?? true), + )) + const isWorkspacePanelOpen = !hasAgentTeamsWorkbench && isWorkbenchOpen && workbenchMode === 'workspace' const isTerminalPanelOpen = useTerminalPanelStore((state) => activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false, ) @@ -147,15 +155,6 @@ export function TabBar() { () => new Set(dismissedBackgroundTaskKeyList), [dismissedBackgroundTaskKeyList], ) - const activityTeamMembers = useTeamStore(useShallow((state) => { - const activeTeam = state.activeTeam - if (!activeTabId || !activeTeam || activeTeam.leadSessionId !== activeTabId) { - return [] - } - return activeTeam.members.filter((member) => - !activeTeam.leadAgentId || member.agentId !== activeTeam.leadAgentId - ) - })) const activityState = useChatStore(useShallow((state) => { if (!activeTabId || !isActiveSessionTab) { return { hasVisibleActivity: false } @@ -172,13 +171,12 @@ export function TabBar() { backgroundTasks: Object.values(sessionState?.backgroundAgentTasks ?? {}), dismissedBackgroundTaskKeys, agentNotifications: Object.values(sessionState?.agentTaskNotifications ?? {}), - teamMembers: activityTeamMembers, }) return { hasVisibleActivity: hasVisibleSessionActivity(model), } })) - const showActivityButton = activeTabId && activityState.hasVisibleActivity && !isWorkbenchOpen + const showActivityButton = activeTabId && activityState.hasVisibleActivity && !isWorkbenchOpen && !hasAgentTeamsWorkbench const moveTab = useTabStore((s) => s.moveTab) const scrollRef = useRef(null) @@ -597,6 +595,19 @@ export function TabBar() { {isDesktopRuntime && isActiveSessionTab && ( )} + {isActiveSessionTab && activeTabId && hasAgentTeamsWorkbench && ( + } + label={t(isAgentTeamsWorkbenchOpen + ? 'agentTeams.hideWorkbench' + : 'agentTeams.showWorkbench')} + onClick={() => useTeamStore.getState().toggleWorkbench(activeTabId)} + size="md" + tone={isAgentTeamsWorkbenchOpen ? 'default' : 'muted'} + pressed={isAgentTeamsWorkbenchOpen} + data-active={isAgentTeamsWorkbenchOpen ? 'true' : 'false'} + /> + )} } label={t('tabs.openTerminal')} @@ -612,7 +623,7 @@ export function TabBar() { pressed={isTerminalPanelOpen} data-active={isTerminalPanelOpen ? 'true' : 'false'} /> - {isActiveSessionTab && activeTabId && ( + {isActiveSessionTab && activeTabId && !hasAgentTeamsWorkbench && ( : } label={t(isWorkspacePanelOpen ? 'tabs.hideWorkspace' : 'tabs.showWorkspace')} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 30169953..e322b462 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -2222,6 +2222,62 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le 'teams.leader': 'team-lead', 'teams.memberPlaceholder': 'Message this teammate directly...', 'teams.memberSessionHint': 'Messages here go straight to this teammate while you watch its transcript update.', + 'teams.archivedMemberSessionHint': 'This preserved execution shows the teammate’s original conversation and tool calls.', + 'teams.archivedMemberReadOnly': 'This archived execution is read-only. Return to the leader to continue the work.', + 'agentTeams.title': 'Agent Teams Workbench', + 'agentTeams.showWorkbench': 'Open Agent Teams workbench', + 'agentTeams.hideWorkbench': 'Hide Agent Teams workbench', + 'agentTeams.closeWorkbench': 'Close Agent Teams workbench', + 'agentTeams.loading': 'Waiting for Agent Teams activity…', + 'agentTeams.phase.forming': 'Forming team', + 'agentTeams.phase.running': 'Parallel execution', + 'agentTeams.phase.finishing': 'Wrapping up', + 'agentTeams.phase.completed': 'Completed', + 'agentTeams.progressLabel': 'Team task progress', + 'agentTeams.followingLive': 'Following live', + 'agentTeams.reviewHistory': 'Review history', + 'agentTeams.older': 'Older workbench state', + 'agentTeams.newer': 'Newer workbench state', + 'agentTeams.backToLive': 'Back to live', + 'agentTeams.live': 'LIVE', + 'agentTeams.leader': 'Lead', + 'agentTeams.restArea': 'REST AREA', + 'agentTeams.emptyTasks': 'The team is forming. The dependency graph appears after tasks are created.', + 'agentTeams.resizePanel': 'Resize Agent Teams workbench', + 'agentTeams.task.blocked': 'Blocked', + 'agentTeams.task.open': 'Available', + 'agentTeams.task.running': 'In progress', + 'agentTeams.task.completed': 'Completed', + 'agentTeams.task.dependencies': 'Depends on {dependencies}', + 'agentTeams.task.unclaimed': 'Waiting for a teammate', + 'agentTeams.member.working': 'Working', + 'agentTeams.member.idle': 'Idle', + 'agentTeams.member.stopped': 'Stopped', + 'agentTeams.member.exited': 'Exited', + 'agentTeams.member.error': 'Blocked', + 'agentTeams.openMember': 'Open {name} details', + 'agentTeams.communication.title': 'Communication', + 'agentTeams.communication.count': '{count} messages · direct messages bypass the lead', + 'agentTeams.communication.empty': 'Team messages will appear here.', + 'agentTeams.communication.direct': 'DIRECT', + 'agentTeams.communication.broadcast': 'BROADCAST', + 'agentTeams.communication.system': 'SYSTEM', + 'agentTeams.communication.everyone': 'everyone', + 'agentTeams.communication.taskAssignment': 'Assigned {task} {subject}', + 'agentTeams.communication.shutdownRequest': 'Shutdown requested: {reason}', + 'agentTeams.communication.shutdownResponse': 'Shutdown response received', + 'agentTeams.communication.idle': 'Teammate is now idle', + 'agentTeams.disbanded': '✓ Team disbanded; results merged into the main conversation', + 'agentTeams.memberDetails': '{name} details', + 'agentTeams.memberTasks': 'OWNED TASKS', + 'agentTeams.noMemberTasks': 'No tasks assigned yet.', + 'agentTeams.memberMessages': 'RELATED MESSAGES', + 'agentTeams.noMemberMessages': 'No related messages yet.', + 'agentTeams.fullConversationHint': 'The full conversation, tool calls, and streaming output reuse the existing session view.', + 'agentTeams.archivedConversationHint': 'Open the preserved execution session to review the full conversation and tool calls.', + 'agentTeams.openConversation': 'Talk directly to {name}', + 'agentTeams.openExecution': 'View {name} execution', + 'agentTeams.dependencyLegend': 'Solid: primary dependency · dashed: secondary · hover to focus', // ─── Session Activity ────────────────────────────────────── 'session.activity.title': 'Activity', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 28549593..2d349b83 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -2224,6 +2224,62 @@ export const jp: Record = { 'teams.leader': 'team-lead', 'teams.memberPlaceholder': 'このメンバーに直接メッセージを送信...', 'teams.memberSessionHint': 'ここでのメッセージはこのメンバーに直接届き、記録の更新を見ながらやり取りできます。', + 'teams.archivedMemberSessionHint': 'この保存済み実行には、当時の会話とツール呼び出しがすべて残っています。', + 'teams.archivedMemberReadOnly': 'これは読み取り専用の実行履歴です。作業を続けるにはリーダーへ戻ってください。', + 'agentTeams.title': 'Agent Teams ワークベンチ', + 'agentTeams.showWorkbench': 'Agent Teams ワークベンチを開く', + 'agentTeams.hideWorkbench': 'Agent Teams ワークベンチを隠す', + 'agentTeams.closeWorkbench': 'Agent Teams ワークベンチを閉じる', + 'agentTeams.loading': 'Agent Teams のアクティビティを待機中…', + 'agentTeams.phase.forming': 'チーム編成中', + 'agentTeams.phase.running': '並列実行', + 'agentTeams.phase.finishing': '仕上げ中', + 'agentTeams.phase.completed': '完了', + 'agentTeams.progressLabel': 'チームタスクの進捗', + 'agentTeams.followingLive': 'リアルタイム追従', + 'agentTeams.reviewHistory': '履歴を見る', + 'agentTeams.older': '以前のワークベンチ状態', + 'agentTeams.newer': '新しいワークベンチ状態', + 'agentTeams.backToLive': 'リアルタイムへ戻る', + 'agentTeams.live': 'ライブ', + 'agentTeams.leader': '長', + 'agentTeams.restArea': '休憩エリア', + 'agentTeams.emptyTasks': 'チームを編成中です。タスク作成後に依存グラフが表示されます。', + 'agentTeams.resizePanel': 'Agent Teams ワークベンチの幅を変更', + 'agentTeams.task.blocked': '依存待ち', + 'agentTeams.task.open': '取得可能', + 'agentTeams.task.running': '進行中', + 'agentTeams.task.completed': '完了', + 'agentTeams.task.dependencies': '依存 {dependencies}', + 'agentTeams.task.unclaimed': 'メンバーの取得待ち', + 'agentTeams.member.working': '作業中', + 'agentTeams.member.idle': '待機中', + 'agentTeams.member.stopped': '停止済み', + 'agentTeams.member.exited': '退出済み', + 'agentTeams.member.error': 'ブロック中', + 'agentTeams.openMember': '{name} の詳細を開く', + 'agentTeams.communication.title': '通信', + 'agentTeams.communication.count': '{count} 件 · ダイレクトメッセージはリードを経由しません', + 'agentTeams.communication.empty': 'チームメッセージはここに表示されます。', + 'agentTeams.communication.direct': 'DM', + 'agentTeams.communication.broadcast': 'ブロードキャスト', + 'agentTeams.communication.system': 'システム', + 'agentTeams.communication.everyone': '全員', + 'agentTeams.communication.taskAssignment': '{task} {subject} を割り当てました', + 'agentTeams.communication.shutdownRequest': '終了要求:{reason}', + 'agentTeams.communication.shutdownResponse': '終了応答を受信しました', + 'agentTeams.communication.idle': 'メンバーが待機状態になりました', + 'agentTeams.disbanded': '✓ チームは解散し、成果はメイン会話に統合されました', + 'agentTeams.memberDetails': '{name} の詳細', + 'agentTeams.memberTasks': '担当タスク', + 'agentTeams.noMemberTasks': '割り当てられたタスクはありません。', + 'agentTeams.memberMessages': '関連メッセージ', + 'agentTeams.noMemberMessages': '関連メッセージはありません。', + 'agentTeams.fullConversationHint': '完全な会話、ツール呼び出し、ストリーミング出力は既存のセッション表示を再利用します。', + 'agentTeams.archivedConversationHint': '保存された実行セッションを開き、会話全体とツール呼び出しを確認できます。', + 'agentTeams.openConversation': '{name} と直接話す', + 'agentTeams.openExecution': '{name} の実行を表示', + 'agentTeams.dependencyLegend': '実線:主依存 · 破線:副依存 · ホバーでフォーカス', // ─── Session Activity ────────────────────────────────────── 'session.activity.title': 'アクティビティ', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 8efa78cb..3c5f0228 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -2224,6 +2224,62 @@ export const kr: Record = { 'teams.leader': 'team-lead', 'teams.memberPlaceholder': '이 팀원에게 직접 메시지 보내기...', 'teams.memberSessionHint': '여기서 보내는 메시지는 이 팀원에게 바로 전달되며, 기록이 업데이트되는 것을 보면서 주고받을 수 있습니다.', + 'teams.archivedMemberSessionHint': '보존된 실행에는 당시의 전체 대화와 도구 호출이 남아 있습니다.', + 'teams.archivedMemberReadOnly': '이 실행 기록은 읽기 전용입니다. 작업을 계속하려면 리더 세션으로 돌아가세요.', + 'agentTeams.title': 'Agent Teams 워크벤치', + 'agentTeams.showWorkbench': 'Agent Teams 워크벤치 열기', + 'agentTeams.hideWorkbench': 'Agent Teams 워크벤치 숨기기', + 'agentTeams.closeWorkbench': 'Agent Teams 워크벤치 닫기', + 'agentTeams.loading': 'Agent Teams 활동을 기다리는 중…', + 'agentTeams.phase.forming': '팀 구성 중', + 'agentTeams.phase.running': '병렬 실행', + 'agentTeams.phase.finishing': '마무리 중', + 'agentTeams.phase.completed': '완료됨', + 'agentTeams.progressLabel': '팀 작업 진행률', + 'agentTeams.followingLive': '실시간 팔로우 중', + 'agentTeams.reviewHistory': '기록 돌아보기', + 'agentTeams.older': '이전 워크벤치 상태', + 'agentTeams.newer': '다음 워크벤치 상태', + 'agentTeams.backToLive': '실시간으로 돌아가기', + 'agentTeams.live': 'LIVE', + 'agentTeams.leader': '리드', + 'agentTeams.restArea': '휴식 구역', + 'agentTeams.emptyTasks': '팀을 구성하고 있습니다. 작업이 만들어지면 의존성 그래프가 표시됩니다.', + 'agentTeams.resizePanel': 'Agent Teams 워크벤치 크기 조절', + 'agentTeams.task.blocked': '차단됨', + 'agentTeams.task.open': '할당 가능', + 'agentTeams.task.running': '진행 중', + 'agentTeams.task.completed': '완료됨', + 'agentTeams.task.dependencies': '{dependencies}에 의존', + 'agentTeams.task.unclaimed': '팀원 할당 대기 중', + 'agentTeams.member.working': '작업 중', + 'agentTeams.member.idle': '대기 중', + 'agentTeams.member.stopped': '중지됨', + 'agentTeams.member.exited': '종료됨', + 'agentTeams.member.error': '차단됨', + 'agentTeams.openMember': '{name} 세부 정보 열기', + 'agentTeams.communication.title': '커뮤니케이션', + 'agentTeams.communication.count': '{count}개 메시지 · 다이렉트 메시지는 리드를 거치지 않습니다', + 'agentTeams.communication.empty': '팀 메시지가 여기에 표시됩니다.', + 'agentTeams.communication.direct': 'DIRECT', + 'agentTeams.communication.broadcast': 'BROADCAST', + 'agentTeams.communication.system': 'SYSTEM', + 'agentTeams.communication.everyone': '모두', + 'agentTeams.communication.taskAssignment': '{task} {subject} 할당', + 'agentTeams.communication.shutdownRequest': '종료 요청: {reason}', + 'agentTeams.communication.shutdownResponse': '종료 응답을 받았습니다', + 'agentTeams.communication.idle': '팀원이 대기 상태가 되었습니다', + 'agentTeams.disbanded': '✓ 팀 해산 완료; 결과를 메인 대화에 병합했습니다', + 'agentTeams.memberDetails': '{name} 세부 정보', + 'agentTeams.memberTasks': '담당 작업', + 'agentTeams.noMemberTasks': '아직 할당된 작업이 없습니다.', + 'agentTeams.memberMessages': '관련 메시지', + 'agentTeams.noMemberMessages': '아직 관련 메시지가 없습니다.', + 'agentTeams.fullConversationHint': '전체 대화, 도구 호출, 스트리밍 출력은 기존 세션 화면을 재사용합니다.', + 'agentTeams.archivedConversationHint': '보존된 실행 세션을 열어 전체 대화와 도구 호출을 검토할 수 있습니다.', + 'agentTeams.openConversation': '{name}에게 직접 말하기', + 'agentTeams.openExecution': '{name} 실행 보기', + 'agentTeams.dependencyLegend': '실선: 주 의존성 · 점선: 보조 의존성 · 호버로 집중', // ─── Session Activity ────────────────────────────────────── 'session.activity.title': '활동', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 00c98bf6..cef9e45d 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -2223,6 +2223,62 @@ export const zh: Record = { 'teams.leader': '主控', 'teams.memberPlaceholder': '直接給這個 Agent 發訊息...', 'teams.memberSessionHint': '這裡傳送的訊息會直接投遞給該 Agent,同時繼續重新整理它的 transcript。', + 'teams.archivedMemberSessionHint': '這裡保留了該成員當時的完整對話與工具呼叫。', + 'teams.archivedMemberReadOnly': '這是唯讀的歷史執行記錄;如需繼續工作,請返回主控工作階段。', + 'agentTeams.title': 'Agent Teams 協作工作台', + 'agentTeams.showWorkbench': '開啟 Agent Teams 工作台', + 'agentTeams.hideWorkbench': '隱藏 Agent Teams 工作台', + 'agentTeams.closeWorkbench': '關閉 Agent Teams 工作台', + 'agentTeams.loading': '正在等待 Agent Teams 活動…', + 'agentTeams.phase.forming': '組建團隊', + 'agentTeams.phase.running': '平行執行', + 'agentTeams.phase.finishing': '收尾中', + 'agentTeams.phase.completed': '已完成', + 'agentTeams.progressLabel': '團隊任務進度', + 'agentTeams.followingLive': '跟隨即時', + 'agentTeams.reviewHistory': '回看歷史', + 'agentTeams.older': '更早的工作台狀態', + 'agentTeams.newer': '更新的工作台狀態', + 'agentTeams.backToLive': '回到即時', + 'agentTeams.live': '即時', + 'agentTeams.leader': '長', + 'agentTeams.restArea': '休息區', + 'agentTeams.emptyTasks': '正在組建團隊;建立任務後,依賴圖會出現在這裡。', + 'agentTeams.resizePanel': '調整 Agent Teams 工作台寬度', + 'agentTeams.task.blocked': '等待依賴', + 'agentTeams.task.open': '可領取', + 'agentTeams.task.running': '進行中', + 'agentTeams.task.completed': '已完成', + 'agentTeams.task.dependencies': '依賴 {dependencies}', + 'agentTeams.task.unclaimed': '等待成員領取', + 'agentTeams.member.working': '工作中', + 'agentTeams.member.idle': '待命', + 'agentTeams.member.stopped': '已停止', + 'agentTeams.member.exited': '已退出', + 'agentTeams.member.error': '卡住', + 'agentTeams.openMember': '開啟 {name} 的詳細資料', + 'agentTeams.communication.title': '通訊', + 'agentTeams.communication.count': '{count} 則 · 私訊不經過隊長', + 'agentTeams.communication.empty': '團隊訊息會顯示在這裡。', + 'agentTeams.communication.direct': '私訊', + 'agentTeams.communication.broadcast': '廣播', + 'agentTeams.communication.system': '系統', + 'agentTeams.communication.everyone': '全體', + 'agentTeams.communication.taskAssignment': '已分配 {task} {subject}', + 'agentTeams.communication.shutdownRequest': '請求結束:{reason}', + 'agentTeams.communication.shutdownResponse': '已收到退出確認', + 'agentTeams.communication.idle': '成員已進入待命狀態', + 'agentTeams.disbanded': '✓ 團隊已解散,成果併入主對話', + 'agentTeams.memberDetails': '{name} 的詳細資料', + 'agentTeams.memberTasks': '負責任務', + 'agentTeams.noMemberTasks': '尚未分配任務。', + 'agentTeams.memberMessages': '相關訊息', + 'agentTeams.noMemberMessages': '暫無相關訊息。', + 'agentTeams.fullConversationHint': '完整對話、工具呼叫與串流輸出會沿用既有的工作階段檢視。', + 'agentTeams.archivedConversationHint': '開啟保留的執行工作階段,可回看完整對話與工具呼叫。', + 'agentTeams.openConversation': '直接與 {name} 對話', + 'agentTeams.openExecution': '查看 {name} 的執行過程', + 'agentTeams.dependencyLegend': '實線為主要依賴 · 虛線為次要依賴 · 懸停聚焦', // ─── Session Activity ────────────────────────────────────── 'session.activity.title': '活動', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 6fabffab..096ed787 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -2223,6 +2223,62 @@ export const zh: Record = { 'teams.leader': '主控', 'teams.memberPlaceholder': '直接给这个 Agent 发消息...', 'teams.memberSessionHint': '这里发送的消息会直接投递给该 Agent,同时继续刷新它的 transcript。', + 'teams.archivedMemberSessionHint': '这里保留了该成员当时的完整对话与工具调用。', + 'teams.archivedMemberReadOnly': '这是只读的历史执行记录;如需继续工作,请返回主控会话。', + 'agentTeams.title': 'Agent Teams 协作工作台', + 'agentTeams.showWorkbench': '打开 Agent Teams 工作台', + 'agentTeams.hideWorkbench': '隐藏 Agent Teams 工作台', + 'agentTeams.closeWorkbench': '关闭 Agent Teams 工作台', + 'agentTeams.loading': '正在等待 Agent Teams 活动…', + 'agentTeams.phase.forming': '组建团队', + 'agentTeams.phase.running': '并行执行', + 'agentTeams.phase.finishing': '收尾中', + 'agentTeams.phase.completed': '已完成', + 'agentTeams.progressLabel': '团队任务进度', + 'agentTeams.followingLive': '跟随实时', + 'agentTeams.reviewHistory': '回看历史', + 'agentTeams.older': '更早的工作台状态', + 'agentTeams.newer': '更新的工作台状态', + 'agentTeams.backToLive': '回到实时', + 'agentTeams.live': '实时', + 'agentTeams.leader': '长', + 'agentTeams.restArea': '休息区', + 'agentTeams.emptyTasks': '正在组建团队;任务创建后,依赖图将出现在这里。', + 'agentTeams.resizePanel': '调整 Agent Teams 工作台宽度', + 'agentTeams.task.blocked': '等待依赖', + 'agentTeams.task.open': '可领取', + 'agentTeams.task.running': '进行中', + 'agentTeams.task.completed': '已完成', + 'agentTeams.task.dependencies': '依赖 {dependencies}', + 'agentTeams.task.unclaimed': '等待成员领取', + 'agentTeams.member.working': '工作中', + 'agentTeams.member.idle': '待命', + 'agentTeams.member.stopped': '已停止', + 'agentTeams.member.exited': '已退出', + 'agentTeams.member.error': '卡住', + 'agentTeams.openMember': '打开 {name} 的详情', + 'agentTeams.communication.title': '通讯', + 'agentTeams.communication.count': '{count} 条 · 私信不经过队长', + 'agentTeams.communication.empty': '团队消息会出现在这里。', + 'agentTeams.communication.direct': '私信', + 'agentTeams.communication.broadcast': '广播', + 'agentTeams.communication.system': '系统', + 'agentTeams.communication.everyone': '全体', + 'agentTeams.communication.taskAssignment': '已分配 {task} {subject}', + 'agentTeams.communication.shutdownRequest': '请求结束:{reason}', + 'agentTeams.communication.shutdownResponse': '已收到退出确认', + 'agentTeams.communication.idle': '成员已进入待命状态', + 'agentTeams.disbanded': '✓ 团队已解散,成果并入主会话', + 'agentTeams.memberDetails': '{name} 的详情', + 'agentTeams.memberTasks': '负责任务', + 'agentTeams.noMemberTasks': '暂未分配任务。', + 'agentTeams.memberMessages': '相关消息', + 'agentTeams.noMemberMessages': '暂无相关消息。', + 'agentTeams.fullConversationHint': '完整会话、工具调用与流式输出复用既有会话视图。', + 'agentTeams.archivedConversationHint': '打开保留的执行会话,可回看完整对话与工具调用。', + 'agentTeams.openConversation': '直接与 {name} 对话', + 'agentTeams.openExecution': '查看 {name} 的执行过程', + 'agentTeams.dependencyLegend': '实线为主依赖 · 虚线为次依赖 · 悬停聚焦', // ─── Session Activity ────────────────────────────────────── 'session.activity.title': '活动', diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index 86a422de..5736d031 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -43,11 +43,19 @@ vi.mock('../components/workbench/WorkbenchPanel', () => ({ ), })) +vi.mock('../components/agentTeams/AgentTeamsWorkbench', () => ({ + AgentTeamsWorkbench: ({ sessionId }: { sessionId: string }) => ( +
agent-teams:{sessionId}
+ ), +})) + vi.mock('../api/teams', () => ({ teamsApi: { getMemberTranscript: vi.fn(() => Promise.resolve({ messages: [] })), get: vi.fn(), list: vi.fn(), + getWorkbenchForSession: vi.fn(() => Promise.reject(new Error('not a team session'))), + getWorkbench: vi.fn(), sendMemberMessage: vi.fn(), }, })) @@ -120,7 +128,7 @@ afterEach(() => { useChatStore.setState({ sessions: {} }) useSettingsStore.setState({ locale: 'en' }) useTeamStore.getState().stopMemberPolling() - useTeamStore.setState({ teams: [], activeTeam: null, memberColors: new Map(), error: null }) + useTeamStore.setState(useTeamStore.getInitialState(), true) useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true) useTerminalPanelStore.setState(useTerminalPanelStore.getInitialState(), true) useActivityPanelStore.setState(useActivityPanelStore.getInitialState(), true) @@ -1202,9 +1210,8 @@ describe('ActiveSession task polling', () => { expect(useTabStore.getState().activeTabId).toBe('__subagent__activity-subagent-open-session__agent-tool-1') }) - it('opens a team member session from the activity panel', () => { + it('renders Agent Teams in its dedicated workbench instead of the activity panel', () => { const sessionId = 'team-activity-panel-session' - const memberSessionId = 'team-member:security-reviewer@test-team' useActivityPanelStore.getState().open(sessionId) useTeamStore.setState({ @@ -1229,6 +1236,33 @@ describe('ActiveSession task polling', () => { }, memberColors: new Map(), error: null, + workbenchesBySession: { + [sessionId]: { + teamName: 'test-team', + loading: false, + error: null, + snapshots: [{ + version: 'v1', + generatedAt: '2026-08-08T00:00:00.000Z', + team: { + 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', + }, + ], + }, + tasks: [], + messages: [], + }], + }, + }, }) useSessionStore.setState({ sessions: [{ @@ -1276,17 +1310,20 @@ describe('ActiveSession task polling', () => { 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() + expect(screen.queryByTestId('session-activity-panel')).not.toBeInTheDocument() + expect(screen.getByTestId('agent-teams-workbench-panel')).toHaveClass('bg-[var(--color-surface)]') + expect(screen.getByTestId('agent-teams-resize-handle')).not.toHaveClass('border-x') + expect(screen.getByTestId('agent-teams-resize-handle').firstElementChild).toHaveClass('opacity-0') + expect(screen.getByTestId('agent-teams-workbench')).toHaveTextContent(`agent-teams:${sessionId}`) + expect(screen.getByTestId('message-list')).toHaveAttribute('data-compact', 'true') + expect(useActivityPanelStore.getState().isOpen(sessionId)).toBe(false) - fireEvent.click(within(panel).getByRole('button', { name: /open team member security-reviewer/i })) + act(() => { + useTeamStore.getState().setWorkbenchOpen(sessionId, false) + }) - expect(useTabStore.getState().activeTabId).toBe(memberSessionId) - expect(useTabStore.getState().tabs).toEqual(expect.arrayContaining([ - expect.objectContaining({ sessionId: memberSessionId, title: 'security-reviewer', type: 'session' }), - ])) + expect(screen.queryByTestId('agent-teams-workbench-panel')).not.toBeInTheDocument() + expect(useTeamStore.getState().workbenchesBySession[sessionId]?.snapshots).toHaveLength(1) }) it('clears the last visible background task by closing Activity while preserving later runs', async () => { @@ -1571,7 +1608,7 @@ describe('ActiveSession task polling', () => { useCLITaskStore.setState(originalCliTaskState) }) - it('keeps member sessions interactive and skips leader task polling', () => { + it('keeps live member sessions interactive, makes archived execution read-only, and skips leader task polling', () => { const memberSessionId = 'team-member:security-reviewer@test-team' const originalCliTaskState = useCLITaskStore.getState() const fetchSessionTasks = vi.fn().mockResolvedValue(undefined) @@ -1639,10 +1676,29 @@ describe('ActiveSession task polling', () => { const { queryByTestId, unmount } = render() expect(queryByTestId('chat-input')).toBeInTheDocument() + expect(queryByTestId('member-session-readonly')).not.toBeInTheDocument() expect(queryByTestId('session-task-bar')).not.toBeInTheDocument() expect(queryByTestId('session-activity-panel')).not.toBeInTheDocument() expect(fetchSessionTasks).not.toHaveBeenCalled() + act(() => { + useTeamStore.setState((state) => ({ + activeTeam: state.activeTeam + ? { + ...state.activeTeam, + members: state.activeTeam.members.map((member) => ( + member.agentId === 'security-reviewer@test-team' + ? { ...member, status: 'completed' as const } + : member + )), + } + : null, + })) + }) + + expect(queryByTestId('chat-input')).not.toBeInTheDocument() + expect(queryByTestId('member-session-readonly')).toHaveTextContent('This archived execution is read-only') + unmount() useCLITaskStore.setState(originalCliTaskState) }) diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index 59f3d533..42fc60b5 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -13,7 +13,11 @@ import { import { useSessionStore } from '../stores/sessionStore' import { useChatStore } from '../stores/chatStore' import { useCLITaskStore } from '../stores/cliTaskStore' -import { useTeamStore } from '../stores/teamStore' +import { + AGENT_TEAMS_WORKBENCH_MAX_WIDTH, + AGENT_TEAMS_WORKBENCH_MIN_WIDTH, + useTeamStore, +} from '../stores/teamStore' import { useWorkspacePanelStore } from '../stores/workspacePanelStore' import { TERMINAL_PANEL_DEFAULT_HEIGHT, @@ -31,6 +35,7 @@ import { ChatInput } from '../components/chat/ChatInput' import { getWorktreeDisplayName, WorktreeDetails } from '../components/chat/WorktreeDetails' import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal' import { WorkbenchPanel } from '../components/workbench/WorkbenchPanel' +import { AgentTeamsWorkbench } from '../components/agentTeams/AgentTeamsWorkbench' import { SessionActivityPanel } from '../components/activity/SessionActivityPanel' import { buildSessionActivityModel, hasVisibleSessionActivity } from '../components/activity/sessionActivityModel' import { TerminalSettings } from './TerminalSettings' @@ -222,6 +227,72 @@ function WorkspaceResizeHandle({ panelRef }: { panelRef: RefObject ) } +function AgentTeamsResizeHandle({ panelRef }: { panelRef: RefObject }) { + const t = useTranslation() + const width = useTeamStore((state) => state.workbenchPanelWidth) + const setWidth = useTeamStore((state) => state.setWorkbenchPanelWidth) + const [dragState, setDragState] = useState<{ startX: number; startWidth: number } | null>(null) + const dragStateRef = useRef(dragState) + + useEffect(() => { + dragStateRef.current = dragState + }, [dragState]) + + useEffect(() => { + if (!dragState) return + const handlePointerMove = (event: PointerEvent) => { + const current = dragStateRef.current + if (!current) return + setWidth(current.startWidth + current.startX - event.clientX) + } + const handlePointerUp = () => setDragState(null) + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', handlePointerUp) + window.addEventListener('pointercancel', handlePointerUp) + return () => { + document.body.style.cursor = '' + document.body.style.userSelect = '' + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', handlePointerUp) + window.removeEventListener('pointercancel', handlePointerUp) + } + }, [dragState, setWidth]) + + return ( +
{ + if (event.button !== 0) return + event.preventDefault() + setDragState({ startX: event.clientX, startWidth: getRenderedWorkspacePanelWidth(panelRef, width) }) + }} + onKeyDown={(event) => { + const renderedWidth = getRenderedWorkspacePanelWidth(panelRef, width) + if (event.key === 'ArrowLeft') { + event.preventDefault() + setWidth(renderedWidth + WORKSPACE_RESIZE_STEP) + } + if (event.key === 'ArrowRight') { + event.preventDefault() + setWidth(renderedWidth - WORKSPACE_RESIZE_STEP) + } + }} + className="group relative z-[var(--z-raised)] flex w-[7px] shrink-0 cursor-col-resize items-center justify-center bg-[var(--color-surface)] outline-none" + > +
+ ) +} + function TerminalResizeHandle() { const t = useTranslation() const height = useTerminalPanelStore((state) => state.height) @@ -335,18 +406,35 @@ export function ActiveSession() { const session = sessions.find((s) => s.id === activeTabId) const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null) const activeTeam = useTeamStore((s) => s.activeTeam) + const agentTeamsTimeline = useTeamStore((s) => activeTabId ? s.workbenchesBySession[activeTabId] : undefined) + const agentTeamsWorkbenchOpen = useTeamStore((s) => activeTabId + ? s.workbenchOpenBySession[activeTabId] ?? true + : false) + const agentTeamsPanelWidth = useTeamStore((s) => s.workbenchPanelWidth) + const fetchTeamForSession = useTeamStore((s) => s.fetchTeamForSession) const isMemberSession = !!memberInfo + const isArchivedMemberSession = memberInfo?.status === 'completed' const [sessionGitInfo, setSessionGitInfo] = useState<{ sessionId: string info: SessionGitInfo } | null>(null) - const showWorkbench = useWorkspacePanelStore((state) => + const workspaceWorkbenchOpen = useWorkspacePanelStore((state) => activeTabId && isSessionTabState(activeTabId, activeTabType) && !isMemberSession && !isMobileLayout ? state.isPanelOpen(activeTabId) : false, ) - const showRightPanel = showWorkbench - const rightPanelWidth = useWorkspacePanelStore((state) => state.width) + const showAgentTeamsWorkbench = Boolean( + agentTeamsTimeline?.snapshots.length && + agentTeamsWorkbenchOpen && + activeTabId && + isSessionTabState(activeTabId, activeTabType) && + !isMemberSession && + !isMobileLayout, + ) + const showWorkbench = workspaceWorkbenchOpen && !showAgentTeamsWorkbench + const showRightPanel = showWorkbench || showAgentTeamsWorkbench + const workspacePanelWidth = useWorkspacePanelStore((state) => state.width) + const rightPanelWidth = showAgentTeamsWorkbench ? agentTeamsPanelWidth : workspacePanelWidth const showTerminalPanel = useTerminalPanelStore((state) => activeTabId && isSessionTabState(activeTabId, activeTabType) && !isMemberSession && !isMobileLayout ? state.isPanelOpen(activeTabId) @@ -363,8 +451,9 @@ export function ActiveSession() { useEffect(() => { if (activeTabId && !isMemberSession) { connectToSession(activeTabId) + void fetchTeamForSession(activeTabId) } - }, [activeTabId, isMemberSession, connectToSession]) + }, [activeTabId, isMemberSession, connectToSession, fetchTeamForSession]) useEffect(() => { if (!activeTabId || isMemberSession || !isSessionTabState(activeTabId, activeTabType)) return @@ -454,13 +543,6 @@ export function ActiveSession() { const totalTokens = getTokenUsageTotal(tokenUsage) const cachedTokens = (tokenUsage.cache_read_tokens ?? 0) + (tokenUsage.cache_creation_tokens ?? 0) - const activityTeamMembers = useMemo(() => { - if (!activeTeam || activeTeam.leadSessionId !== activeTabId) return [] - return activeTeam.members.filter((member) => - !activeTeam.leadAgentId || member.agentId !== activeTeam.leadAgentId - ) - }, [activeTabId, activeTeam]) - useEffect(() => { if (!activeTabId) return pruneDismissedBackgroundTaskKeys( @@ -482,11 +564,9 @@ export function ActiveSession() { backgroundTasks, dismissedBackgroundTaskKeys, agentNotifications: Object.values(agentTaskNotifications), - teamMembers: activityTeamMembers, }) }, [ activeTabId, - activityTeamMembers, agentTaskNotifications, backgroundTasks, cliTasks, @@ -540,9 +620,9 @@ export function ActiveSession() { }, [activeTabId, closeActivityPanel, hasVisibleActivity, isActivityPanelOpen, sessionState?.historyStatus]) useEffect(() => { - if (!activeTabId || !showWorkbench || !isActivityPanelOpen) return + if (!activeTabId || !showRightPanel || !isActivityPanelOpen) return closeActivityPanel(activeTabId) - }, [activeTabId, closeActivityPanel, isActivityPanelOpen, showWorkbench]) + }, [activeTabId, closeActivityPanel, isActivityPanelOpen, showRightPanel]) const handleOpenSubagentRun = useCallback((payload: { sessionId: string; taskId?: string; toolUseId: string; title: string }) => { useTabStore.getState().openSubagentTab(payload.sessionId, payload.toolUseId, payload.title, payload.taskId) @@ -574,7 +654,7 @@ export function ActiveSession() { // squeezes the column by itself — the column yields with padding instead. const showActivityRail = Boolean(activityModel) && hasVisibleActivity && - !showWorkbench && + !showRightPanel && !isMobileLayout && !isMemberSession && isSessionTabState(activeTabId, activeTabType) @@ -615,7 +695,9 @@ export function ActiveSession() { )}

- {t('teams.memberSessionHint')} + {t(isArchivedMemberSession + ? 'teams.archivedMemberSessionHint' + : 'teams.memberSessionHint')}