refactor(agent-teams): make the workbench progressive instead of pre-emptive

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.
This commit is contained in:
程序员阿江(Relakkes)
2026-08-08 23:57:14 +08:00
parent 4fbbafa0b6
commit f5b3faac12
34 changed files with 1685 additions and 371 deletions
+1
View File
@@ -15,6 +15,7 @@ type TranscriptMessage = {
timestamp: string
model?: string
parentToolUseId?: string
toolUseResult?: unknown
}
type TranscriptResponse = {
@@ -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<TeamWorkbenchMessage> & { 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(<AgentTeamsCommunicationFeed snapshot={snapshot([
message({ id: 'm1', text: 'Race condition confirmed in queue.ts' }),
message({
id: 'm2',
from: 'release-engineer',
to: 'team-lead',
text: '{"type":"idle_notification","from":"release-engineer","timestamp":"2026-08-08T07:42:16.666Z","idleReason":"available"}',
}),
])} />)
// 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(<AgentTeamsCommunicationFeed snapshot={snapshot([
message({ id: 'm1', text: 'first', timestamp: '2026-08-08T07:42:00.000Z' }),
message({ id: 'm2', text: 'second', timestamp: '2026-08-08T09:15:00.000Z' }),
])} />)
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(<AgentTeamsCommunicationFeed snapshot={snapshot([
message({ id: 'm1', kind: 'broadcast', to: '*', text: long }),
])} />)
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)
})
})
@@ -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<typeof useTranslation>
/** 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<WorkbenchMessageBody, { kind: 'lifecycle' }>,
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 (
<section
data-testid="agent-teams-communication"
className={[
'flex min-h-0 flex-col border-[var(--color-border)] bg-[var(--color-surface)]',
fill
? 'h-full w-[min(400px,38%)] shrink-0 border-l'
: 'h-[210px] shrink-0 border-t',
].join(' ')}
>
<div className="flex shrink-0 items-center gap-2 px-4 pb-1.5 pt-2.5">
<h3 className="text-[12px] font-extrabold tracking-[-0.1px]">
{t('agentTeams.communication.title')}
</h3>
<span className="text-[10.5px] text-[var(--color-text-tertiary)]">
{t('agentTeams.communication.count', { count: visibleRows.length })}
</span>
{lifecycleCount > 0 ? (
<button
type="button"
data-testid="agent-teams-lifecycle-toggle"
aria-pressed={showLifecycle}
onClick={() => setShowLifecycle((current) => !current)}
className="rounded-[var(--radius-sm)] px-1.5 py-0.5 text-[10px] font-semibold text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
{t(showLifecycle
? 'agentTeams.communication.hideLifecycle'
: 'agentTeams.communication.showLifecycle', { count: lifecycleCount })}
</button>
) : null}
{snapshot.deletedAt ? (
<span className="ml-auto shrink-0 text-[10px] font-semibold text-[var(--color-success)]">
{t('agentTeams.disbanded')}
</span>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2.5 pb-2">
{visibleRows.length === 0 ? (
<div className="px-1 py-6 text-center text-[11px] text-[var(--color-text-tertiary)]">
{t('agentTeams.communication.empty')}
</div>
) : visibleRows.map(({ message, body }, index) => (
<FeedRow
key={message.id}
message={message}
body={body}
isLatest={index === 0}
t={t}
/>
))}
</div>
</section>
)
}
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 (
<article
data-testid={`agent-teams-message-${message.id}`}
data-message-body={body.kind}
className={[
'mb-0.5 rounded-[var(--radius-md)] px-2 py-1.5',
isLatest ? 'bg-[var(--color-brand-soft)]' : '',
body.kind === 'lifecycle' ? 'opacity-70' : '',
].filter(Boolean).join(' ')}
>
<div className="flex min-w-0 items-center gap-1.5">
<Icon size={11} strokeWidth={2.4} style={{ color }} aria-hidden="true" className="shrink-0" />
<span className="shrink-0 text-[9px] font-extrabold" style={{ color }}>
{kindLabel(message.kind, t)}
</span>
{message.kind !== 'system' ? (
<span className="min-w-0 truncate font-mono text-[10px] font-bold text-[var(--color-text-secondary)]">
{message.from} → {message.kind === 'broadcast'
? t('agentTeams.communication.everyone')
: message.to}
</span>
) : null}
{message.taskId ? (
<span className="shrink-0 font-mono text-[9px] text-[var(--color-text-tertiary)]">
#{message.taskId}
</span>
) : null}
{isLatest ? (
<Radio size={9} strokeWidth={2.6} aria-hidden="true" className="shrink-0 text-[var(--color-brand)]" />
) : null}
<span className="ml-auto shrink-0 font-mono text-[9px] tabular-nums text-[var(--color-text-tertiary)]">
{time}
</span>
</div>
<div
className={[
'mt-0.5 whitespace-pre-wrap break-words text-[11px] leading-[1.55]',
body.kind === 'lifecycle'
? 'text-[var(--color-text-tertiary)]'
: 'text-[var(--color-text-secondary)]',
].join(' ')}
>
{shownText}
</div>
{isTruncatable ? (
<button
type="button"
onClick={() => setExpanded((current) => !current)}
className="mt-0.5 rounded-[var(--radius-sm)] text-[10px] font-semibold text-[var(--color-brand)] transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
{t(expanded ? 'agentTeams.communication.collapse' : 'agentTeams.communication.expand')}
</button>
) : null}
</article>
)
}
@@ -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 (
<div
data-testid="agent-teams-member-view"
data-member-agent-id={member.agentId}
className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)]"
>
<header className="shrink-0 border-b border-[var(--color-border)] px-3 py-2.5">
<div className="flex items-center gap-2">
<IconButton
icon={<ArrowLeft aria-hidden="true" />}
label={t('agentTeams.backToOverview')}
size="sm"
tone="muted"
onClick={onBack}
/>
<span className="relative inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-surface-container)]">
<img
src={MEMBER_AVATARS[avatarKey]}
alt=""
draggable={false}
className="h-8 w-8 select-none object-contain"
/>
<span
aria-hidden="true"
className="absolute bottom-0.5 left-1/2 h-1 w-4 -translate-x-1/2 rounded-full"
style={{ background: accent }}
/>
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 truncate font-mono text-[13px] font-extrabold">{name}</span>
{isLead ? <Badge tone="brand" size="xs" bordered>{t('agentTeams.leader')}</Badge> : null}
</div>
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11px] text-[var(--color-text-secondary)]">
<StatusDot tone={memberStatusTone(member)} pulse={member.status === 'running'} />
<span className="shrink-0">{statusLabel}</span>
<span aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]">·</span>
<span className="min-w-0 truncate text-[var(--color-text-tertiary)]">{member.role}</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="shrink-0"
onClick={onOpenInTab}
icon={<ExternalLink size={13} aria-hidden="true" />}
>
{t('agentTeams.openInTab')}
</Button>
</div>
{currentTask || member.currentTask ? (
<p
data-testid="agent-teams-member-current-task"
className="mt-2 truncate rounded-[var(--radius-sm)] bg-[var(--color-surface-container)] px-2.5 py-1.5 font-mono text-[10.5px] text-[var(--color-text-secondary)]"
title={currentTask?.activeForm || currentTask?.subject || member.currentTask}
>
{currentTask?.activeForm || currentTask?.subject || member.currentTask}
</p>
) : null}
{ownedTasks.length > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1">
{ownedTasks.map((task) => (
<span
key={task.id}
title={task.subject}
className={[
'inline-flex max-w-[190px] items-center gap-1 rounded-[var(--radius-sm)] border px-1.5 py-0.5 text-[10px]',
task.status === 'completed'
? 'border-[var(--color-success)] text-[var(--color-success)]'
: task.status === 'in_progress'
? 'border-[var(--color-brand)] text-[var(--color-brand)]'
: 'border-[var(--color-border)] text-[var(--color-text-tertiary)]',
].join(' ')}
>
<span className="shrink-0 font-mono font-bold">#{task.id}</span>
<span className="min-w-0 truncate">{task.subject}</span>
</span>
))}
</div>
) : null}
</header>
<MessageList sessionId={memberSessionId(member.agentId)} compact />
</div>
)
}
@@ -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 (
<span className="flex shrink-0 items-center" aria-hidden="true">
{shown.map((member, index) => (
<span
key={member.agentId}
className="inline-flex items-center justify-center rounded-full bg-[var(--color-surface-container)] ring-1 ring-[var(--color-surface)]"
style={{
width: size,
height: size,
marginLeft: index === 0 ? 0 : -Math.round(size * 0.32),
zIndex: shown.length - index,
}}
>
<img
src={MEMBER_AVATARS[getMemberAvatarKey(member, member.agentId === leadAgentId)]}
alt=""
draggable={false}
className="select-none object-contain"
style={{ width: size - 2, height: size - 2 }}
/>
</span>
))}
{overflow > 0 ? (
<span
className="inline-flex items-center justify-center rounded-full bg-[var(--color-surface-container)] font-mono text-[9px] font-extrabold text-[var(--color-text-secondary)] ring-1 ring-[var(--color-surface)]"
style={{ width: size, height: size, marginLeft: -Math.round(size * 0.32) }}
>
+{overflow}
</span>
) : null}
</span>
)
}
/**
* 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 (
<button
type="button"
data-testid="agent-teams-strip"
data-open={open ? 'true' : 'false'}
aria-expanded={open}
onClick={onOpen}
title={snapshot.team.name}
className={[
'mt-2 flex w-full max-w-full items-center gap-2 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5 py-1.5 text-left transition-colors',
'hover:border-[var(--color-border-strong)] hover:bg-[var(--color-surface-hover)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]',
compact ? 'text-[11px]' : 'text-[12px]',
].join(' ')}
>
<UsersRound size={compact ? 13 : 14} strokeWidth={2.25} aria-hidden="true" className="shrink-0 text-[var(--color-brand)]" />
<span className="shrink-0 font-semibold text-[var(--color-text-primary)]">
{t('agentTeams.strip.label')}
</span>
<AvatarStack members={members} leadAgentId={snapshot.team.leadAgentId} size={compact ? 18 : 20} />
<span className="flex shrink-0 items-center gap-1 text-[var(--color-text-tertiary)]">
<StatusDot tone={phaseTone(phase)} pulse={phase === 'running'} />
{t(`agentTeams.phase.${phase}` as TranslationKey)}
</span>
{progress.total > 0 ? (
<span className="shrink-0 tabular-nums text-[var(--color-text-secondary)]">
{t('agentTeams.inline.tasks', { completed: progress.completed, total: progress.total })}
</span>
) : null}
<span className="ml-auto flex shrink-0 items-center gap-0.5 font-medium text-[var(--color-brand)]">
{t(open ? 'agentTeams.hideWorkbench' : 'agentTeams.inline.open')}
<ChevronRight size={12} strokeWidth={2.4} aria-hidden="true" />
</span>
</button>
)
}
/**
* 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 (
<div className="mb-5 px-1">
<button
type="button"
data-testid="agent-teams-inline-card"
onClick={onOpen}
className="flex w-full items-center gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3.5 py-2.5 text-left shadow-[var(--shadow-card)] transition-colors hover:border-[var(--color-border-strong)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<AvatarStack members={members} leadAgentId={snapshot.team.leadAgentId} size={30} />
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 truncate font-mono text-[12px] font-extrabold text-[var(--color-text-primary)]">
{snapshot.team.name}
</span>
<Badge tone={phaseTone(phase)} size="xs" bordered>
{t(`agentTeams.phase.${phase}` as TranslationKey)}
</Badge>
</span>
<span className="mt-0.5 flex items-center gap-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<span>{t('agentTeams.inline.created', { count: members.length })}</span>
{progress.total > 0 ? (
<>
<span aria-hidden="true">·</span>
<span className="tabular-nums">
{t('agentTeams.inline.tasks', { completed: progress.completed, total: progress.total })}
</span>
</>
) : null}
</span>
</span>
<span className="flex shrink-0 items-center gap-0.5 text-[11.5px] font-medium text-[var(--color-brand)]">
{t('agentTeams.inline.open')}
<ChevronRight size={13} strokeWidth={2.4} aria-hidden="true" />
</span>
</button>
</div>
)
}
@@ -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(<AgentTeamsWorkbench sessionId="lead-session" />)
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(<AgentTeamsWorkbench sessionId="lead-session" />)
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(<AgentTeamsWorkbench sessionId="lead-session" />)
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')
})
})
@@ -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<MemberAvatarKey, string> = {
'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<HTMLDivElement>(null)
const [officeWidth, setOfficeWidth] = useState(604)
const [selectedMemberId, setSelectedMemberId] = useState<string | null>(null)
const [focusedTaskId, setFocusedTaskId] = useState<string | null>(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 (
<section
aria-label={t('agentTeams.title')}
data-workbench-variant={variant}
className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[var(--color-surface)] text-[var(--color-text-primary)]"
>
<header className="flex h-[42px] shrink-0 items-center gap-2.5 border-b border-[var(--color-border)] px-4">
@@ -340,17 +309,41 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
<span className="hidden w-[64px] text-right font-mono text-[10px] tabular-nums text-[var(--color-text-tertiary)] 2xl:block">
{historyIndex === null ? t('agentTeams.live') : `T+${selectedIndex}`} · {formatSnapshotTime(snapshot)}
</span>
<IconButton
icon={<X aria-hidden="true" />}
label={t('agentTeams.closeWorkbench')}
size="sm"
tone="muted"
onClick={() => setWorkbenchOpen(sessionId, false)}
/>
{isFull ? null : (
<>
<IconButton
icon={<Maximize2 aria-hidden="true" />}
label={t('agentTeams.openFullscreen')}
size="sm"
tone="muted"
onClick={() => {
useTabStore.getState().openTeamWorkbenchTab(sessionId, snapshot.team.name)
setWorkbenchOpen(sessionId, false)
}}
/>
<IconButton
icon={<X aria-hidden="true" />}
label={t('agentTeams.closeWorkbench')}
size="sm"
tone="muted"
onClick={() => setWorkbenchOpen(sessionId, false)}
/>
</>
)}
</div>
</header>
<div ref={officeViewportRef} className="min-h-0 flex-1 overflow-auto">
{selectedMember ? (
<AgentTeamsMemberView
member={selectedMember}
snapshot={snapshot}
onBack={() => setWorkbenchView(sessionId, { kind: 'overview' })}
onOpenInTab={() => openMemberSession(selectedMember, snapshot.team)}
/>
) : (
<div className={isFull ? 'flex min-h-0 flex-1' : 'flex min-h-0 flex-1 flex-col'}>
<div ref={officeViewportRef} className="min-h-0 min-w-0 flex-1 overflow-auto">
<div
data-testid="agent-teams-office"
className="agent-teams-org-canvas relative mx-auto"
@@ -402,7 +395,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
canvasWidth={layout.width}
accent={memberAccent(leadMember, members.indexOf(leadMember))}
isMessageSender={Boolean(hasNewMessage && latestMessage && memberMatchesIdentity(leadMember, latestMessage.from))}
onSelect={() => 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 }) {
</div>
</div>
<CommunicationFeed snapshot={snapshot} selectedIndex={selectedIndex} t={t} />
{selectedMember ? (
<MemberDrawer
member={selectedMember}
snapshot={snapshot}
avatarKey={getMemberAvatarKey(selectedMember, selectedMember.agentId === leadId)}
accent={memberAccent(selectedMember, members.indexOf(selectedMember))}
onClose={() => 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. */}
<AgentTeamsCommunicationFeed
snapshot={snapshot}
{...(isFull ? { fill: true } : {})}
/>
</div>
)}
</section>
)
}
@@ -834,172 +823,3 @@ function TaskCard({
</article>
)
}
function CommunicationFeed({
snapshot,
selectedIndex,
t,
}: {
snapshot: TeamWorkbenchSnapshot
selectedIndex: number
t: TranslationFn
}) {
const messages = [...snapshot.messages].reverse()
return (
<section className="flex h-[196px] shrink-0 flex-col border-t border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="flex shrink-0 items-baseline gap-2 px-4 pb-1 pt-2">
<h3 className="text-[12px] font-extrabold">{t('agentTeams.communication.title')}</h3>
<span className="text-[10px] text-[var(--color-text-tertiary)]">
{t('agentTeams.communication.count', { count: messages.length })}
</span>
{snapshot.deletedAt ? (
<span className="ml-auto text-[10px] font-semibold text-[var(--color-success)]">
{t('agentTeams.disbanded')}
</span>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-2">
{messages.length === 0 ? (
<div className="px-1 py-6 text-center text-[11px] text-[var(--color-text-tertiary)]">
{t('agentTeams.communication.empty')}
</div>
) : messages.map((message, index) => (
<div
key={message.id}
className={`mb-0.5 rounded-[var(--radius-md)] px-2 py-1.5 ${index === 0 ? 'bg-[var(--color-brand-soft)]' : ''}`}
>
<div className="flex min-w-0 items-center gap-1.5">
<span className="text-[9px] font-extrabold" style={{ color: messageTone(message) }}>
{messageKindLabel(message, t)}
</span>
{message.kind !== 'system' ? (
<span className="min-w-0 truncate font-mono text-[10px] font-bold text-[var(--color-text-secondary)]">
{message.from} → {message.kind === 'broadcast' ? t('agentTeams.communication.everyone') : message.to}
</span>
) : null}
{message.taskId ? (
<span className="shrink-0 font-mono text-[9px] text-[var(--color-text-tertiary)]">#{message.taskId}</span>
) : null}
<span className="ml-auto shrink-0 font-mono text-[9px] tabular-nums text-[var(--color-text-tertiary)]">
T+{selectedIndex}
</span>
</div>
<div className="mt-0.5 line-clamp-2 text-[11px] leading-[1.55] text-[var(--color-text-secondary)]">
{messageText(message, t)}
</div>
</div>
))}
</div>
</section>
)
}
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 (
<aside
role="dialog"
aria-label={t('agentTeams.memberDetails', { name })}
className="agent-teams-drawer absolute inset-y-0 right-0 z-[var(--z-drawer)] flex w-[min(360px,100%)] flex-col bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-overlay)]"
>
<header className="shrink-0 border-b border-[var(--color-border)] px-5 py-4">
<div className="flex items-center gap-3">
<span className="relative inline-flex h-[72px] w-[72px] shrink-0 items-center justify-center rounded-[var(--radius-xl)] bg-[var(--color-surface-container)]">
<img
src={MEMBER_AVATARS[avatarKey]}
alt=""
draggable={false}
className="h-[68px] w-[68px] select-none object-contain drop-shadow-[0_5px_4px_rgba(0,0,0,0.16)]"
/>
<span
aria-hidden="true"
className="absolute bottom-1 left-1/2 h-1.5 w-7 -translate-x-1/2 rounded-full border border-[var(--color-surface-container-lowest)]"
style={{ background: accent }}
/>
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-[14px] font-extrabold">{name}</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-[var(--color-text-secondary)]">
<StatusDot tone={state === 'error' ? 'danger' : state === 'working' ? 'brand' : state === 'exited' ? 'success' : 'neutral'} pulse={state === 'working'} />
{member.role} · {memberStateLabel(state, t)}
</div>
</div>
<IconButton icon={<X aria-hidden="true" />} label={t('workbench.close')} size="sm" tone="muted" onClick={onClose} />
</div>
<div className="mt-3 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2 font-mono text-[10.5px] leading-5 text-[var(--color-text-secondary)]">
{currentTask?.activeForm || currentTask?.subject || member.currentTask || memberStateLabel(state, t)}
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<DrawerSection title={t('agentTeams.memberTasks')}>
{tasks.length === 0 ? (
<p className="py-2 text-[11px] text-[var(--color-text-tertiary)]">{t('agentTeams.noMemberTasks')}</p>
) : tasks.map((task) => (
<div key={task.id} className="flex items-center gap-2 border-b border-[var(--color-border-separator)] py-2 text-[11px]">
<span className="font-mono font-bold text-[var(--color-brand)]">#{task.id}</span>
<span className="min-w-0 flex-1 truncate font-medium">{task.subject}</span>
<Badge tone={taskTone(task.status === 'completed' ? 'completed' : task.status === 'in_progress' ? 'running' : 'open')} size="xs">
{taskStateLabel(task.status === 'completed' ? 'completed' : task.status === 'in_progress' ? 'running' : 'open', t)}
</Badge>
</div>
))}
</DrawerSection>
<DrawerSection title={t('agentTeams.memberMessages')}>
{messages.length === 0 ? (
<p className="py-2 text-[11px] text-[var(--color-text-tertiary)]">{t('agentTeams.noMemberMessages')}</p>
) : messages.map((message) => (
<div key={message.id} className="border-b border-[var(--color-border-separator)] py-2">
<div className="text-[9px] font-extrabold" style={{ color: messageTone(message) }}>
{messageKindLabel(message, t)} · {message.from} → {message.to}
</div>
<div className="mt-1 line-clamp-3 text-[11px] leading-5 text-[var(--color-text-secondary)]">
{messageText(message, t)}
</div>
</div>
))}
</DrawerSection>
<p className="mt-5 text-[10px] text-[var(--color-text-tertiary)]">
{t(snapshot.deletedAt ? 'agentTeams.archivedConversationHint' : 'agentTeams.fullConversationHint')}
</p>
</div>
<div className="shrink-0 border-t border-[var(--color-border)] p-4">
<Button variant="accent" size="md" block onClick={onOpenConversation} disabled={isLead}>
{t(snapshot.deletedAt ? 'agentTeams.openExecution' : 'agentTeams.openConversation', { name })}
</Button>
</div>
</aside>
)
}
function DrawerSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="mb-5">
<h3 className="mb-1 text-[10.5px] font-extrabold tracking-[0.08em] text-[var(--color-text-tertiary)]">{title}</h3>
{children}
</section>
)
}
@@ -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(<AgentTeamsWorkbenchTab tabId="__team__lead-session" leadSessionId="lead-session" />)
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()
})
})
@@ -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 (
<div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)]">
<div className="flex shrink-0 items-center gap-2 border-b border-[var(--color-border)] px-3 py-1.5">
<Button
variant="ghost"
size="sm"
icon={<ArrowLeft size={14} strokeWidth={2} aria-hidden="true" />}
onClick={() => useTabStore.getState().returnFromTeamWorkbench(tabId)}
>
{t('agentTeams.backToSession')}
</Button>
</div>
<AgentTeamsWorkbench sessionId={leadSessionId} variant="full" />
</div>
)
}
@@ -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<MemberAvatarKey, string> = {
'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]!
}
@@ -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('')
})
})
@@ -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<string, unknown> | 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<string, unknown>
: null
} catch {
return null
}
}
function firstNonEmptyString(
record: Record<string, unknown>,
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<TeamWorkbenchMessage, 'text' | 'protocolType'>,
): 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)
@@ -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'])
})
})
+40 -2
View File
@@ -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 ? (
<AgentTeamsInlineCard
snapshot={teamSnapshot}
onOpen={() => openTeamWorkbench(resolvedSessionId)}
/>
) : null
) : (
<MessageBlock
sessionId={resolvedSessionId}
@@ -2964,7 +2997,11 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false }
>
<div
ref={scrollContentRef}
className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[900px]'}
// The reading measure holds whether or not a right-hand panel is
// open — `compact` only tightens padding. Dropping to `max-w-full`
// was what made the transcript lose its centred structure the moment
// the agent-teams workbench appeared.
className="mx-auto max-w-[900px]"
>
{virtualTranscriptWindow.enabled ? (
<VirtualSpacer height={virtualTranscriptWindow.beforeHeight} position="top" />
@@ -3116,6 +3153,7 @@ export const MessageBlock = memo(function MessageBlock({
branchAction={branchAction}
timestamp={message.timestamp}
sessionId={sessionId ?? undefined}
teammateFrom={message.teammateFrom}
/>
</SelectableChatMessage>
)
@@ -118,4 +118,32 @@ describe('UserMessage bare-URL linkify', () => {
const { container } = render(<UserMessage sessionId="s1" content={'把样式改一下'} />)
expect(container.querySelectorAll('a')).toHaveLength(0)
})
it('attributes a teammate turn and left-aligns it away from the user bubble', () => {
const { container } = render(
<UserMessage content="Review the auth diff." teammateFrom="team-lead" />,
)
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(<UserMessage content="Review the auth diff." />)
expect(container.querySelector('[data-message-shell="teammate"]')).toBeNull()
expect(bubbleOf(container).textContent).toBe('Review the auth diff.')
expect(container.firstElementChild?.className).toContain('justify-end')
})
})
+71 -18
View File
@@ -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' ? (
<a
key={index}
href={segment.value}
target="_blank"
rel="noreferrer noopener"
className="text-[var(--color-text-accent)] underline decoration-[1px] underline-offset-[3px] decoration-[var(--color-text-accent)] [overflow-wrap:anywhere] hover:decoration-[2px]"
onClick={(event) => handleLinkClick(event, segment.value)}
>
{segment.value}
</a>
) : (
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 (
<div className="mb-5 flex justify-start">
<div
data-message-shell="teammate"
data-teammate-from={teammateFrom}
className="group flex min-w-0 max-w-[82%] flex-col items-start sm:max-w-[78%] lg:max-w-[680px]"
>
<div className="mb-1 flex min-w-0 items-center gap-1.5 px-0.5 text-[11px] text-[var(--color-text-tertiary)]">
<UsersRound size={12} strokeWidth={2.2} aria-hidden="true" className="shrink-0 text-[var(--color-brand)]" />
<span className="min-w-0 truncate font-mono font-bold text-[var(--color-text-secondary)]">
{teammateFrom}
</span>
<span className="shrink-0">{t('chat.teammateMessage')}</span>
</div>
<div className="flex max-w-full flex-col items-start gap-2">
{attachments && attachments.length > 0 && (
<AttachmentGallery attachments={attachments} variant="message" />
)}
{hasText && (
<div
data-message-body="teammate"
className="min-w-0 max-w-full whitespace-pre-wrap break-words rounded-[var(--radius-lg)] border-l-2 border-[var(--color-brand)] bg-[var(--color-surface-container)] px-[16px] py-[12px] text-[14px] leading-relaxed text-[var(--color-text-primary)]"
style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}
>
{body}
</div>
)}
</div>
{hasText && (
<MessageActionBar
copyText={content}
copyLabel={t('chat.copyPrompt')}
align="start"
timestamp={timestamp}
/>
)}
</div>
</div>
)
}
return (
<div className="mb-5 flex justify-end">
<div
@@ -52,22 +120,7 @@ export const UserMessage = memo(function UserMessage({ content, attachments, bra
wordBreak: 'break-word',
}}
>
{segments.map((segment, index) =>
segment.type === 'url' ? (
<a
key={index}
href={segment.value}
target="_blank"
rel="noreferrer noopener"
className="text-[var(--color-text-accent)] underline decoration-[1px] underline-offset-[3px] decoration-[var(--color-text-accent)] [overflow-wrap:anywhere] hover:decoration-[2px]"
onClick={(event) => handleLinkClick(event, segment.value)}
>
{segment.value}
</a>
) : (
segment.value
),
)}
{body}
</div>
)}
</div>
@@ -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
? <WorkbenchTab tabId={activeTabId} sessionId={workbenchTab.workbenchSessionId} />
: <EmptySession />
} else if (activeTabType === 'team') {
const teamTab = tabs.find((t) => t.sessionId === activeTabId)
page = teamTab?.teamLeadSessionId
? <AgentTeamsWorkbenchTab tabId={activeTabId} leadSessionId={teamTab.teamLeadSessionId} />
: <EmptySession />
} else if (activeTabType !== 'terminal') {
page = <ActiveSession />
}
+55 -14
View File
@@ -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(<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()
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<ReturnType<typeof useSessionStore.getState>>)
useChatStore.setState({
sessions: { [sessionId]: makeChatSession('idle') },
disconnectSession: vi.fn(),
} as Partial<ReturnType<typeof useChatStore.getState>>)
useTeamStore.setState({
workbenchesBySession: { [sessionId]: teamWorkbenchTimeline(sessionId) },
workbenchOpenBySession: { [sessionId]: true },
} as Partial<ReturnType<typeof useTeamStore.getState>>)
await act(async () => {
render(<TabBar />)
})
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 () => {
+19 -10
View File
@@ -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<HTMLDivElement>(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 && (
<IconButton
icon={isWorkspacePanelOpen ? <FolderOpen size={18} strokeWidth={1.9} /> : <Folder size={18} strokeWidth={1.9} />}
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)
}
+19
View File
@@ -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',
+19
View File
@@ -38,6 +38,7 @@ export const jp: Record<TranslationKey, string> = {
'chat.generatingDiagram': '図を生成中…',
'chat.copyReply': '返信をコピー',
'chat.copyPrompt': 'プロンプトをコピー',
'chat.teammateMessage': 'メンバーからのメッセージ',
'chat.copyPath': 'パスをコピー',
'common.dismissNotification': '通知を閉じる',
@@ -2280,6 +2281,24 @@ export const jp: Record<TranslationKey, string> = {
'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': 'アクティビティ',
+19
View File
@@ -38,6 +38,7 @@ export const kr: Record<TranslationKey, string> = {
'chat.generatingDiagram': '다이어그램 생성 중…',
'chat.copyReply': '답변 복사',
'chat.copyPrompt': '프롬프트 복사',
'chat.teammateMessage': '팀원 메시지',
'chat.copyPath': '경로 복사',
'common.dismissNotification': '알림 닫기',
@@ -2280,6 +2281,24 @@ export const kr: Record<TranslationKey, string> = {
'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': '활동',
+19
View File
@@ -38,6 +38,7 @@ export const zh: Record<TranslationKey, string> = {
'chat.generatingDiagram': '正在產生圖表…',
'chat.copyReply': '複製回覆',
'chat.copyPrompt': '複製提示詞',
'chat.teammateMessage': '隊友訊息',
'chat.copyPath': '複製路徑',
'common.dismissNotification': '關閉通知',
@@ -2279,6 +2280,24 @@ export const zh: Record<TranslationKey, string> = {
'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': '活動',
+19
View File
@@ -38,6 +38,7 @@ export const zh: Record<TranslationKey, string> = {
'chat.generatingDiagram': '正在生成图表…',
'chat.copyReply': '复制回复',
'chat.copyPrompt': '复制提示词',
'chat.teammateMessage': '队友消息',
'chat.copyPath': '复制路径',
'common.dismissNotification': '关闭通知',
@@ -2279,6 +2280,24 @@ export const zh: Record<TranslationKey, string> = {
'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': '活动',
+14 -2
View File
@@ -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(<ActiveSession />)
// 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()
+39 -15
View File
@@ -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 && (
<div
data-testid="session-header"
className={
showRightPanel
? 'flex w-full items-center border-b border-[var(--color-border)] px-4 py-2.5'
: 'w-full border-b border-[var(--color-border)] px-9 py-3'
}
className={[
'w-full border-b border-[var(--color-border)]',
showRightPanel ? 'px-4 py-2.5' : 'px-9 py-3',
].join(' ')}
>
<div className={showRightPanel ? 'min-w-0 flex-1' : 'mx-auto w-full max-w-[900px] min-w-0'}>
{/* 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. */}
<div className="mx-auto w-full min-w-0 max-w-[900px]">
<div className="flex min-w-0 items-center gap-3">
<h1
className={`min-w-0 flex-1 truncate font-bold leading-tight tracking-[-0.2px] text-[var(--color-text-primary)] ${
@@ -857,6 +866,21 @@ export function ActiveSession() {
isRunning={isActive}
compact={showRightPanel}
/>
{agentTeamsSnapshot ? (
<AgentTeamsStrip
snapshot={agentTeamsSnapshot}
open={showAgentTeamsWorkbench}
compact={showRightPanel}
onOpen={() => {
if (showAgentTeamsWorkbench) {
useTeamStore.getState().setWorkbenchOpen(activeTabId, false)
return
}
useWorkspacePanelStore.getState().closePanel(activeTabId)
useTeamStore.getState().setWorkbenchOpen(activeTabId, true)
}}
/>
) : null}
</div>
</div>
)}
@@ -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%)',
}}
>
<AgentTeamsWorkbench sessionId={activeTabId} />
+28 -1
View File
@@ -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: '<teammate-message teammate_id="security-reviewer">Review the auth diff and call out risks.</teammate-message>',
},
{
id: 'user-2',
type: 'user',
timestamp: '2026-04-06T00:01:00.000Z',
content: [
{ type: 'text', text: '<teammate-message teammate_id="team-lead">Ship it once tests pass.</teammate-message>' },
],
},
{
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', () => {
+21 -8
View File
@@ -4208,8 +4208,14 @@ function buildAbortedHistoryLoadUpdate(
const TEAMMATE_CONTENT_REGEX = /<teammate-message\s+teammate_id="([^"]+)"[^>]*>\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,
})
+51 -5
View File
@@ -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<TabStore>((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<TabStore>((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<TabStore>((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)
})
+16 -3
View File
@@ -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 () => {
+87 -15
View File
@@ -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<typeof setInterval> | null = null
@@ -36,6 +41,20 @@ const memberTranscriptCursors = new Map<string, {
const memberRefreshGenerations = new Map<string, number>()
const workbenchRefreshGenerations = new Map<string, number>()
/**
* 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<TeamStore, 'workbenchesBySession' | 'workbenchOpenBySession'>,
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<TeamStore, 'workbenchViewBySession'>,
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<UIMessage, { type: 'user_text' }> & { 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<string, TeamWorkbenchTimeline | undefined>
workbenchHistoryIndexBySession: Record<string, number | null | undefined>
workbenchOpenBySession: Record<string, boolean | undefined>
workbenchViewBySession: Record<string, WorkbenchView | undefined>
workbenchPanelWidth: number
fetchTeams: () => Promise<void>
@@ -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<void>
@@ -238,6 +280,7 @@ export const useTeamStore = create<TeamStore>((set, get) => ({
workbenchesBySession: {},
workbenchHistoryIndexBySession: {},
workbenchOpenBySession: {},
workbenchViewBySession: {},
workbenchPanelWidth: AGENT_TEAMS_WORKBENCH_DEFAULT_WIDTH,
fetchTeams: async () => {
@@ -283,10 +326,6 @@ export const useTeamStore = create<TeamStore>((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<TeamStore>((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<TeamStore>((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<TeamStore>((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<typeof mapHistoryMessagesToUiMessages>[0],
@@ -498,8 +567,10 @@ export const useTeamStore = create<TeamStore>((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<TeamStore>((set, get) => ({
workbenchesBySession: {},
workbenchHistoryIndexBySession: {},
workbenchOpenBySession: {},
workbenchViewBySession: {},
})
},
+7 -1
View File
@@ -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 }
| {
+44
View File
@@ -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',
+7
View File
@@ -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 } : {}),
}
}