mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
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:
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'アクティビティ',
|
||||
|
||||
@@ -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': '활동',
|
||||
|
||||
@@ -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': '活動',
|
||||
|
||||
@@ -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': '活动',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: {},
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -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 }
|
||||
| {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user