diff --git a/desktop/src/api/teams.ts b/desktop/src/api/teams.ts index fbcdf747..3948b9d9 100644 --- a/desktop/src/api/teams.ts +++ b/desktop/src/api/teams.ts @@ -15,6 +15,7 @@ type TranscriptMessage = { timestamp: string model?: string parentToolUseId?: string + toolUseResult?: unknown } type TranscriptResponse = { diff --git a/desktop/src/components/agentTeams/AgentTeamsCommunicationFeed.test.tsx b/desktop/src/components/agentTeams/AgentTeamsCommunicationFeed.test.tsx new file mode 100644 index 00000000..92694f8a --- /dev/null +++ b/desktop/src/components/agentTeams/AgentTeamsCommunicationFeed.test.tsx @@ -0,0 +1,88 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import { useSettingsStore } from '../../stores/settingsStore' +import type { TeamWorkbenchMessage, TeamWorkbenchSnapshot } from '../../types/team' +import { AgentTeamsCommunicationFeed } from './AgentTeamsCommunicationFeed' + +function message(overrides: Partial & { id: string }): TeamWorkbenchMessage { + return { + from: 'builder', + to: 'reviewer', + recipients: ['reviewer'], + kind: 'direct', + text: 'body', + timestamp: '2026-08-08T07:42:16.666Z', + ...overrides, + } +} + +function snapshot(messages: TeamWorkbenchMessage[]): TeamWorkbenchSnapshot { + return { + version: 'v1', + generatedAt: '2026-08-08T07:00:00.000Z', + team: { name: 'team-a', leadAgentId: 'lead@team-a', leadSessionId: 'lead-session', members: [] }, + tasks: [], + messages, + } +} + +describe('AgentTeamsCommunicationFeed', () => { + beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + }) + + it('narrates lifecycle signals behind a toggle instead of printing their JSON', () => { + render() + + // Lifecycle noise is out of the way by default, and never as raw JSON. + expect(screen.queryByText(/idle_notification/)).toBeNull() + expect(screen.getByText('Race condition confirmed in queue.ts')).toBeTruthy() + + fireEvent.click(screen.getByTestId('agent-teams-lifecycle-toggle')) + + expect(screen.queryByText(/idle_notification/)).toBeNull() + expect(screen.getByText(/Went idle, waiting for work · available/)).toBeTruthy() + expect(screen.getByTestId('agent-teams-message-m2').getAttribute('data-message-body')).toBe('lifecycle') + }) + + it('stamps each row with its own send time rather than a shared snapshot index', () => { + render() + + const times = ['m1', 'm2'].map((id) => { + const row = screen.getByTestId(`agent-teams-message-${id}`) + return row.querySelector('.tabular-nums')?.textContent + }) + + expect(times[0]).toBeTruthy() + expect(times[0]).not.toBe(times[1]) + expect(screen.queryByText(/^T\+\d+$/)).toBeNull() + }) + + it('keeps a long broadcast readable by collapsing it behind an expander', () => { + const long = 'x'.repeat(400) + render() + + const row = screen.getByTestId('agent-teams-message-m1') + expect(row.textContent).toContain('…') + expect(row.textContent).not.toContain(long) + + fireEvent.click(screen.getByRole('button', { name: 'Show more' })) + expect(screen.getByTestId('agent-teams-message-m1').textContent).toContain(long) + + fireEvent.click(screen.getByRole('button', { name: 'Show less' })) + expect(screen.getByTestId('agent-teams-message-m1').textContent).not.toContain(long) + }) +}) diff --git a/desktop/src/components/agentTeams/AgentTeamsCommunicationFeed.tsx b/desktop/src/components/agentTeams/AgentTeamsCommunicationFeed.tsx new file mode 100644 index 00000000..2bfb377b --- /dev/null +++ b/desktop/src/components/agentTeams/AgentTeamsCommunicationFeed.tsx @@ -0,0 +1,200 @@ +import { useMemo, useState } from 'react' +import { Megaphone, MessageSquare, Radio, Settings2 } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import { useTranslation, type TranslationKey } from '../../i18n' +import type { TeamWorkbenchMessage, TeamWorkbenchSnapshot } from '../../types/team' +import { + formatWorkbenchMessageTime, + parseWorkbenchMessageBody, + type WorkbenchMessageBody, +} from './agentTeamsModel' + +type TranslationFn = ReturnType + +/** Beyond this the body collapses behind a "show more" toggle. */ +const COLLAPSED_BODY_CHARS = 260 + +function kindIcon(kind: TeamWorkbenchMessage['kind']): LucideIcon { + if (kind === 'broadcast') return Megaphone + if (kind === 'direct') return MessageSquare + return Settings2 +} + +function kindLabel(kind: TeamWorkbenchMessage['kind'], t: TranslationFn): string { + if (kind === 'direct') return t('agentTeams.communication.direct') + if (kind === 'broadcast') return t('agentTeams.communication.broadcast') + return t('agentTeams.communication.system') +} + +function kindColor(kind: TeamWorkbenchMessage['kind']): string { + if (kind === 'direct') return 'var(--color-brand)' + if (kind === 'broadcast') return 'var(--color-warning)' + return 'var(--color-text-tertiary)' +} + +function lifecycleNarration( + body: Extract, + message: TeamWorkbenchMessage, + t: TranslationFn, +): string { + if (body.type === 'task_assignment') { + return t('agentTeams.communication.taskAssignment', { + task: message.taskId ? `#${message.taskId}` : '', + subject: body.detail ?? '', + }) + } + const narration = t(`agentTeams.communication.lifecycle.${body.type}` as TranslationKey) + return body.detail ? `${narration} · ${body.detail}` : narration +} + +export function AgentTeamsCommunicationFeed({ + snapshot, + fill = false, +}: { + snapshot: TeamWorkbenchSnapshot + /** Full-height layouts let the feed own its column instead of a fixed strip. */ + fill?: boolean +}) { + const t = useTranslation() + const [showLifecycle, setShowLifecycle] = useState(false) + + const rows = useMemo(() => snapshot.messages + .map((message) => ({ message, body: parseWorkbenchMessageBody(message) })) + .reverse(), [snapshot.messages]) + const lifecycleCount = rows.filter(({ body }) => body.kind === 'lifecycle').length + const visibleRows = showLifecycle ? rows : rows.filter(({ body }) => body.kind !== 'lifecycle') + + return ( +
+
+

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

+ + {t('agentTeams.communication.count', { count: visibleRows.length })} + + {lifecycleCount > 0 ? ( + + ) : null} + {snapshot.deletedAt ? ( + + {t('agentTeams.disbanded')} + + ) : null} +
+
+ {visibleRows.length === 0 ? ( +
+ {t('agentTeams.communication.empty')} +
+ ) : visibleRows.map(({ message, body }, index) => ( + + ))} +
+
+ ) +} + +function FeedRow({ + message, + body, + isLatest, + t, +}: { + message: TeamWorkbenchMessage + body: WorkbenchMessageBody + isLatest: boolean + t: TranslationFn +}) { + const [expanded, setExpanded] = useState(false) + const Icon = kindIcon(message.kind) + const color = kindColor(message.kind) + const time = formatWorkbenchMessageTime(message.timestamp) + const text = body.kind === 'lifecycle' + ? lifecycleNarration(body, message, t) + : body.text + const isTruncatable = body.kind === 'text' && text.length > COLLAPSED_BODY_CHARS + const shownText = isTruncatable && !expanded + ? `${text.slice(0, COLLAPSED_BODY_CHARS)}…` + : text + + return ( +
+
+
+
+ {shownText} +
+ {isTruncatable ? ( + + ) : null} +
+ ) +} diff --git a/desktop/src/components/agentTeams/AgentTeamsMemberView.tsx b/desktop/src/components/agentTeams/AgentTeamsMemberView.tsx new file mode 100644 index 00000000..7fdfe478 --- /dev/null +++ b/desktop/src/components/agentTeams/AgentTeamsMemberView.tsx @@ -0,0 +1,147 @@ +import { ArrowLeft, ExternalLink } from 'lucide-react' +import { Badge, StatusDot } from '@/components/ui/Badge' +import { Button } from '@/components/ui/Button' +import { IconButton } from '@/components/ui/IconButton' +import { MessageList } from '../chat/MessageList' +import { useTranslation, type TranslationKey } from '../../i18n' +import { memberSessionId } from '../../stores/teamStore' +import type { TeamMember, TeamWorkbenchSnapshot } from '../../types/team' +import { MEMBER_AVATARS, memberAccentColor } from './agentTeamsAvatars' +import { + getMemberAvatarKey, + runningTaskForMember, + taskOwnedByMember, +} from './agentTeamsModel' + +function memberName(member: TeamMember): string { + return member.name || member.role || member.agentId.split('@')[0] || member.agentId +} + +function memberStatusTone(member: TeamMember) { + if (member.status === 'error') return 'danger' as const + if (member.status === 'running') return 'brand' as const + if (member.status === 'completed') return 'success' as const + return 'neutral' as const +} + +/** + * A teammate's run rendered with the same transcript machinery as the main + * session — thinking blocks, grouped tool calls, streaming indicator — instead + * of the summary-only drawer that used to cover the team map. + */ +export function AgentTeamsMemberView({ + member, + snapshot, + onBack, + onOpenInTab, +}: { + member: TeamMember + snapshot: TeamWorkbenchSnapshot + onBack: () => void + onOpenInTab: () => void +}) { + const t = useTranslation() + const isLead = member.agentId === snapshot.team.leadAgentId + const name = memberName(member) + const avatarKey = getMemberAvatarKey(member, isLead) + const accent = memberAccentColor(member.color, snapshot.team.members.indexOf(member)) + const ownedTasks = snapshot.tasks.filter((task) => taskOwnedByMember(task, member)) + const currentTask = runningTaskForMember(snapshot.tasks, member) + const statusLabel = t(`agentTeams.member.${ + snapshot.deletedAt || member.status === 'completed' + ? 'exited' + : member.status === 'error' + ? 'error' + : member.status === 'running' + ? 'working' + : 'idle' + }` as TranslationKey) + + return ( +
+
+
+
+ + {currentTask || member.currentTask ? ( +

+ {currentTask?.activeForm || currentTask?.subject || member.currentTask} +

+ ) : null} + + {ownedTasks.length > 0 ? ( +
+ {ownedTasks.map((task) => ( + + #{task.id} + {task.subject} + + ))} +
+ ) : null} +
+ + +
+ ) +} diff --git a/desktop/src/components/agentTeams/AgentTeamsSummary.tsx b/desktop/src/components/agentTeams/AgentTeamsSummary.tsx new file mode 100644 index 00000000..44465560 --- /dev/null +++ b/desktop/src/components/agentTeams/AgentTeamsSummary.tsx @@ -0,0 +1,180 @@ +import { ChevronRight, UsersRound } from 'lucide-react' +import { Badge, StatusDot, type Tone } from '@/components/ui/Badge' +import { useTranslation, type TranslationKey } from '../../i18n' +import type { TeamMember, TeamWorkbenchSnapshot } from '../../types/team' +import { MEMBER_AVATARS } from './agentTeamsAvatars' +import { + getMemberAvatarKey, + getWorkbenchPhase, + getWorkbenchProgress, + type WorkbenchPhase, +} from './agentTeamsModel' + +/** Faces beyond this collapse into a `+N`, so the strip never wraps. */ +const MAX_STACKED_AVATARS = 5 + +function phaseTone(phase: WorkbenchPhase): Tone { + if (phase === 'forming') return 'warning' + if (phase === 'running') return 'brand' + if (phase === 'finishing') return 'info' + return 'success' +} + +function AvatarStack({ + members, + leadAgentId, + size, +}: { + members: TeamMember[] + leadAgentId: string | undefined + size: number +}) { + const shown = members.slice(0, MAX_STACKED_AVATARS) + const overflow = members.length - shown.length + return ( + + ) +} + +/** + * The always-visible trace of a running team in the main session header. The + * workbench itself stays closed until this is clicked — a team appearing is + * not on its own a reason to take over the right-hand half of the window. + */ +export function AgentTeamsStrip({ + snapshot, + open, + onOpen, + compact, +}: { + snapshot: TeamWorkbenchSnapshot + open: boolean + onOpen: () => void + compact: boolean +}) { + const t = useTranslation() + const phase = getWorkbenchPhase(snapshot) + const progress = getWorkbenchProgress(snapshot) + const members = snapshot.team.members + + return ( + + ) +} + +/** + * The in-transcript record of the team being formed. It sits where the + * TeamCreate call happened so scrolling back through the conversation still + * shows that this turn handed work to a team. + */ +export function AgentTeamsInlineCard({ + snapshot, + onOpen, +}: { + snapshot: TeamWorkbenchSnapshot + onOpen: () => void +}) { + const t = useTranslation() + const phase = getWorkbenchPhase(snapshot) + const progress = getWorkbenchProgress(snapshot) + const members = snapshot.team.members + + return ( +
+ +
+ ) +} diff --git a/desktop/src/components/agentTeams/AgentTeamsWorkbench.test.tsx b/desktop/src/components/agentTeams/AgentTeamsWorkbench.test.tsx index 79e12313..48dfc2cf 100644 --- a/desktop/src/components/agentTeams/AgentTeamsWorkbench.test.tsx +++ b/desktop/src/components/agentTeams/AgentTeamsWorkbench.test.tsx @@ -132,24 +132,71 @@ describe('AgentTeamsWorkbench', () => { expect(screen.getByText('Snapshot v3 ready')).toBeTruthy() }) - it('opens teammate details from the moving figure without replacing the workbench', async () => { + it('swaps the workbench body to a teammate run and back without leaving the panel', 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')) + await act(async () => { + 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() + const memberView = screen.getByTestId('agent-teams-member-view') + expect(memberView.getAttribute('data-member-agent-id')).toBe('reviewer@visual-team') + expect(screen.getByTestId('agent-teams-member-current-task').textContent).toContain('Working on task 2') + // The map is replaced, not overlaid — the panel is too narrow to show both. + expect(screen.queryByTestId('agent-teams-office')).toBeNull() + // Selecting a teammate must not steal the tab; that is what the explicit + // "open in tab" action is for. + expect(useTabStore.getState().activeTabId).toBeNull() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Back to team overview' })) + }) expect(screen.getByTestId('agent-teams-office')).toBeTruthy() + expect(screen.queryByTestId('agent-teams-member-view')).toBeNull() + }) + + it('detaches the selected teammate into its own tab on request', async () => { + getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending'])) + await act(async () => { + await useTeamStore.getState().fetchWorkbench('visual-team') + }) + render() + + await act(async () => { + fireEvent.click(screen.getByTestId('agent-teams-member-reviewer@visual-team')) + }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Open in tab' })) + }) - fireEvent.click(screen.getByRole('button', { name: 'Talk directly to reviewer' })) expect(useTabStore.getState().activeTabId).toBe('team-member:reviewer@visual-team') }) + it('detaches the whole workbench into a full-screen tab and closes the panel', async () => { + getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending'])) + await act(async () => { + await useTeamStore.getState().fetchWorkbench('visual-team') + }) + useTeamStore.getState().setWorkbenchOpen('lead-session', true) + render() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Open the workbench full screen/i })) + }) + + const tab = useTabStore.getState().tabs.find((current) => current.type === 'team') + expect(tab?.sessionId).toBe('__team__lead-session') + expect(tab?.teamLeadSessionId).toBe('lead-session') + expect(useTabStore.getState().activeTabId).toBe('__team__lead-session') + // The docked panel would otherwise keep a duplicate of the same workbench + // pinned beside the tab that replaced it. + expect(useTeamStore.getState().workbenchOpenBySession['lead-session']).toBe(false) + }) + it('closes the workbench without discarding its archived timeline', async () => { getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'completed', 'completed'])) await act(async () => { @@ -184,10 +231,14 @@ describe('AgentTeamsWorkbench', () => { 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' }) + await act(async () => { + fireEvent.click(screen.getByTestId('agent-teams-member-reviewer@visual-team')) + }) + const executionButton = screen.getByRole('button', { name: 'Open in tab' }) expect((executionButton as HTMLButtonElement).disabled).toBe(false) - fireEvent.click(executionButton) + await act(async () => { + 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 index 31f2d081..56d1872e 100644 --- a/desktop/src/components/agentTeams/AgentTeamsWorkbench.tsx +++ b/desktop/src/components/agentTeams/AgentTeamsWorkbench.tsx @@ -1,24 +1,20 @@ -import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' -import { ChevronLeft, ChevronRight, Radio, X } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { ChevronLeft, ChevronRight, Maximize2, 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 { useTabStore } from '../../stores/tabStore' +import { useTeamStore, type WorkbenchView } from '../../stores/teamStore' import type { TeamMember, TeamWorkbenchMessage, TeamWorkbenchSnapshot, } from '../../types/team' +import { MEMBER_AVATARS, memberAccentColor } from './agentTeamsAvatars' +import { AgentTeamsCommunicationFeed } from './AgentTeamsCommunicationFeed' +import { AgentTeamsMemberView } from './AgentTeamsMemberView' import { getWorkbenchPhase, getWorkbenchProgress, @@ -29,30 +25,16 @@ import { 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 +/** + * Stable default for the view selector. A `?? { kind: 'overview' }` literal + * allocates a fresh object on every store read, which zustand compares by + * identity — that renders forever. + */ +const OVERVIEW_VIEW: WorkbenchView = { kind: 'overview' } type MemberWorkState = 'working' | 'idle' | 'stopped' | 'exited' | 'error' @@ -111,10 +93,7 @@ function memberStateLabel(state: MemberWorkState, t: TranslationFn): string { } 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]! + return memberAccentColor(member.color, index) } function memberName(member: TeamMember): string { @@ -127,37 +106,6 @@ function memberMatchesIdentity(member: TeamMember, identity: string): 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()) @@ -165,16 +113,25 @@ function formatSnapshotTime(snapshot: TeamWorkbenchSnapshot): string { : time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) } -export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) { +export function AgentTeamsWorkbench({ + sessionId, + variant = 'panel', +}: { + sessionId: string + /** `full` is the detached tab: the feed gets its own column beside the map. */ + variant?: 'panel' | 'full' +}) { const t = useTranslation() const timeline = useTeamStore((state) => state.workbenchesBySession[sessionId]) const historyIndex = useTeamStore((state) => state.workbenchHistoryIndexBySession[sessionId] ?? null) + const view = useTeamStore((state) => state.workbenchViewBySession[sessionId] ?? OVERVIEW_VIEW) const setHistoryIndex = useTeamStore((state) => state.setWorkbenchHistoryIndex) const setWorkbenchOpen = useTeamStore((state) => state.setWorkbenchOpen) + const setWorkbenchView = useTeamStore((state) => state.setWorkbenchView) + const openMemberInWorkbench = useTeamStore((state) => state.openMemberInWorkbench) 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 ?? [] @@ -182,6 +139,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) { const selectedIndex = historyIndex === null ? latestIndex : Math.min(historyIndex, latestIndex) const snapshot = selectedIndex >= 0 ? snapshots[selectedIndex] : undefined const previousSnapshot = selectedIndex > 0 ? snapshots[selectedIndex - 1] : undefined + const isFull = variant === 'full' useEffect(() => { const element = officeViewportRef.current @@ -194,12 +152,15 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) { return () => observer.disconnect() }, []) + // A member view outlives its member when the roster changes mid-run; fall + // back to the map rather than rendering an empty body. + const selectedAgentId = view.kind === 'member' ? view.agentId : null useEffect(() => { - if (!selectedMemberId || !snapshot) return - if (!snapshot.team.members.some((member) => member.agentId === selectedMemberId)) { - setSelectedMemberId(null) + if (!selectedAgentId || !snapshot) return + if (!snapshot.team.members.some((member) => member.agentId === selectedAgentId)) { + setWorkbenchView(sessionId, { kind: 'overview' }) } - }, [selectedMemberId, snapshot]) + }, [selectedAgentId, sessionId, setWorkbenchView, snapshot]) const layout = useMemo( () => layoutWorkbenchTasks(snapshot?.tasks ?? [], officeWidth), @@ -266,13 +227,21 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) { const hasNewMessage = Boolean( previousSnapshot && latestMessage && previousSnapshot.messages.at(-1)?.id !== latestMessage.id, ) - const selectedMember = selectedMemberId - ? members.find((member) => member.agentId === selectedMemberId) ?? null + const selectedMember = selectedAgentId + ? members.find((member) => member.agentId === selectedAgentId) ?? null : null + const selectMember = (member: TeamMember) => { + if (selectedAgentId === member.agentId) { + setWorkbenchView(sessionId, { kind: 'overview' }) + return + } + openMemberInWorkbench(sessionId, member, snapshot.team) + } return (
@@ -340,17 +309,41 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) { {historyIndex === null ? t('agentTeams.live') : `T+${selectedIndex}`} · {formatSnapshotTime(snapshot)} -
-
+ {selectedMember ? ( + setWorkbenchView(sessionId, { kind: 'overview' })} + onOpenInTab={() => openMemberSession(selectedMember, snapshot.team)} + /> + ) : ( +
+ +
setSelectedMemberId((current) => current === leadMember.agentId ? null : leadMember.agentId)} + onSelect={() => selectMember(leadMember)} t={t} /> ) : null} @@ -418,7 +411,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) { position={position} accent={memberAccent(member, members.indexOf(member))} isMessageSender={Boolean(hasNewMessage && latestMessage && memberMatchesIdentity(member, latestMessage.from))} - onSelect={() => setSelectedMemberId((current) => current === member.agentId ? null : member.agentId)} + onSelect={() => selectMember(member)} t={t} /> ) @@ -438,7 +431,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) { snapshot={snapshot} primaryTaskByMemberId={primaryTaskByMemberId} latestMessage={hasNewMessage ? latestMessage : undefined} - onSelectMember={(member) => setSelectedMemberId((current) => current === member.agentId ? null : member.agentId)} + onSelectMember={selectMember} onFocusTask={setFocusedTaskId} t={t} /> @@ -446,19 +439,15 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
- - - {selectedMember ? ( - setSelectedMemberId(null)} - onOpenConversation={() => openMemberSession(selectedMember, snapshot.team)} - t={t} - /> - ) : null} + {/* Detached, the feed earns a full-height column of its own — who said + what to whom is the substance of a multi-agent run, and the panel + form had it squeezed into a 200px strip under the map. */} + +
+ )}
) } @@ -834,172 +823,3 @@ function TaskCard({ ) } - -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/AgentTeamsWorkbenchTab.test.tsx b/desktop/src/components/agentTeams/AgentTeamsWorkbenchTab.test.tsx new file mode 100644 index 00000000..7212cb37 --- /dev/null +++ b/desktop/src/components/agentTeams/AgentTeamsWorkbenchTab.test.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSettingsStore } from '../../stores/settingsStore' +import { useTabStore } from '../../stores/tabStore' +import { useTeamStore } from '../../stores/teamStore' +import { AgentTeamsWorkbenchTab } from './AgentTeamsWorkbenchTab' + +vi.mock('../../api/teams', () => ({ + teamsApi: { + list: vi.fn(), get: vi.fn(), + getWorkbenchForSession: vi.fn().mockRejectedValue(new Error('no')), + getWorkbench: vi.fn(), getMemberTranscript: vi.fn(), sendMemberMessage: vi.fn(), delete: vi.fn(), + }, +})) + +describe('AgentTeamsWorkbenchTab', () => { + beforeEach(() => { + useTeamStore.getState().clearTeam() + useTabStore.setState({ tabs: [], activeTabId: null }) + useSettingsStore.setState({ locale: 'en' }) + }) + + it('renders the workbench in full variant with the feed in its own column', () => { + useTeamStore.setState({ + workbenchesBySession: { + 'lead-session': { + teamName: 't', loading: false, error: null, + snapshots: [{ + version: 'v1', generatedAt: '2026-08-08T00:00:00.000Z', + team: { name: 't', leadAgentId: 'lead@t', leadSessionId: 'lead-session', members: [{ agentId: 'lead@t', name: 'lead', role: 'lead', status: 'running' }] }, + tasks: [], messages: [{ id: 'm1', from: 'a', to: 'b', recipients: ['b'], kind: 'direct', text: 'hello there', timestamp: '2026-08-08T00:00:00.000Z' }], + }], + }, + }, + }) + + render() + + expect(screen.getByTestId('agent-teams-office')).toBeTruthy() + expect(screen.getByText('hello there')).toBeTruthy() + // Full variant: the feed owns a column, so it must not carry the docked strip height. + const feed = screen.getByTestId('agent-teams-communication') + expect(feed.className).toContain('h-full') + expect(feed.className).not.toContain('h-[210px]') + // The detached tab has no close/fullscreen chrome of its own. + expect(screen.queryByRole('button', { name: /Open the workbench full screen/i })).toBeNull() + expect(screen.getByRole('button', { name: 'Back to session' })).toBeTruthy() + }) +}) diff --git a/desktop/src/components/agentTeams/AgentTeamsWorkbenchTab.tsx b/desktop/src/components/agentTeams/AgentTeamsWorkbenchTab.tsx new file mode 100644 index 00000000..59999ecf --- /dev/null +++ b/desktop/src/components/agentTeams/AgentTeamsWorkbenchTab.tsx @@ -0,0 +1,49 @@ +import { useEffect } from 'react' +import { ArrowLeft } from 'lucide-react' +import { Button } from '@/components/ui/Button' +import { useTranslation } from '../../i18n' +import { useTabStore } from '../../stores/tabStore' +import { useTeamStore } from '../../stores/teamStore' +import { AgentTeamsWorkbench } from './AgentTeamsWorkbench' + +/** + * The detached, full-window form of the workbench. It reads the same + * per-session timeline the docked panel does, so switching between the two + * keeps history position and the selected member. + */ +export function AgentTeamsWorkbenchTab({ + tabId, + leadSessionId, +}: { + tabId: string + leadSessionId: string +}) { + const t = useTranslation() + const hasTimeline = useTeamStore((state) => + Boolean(state.workbenchesBySession[leadSessionId]?.snapshots.length), + ) + const fetchTeamForSession = useTeamStore((state) => state.fetchTeamForSession) + + // Restoring straight into this tab (or opening it for a session whose team + // was never fetched) leaves the store empty, so pull the timeline here too. + useEffect(() => { + if (hasTimeline) return + void fetchTeamForSession(leadSessionId) + }, [fetchTeamForSession, hasTimeline, leadSessionId]) + + return ( +
+
+ +
+ +
+ ) +} diff --git a/desktop/src/components/agentTeams/agentTeamsAvatars.ts b/desktop/src/components/agentTeams/agentTeamsAvatars.ts new file mode 100644 index 00000000..81afba39 --- /dev/null +++ b/desktop/src/components/agentTeams/agentTeamsAvatars.ts @@ -0,0 +1,36 @@ +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 type { MemberAvatarKey } from './agentTeamsModel' + +export 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, +} + +export const MEMBER_ACCENTS = [ + 'var(--color-brand)', + 'var(--color-warning)', + 'var(--color-success)', + 'var(--color-info)', + 'var(--color-text-secondary)', + 'var(--color-error)', +] as const + +const ACCENT_COLOR_ORDER = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan'] + +export function memberAccentColor(color: string | undefined, index: number): string { + const colorIndex = color ? ACCENT_COLOR_ORDER.indexOf(color) : -1 + return MEMBER_ACCENTS[(colorIndex >= 0 ? colorIndex : index) % MEMBER_ACCENTS.length]! +} diff --git a/desktop/src/components/agentTeams/agentTeamsModel.test.ts b/desktop/src/components/agentTeams/agentTeamsModel.test.ts index 361f009c..fd7ad141 100644 --- a/desktop/src/components/agentTeams/agentTeamsModel.test.ts +++ b/desktop/src/components/agentTeams/agentTeamsModel.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest' import type { TeamMember, TeamWorkbenchSnapshot, TeamWorkbenchTask } from '../../types/team' import { + formatWorkbenchMessageTime, getWorkbenchPhase, getWorkbenchProgress, getWorkbenchTaskState, getMemberAvatarKey, layoutWorkbenchTasks, + parseWorkbenchMessageBody, runningTaskForMember, taskOwnedByMember, WORKBENCH_TASK_WIDTH, @@ -136,4 +138,41 @@ describe('Agent Teams workbench model', () => { deletedAt: '2026-08-08T00:01:00.000Z', })).toBe('completed') }) + + it('narrates protocol payloads instead of leaking their raw JSON into the feed', () => { + // This exact shape was rendering verbatim in the communication feed. + const idle = parseWorkbenchMessageBody({ + text: '{"type":"idle_notification","from":"release-engineer","timestamp":"2026-08-08T07:42:16.666Z","idleReason":"available"}', + }) + expect(idle).toEqual({ kind: 'lifecycle', type: 'idle_notification', detail: 'available' }) + + // protocolType wins over the body, and is enough on its own. + expect(parseWorkbenchMessageBody({ text: 'shutting down', protocolType: 'shutdown_request' })) + .toEqual({ kind: 'lifecycle', type: 'shutdown_request', detail: undefined }) + + // Authored prose is never reclassified. + expect(parseWorkbenchMessageBody({ text: 'Race condition confirmed in queue.ts' })) + .toEqual({ kind: 'text', text: 'Race condition confirmed in queue.ts' }) + }) + + it('surfaces readable fields from unrecognised JSON rather than printing braces', () => { + expect(parseWorkbenchMessageBody({ text: '{"type":"custom_event","message":"handoff ready"}' })) + .toEqual({ kind: 'text', text: 'handoff ready' }) + + // Nothing prose-like inside: keep the payload rather than silently dropping it. + const opaque = '{"type":"custom_event","count":3}' + expect(parseWorkbenchMessageBody({ text: opaque })).toEqual({ kind: 'text', text: opaque }) + + // Malformed JSON must not throw or be mistaken for a protocol signal. + expect(parseWorkbenchMessageBody({ text: '{not json' })).toEqual({ kind: 'text', text: '{not json' }) + }) + + it('stamps messages with their own send time, not a shared snapshot index', () => { + const first = formatWorkbenchMessageTime('2026-08-08T07:42:16.666Z') + const second = formatWorkbenchMessageTime('2026-08-08T09:15:00.000Z') + expect(first).toBeTruthy() + // Two messages inside one snapshot used to render an identical `T+0`. + expect(first).not.toBe(second) + expect(formatWorkbenchMessageTime('not-a-date')).toBe('') + }) }) diff --git a/desktop/src/components/agentTeams/agentTeamsModel.ts b/desktop/src/components/agentTeams/agentTeamsModel.ts index 9a1b3087..5cbe7605 100644 --- a/desktop/src/components/agentTeams/agentTeamsModel.ts +++ b/desktop/src/components/agentTeams/agentTeamsModel.ts @@ -1,5 +1,7 @@ +import { AGENT_LIFECYCLE_TYPES } from '../../types/team' import type { TeamMember, + TeamWorkbenchMessage, TeamWorkbenchSnapshot, TeamWorkbenchTask, } from '../../types/team' @@ -199,6 +201,88 @@ export function getWorkbenchProgress(snapshot: TeamWorkbenchSnapshot) { } } +/** + * A workbench message is either something a teammate wrote or a protocol + * signal the runtime emitted. The feed used to print the latter as raw JSON + * (`{"type":"idle_notification",...}`), which is both unreadable and the + * opposite of what the transcript does — `extractVisibleTeammateMessageContents` + * has always dropped these payloads from chat. Classifying here lets the feed + * render a sentence and de-emphasise it instead. + */ +export type WorkbenchMessageBody = + | { kind: 'text'; text: string } + | { kind: 'lifecycle'; type: string; detail?: string } + +/** Protocol payloads the feed states in words rather than dumping verbatim. */ +const NARRATED_PROTOCOL_TYPES = new Set([ + ...AGENT_LIFECYCLE_TYPES, + 'shutdown_response', + 'task_assignment', +]) + +const LIFECYCLE_DETAIL_FIELDS = ['idleReason', 'reason', 'detail', 'message'] as const +const READABLE_BODY_FIELDS = ['message', 'content', 'text', 'summary', 'reason'] as const + +function parseJsonObject(raw: string): Record | null { + if (!raw.startsWith('{') || !raw.endsWith('}')) return null + try { + const parsed: unknown = JSON.parse(raw) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : null + } catch { + return null + } +} + +function firstNonEmptyString( + record: Record, + fields: readonly string[], +): string | undefined { + for (const field of fields) { + const value = record[field] + if (typeof value === 'string' && value.trim()) return value.trim() + } + return undefined +} + +export function parseWorkbenchMessageBody( + message: Pick, +): WorkbenchMessageBody { + const raw = message.text?.trim() ?? '' + const payload = parseJsonObject(raw) + const payloadType = typeof payload?.type === 'string' ? payload.type : undefined + const type = message.protocolType ?? payloadType + + if (type && NARRATED_PROTOCOL_TYPES.has(type)) { + return { + kind: 'lifecycle', + type, + ...(payload ? { detail: firstNonEmptyString(payload, LIFECYCLE_DETAIL_FIELDS) } : {}), + } + } + + // An unrecognised JSON payload still beats raw braces: surface whichever + // field actually carries prose, and only fall back to the literal text when + // nothing readable is in there. + if (payload) { + return { kind: 'text', text: firstNonEmptyString(payload, READABLE_BODY_FIELDS) ?? raw } + } + return { kind: 'text', text: raw } +} + +/** + * Wall-clock time the message was sent. The feed previously stamped every row + * with `T+{snapshotIndex}`, which is identical for every message in a snapshot + * and therefore carries no information at all. + */ +export function formatWorkbenchMessageTime(timestamp: string): string { + const time = new Date(timestamp) + return Number.isNaN(time.getTime()) + ? '' + : time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +} + 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) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 0ba5437a..7b279870 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -2510,7 +2510,9 @@ describe('MessageList nested tool calls', () => { const renderedKinds = renderItems.map((item) => item.kind === 'tool_group' ? `tool:${item.toolCalls[0]?.toolUseId}` - : `message:${item.message.id}`, + : item.kind === 'team_card' + ? `team:${item.id}` + : `message:${item.message.id}`, ) expect(renderedKinds).toEqual([ @@ -6862,4 +6864,38 @@ describe('Agent Teams chat projection', () => { hideTeamCoordinationTools: true, }).renderItems).toHaveLength(1) }) + + it('replaces the TeamCreate call with a team card at the point the team was formed', () => { + const messages: UIMessage[] = [ + { id: 'user-1', type: 'user_text', content: 'Audit the queue', timestamp: 1 }, + { + id: 'tool-create', + type: 'tool_use', + toolName: 'TeamCreate', + toolUseId: 'create-1', + input: { team_name: 'audit-team' }, + timestamp: 2, + }, + { + id: 'result-create', + type: 'tool_result', + toolUseId: 'create-1', + content: '{"team_name":"audit-team","lead_agent_id":"team-lead@audit-team"}', + isError: false, + timestamp: 3, + }, + { id: 'assistant-1', type: 'assistant_text', content: 'Team is up.', timestamp: 4 }, + ] + + const lead = buildRenderModel(messages, null, { hideTeamCoordinationTools: true }) + expect(lead.renderItems.map((item) => item.kind)) + .toEqual(['message', 'team_card', 'message']) + // The card sits where the call was, not appended at the end — scrolling + // back must still show that this turn handed work to a team. + expect(lead.renderItems[1]).toMatchObject({ kind: 'team_card', id: 'team-card-tool-create' }) + + // An ordinary session with no workbench keeps the raw tool call. + expect(buildRenderModel(messages).renderItems.map((item) => item.kind)) + .toEqual(['message', 'tool_group', 'message']) + }) }) diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index c32fc348..9179af3d 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -24,6 +24,7 @@ import { AskUserQuestion } from './AskUserQuestion' import { StreamingIndicator } from './StreamingIndicator' import { InlineTaskSummary } from './InlineTaskSummary' import { CurrentTurnChangeCard } from './CurrentTurnChangeCard' +import { AgentTeamsInlineCard } from '../agentTeams/AgentTeamsSummary' import { buildConversationNavigationItems, ConversationNavigator, @@ -64,6 +65,11 @@ type RenderItem = */ | { kind: 'tool_group'; toolCalls: ToolCall[]; steps: ActivityStep[]; id: string } | { kind: 'message'; message: UIMessage } + /** + * Stands in for the TeamCreate call so the transcript records that this turn + * handed work to a team, without expanding into the workbench inline. + */ + | { kind: 'team_card'; id: string } type RenderModel = { renderItems: RenderItem[] @@ -784,6 +790,13 @@ export function buildRenderModel( ) { continue } + // The raw TeamCreate call and its JSON result say nothing a reader can + // use; the team card in its place links to the workbench that does. + if (options.hideTeamCoordinationTools && msg.toolName === 'TeamCreate') { + flushGroup() + items.push({ kind: 'team_card', id: `team-card-${msg.id}` }) + continue + } if (msg.toolName === 'AskUserQuestion') { const isResolved = toolResultMap.has(msg.toolUseId) const lastUnresolvedIndex = lastUnresolvedAskUserQuestionIndexByToolUseId.get(msg.toolUseId) @@ -1241,7 +1254,8 @@ function clampNumber(value: number, min: number, max: number) { } function getRenderItemKey(item: RenderItem) { - return item.kind === 'tool_group' ? item.id : item.message.id + if (item.kind === 'tool_group' || item.kind === 'team_card') return item.id + return item.message.id } function findConversationMatches( @@ -1405,6 +1419,9 @@ function getMessageContentWeight(message: UIMessage): number { function getRenderItemContentWeight(item: RenderItem): number { if (item.kind === 'message') return getMessageContentWeight(item.message) + // The team card is a fixed-height summary, so it contributes no text weight + // to the virtualization heuristic. + if (item.kind === 'team_card') return 0 return item.steps.reduce( (total, step) => total + getMessageContentWeight(step.kind === 'tool' ? step.toolCall : step.message), 0, @@ -1477,9 +1494,12 @@ function estimateMessageHeight(message: UIMessage): number { /** A collapsed activity group is one header line, however many steps it holds. */ const ACTIVITY_GROUP_COLLAPSED_HEIGHT = 52 +/** Avatar row plus two text lines plus the card's own margin. */ +const TEAM_CARD_HEIGHT = 78 function estimateRenderItemHeight(item: RenderItem): number { if (item.kind === 'message') return estimateMessageHeight(item.message) + if (item.kind === 'team_card') return TEAM_CARD_HEIGHT // Agent dispatch groups keep their taller per-agent cards; everything else // collapses to the single-line activity header. const isAgentGroup = item.toolCalls.length > 0 && item.toolCalls.every((toolCall) => toolCall.toolName === 'Agent') @@ -1519,6 +1539,7 @@ function getMessageMetricSignature(message: UIMessage): string { function getRenderItemMetricSignature(item: RenderItem): string { if (item.kind === 'message') return getMessageMetricSignature(item.message) + if (item.kind === 'team_card') return `team_card:${item.id}` return item.steps .map((step) => getMessageMetricSignature(step.kind === 'tool' ? step.toolCall : step.message)) .join('|') @@ -1753,6 +1774,11 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false } resolvedSessionId ? s.workbenchesBySession[resolvedSessionId] : undefined, ) const isTeamLeadSession = Boolean(teamWorkbench?.snapshots.length) + const teamSnapshot = teamWorkbench?.snapshots.at(-1) + const openTeamWorkbench = useCallback((leadSessionId: string) => { + useWorkspacePanelStore.getState().closePanel(leadSessionId) + useTeamStore.getState().setWorkbenchOpen(leadSessionId, true) + }, []) const teamMemberNames = useMemo(() => { const snapshots = teamWorkbench?.snapshots if (!snapshots?.length) return undefined @@ -2915,6 +2941,13 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false } item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId)) } /> + ) : item.kind === 'team_card' ? ( + teamSnapshot && resolvedSessionId ? ( + openTeamWorkbench(resolvedSessionId)} + /> + ) : null ) : (
{virtualTranscriptWindow.enabled ? ( @@ -3116,6 +3153,7 @@ export const MessageBlock = memo(function MessageBlock({ branchAction={branchAction} timestamp={message.timestamp} sessionId={sessionId ?? undefined} + teammateFrom={message.teammateFrom} /> ) diff --git a/desktop/src/components/chat/UserMessage.test.tsx b/desktop/src/components/chat/UserMessage.test.tsx index 229e596e..8767c680 100644 --- a/desktop/src/components/chat/UserMessage.test.tsx +++ b/desktop/src/components/chat/UserMessage.test.tsx @@ -118,4 +118,32 @@ describe('UserMessage bare-URL linkify', () => { const { container } = render() expect(container.querySelectorAll('a')).toHaveLength(0) }) + + it('attributes a teammate turn and left-aligns it away from the user bubble', () => { + const { container } = render( + , + ) + + const shell = container.querySelector('[data-message-shell="teammate"]') + expect(shell).toBeTruthy() + expect(shell?.getAttribute('data-teammate-from')).toBe('team-lead') + // Attribution is the point: an unlabelled bubble reads as the operator. + expect(shell?.textContent).toContain('team-lead') + expect(shell?.textContent).toContain('teammate message') + + // A teammate turn must not reuse the user's right-aligned bubble, and must + // not offer branching — the operator did not author it. + expect(container.querySelector('[data-message-shell="user"]')).toBeNull() + expect(container.querySelector('[data-message-body="user"]')).toBeNull() + expect(container.querySelector('[data-message-body="teammate"]')).toBeTruthy() + expect(container.firstElementChild?.className).toContain('justify-start') + }) + + it('keeps an ordinary prompt in the right-aligned user bubble', () => { + const { container } = render() + + expect(container.querySelector('[data-message-shell="teammate"]')).toBeNull() + expect(bubbleOf(container).textContent).toBe('Review the auth diff.') + expect(container.firstElementChild?.className).toContain('justify-end') + }) }) diff --git a/desktop/src/components/chat/UserMessage.tsx b/desktop/src/components/chat/UserMessage.tsx index 5829dd51..89cfd126 100644 --- a/desktop/src/components/chat/UserMessage.tsx +++ b/desktop/src/components/chat/UserMessage.tsx @@ -1,5 +1,6 @@ import { memo, useCallback, useMemo } from 'react' -import type { MouseEvent as ReactMouseEvent } from 'react' +import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react' +import { UsersRound } from 'lucide-react' import type { UIAttachment } from '../../types/chat' import { useTranslation } from '../../i18n' import { openPreviewLink } from '../../lib/openPreviewLink' @@ -13,9 +14,11 @@ type Props = { branchAction?: MessageBranchAction timestamp?: number sessionId?: string + /** Set when this turn came from another agent rather than from the user. */ + teammateFrom?: string } -export const UserMessage = memo(function UserMessage({ content, attachments, branchAction, timestamp, sessionId }: Props) { +export const UserMessage = memo(function UserMessage({ content, attachments, branchAction, timestamp, sessionId, teammateFrom }: Props) { const t = useTranslation() const hasText = content.trim().length > 0 @@ -32,6 +35,71 @@ export const UserMessage = memo(function UserMessage({ content, attachments, bra [sessionId], ) + const body: ReactNode = segments.map((segment, index) => + segment.type === 'url' ? ( + handleLinkClick(event, segment.value)} + > + {segment.value} + + ) : ( + segment.value + ), + ) + + // A teammate's instruction is not the user speaking, so it does not take the + // user's right-aligned bubble. Attributing and left-aligning it is what gives + // a member transcript the same read-at-a-glance structure the main session + // has: prompt right, everything the agents said left. + if (teammateFrom) { + return ( +
+
+
+
+ +
+ {attachments && attachments.length > 0 && ( + + )} + {hasText && ( +
+ {body} +
+ )} +
+ + {hasText && ( + + )} +
+
+ ) + } + return (
- {segments.map((segment, index) => - segment.type === 'url' ? ( - handleLinkClick(event, segment.value)} - > - {segment.value} - - ) : ( - segment.value - ), - )} + {body}
)}
diff --git a/desktop/src/components/layout/ContentRouter.tsx b/desktop/src/components/layout/ContentRouter.tsx index 52b63725..100f14cb 100644 --- a/desktop/src/components/layout/ContentRouter.tsx +++ b/desktop/src/components/layout/ContentRouter.tsx @@ -10,6 +10,7 @@ import { TraceList } from '../../pages/TraceList' import { TraceSession } from '../../pages/TraceSession' import { SubagentRunPage } from '../../pages/SubagentRunPage' import { WorkbenchTab } from '../workbench/WorkbenchTab' +import { AgentTeamsWorkbenchTab } from '../agentTeams/AgentTeamsWorkbenchTab' import { previewBridge } from '../../lib/previewBridge' import { returnToTraceList } from '../../lib/traceNavigation' @@ -58,6 +59,11 @@ export function ContentRouter() { page = workbenchTab?.workbenchSessionId ? : + } else if (activeTabType === 'team') { + const teamTab = tabs.find((t) => t.sessionId === activeTabId) + page = teamTab?.teamLeadSessionId + ? + : } else if (activeTabType !== 'terminal') { page = } diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index ffc999a7..b77e2616 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -458,7 +458,7 @@ describe('TabBar', () => { expect(useActivityPanelStore.getState().isOpen(sessionId)).toBe(true) }) - it('moves team activity out of the activity rail and reserves the workspace slot', async () => { + it('offers the team workbench alongside the workspace entry instead of replacing it', async () => { const { TabBar } = await import('./TabBar') const { useTabStore } = await import('../../stores/tabStore') const { useChatStore } = await import('../../stores/chatStore') @@ -498,22 +498,60 @@ describe('TabBar', () => { render() }) - 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') + // A team existing is not a reason to take over the right-hand slot, so the + // workbench starts closed and the workspace entry stays reachable. + const workbenchButton = screen.getByRole('button', { name: 'Show Agent Teams Workbench' }) + expect(workbenchButton).toHaveAttribute('aria-pressed', 'false') + expect(screen.getByRole('button', { name: 'Show Workspace' })).toBeInTheDocument() 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) + expect(screen.getByRole('button', { name: 'Hide Agent Teams Workbench' })).toHaveAttribute( + 'aria-pressed', + 'true', + ) + // Only an open workbench suppresses the activity rail — the two would + // otherwise fight over the same edge of the window. + expect(screen.queryByRole('button', { name: /activity/i })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Hide Agent Teams Workbench' })) + expect(useTeamStore.getState().workbenchOpenBySession[sessionId]).toBe(false) + }) + + it('lets the workspace panel evict an open team workbench from the shared slot', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + const { useSessionStore } = await import('../../stores/sessionStore') + const { useTeamStore } = await import('../../stores/teamStore') + const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore') + const sessionId = 'session-team' + + useTabStore.setState({ + tabs: [{ sessionId, title: 'Team Chat', type: 'session', status: 'idle' }], + activeTabId: sessionId, + }) + useSessionStore.setState({ + sessions: [{ id: sessionId, title: 'Team Chat', workDir: '/tmp/project', workDirExists: true }], + } as Partial>) + useChatStore.setState({ + sessions: { [sessionId]: makeChatSession('idle') }, + disconnectSession: vi.fn(), + } as Partial>) + useTeamStore.setState({ + workbenchesBySession: { [sessionId]: teamWorkbenchTimeline(sessionId) }, + workbenchOpenBySession: { [sessionId]: true }, + } as Partial>) + + await act(async () => { + render() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Show Workspace' })) + + expect(useTeamStore.getState().workbenchOpenBySession[sessionId]).toBe(false) + expect(useWorkspacePanelStore.getState().isPanelOpen(sessionId)).toBe(true) }) it('hides team-only activity when the active team belongs to another session', async () => { @@ -602,7 +640,10 @@ describe('TabBar', () => { 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() + // The workspace entry survives a team arriving mid-session; only the + // workbench toggle is added. + expect(screen.getByRole('button', { name: 'Show Workspace' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Show Agent Teams Workbench' })).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 9f67998b..ca6ad2a4 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -19,7 +19,7 @@ import { isPlaceholderSessionTitle } from '../../lib/sessionTitle' import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' import { useTerminalPanelStore } from '../../stores/terminalPanelStore' import { useCLITaskStore } from '../../stores/cliTaskStore' -import { useTeamStore } from '../../stores/teamStore' +import { isAgentTeamsWorkbenchOpen as isAgentTeamsWorkbenchOpenFor, useTeamStore } from '../../stores/teamStore' import { StatusDot } from '@/components/ui/Badge' import { IconButton } from '@/components/ui/IconButton' import { useDismissable } from '@/hooks/useDismissable' @@ -134,12 +134,10 @@ export function TabBar() { 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 isAgentTeamsWorkbenchOpen = useTeamStore((state) => + isAgentTeamsWorkbenchOpenFor(state, activeTabId), + ) + const isWorkspacePanelOpen = !isAgentTeamsWorkbenchOpen && isWorkbenchOpen && workbenchMode === 'workspace' const isTerminalPanelOpen = useTerminalPanelStore((state) => activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false, ) @@ -176,7 +174,10 @@ export function TabBar() { hasVisibleActivity: hasVisibleSessionActivity(model), } })) - const showActivityButton = activeTabId && activityState.hasVisibleActivity && !isWorkbenchOpen && !hasAgentTeamsWorkbench + const showActivityButton = activeTabId && + activityState.hasVisibleActivity && + !isWorkbenchOpen && + !isAgentTeamsWorkbenchOpen const moveTab = useTabStore((s) => s.moveTab) const scrollRef = useRef(null) @@ -601,7 +602,14 @@ export function TabBar() { label={t(isAgentTeamsWorkbenchOpen ? 'agentTeams.hideWorkbench' : 'agentTeams.showWorkbench')} - onClick={() => useTeamStore.getState().toggleWorkbench(activeTabId)} + onClick={() => { + // One right-hand slot, two tenants: claiming it for the team + // evicts the workspace panel rather than silently losing to it. + if (!isAgentTeamsWorkbenchOpen) { + useWorkspacePanelStore.getState().closePanel(activeTabId) + } + useTeamStore.getState().toggleWorkbench(activeTabId) + }} size="md" tone={isAgentTeamsWorkbenchOpen ? 'default' : 'muted'} pressed={isAgentTeamsWorkbenchOpen} @@ -623,7 +631,7 @@ export function TabBar() { pressed={isTerminalPanelOpen} data-active={isTerminalPanelOpen ? 'true' : 'false'} /> - {isActiveSessionTab && activeTabId && !hasAgentTeamsWorkbench && ( + {isActiveSessionTab && activeTabId && ( : } label={t(isWorkspacePanelOpen ? 'tabs.hideWorkspace' : 'tabs.showWorkspace')} @@ -632,6 +640,7 @@ export function TabBar() { if (workbench.isPanelOpen(activeTabId) && workbench.getMode(activeTabId) === 'workspace') { workbench.closePanel(activeTabId) } else { + useTeamStore.getState().setWorkbenchOpen(activeTabId, false) workbench.setMode(activeTabId, 'workspace') workbench.openPanel(activeTabId) } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index e322b462..e0623c3f 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -36,6 +36,7 @@ export const en = { 'chat.generatingDiagram': 'Generating diagram…', 'chat.copyReply': 'Copy reply', 'chat.copyPrompt': 'Copy prompt', + 'chat.teammateMessage': 'teammate message', 'chat.copyPath': 'Copy path', 'common.dismissNotification': 'Dismiss notification', @@ -2278,6 +2279,24 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le 'agentTeams.openConversation': 'Talk directly to {name}', 'agentTeams.openExecution': 'View {name} execution', 'agentTeams.dependencyLegend': 'Solid: primary dependency · dashed: secondary · hover to focus', + 'agentTeams.openFullscreen': 'Open the workbench full screen', + 'agentTeams.backToSession': 'Back to session', + 'agentTeams.backToOverview': 'Back to team overview', + 'agentTeams.openInTab': 'Open in tab', + 'agentTeams.communication.lifecycle.idle_notification': 'Went idle, waiting for work', + 'agentTeams.communication.lifecycle.shutdown_request': 'Asked to wrap up', + 'agentTeams.communication.lifecycle.shutdown_approved': 'Wrap-up approved', + 'agentTeams.communication.lifecycle.shutdown_rejected': 'Wrap-up declined', + 'agentTeams.communication.lifecycle.teammate_terminated': 'Teammate stopped', + 'agentTeams.communication.lifecycle.shutdown_response': 'Acknowledged the exit', + 'agentTeams.communication.showLifecycle': 'Show {count} status signals', + 'agentTeams.communication.hideLifecycle': 'Hide status signals', + 'agentTeams.communication.expand': 'Show more', + 'agentTeams.communication.collapse': 'Show less', + 'agentTeams.inline.created': 'Agent team · {count} teammates', + 'agentTeams.inline.open': 'Open workbench', + 'agentTeams.inline.tasks': '{completed}/{total} tasks', + 'agentTeams.strip.label': 'Team', // ─── Session Activity ────────────────────────────────────── 'session.activity.title': 'Activity', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 2d349b83..e27962d0 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -38,6 +38,7 @@ export const jp: Record = { 'chat.generatingDiagram': '図を生成中…', 'chat.copyReply': '返信をコピー', 'chat.copyPrompt': 'プロンプトをコピー', + 'chat.teammateMessage': 'メンバーからのメッセージ', 'chat.copyPath': 'パスをコピー', 'common.dismissNotification': '通知を閉じる', @@ -2280,6 +2281,24 @@ export const jp: Record = { 'agentTeams.openConversation': '{name} と直接話す', 'agentTeams.openExecution': '{name} の実行を表示', 'agentTeams.dependencyLegend': '実線:主依存 · 破線:副依存 · ホバーでフォーカス', + 'agentTeams.openFullscreen': 'ワークベンチを全画面で開く', + 'agentTeams.backToSession': 'セッションに戻る', + 'agentTeams.backToOverview': 'チーム概要に戻る', + 'agentTeams.openInTab': 'タブで開く', + 'agentTeams.communication.lifecycle.idle_notification': '待機状態に入り、次の作業を待っています', + 'agentTeams.communication.lifecycle.shutdown_request': '終了を要請しました', + 'agentTeams.communication.lifecycle.shutdown_approved': '終了が承認されました', + 'agentTeams.communication.lifecycle.shutdown_rejected': '終了が却下されました', + 'agentTeams.communication.lifecycle.teammate_terminated': 'メンバーが停止しました', + 'agentTeams.communication.lifecycle.shutdown_response': '終了を確認しました', + 'agentTeams.communication.showLifecycle': 'ステータス通知 {count} 件を表示', + 'agentTeams.communication.hideLifecycle': 'ステータス通知を隠す', + 'agentTeams.communication.expand': 'もっと見る', + 'agentTeams.communication.collapse': '折りたたむ', + 'agentTeams.inline.created': 'エージェントチーム · メンバー {count} 名', + 'agentTeams.inline.open': 'ワークベンチを開く', + 'agentTeams.inline.tasks': 'タスク {completed}/{total}', + 'agentTeams.strip.label': 'チーム', // ─── Session Activity ────────────────────────────────────── 'session.activity.title': 'アクティビティ', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 3c5f0228..3add9a26 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -38,6 +38,7 @@ export const kr: Record = { 'chat.generatingDiagram': '다이어그램 생성 중…', 'chat.copyReply': '답변 복사', 'chat.copyPrompt': '프롬프트 복사', + 'chat.teammateMessage': '팀원 메시지', 'chat.copyPath': '경로 복사', 'common.dismissNotification': '알림 닫기', @@ -2280,6 +2281,24 @@ export const kr: Record = { 'agentTeams.openConversation': '{name}에게 직접 말하기', 'agentTeams.openExecution': '{name} 실행 보기', 'agentTeams.dependencyLegend': '실선: 주 의존성 · 점선: 보조 의존성 · 호버로 집중', + 'agentTeams.openFullscreen': '워크벤치를 전체 화면으로 열기', + 'agentTeams.backToSession': '세션으로 돌아가기', + 'agentTeams.backToOverview': '팀 개요로 돌아가기', + 'agentTeams.openInTab': '탭에서 열기', + 'agentTeams.communication.lifecycle.idle_notification': '대기 상태로 전환, 다음 작업 대기 중', + 'agentTeams.communication.lifecycle.shutdown_request': '종료를 요청했습니다', + 'agentTeams.communication.lifecycle.shutdown_approved': '종료가 승인되었습니다', + 'agentTeams.communication.lifecycle.shutdown_rejected': '종료가 거절되었습니다', + 'agentTeams.communication.lifecycle.teammate_terminated': '팀원이 중지되었습니다', + 'agentTeams.communication.lifecycle.shutdown_response': '종료를 확인했습니다', + 'agentTeams.communication.showLifecycle': '상태 신호 {count}건 표시', + 'agentTeams.communication.hideLifecycle': '상태 신호 숨기기', + 'agentTeams.communication.expand': '더 보기', + 'agentTeams.communication.collapse': '접기', + 'agentTeams.inline.created': '에이전트 팀 · 팀원 {count}명', + 'agentTeams.inline.open': '워크벤치 열기', + 'agentTeams.inline.tasks': '작업 {completed}/{total}', + 'agentTeams.strip.label': '팀', // ─── Session Activity ────────────────────────────────────── 'session.activity.title': '활동', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index cef9e45d..b9257f08 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -38,6 +38,7 @@ export const zh: Record = { 'chat.generatingDiagram': '正在產生圖表…', 'chat.copyReply': '複製回覆', 'chat.copyPrompt': '複製提示詞', + 'chat.teammateMessage': '隊友訊息', 'chat.copyPath': '複製路徑', 'common.dismissNotification': '關閉通知', @@ -2279,6 +2280,24 @@ export const zh: Record = { 'agentTeams.openConversation': '直接與 {name} 對話', 'agentTeams.openExecution': '查看 {name} 的執行過程', 'agentTeams.dependencyLegend': '實線為主要依賴 · 虛線為次要依賴 · 懸停聚焦', + 'agentTeams.openFullscreen': '全螢幕開啟工作台', + 'agentTeams.backToSession': '返回工作階段', + 'agentTeams.backToOverview': '返回團隊總覽', + 'agentTeams.openInTab': '在分頁開啟', + 'agentTeams.communication.lifecycle.idle_notification': '進入待命,等待新任務', + 'agentTeams.communication.lifecycle.shutdown_request': '請求結束工作', + 'agentTeams.communication.lifecycle.shutdown_approved': '結束請求已核准', + 'agentTeams.communication.lifecycle.shutdown_rejected': '結束請求遭駁回', + 'agentTeams.communication.lifecycle.teammate_terminated': '成員已停止', + 'agentTeams.communication.lifecycle.shutdown_response': '已確認退出', + 'agentTeams.communication.showLifecycle': '顯示 {count} 則狀態訊號', + 'agentTeams.communication.hideLifecycle': '隱藏狀態訊號', + 'agentTeams.communication.expand': '展開', + 'agentTeams.communication.collapse': '收合', + 'agentTeams.inline.created': 'Agent 團隊 · {count} 名成員', + 'agentTeams.inline.open': '開啟工作台', + 'agentTeams.inline.tasks': '{completed}/{total} 任務', + 'agentTeams.strip.label': '團隊', // ─── Session Activity ────────────────────────────────────── 'session.activity.title': '活動', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 096ed787..4bc5adc4 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -38,6 +38,7 @@ export const zh: Record = { 'chat.generatingDiagram': '正在生成图表…', 'chat.copyReply': '复制回复', 'chat.copyPrompt': '复制提示词', + 'chat.teammateMessage': '队友消息', 'chat.copyPath': '复制路径', 'common.dismissNotification': '关闭通知', @@ -2279,6 +2280,24 @@ export const zh: Record = { 'agentTeams.openConversation': '直接与 {name} 对话', 'agentTeams.openExecution': '查看 {name} 的执行过程', 'agentTeams.dependencyLegend': '实线为主依赖 · 虚线为次依赖 · 悬停聚焦', + 'agentTeams.openFullscreen': '全屏打开工作台', + 'agentTeams.backToSession': '返回会话', + 'agentTeams.backToOverview': '返回团队总览', + 'agentTeams.openInTab': '在标签页打开', + 'agentTeams.communication.lifecycle.idle_notification': '进入待命,等待新任务', + 'agentTeams.communication.lifecycle.shutdown_request': '请求结束工作', + 'agentTeams.communication.lifecycle.shutdown_approved': '结束请求已批准', + 'agentTeams.communication.lifecycle.shutdown_rejected': '结束请求被驳回', + 'agentTeams.communication.lifecycle.teammate_terminated': '成员已停止', + 'agentTeams.communication.lifecycle.shutdown_response': '已确认退出', + 'agentTeams.communication.showLifecycle': '显示 {count} 条状态信号', + 'agentTeams.communication.hideLifecycle': '隐藏状态信号', + 'agentTeams.communication.expand': '展开', + 'agentTeams.communication.collapse': '收起', + 'agentTeams.inline.created': 'Agent 团队 · {count} 名成员', + 'agentTeams.inline.open': '打开工作台', + 'agentTeams.inline.tasks': '{completed}/{total} 任务', + 'agentTeams.strip.label': '团队', // ─── Session Activity ────────────────────────────────────── 'session.activity.title': '活动', diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index 5736d031..7d7ff86d 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -1210,7 +1210,7 @@ describe('ActiveSession task polling', () => { expect(useTabStore.getState().activeTabId).toBe('__subagent__activity-subagent-open-session__agent-tool-1') }) - it('renders Agent Teams in its dedicated workbench instead of the activity panel', () => { + it('keeps the team workbench closed behind the header strip until it is opened', () => { const sessionId = 'team-activity-panel-session' useActivityPanelStore.getState().open(sessionId) @@ -1310,16 +1310,28 @@ describe('ActiveSession task polling', () => { render() + // Discovering a team must not seize the right-hand slot or compact the + // transcript; the header strip is the whole of its main-session footprint. + const strip = screen.getByTestId('agent-teams-strip') + expect(strip).toHaveAttribute('data-open', 'false') + expect(screen.queryByTestId('agent-teams-workbench-panel')).not.toBeInTheDocument() + expect(screen.getByTestId('message-list')).toHaveAttribute('data-compact', 'false') + + act(() => { + fireEvent.click(strip) + }) + 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(screen.getByTestId('agent-teams-strip')).toHaveAttribute('data-open', 'true') expect(useActivityPanelStore.getState().isOpen(sessionId)).toBe(false) act(() => { - useTeamStore.getState().setWorkbenchOpen(sessionId, false) + fireEvent.click(screen.getByTestId('agent-teams-strip')) }) expect(screen.queryByTestId('agent-teams-workbench-panel')).not.toBeInTheDocument() diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index 42fc60b5..0eab693c 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -4,6 +4,7 @@ import { GitFork, Target } from 'lucide-react' import { SCHEDULED_TAB_ID, SETTINGS_TAB_ID, + TEAM_TAB_PREFIX, TERMINAL_TAB_PREFIX, TRACE_TAB_PREFIX, WORKBENCH_TAB_PREFIX, @@ -16,6 +17,7 @@ import { useCLITaskStore } from '../stores/cliTaskStore' import { AGENT_TEAMS_WORKBENCH_MAX_WIDTH, AGENT_TEAMS_WORKBENCH_MIN_WIDTH, + isAgentTeamsWorkbenchOpen, useTeamStore, } from '../stores/teamStore' import { useWorkspacePanelStore } from '../stores/workspacePanelStore' @@ -36,6 +38,7 @@ import { getWorktreeDisplayName, WorktreeDetails } from '../components/chat/Work import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal' import { WorkbenchPanel } from '../components/workbench/WorkbenchPanel' import { AgentTeamsWorkbench } from '../components/agentTeams/AgentTeamsWorkbench' +import { AgentTeamsStrip } from '../components/agentTeams/AgentTeamsSummary' import { SessionActivityPanel } from '../components/activity/SessionActivityPanel' import { buildSessionActivityModel, hasVisibleSessionActivity } from '../components/activity/sessionActivityModel' import { TerminalSettings } from './TerminalSettings' @@ -69,7 +72,7 @@ const ACTIVITY_AUTOCLOSE_GRACE_MS = 2000 const WORKSPACE_RESIZE_STEP = 32 const TERMINAL_RESIZE_STEP = 24 const CHAT_COLUMN_WITH_WORKSPACE_CLASS = - 'min-w-[320px] flex-1 bg-[var(--color-surface)]' + 'min-w-[400px] flex-1 bg-[var(--color-surface)]' const EMPTY_DISMISSED_BACKGROUND_TASK_KEYS: readonly string[] = [] function isSessionTabState(activeTabId: string | null, activeTabType: TabType | null | undefined) { @@ -80,7 +83,8 @@ function isSessionTabState(activeTabId: string | null, activeTabType: TabType | activeTabId !== SCHEDULED_TAB_ID && !activeTabId.startsWith(TERMINAL_TAB_PREFIX) && !activeTabId.startsWith(TRACE_TAB_PREFIX) && - !activeTabId.startsWith(WORKBENCH_TAB_PREFIX) + !activeTabId.startsWith(WORKBENCH_TAB_PREFIX) && + !activeTabId.startsWith(TEAM_TAB_PREFIX) } function getTokenUsageTotal(usage: TokenUsage): number { @@ -406,10 +410,10 @@ 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 agentTeamsSnapshot = useTeamStore((s) => activeTabId + ? s.workbenchesBySession[activeTabId]?.snapshots.at(-1) + : undefined) + const agentTeamsWorkbenchOpen = useTeamStore((s) => isAgentTeamsWorkbenchOpen(s, activeTabId)) const agentTeamsPanelWidth = useTeamStore((s) => s.workbenchPanelWidth) const fetchTeamForSession = useTeamStore((s) => s.fetchTeamForSession) const isMemberSession = !!memberInfo @@ -424,13 +428,15 @@ export function ActiveSession() { : false, ) const showAgentTeamsWorkbench = Boolean( - agentTeamsTimeline?.snapshots.length && agentTeamsWorkbenchOpen && activeTabId && isSessionTabState(activeTabId, activeTabType) && !isMemberSession && !isMobileLayout, ) + // Both panels share the one right-hand slot, so the last one opened wins. + // Their toolbar entries stay independent — opening a team no longer removes + // the workspace and activity affordances from the tab bar. const showWorkbench = workspaceWorkbenchOpen && !showAgentTeamsWorkbench const showRightPanel = showWorkbench || showAgentTeamsWorkbench const workspacePanelWidth = useWorkspacePanelStore((state) => state.width) @@ -764,13 +770,16 @@ export function ActiveSession() { {!isMemberSession && !isMobileLayout && (
-
+ {/* Stays centred on the same 900px measure as the transcript + regardless of the right-hand panel — only the padding + tightens, so the header never drifts out of alignment + with the messages beneath it. */} +

+ {agentTeamsSnapshot ? ( + { + if (showAgentTeamsWorkbench) { + useTeamStore.getState().setWorkbenchOpen(activeTabId, false) + return + } + useWorkspacePanelStore.getState().closePanel(activeTabId) + useTeamStore.getState().setWorkbenchOpen(activeTabId, true) + }} + /> + ) : null}

)} @@ -955,8 +979,8 @@ export function ActiveSession() { className="flex h-full shrink-0 flex-col bg-[var(--color-surface)]" style={{ width: rightPanelWidth, - maxWidth: 'min(940px, 72%)', - minWidth: 'min(440px, 54%)', + maxWidth: '62%', + minWidth: 'min(380px, 46%)', }} > diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 9e0da443..9d0bcb14 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -2092,7 +2092,7 @@ describe('chatStore history mapping', () => { }) }) - it('surfaces teammate prompt content when mapping member transcript history', () => { + it('attributes teammate prompts to their sender when mapping member transcript history', () => { const messages: MessageEntry[] = [ { id: 'user-1', @@ -2100,18 +2100,45 @@ describe('chatStore history mapping', () => { timestamp: '2026-04-06T00:00:00.000Z', content: 'Review the auth diff and call out risks.', }, + { + id: 'user-2', + type: 'user', + timestamp: '2026-04-06T00:01:00.000Z', + content: [ + { type: 'text', text: 'Ship it once tests pass.' }, + ], + }, + { + id: 'user-3', + type: 'user', + timestamp: '2026-04-06T00:02:00.000Z', + content: 'What did you find?', + }, ] const mapped = mapHistoryMessagesToUiMessages(messages, { includeTeammateMessages: true, }) + // Without the sender, a teammate's instruction and the operator's own + // prompt render as the same anonymous bubble. expect(mapped).toMatchObject([ { type: 'user_text', content: 'Review the auth diff and call out risks.', + teammateFrom: 'security-reviewer', + }, + { + type: 'user_text', + content: 'Ship it once tests pass.', + teammateFrom: 'team-lead', + }, + { + type: 'user_text', + content: 'What did you find?', }, ]) + expect(mapped[2]).not.toHaveProperty('teammateFrom') }) it('preserves source user ids when restoring array-content user prompts', () => { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 9d78b77a..97701d01 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -4208,8 +4208,14 @@ function buildAbortedHistoryLoadUpdate( const TEAMMATE_CONTENT_REGEX = /]*>\n?([\s\S]*?)\n?<\/teammate-message>/g -function extractVisibleTeammateMessageContents(text: string): string[] { - const contents: string[] = [] +export type VisibleTeammateMessage = { from: string; content: string } + +/** + * Teammate turns carry the sender in the tag; keeping it lets the transcript + * attribute the message instead of rendering it as an anonymous user turn. + */ +function extractVisibleTeammateMessages(text: string): VisibleTeammateMessage[] { + const messages: VisibleTeammateMessage[] = [] for (const match of text.matchAll(TEAMMATE_CONTENT_REGEX)) { const content = match[2]?.trim() @@ -4226,12 +4232,13 @@ function extractVisibleTeammateMessageContents(text: string): string[] { } } - contents.push(content) + messages.push({ from: match[1] ?? '', content }) } - return contents + return messages } + function pushAssistantHistoryText( messages: UIMessage[], content: string, @@ -4893,13 +4900,15 @@ export function mapHistoryMessagesToUiMessages( if (isTeammateMessage(msg.content)) { if (!includeTeammateMessages) continue - const teammateContents = extractVisibleTeammateMessageContents(msg.content) - if (teammateContents.length === 0) continue + const teammateMessages = extractVisibleTeammateMessages(msg.content) + if (teammateMessages.length === 0) continue + const sender = teammateMessages[0]!.from uiMessages.push({ id: msg.id || nextId(), type: 'user_text', - content: teammateContents.join('\n\n'), + content: teammateMessages.map((message) => message.content).join('\n\n'), ...(msg.id ? { transcriptMessageId: msg.id } : {}), + ...(sender ? { teammateFrom: sender } : {}), timestamp, }) continue @@ -4943,12 +4952,15 @@ export function mapHistoryMessagesToUiMessages( const modelTextParts: string[] = [] const attachments: UIAttachment[] = [] const imageSourcePaths: string[] = [] + let teammateSender: string | undefined const hasImageBlock = (msg.content as UserHistoryBlock[]).some((block) => block.type === 'image') for (const [blockIndex, block] of (msg.content as UserHistoryBlock[]).entries()) { if (block.type === 'text' && block.text && isTeammateMessage(block.text)) { modelTextParts.push(block.text) if (!includeTeammateMessages) continue - visibleTextParts.push(...extractVisibleTeammateMessageContents(block.text)) + const teammateMessages = extractVisibleTeammateMessages(block.text) + teammateSender ??= teammateMessages.find((message) => message.from)?.from + visibleTextParts.push(...teammateMessages.map((message) => message.content)) } else if (block.type === 'text' && block.text) { modelTextParts.push(block.text) const imageSourcePath = hasImageBlock ? extractImageMetadataSourcePath(block.text) : undefined @@ -4997,6 +5009,7 @@ export function mapHistoryMessagesToUiMessages( content: userContent, ...(msg.id ? { transcriptMessageId: msg.id } : {}), ...(modelContent ? { modelContent } : {}), + ...(teammateSender ? { teammateFrom: teammateSender } : {}), attachments: allAttachments.length > 0 ? allAttachments : undefined, timestamp, }) diff --git a/desktop/src/stores/tabStore.ts b/desktop/src/stores/tabStore.ts index 46ef4fa3..7d5fb2f4 100644 --- a/desktop/src/stores/tabStore.ts +++ b/desktop/src/stores/tabStore.ts @@ -14,8 +14,9 @@ export const TERMINAL_TAB_PREFIX = '__terminal__' export const TRACE_TAB_PREFIX = '__trace__' export const WORKBENCH_TAB_PREFIX = '__workbench__' export const SUBAGENT_TAB_PREFIX = '__subagent__' +export const TEAM_TAB_PREFIX = '__team__' -export type TabType = 'session' | 'settings' | 'scheduled' | 'market' | 'terminal' | 'trace' | 'traces' | 'workbench' | 'subagent' +export type TabType = 'session' | 'settings' | 'scheduled' | 'market' | 'terminal' | 'trace' | 'traces' | 'workbench' | 'subagent' | 'team' type PersistentSpecialTabType = 'settings' | 'scheduled' | 'market' | 'traces' export type Tab = { @@ -32,6 +33,7 @@ export type Tab = { sourceElementId?: string subagentToolUseId?: string subagentTaskId?: string + teamLeadSessionId?: string } export type WorkbenchTabOrigin = { @@ -57,6 +59,8 @@ type TabStore = { returnFromWorkbench: (tabId: string) => void openSubagentTab: (sourceSessionId: string, toolUseId: string, title?: string, taskId?: string) => string returnFromSubagent: (tabId: string) => void + openTeamWorkbenchTab: (leadSessionId: string, title?: string) => string + returnFromTeamWorkbench: (tabId: string) => void closeTab: (sessionId: string) => void setActiveTab: (sessionId: string) => void updateTabTitle: (sessionId: string, title: string) => void @@ -254,6 +258,38 @@ export const useTabStore = create((set, get) => ({ get().closeTab(tabId) }, + openTeamWorkbenchTab: (leadSessionId, title = 'Agent Teams') => { + const tabId = `${TEAM_TAB_PREFIX}${leadSessionId}` + const { tabs } = get() + const tab: Tab = { + sessionId: tabId, + title, + type: 'team', + status: 'idle', + teamLeadSessionId: leadSessionId, + sourceSessionId: leadSessionId, + } + + set({ + tabs: tabs.some((current) => current.sessionId === tabId) + ? tabs.map((current) => current.sessionId === tabId ? tab : current) + : [...tabs, tab], + activeTabId: tabId, + }) + get().saveTabs() + return tabId + }, + + returnFromTeamWorkbench: (tabId) => { + const tab = get().tabs.find((current) => current.sessionId === tabId) + if (tab?.type !== 'team') return + + if (tab.sourceSessionId && get().tabs.some((current) => current.sessionId === tab.sourceSessionId)) { + get().setActiveTab(tab.sourceSessionId) + } + get().closeTab(tabId) + }, + closeTab: (sessionId) => { const { tabs, activeTabId } = get() const index = tabs.findIndex((t) => t.sessionId === sessionId) @@ -323,12 +359,22 @@ export const useTabStore = create((set, get) => ({ saveTabs: () => { const { tabs, activeTabId } = get() - const persistableTabs = tabs.filter((tab) => tab.type !== 'terminal' && tab.type !== 'workbench' && tab.type !== 'subagent') + const persistableTabs = tabs.filter((tab) => ( + tab.type !== 'terminal' && + tab.type !== 'workbench' && + tab.type !== 'subagent' && + tab.type !== 'team' + )) const activeTab = tabs.find((tab) => tab.sessionId === activeTabId) + // Detached views (workbench, team) restore to the session they were spun + // out of rather than to a stale synthetic tab id. + const detachedOrigin = (activeTab?.type === 'workbench' || activeTab?.type === 'team') + ? activeTab.sourceSessionId + : undefined const persistedActiveTabId = activeTabId && persistableTabs.some((tab) => tab.sessionId === activeTabId) ? activeTabId - : activeTab?.type === 'workbench' && activeTab.sourceSessionId && persistableTabs.some((tab) => tab.sessionId === activeTab.sourceSessionId) - ? activeTab.sourceSessionId + : detachedOrigin && persistableTabs.some((tab) => tab.sessionId === detachedOrigin) + ? detachedOrigin : (persistableTabs[0]?.sessionId ?? null) const data: TabPersistence = { openTabs: persistableTabs.map((t) => ({ @@ -373,7 +419,7 @@ export const useTabStore = create((set, get) => ({ // Special tabs are always valid if (getPersistentSpecialTabType(t)) return true if (t.type === 'trace') return !!t.traceSessionId && existingIds.has(t.traceSessionId) - if (t.type === 'terminal') return false + if (t.type === 'terminal' || t.type === 'team') return false // Session tabs must exist on server return existingIds.has(t.sessionId) }) diff --git a/desktop/src/stores/teamStore.test.ts b/desktop/src/stores/teamStore.test.ts index e0aa032c..0e5b284f 100644 --- a/desktop/src/stores/teamStore.test.ts +++ b/desktop/src/stores/teamStore.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mergeMemberTranscriptDelta, useTeamStore } from './teamStore' +import { isAgentTeamsWorkbenchOpen, mergeMemberTranscriptDelta, useTeamStore } from './teamStore' import { useChatStore } from './chatStore' import { useTabStore } from './tabStore' import type { UIMessage } from '../types/chat' @@ -355,7 +355,7 @@ describe('teamStore workbench timeline', () => { expect(snapshots[1]?.deletedAt).toBeTruthy() }) - it('restores an archived workbench by lead session and opens it by default', async () => { + it('restores an archived workbench by lead session without forcing it open', async () => { const archived = { ...workbench('v9', 'completed'), deletedAt: '2026-08-08T00:10:00.000Z', @@ -376,7 +376,20 @@ describe('teamStore workbench timeline', () => { snapshots: [expect.objectContaining({ version: 'v9', deletedAt: archived.deletedAt })], }) expect(state.activeTeam?.name).toBe('team-workbench') - expect(state.workbenchOpenBySession['lead-session']).toBe(true) + // Discovery must not open the panel — that is the header strip's job, and + // auto-opening evicted the workspace panel and compacted the transcript. + expect(isAgentTeamsWorkbenchOpen(state, 'lead-session')).toBe(false) + + state.setWorkbenchOpen('lead-session', true) + expect(isAgentTeamsWorkbenchOpen(useTeamStore.getState(), 'lead-session')).toBe(true) + }) + + it('never reports the workbench open for a session that has no timeline', () => { + const state = useTeamStore.getState() + state.setWorkbenchOpen('session-without-team', true) + + expect(isAgentTeamsWorkbenchOpen(useTeamStore.getState(), 'session-without-team')).toBe(false) + expect(isAgentTeamsWorkbenchOpen(useTeamStore.getState(), null)).toBe(false) }) it('keeps a restored workbench available after closing and reopening it', async () => { diff --git a/desktop/src/stores/teamStore.ts b/desktop/src/stores/teamStore.ts index 2c44be99..c7e863e4 100644 --- a/desktop/src/stores/teamStore.ts +++ b/desktop/src/stores/teamStore.ts @@ -16,12 +16,17 @@ import { useTabStore } from './tabStore' const MEMBER_POLL_INTERVAL_MS = 1500 const MEMBER_TRANSCRIPT_MATCH_WINDOW_MS = 120_000 const WORKBENCH_HISTORY_LIMIT = 200 -export const AGENT_TEAMS_WORKBENCH_DEFAULT_WIDTH = 700 -export const AGENT_TEAMS_WORKBENCH_MIN_WIDTH = 440 -export const AGENT_TEAMS_WORKBENCH_MAX_WIDTH = 940 +/** + * Sized against the workspace panel rather than the workbench's own content: + * both share the one right-hand slot, and a wider default was what squeezed the + * chat column past the point where its 900px reading measure survives. + */ +export const AGENT_TEAMS_WORKBENCH_DEFAULT_WIDTH = 480 +export const AGENT_TEAMS_WORKBENCH_MIN_WIDTH = 380 +export const AGENT_TEAMS_WORKBENCH_MAX_WIDTH = 720 /** Generate a synthetic sessionId for team member tabs */ -const memberSessionId = (agentId: string) => `team-member:${agentId}` +export const memberSessionId = (agentId: string) => `team-member:${agentId}` /** Module-level timer for polling member transcript */ let memberPollTimer: ReturnType | null = null @@ -36,6 +41,20 @@ const memberTranscriptCursors = new Map() const workbenchRefreshGenerations = new Map() +/** + * The workbench opens on an explicit user gesture, never on discovery. Auto + * expanding it took over the one right-hand slot, hid the workspace and + * activity entries, and compacted the transcript the moment a team appeared. + */ +export function isAgentTeamsWorkbenchOpen( + state: Pick, + sessionId: string | null | undefined, +): boolean { + if (!sessionId) return false + if (!state.workbenchesBySession[sessionId]?.snapshots.length) return false + return state.workbenchOpenBySession[sessionId] === true +} + export function clampAgentTeamsWorkbenchWidth(width: number): number { if (!Number.isFinite(width)) return AGENT_TEAMS_WORKBENCH_DEFAULT_WIDTH return Math.min( @@ -125,6 +144,21 @@ function appendWorkbenchSnapshot( } } +/** + * A member transcript stays worth polling while any surface is showing it: the + * detached member tab, or a workbench body switched to that member. + */ +function isMemberSessionWatched( + state: Pick, + memberTabId: string, + agentId: string, +): boolean { + if (useTabStore.getState().activeTabId === memberTabId) return true + return Object.values(state.workbenchViewBySession).some( + (view) => view?.kind === 'member' && view.agentId === agentId, + ) +} + function isPendingMemberMessage(message: UIMessage): message is Extract & { pending: true } { return message.type === 'user_text' && message.pending === true } @@ -197,6 +231,11 @@ function syncMemberSessionMessages( }) } +/** Which face of the workbench is showing: the team map, or one member's run. */ +export type WorkbenchView = + | { kind: 'overview' } + | { kind: 'member'; agentId: string } + type TeamStore = { teams: TeamSummary[] activeTeam: TeamDetail | null @@ -205,6 +244,7 @@ type TeamStore = { workbenchesBySession: Record workbenchHistoryIndexBySession: Record workbenchOpenBySession: Record + workbenchViewBySession: Record workbenchPanelWidth: number fetchTeams: () => Promise @@ -214,6 +254,8 @@ type TeamStore = { setWorkbenchHistoryIndex: (sessionId: string, index: number | null) => void setWorkbenchOpen: (sessionId: string, open: boolean) => void toggleWorkbench: (sessionId: string) => void + setWorkbenchView: (sessionId: string, view: WorkbenchView) => void + openMemberInWorkbench: (sessionId: string, member: TeamMember, team?: TeamDetail) => void setWorkbenchPanelWidth: (width: number) => void getMemberBySessionId: (sessionId: string) => TeamMember | null refreshMemberSession: (sessionId: string) => Promise @@ -238,6 +280,7 @@ export const useTeamStore = create((set, get) => ({ workbenchesBySession: {}, workbenchHistoryIndexBySession: {}, workbenchOpenBySession: {}, + workbenchViewBySession: {}, workbenchPanelWidth: AGENT_TEAMS_WORKBENCH_DEFAULT_WIDTH, fetchTeams: async () => { @@ -283,10 +326,6 @@ export const useTeamStore = create((set, get) => ({ error: null, }, }, - workbenchOpenBySession: { - ...state.workbenchOpenBySession, - [sessionId]: state.workbenchOpenBySession[sessionId] ?? true, - }, })) } catch { // Workbench discovery supplements the session; an ordinary conversation @@ -330,10 +369,6 @@ export const useTeamStore = create((set, get) => ({ snapshot, ), }, - workbenchOpenBySession: { - ...state.workbenchOpenBySession, - [sessionId]: state.workbenchOpenBySession[sessionId] ?? true, - }, })) } catch (err) { if (workbenchRefreshGenerations.get(teamName) !== generation) return @@ -377,10 +412,41 @@ export const useTeamStore = create((set, get) => ({ toggleWorkbench: (sessionId) => set((state) => ({ workbenchOpenBySession: { ...state.workbenchOpenBySession, - [sessionId]: !(state.workbenchOpenBySession[sessionId] ?? true), + [sessionId]: state.workbenchOpenBySession[sessionId] !== true, }, })), + setWorkbenchView: (sessionId, view) => set((state) => ({ + workbenchViewBySession: { + ...state.workbenchViewBySession, + [sessionId]: view, + }, + })), + + /** + * Selecting a teammate swaps the workbench's own body instead of opening a + * tab, so the team map stays one click away. The member transcript still + * loads through the same poll path a detached member tab uses. + */ + openMemberInWorkbench: (sessionId, member, requestedTeam) => { + const team = requestedTeam ?? get().activeTeam + if (team && get().activeTeam?.name !== team.name) { + set({ activeTeam: team, memberColors: memberColorsForTeam(team) }) + } + set((state) => ({ + workbenchViewBySession: { + ...state.workbenchViewBySession, + [sessionId]: { kind: 'member', agentId: member.agentId }, + }, + })) + + const memberTabId = memberSessionId(member.agentId) + void get().refreshMemberSession(memberTabId) + if (member.status === 'running' || member.status === 'idle') { + get().startMemberPolling(memberTabId) + } + }, + setWorkbenchPanelWidth: (width) => set({ workbenchPanelWidth: clampAgentTeamsWorkbenchWidth(width), }), @@ -426,6 +492,9 @@ export const useTeamStore = create((set, get) => ({ timestamp: msg.timestamp, model: msg.model, parentToolUseId: msg.parentToolUseId, + // Structured tool output drives the richer result renderers; omitting + // it here flattened every member tool call back to plain text. + toolUseResult: msg.toolUseResult, })) const transcriptMessages = mapHistoryMessagesToUiMessages( asEntries as Parameters[0], @@ -498,8 +567,10 @@ export const useTeamStore = create((set, get) => ({ get().stopMemberPolling() polledMemberSessionId = sessionId memberPollTimer = setInterval(() => { - const currentTabId = useTabStore.getState().activeTabId - if (currentTabId !== sessionId) { + // A member transcript is watched either through its own tab or through a + // workbench body showing that member; polling only on the former stopped + // the embedded view dead on its first tick. + if (!isMemberSessionWatched(get(), sessionId, member.agentId)) { get().stopMemberPolling() return } @@ -526,6 +597,7 @@ export const useTeamStore = create((set, get) => ({ workbenchesBySession: {}, workbenchHistoryIndexBySession: {}, workbenchOpenBySession: {}, + workbenchViewBySession: {}, }) }, diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index 74982aae..2eeaf44b 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -296,7 +296,13 @@ export type TaskSummaryItem = { } export type UIMessage = - | { id: string; type: 'user_text'; content: string; modelContent?: string; transcriptMessageId?: string; timestamp: number; attachments?: UIAttachment[]; pending?: boolean; optimisticQueued?: boolean } + /** + * `teammateFrom` marks a turn that arrived from another agent rather than + * from the person at the keyboard. Without it a teammate's instruction and + * the user's own prompt render identically, which is what flattened the + * member transcript. + */ + | { id: string; type: 'user_text'; content: string; modelContent?: string; transcriptMessageId?: string; timestamp: number; attachments?: UIAttachment[]; pending?: boolean; optimisticQueued?: boolean; teammateFrom?: string } | { id: string; type: 'assistant_text'; content: string; transcriptMessageId?: string; timestamp: number; model?: string } | { id: string; type: 'thinking'; content: string; timestamp: number } | { diff --git a/src/server/__tests__/teams.test.ts b/src/server/__tests__/teams.test.ts index 8975d511..f2493550 100644 --- a/src/server/__tests__/teams.test.ts +++ b/src/server/__tests__/teams.test.ts @@ -388,6 +388,50 @@ describe('TeamService', () => { expect(continued.messages.map(message => message.id)).toEqual(['continued-fragment']) }) + it('carries structured tool results through the member transcript', async () => { + await writeTeamConfig('tool-result-team', makeTeamConfig({ + name: 'tool-result-team', + leadSessionId: 'lead-session-tool-result', + members: [{ + agentId: 'security-reviewer@tool-result-team', + name: 'security-reviewer', + agentType: 'security-reviewer', + joinedAt: 1700000000000, + cwd: '/tmp/project', + isActive: false, + }], + })) + await writeSubagentTranscriptFile( + '-tmp-project', + 'lead-session-tool-result', + 'agent-tools.jsonl', + [{ + type: 'tool_result', + agentName: 'security-reviewer', + uuid: 'tool-result-message', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'call-1', content: 'ok' }], + }, + // The desktop transcript renders structured results from this field; + // dropping it degraded every member tool call to plain text. + toolUseResult: { questions: [{ question: 'Ship?' }], answers: { Ship: 'yes' } }, + timestamp: '2026-01-01T00:00:01.000Z', + }], + ) + + const page = await service.getMemberTranscriptPage( + 'tool-result-team', + 'security-reviewer@tool-result-team', + ) + + expect(page.messages).toHaveLength(1) + expect(page.messages[0]!.toolUseResult).toEqual({ + questions: [{ question: 'Ship?' }], + answers: { Ship: 'yes' }, + }) + }) + it('does not identify a teammate transcript from another member prompt mention', async () => { await writeTeamConfig('identity-team', makeTeamConfig({ name: 'identity-team', diff --git a/src/server/services/teamService.ts b/src/server/services/teamService.ts index 0cd25bab..d880ec90 100644 --- a/src/server/services/teamService.ts +++ b/src/server/services/teamService.ts @@ -90,6 +90,12 @@ export type TranscriptMessage = { timestamp: string model?: string parentToolUseId?: string + /** + * Structured tool output the desktop transcript needs to render a result as + * anything richer than plain text. Dropping it here degraded every member + * tool call to its stringified body. + */ + toolUseResult?: unknown } export type TeamTranscriptPage = { @@ -1608,6 +1614,7 @@ export class TeamService { ? { parentToolUseId: entry.parentToolUseId } : {}), ...(model ? { model } : {}), + ...(entry.toolUseResult !== undefined ? { toolUseResult: entry.toolUseResult } : {}), } }