From f5b3faac12a8a2bc991405605468e9c85b2485c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 8 Aug 2026 23:57:14 +0800 Subject: [PATCH] refactor(agent-teams): make the workbench progressive instead of pre-emptive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workbench took over the right-hand slot the moment a team was discovered: it opened by default, evicted the workspace panel, hid the workspace and activity toolbar entries outright, and compacted the transcript down from its 900px reading measure. The main session was left with the coordination tools filtered out and nothing in their place, so a team run read as the lead talking to itself. Replace that with three graduated surfaces. A header strip and an in-transcript card (rendered where TeamCreate happened) are the whole main-session footprint; the docked panel now opens on an explicit gesture; and a `__team__` tab detaches the workbench full screen with the communication feed in its own column. The panel and the workspace share one slot but keep independent entries — opening one evicts the other rather than making it disappear. Inside the workbench: - Protocol payloads are narrated instead of printed. The feed was emitting raw `{"type":"idle_notification",...}` even though AGENT_LIFECYCLE_TYPES already filters these out of chat; they now read as sentences and collapse behind a toggle. - Rows carry their own send time. Every row previously rendered `T+{snapshotIndex}`, identical for every message in a snapshot. - The member drawer becomes a workbench view. A full-panel overlay was unusable at 440px; a teammate's run now renders through MessageList, so thinking blocks and grouped tool calls match the main session. Two data-layer causes of the flat member transcript: - Teammate turns mapped to anonymous `user_text`, making a lead's instruction indistinguishable from the operator's own prompt. They now carry `teammateFrom` and render left-aligned and attributed. - `transcriptMessageFromEntry` dropped `toolUseResult` (and teamStore's `asEntries` dropped it again), degrading structured tool output to plain text. --- desktop/src/api/teams.ts | 1 + .../AgentTeamsCommunicationFeed.test.tsx | 88 +++++ .../AgentTeamsCommunicationFeed.tsx | 200 ++++++++++ .../agentTeams/AgentTeamsMemberView.tsx | 147 ++++++++ .../agentTeams/AgentTeamsSummary.tsx | 180 +++++++++ .../agentTeams/AgentTeamsWorkbench.test.tsx | 69 +++- .../agentTeams/AgentTeamsWorkbench.tsx | 354 +++++------------- .../AgentTeamsWorkbenchTab.test.tsx | 49 +++ .../agentTeams/AgentTeamsWorkbenchTab.tsx | 49 +++ .../agentTeams/agentTeamsAvatars.ts | 36 ++ .../agentTeams/agentTeamsModel.test.ts | 39 ++ .../components/agentTeams/agentTeamsModel.ts | 84 +++++ .../src/components/chat/MessageList.test.tsx | 38 +- desktop/src/components/chat/MessageList.tsx | 42 ++- .../src/components/chat/UserMessage.test.tsx | 28 ++ desktop/src/components/chat/UserMessage.tsx | 89 ++++- .../src/components/layout/ContentRouter.tsx | 6 + desktop/src/components/layout/TabBar.test.tsx | 69 +++- desktop/src/components/layout/TabBar.tsx | 29 +- desktop/src/i18n/locales/en.ts | 19 + desktop/src/i18n/locales/jp.ts | 19 + desktop/src/i18n/locales/kr.ts | 19 + desktop/src/i18n/locales/zh-TW.ts | 19 + desktop/src/i18n/locales/zh.ts | 19 + desktop/src/pages/ActiveSession.test.tsx | 16 +- desktop/src/pages/ActiveSession.tsx | 54 ++- desktop/src/stores/chatStore.test.ts | 29 +- desktop/src/stores/chatStore.ts | 29 +- desktop/src/stores/tabStore.ts | 56 ++- desktop/src/stores/teamStore.test.ts | 19 +- desktop/src/stores/teamStore.ts | 102 ++++- desktop/src/types/chat.ts | 8 +- src/server/__tests__/teams.test.ts | 44 +++ src/server/services/teamService.ts | 7 + 34 files changed, 1685 insertions(+), 371 deletions(-) create mode 100644 desktop/src/components/agentTeams/AgentTeamsCommunicationFeed.test.tsx create mode 100644 desktop/src/components/agentTeams/AgentTeamsCommunicationFeed.tsx create mode 100644 desktop/src/components/agentTeams/AgentTeamsMemberView.tsx create mode 100644 desktop/src/components/agentTeams/AgentTeamsSummary.tsx create mode 100644 desktop/src/components/agentTeams/AgentTeamsWorkbenchTab.test.tsx create mode 100644 desktop/src/components/agentTeams/AgentTeamsWorkbenchTab.tsx create mode 100644 desktop/src/components/agentTeams/agentTeamsAvatars.ts 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 } : {}), } }