mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
feat(desktop): show where each team member stands on its tasks
A member that stopped or failed left its task reading "In progress" with an animated bar, and its card said only "Stopped" or "Error". Tasks whose owner stopped, failed or waits to retry now show that state without animation, and the card says which task the member stopped on. The member card chips tell done, current and next tasks apart, with the subject on hover. The member drawer groups tasks into now, up next and done, names the unfinished dependencies of blocked work, shows done as 3/5, and no longer prints +0:00 for a duration polling could not measure.
This commit is contained in:
@@ -402,6 +402,32 @@ describe('AgentTeamsCanvas', () => {
|
||||
expect(lead).toBeNull()
|
||||
})
|
||||
|
||||
it('chips what each member has done, is doing and does next, with the subject on hover', () => {
|
||||
render(<AgentTeamsCanvas {...props()} />)
|
||||
const chips = (agentId: string) => Array.from(
|
||||
screen.getByTestId(`agent-teams-canvas-member-${agentId}`).querySelectorAll('[data-member-task]'),
|
||||
).map(chip => ({
|
||||
id: chip.getAttribute('data-member-task'),
|
||||
status: chip.getAttribute('data-task-status'),
|
||||
next: chip.getAttribute('data-task-next') === 'true',
|
||||
title: chip.getAttribute('title'),
|
||||
}))
|
||||
|
||||
expect(chips('builder@canvas-team')).toEqual([
|
||||
{ id: '1', status: 'completed', next: false, title: 'Task 1' },
|
||||
{ id: '2', status: 'in_progress', next: false, title: 'Task 2' },
|
||||
])
|
||||
// Nothing started yet, but the member's next task is already known.
|
||||
expect(chips('reviewer@canvas-team')).toEqual([
|
||||
{ id: '3', status: 'pending', next: true, title: 'Task 3' },
|
||||
])
|
||||
// The connector line runs behind the chip row; a see-through chip lets
|
||||
// it strike through the label.
|
||||
const next = screen.getByTestId('agent-teams-canvas-member-reviewer@canvas-team').querySelector<HTMLElement>('[data-task-next]')!
|
||||
expect(next.style.backgroundColor).not.toBe('transparent')
|
||||
expect(next.style.backgroundColor).not.toBe('')
|
||||
})
|
||||
|
||||
it('labels stopped, retrying and failed members and keeps their failure on hover', () => {
|
||||
const current = snapshot('current')
|
||||
const recovering: TeamWorkbenchSnapshot = {
|
||||
@@ -436,7 +462,15 @@ describe('AgentTeamsCanvas', () => {
|
||||
|
||||
const builder = screen.getByTestId('agent-teams-canvas-member-builder@canvas-team')
|
||||
expect(builder.getAttribute('data-member-state')).toBe('retrying')
|
||||
expect(screen.getByText('Auto-retry 2/5').getAttribute('title')).toBe('API Error: 529 overloaded')
|
||||
// It names the task it stopped on, not just that it stopped.
|
||||
expect(screen.getByText('Auto-retry 2/5 · at #2').getAttribute('title')).toBe('API Error: 529 overloaded')
|
||||
// The task list still says in progress; the card must not animate work
|
||||
// nobody is doing.
|
||||
const stalledTask = screen.getByTestId('agent-teams-canvas-task-2')
|
||||
expect(stalledTask.getAttribute('data-stalled')).toBe('retrying')
|
||||
expect(stalledTask.textContent).toContain('Auto-retry 2/5')
|
||||
expect(stalledTask.textContent).not.toContain('In progress')
|
||||
expect(stalledTask.querySelector('[data-progress="indeterminate"]')).toBeNull()
|
||||
|
||||
const reviewer = screen.getByTestId('agent-teams-canvas-member-reviewer@canvas-team')
|
||||
expect(reviewer.getAttribute('data-member-state')).toBe('stopped')
|
||||
|
||||
@@ -17,9 +17,11 @@ import {
|
||||
parseWorkbenchMessageBody,
|
||||
resolveMemberModel,
|
||||
resolveTeamMemberIdentity,
|
||||
stalledTaskOwnerState,
|
||||
taskOwnedByMember,
|
||||
type MemberWorkState,
|
||||
type PositionedWorkbenchTask,
|
||||
type StalledTaskOwnerState,
|
||||
type WorkbenchTaskState,
|
||||
} from './agentTeamsModel'
|
||||
|
||||
@@ -50,6 +52,8 @@ type MemberPosition = {
|
||||
percent: number
|
||||
inbox: number
|
||||
recentTasks: TeamWorkbenchTask[]
|
||||
/** The first of the member's tasks that has not started yet. */
|
||||
nextTask?: TeamWorkbenchTask
|
||||
}
|
||||
|
||||
type OwnerVisual = {
|
||||
@@ -138,6 +142,32 @@ function memberStateLabel(state: MemberWorkState, member: TeamMember, t: Transla
|
||||
return t(`agentTeams.member.${state}` as TranslationKey)
|
||||
}
|
||||
|
||||
function isStalledState(state: MemberWorkState): state is StalledTaskOwnerState {
|
||||
return state === 'stopped' || state === 'error' || state === 'retrying'
|
||||
}
|
||||
|
||||
function stalledColors(state: StalledTaskOwnerState) {
|
||||
if (state === 'error') {
|
||||
return { background: 'var(--color-error-container)', foreground: 'var(--color-on-error-container)', border: 'var(--color-error)' }
|
||||
}
|
||||
if (state === 'retrying') {
|
||||
return { background: 'var(--color-warning-container)', foreground: 'var(--color-on-warning-container)', border: 'var(--color-warning)' }
|
||||
}
|
||||
return { background: 'var(--color-surface-container-high)', foreground: 'var(--color-text-secondary)', border: 'var(--color-border-strong)' }
|
||||
}
|
||||
|
||||
function memberTaskChipStyle(task: TeamWorkbenchTask, next: boolean, accent: string) {
|
||||
if (next) {
|
||||
// Opaque like the other chips: the formation's connector line runs
|
||||
// behind this row and would cut through the label.
|
||||
return { borderStyle: 'dashed', borderColor: 'var(--color-outline)', backgroundColor: 'var(--color-background)', color: 'var(--color-text-tertiary)' }
|
||||
}
|
||||
if (task.status === 'completed') {
|
||||
return { borderColor: 'var(--color-border)', backgroundColor: 'var(--color-success-container)', color: 'var(--color-on-success-container)' }
|
||||
}
|
||||
return { borderColor: accent, backgroundColor: 'var(--color-surface-container-lowest)', color: 'var(--color-text-primary)' }
|
||||
}
|
||||
|
||||
function leadStatusLabel(snapshot: TeamWorkbenchSnapshot, t: TranslationFn): string {
|
||||
if (snapshot.deletedAt) return t('agentTeams.lead.archived')
|
||||
if (snapshot.tasks.length === 0) return t('agentTeams.lead.forming')
|
||||
@@ -254,6 +284,18 @@ function memberInboxCount(member: TeamMember, messages: TeamWorkbenchMessage[]):
|
||||
)).length
|
||||
}
|
||||
|
||||
/**
|
||||
* The chips under a member card: what it has done and is doing, then the next
|
||||
* task waiting for it, within the four chips the card has room for.
|
||||
*/
|
||||
function memberTaskChips(ownedTasks: TeamWorkbenchTask[]): Pick<MemberPosition, 'recentTasks' | 'nextTask'> {
|
||||
const nextTask = ownedTasks
|
||||
.filter(task => task.status === 'pending')
|
||||
.sort((left, right) => left.id.localeCompare(right.id, undefined, { numeric: true }))[0]
|
||||
const started = ownedTasks.filter(task => task.status !== 'pending')
|
||||
return { recentTasks: started.slice(nextTask ? -3 : -4), nextTask }
|
||||
}
|
||||
|
||||
function workState(
|
||||
member: TeamMember,
|
||||
snapshot: TeamWorkbenchSnapshot,
|
||||
@@ -479,7 +521,9 @@ function MemberNode({
|
||||
: waitingDependency
|
||||
? t('agentTeams.member.waitingForDependency', { task: waitingDependency })
|
||||
: t('agentTeams.member.waitingForTask')
|
||||
: memberStateLabel(state, member, t)
|
||||
: isStalledState(state) && position.currentTask
|
||||
? t('agentTeams.member.stalledOnTask', { state: memberStateLabel(state, member, t), task: position.currentTask.id })
|
||||
: memberStateLabel(state, member, t)
|
||||
const characterClass = state === 'working'
|
||||
? 'agent-teams-character-working'
|
||||
: state === 'idle' || state === 'retrying'
|
||||
@@ -582,14 +626,22 @@ function MemberNode({
|
||||
<span className="block h-full rounded-full" style={{ width: `${position.percent}%`, backgroundColor: accent }} />
|
||||
</span>
|
||||
<span className="mt-1.5 flex max-w-[172px] items-center justify-center gap-1 overflow-hidden">
|
||||
{position.recentTasks.map(task => (
|
||||
<span
|
||||
key={task.id}
|
||||
className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-high)] px-1.5 py-px font-mono text-[9px] font-extrabold text-[var(--color-text-secondary)]"
|
||||
>
|
||||
#{task.id}
|
||||
</span>
|
||||
))}
|
||||
{[...position.recentTasks, ...(position.nextTask ? [position.nextTask] : [])].map(task => {
|
||||
const next = task === position.nextTask
|
||||
return (
|
||||
<span
|
||||
key={task.id}
|
||||
data-member-task={task.id}
|
||||
data-task-status={task.status}
|
||||
data-task-next={next ? 'true' : undefined}
|
||||
title={task.subject}
|
||||
className="rounded-full border px-1.5 py-px font-mono text-[9px] font-extrabold"
|
||||
style={memberTaskChipStyle(task, next, accent)}
|
||||
>
|
||||
#{task.id}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
@@ -628,6 +680,10 @@ function TaskCard({
|
||||
const owner = taskOwnerVisual(task, snapshot, members, depth)
|
||||
const accent = owner?.accent ?? 'var(--color-brand)'
|
||||
const colors = taskStateColors(state, accent)
|
||||
// Still in progress on the list, but its owner stopped, failed or waits to
|
||||
// retry: say so instead of animating work nobody is doing.
|
||||
const stalled = owner ? stalledTaskOwnerState(task, snapshot) : undefined
|
||||
const stall = stalled ? stalledColors(stalled) : undefined
|
||||
const progress = taskProgress(task)
|
||||
const dependencies = task.blockedBy.map(id => `#${id}`).join(' ')
|
||||
const ownerLabel = owner
|
||||
@@ -645,7 +701,8 @@ function TaskCard({
|
||||
data-state={state}
|
||||
data-depth={depth}
|
||||
data-chain-active={focused ? 'true' : 'false'}
|
||||
aria-label={`${task.subject}, ${taskStateLabel(state, t)}`}
|
||||
data-stalled={stalled}
|
||||
aria-label={`${task.subject}, ${stalled && owner ? memberStateLabel(stalled, owner.member, t) : taskStateLabel(state, t)}`}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={onHover}
|
||||
onMouseLeave={onHoverEnd}
|
||||
@@ -656,7 +713,7 @@ function TaskCard({
|
||||
left: x,
|
||||
top: y,
|
||||
backgroundColor: colors.background,
|
||||
borderColor: justUnlocked ? 'var(--color-success)' : colors.border,
|
||||
borderColor: justUnlocked ? 'var(--color-success)' : stall?.border ?? colors.border,
|
||||
opacity: dimmed ? 0.34 : state === 'blocked' ? 0.72 : 1,
|
||||
}}
|
||||
>
|
||||
@@ -667,12 +724,16 @@ function TaskCard({
|
||||
<span
|
||||
className="shrink-0 rounded-full border px-1.5 py-px text-[9.5px] font-extrabold"
|
||||
style={{
|
||||
backgroundColor: justUnlocked ? 'var(--color-success-container)' : colors.pillBackground,
|
||||
borderColor: justUnlocked ? 'var(--color-success)' : colors.border,
|
||||
color: justUnlocked ? 'var(--color-on-success-container)' : colors.pillForeground,
|
||||
backgroundColor: justUnlocked ? 'var(--color-success-container)' : stall?.background ?? colors.pillBackground,
|
||||
borderColor: justUnlocked ? 'var(--color-success)' : stall?.border ?? colors.border,
|
||||
color: justUnlocked ? 'var(--color-on-success-container)' : stall?.foreground ?? colors.pillForeground,
|
||||
}}
|
||||
>
|
||||
{justUnlocked ? t('agentTeams.task.unlocked') : taskStateLabel(state, t)}
|
||||
{justUnlocked
|
||||
? t('agentTeams.task.unlocked')
|
||||
: stalled && owner
|
||||
? memberStateLabel(stalled, owner.member, t)
|
||||
: taskStateLabel(state, t)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -706,7 +767,7 @@ function TaskCard({
|
||||
: 'var(--color-surface-container-high)',
|
||||
}}
|
||||
>
|
||||
{progress === null ? (
|
||||
{stalled && progress === null ? null : progress === null ? (
|
||||
<span
|
||||
data-progress="indeterminate"
|
||||
className="agent-teams-task-running-fill block h-full rounded-full"
|
||||
@@ -716,7 +777,7 @@ function TaskCard({
|
||||
<span
|
||||
data-progress={Math.round(progress)}
|
||||
className="block h-full rounded-full"
|
||||
style={{ width: `${progress}%`, backgroundColor: colors.progress }}
|
||||
style={{ width: `${progress}%`, backgroundColor: stall ? stall.border : colors.progress }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
@@ -779,7 +840,7 @@ export function AgentTeamsCanvas({
|
||||
total: ownedTasks.length,
|
||||
percent: ownedTasks.length === 0 ? 0 : Math.round((completed / ownedTasks.length) * 100),
|
||||
inbox: memberInboxCount(member, snapshot.messages),
|
||||
recentTasks: ownedTasks.filter(task => task.status !== 'pending').slice(-4),
|
||||
...memberTaskChips(ownedTasks),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -125,7 +125,59 @@ describe('AgentTeamsMemberInspector', () => {
|
||||
expect(row.textContent).toContain('Completed')
|
||||
expect(row.textContent).toContain(`${expectedStart} +7:00`)
|
||||
expect(row.getAttribute('data-task-state')).toBe('completed')
|
||||
expect(screen.getByText('1', { selector: 'dd' })).toBeTruthy()
|
||||
expect(screen.getByText('1/1', { selector: 'dd' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('groups what a member is doing, will do next and has done, and says where a stopped member stopped', () => {
|
||||
const memberTask = (id: string, status: TeamWorkbenchTask['status'], blockedBy: string[] = []): TeamWorkbenchTask => ({
|
||||
id, subject: `Task ${id}`, description: '', owner: 'builder', status, blocks: [], blockedBy, taskListId: 'team-a',
|
||||
})
|
||||
const stopped: TeamMember = { ...builder, activity: 'stopped' }
|
||||
const frame = (generatedAt: string, tasks: TeamWorkbenchTask[]): TeamWorkbenchSnapshot => ({
|
||||
version: 'v1',
|
||||
generatedAt,
|
||||
team: { name: 'team-a', leadAgentId: 'lead@team-a', leadSessionId: 'lead-session', members: [stopped, reviewer] },
|
||||
tasks,
|
||||
messages: [],
|
||||
})
|
||||
const snapshots = [
|
||||
frame('2026-08-08T07:00:00.000Z', [memberTask('1', 'pending'), memberTask('2', 'pending'), memberTask('3', 'pending', ['2']), memberTask('4', 'pending')]),
|
||||
// #1 started and finished between two polls: its duration is unknown,
|
||||
// not zero.
|
||||
frame('2026-08-08T07:05:00.000Z', [memberTask('1', 'completed'), memberTask('2', 'in_progress'), memberTask('3', 'pending', ['2']), memberTask('4', 'pending')]),
|
||||
]
|
||||
render(
|
||||
<AgentTeamsMemberInspector
|
||||
snapshots={snapshots}
|
||||
selectedIndex={1}
|
||||
snapshot={snapshots[1]!}
|
||||
member={stopped}
|
||||
isLead={false}
|
||||
leadIsStreaming={false}
|
||||
onBack={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onOpenExecution={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const group = (name: string) => screen.getByTestId(`agent-teams-member-task-group-${name}`)
|
||||
const ids = (name: string) => Array.from(group(name).querySelectorAll('[data-task-state]')).map(row => row.getAttribute('data-testid'))
|
||||
expect(screen.getAllByTestId(/^agent-teams-member-task-group-/).map(node => node.getAttribute('data-testid'))).toEqual([
|
||||
'agent-teams-member-task-group-running',
|
||||
'agent-teams-member-task-group-upcoming',
|
||||
'agent-teams-member-task-group-completed',
|
||||
])
|
||||
expect(ids('running')).toEqual(['agent-teams-member-task-2'])
|
||||
expect(ids('upcoming')).toEqual(['agent-teams-member-task-3', 'agent-teams-member-task-4'])
|
||||
expect(ids('completed')).toEqual(['agent-teams-member-task-1'])
|
||||
|
||||
// The task list still says in progress, but nobody is working on it.
|
||||
expect(screen.getByTestId('agent-teams-member-task-2-state').textContent).toBe('Stopped')
|
||||
expect(screen.getByText('Stopped · at #2')).toBeTruthy()
|
||||
expect(screen.getByTestId('agent-teams-member-task-3').textContent).toContain('Depends on #2')
|
||||
expect(screen.getByTestId('agent-teams-member-task-1').textContent).toContain(formatWorkbenchMessageTime('2026-08-08T07:05:00.000Z'))
|
||||
expect(screen.getByTestId('agent-teams-member-task-1').textContent).not.toContain('+0:00')
|
||||
expect(screen.getByText('1/4', { selector: 'dd' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows message direction, renders human Markdown, and narrates protocol payloads', () => {
|
||||
@@ -282,7 +334,8 @@ describe('AgentTeamsMemberInspector', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Stopped')).toBeTruthy()
|
||||
// Where it stopped, not just that it stopped.
|
||||
expect(screen.getByText('Stopped · at #7')).toBeTruthy()
|
||||
const notice = screen.getByTestId('agent-teams-member-recovery')
|
||||
expect(notice.getAttribute('data-member-state')).toBe('stopped')
|
||||
expect(screen.getByTestId('agent-teams-member-recovery-hint').textContent)
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
parseWorkbenchMessageBody,
|
||||
resolveMemberModel,
|
||||
resolveTeamMemberIdentity,
|
||||
stalledTaskOwnerState,
|
||||
taskOwnedByMember,
|
||||
type MemberWorkState,
|
||||
type StalledTaskOwnerState,
|
||||
type WorkbenchMessageBody,
|
||||
type WorkbenchTaskState,
|
||||
} from '@/components/agentTeams/agentTeamsModel'
|
||||
@@ -183,7 +185,30 @@ function formatDuration(durationMs: number): string {
|
||||
|
||||
function formatTaskSpan(entry: TaskHistoryEntry): string {
|
||||
if (entry.startedAt === null || entry.durationMs === null) return '—'
|
||||
return `${formatWorkbenchMessageTime(new Date(entry.startedAt).toISOString())} +${formatDuration(entry.durationMs)}`
|
||||
const start = formatWorkbenchMessageTime(new Date(entry.startedAt).toISOString())
|
||||
// Times come from polled frames: a task that started and ended between two
|
||||
// of them has an unknown duration, not a zero one.
|
||||
return entry.durationMs < 1000 ? start : `${start} +${formatDuration(entry.durationMs)}`
|
||||
}
|
||||
|
||||
type TaskGroup = 'running' | 'upcoming' | 'completed'
|
||||
|
||||
const TASK_GROUPS: Array<{ group: TaskGroup; label: TranslationKey }> = [
|
||||
{ group: 'running', label: 'agentTeams.inspector.groupRunning' },
|
||||
{ group: 'upcoming', label: 'agentTeams.inspector.groupUpcoming' },
|
||||
{ group: 'completed', label: 'agentTeams.inspector.groupCompleted' },
|
||||
]
|
||||
|
||||
function taskGroup(state: WorkbenchTaskState): TaskGroup {
|
||||
if (state === 'running') return 'running'
|
||||
if (state === 'completed') return 'completed'
|
||||
return 'upcoming'
|
||||
}
|
||||
|
||||
function stalledTone(state: StalledTaskOwnerState): Tone {
|
||||
if (state === 'error') return 'danger'
|
||||
if (state === 'retrying') return 'warning'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
function taskTone(state: WorkbenchTaskState): Tone {
|
||||
@@ -193,6 +218,15 @@ function taskTone(state: WorkbenchTaskState): Tone {
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
function memberStateLabel(state: MemberWorkState, member: TeamMember, t: TranslationFn): string {
|
||||
return state === 'retrying'
|
||||
? t('agentTeams.member.retrying', {
|
||||
attempt: member.autoRetry?.attempt ?? '?',
|
||||
max: member.autoRetry?.max ?? '?',
|
||||
})
|
||||
: t(`agentTeams.member.${state}` as TranslationKey)
|
||||
}
|
||||
|
||||
function memberTone(state: MemberWorkState): Tone {
|
||||
if (state === 'working') return 'brand'
|
||||
if (state === 'error') return 'danger'
|
||||
@@ -346,12 +380,9 @@ export function AgentTeamsMemberInspector({
|
||||
? t('agentTeams.member.waitingForDependency', { task: waitingDependency })
|
||||
: workState === 'idle'
|
||||
? t('agentTeams.member.waitingForTask')
|
||||
: workState === 'retrying'
|
||||
? t('agentTeams.member.retrying', {
|
||||
attempt: member.autoRetry?.attempt ?? '?',
|
||||
max: member.autoRetry?.max ?? '?',
|
||||
})
|
||||
: t(`agentTeams.member.${workState}` as TranslationKey)
|
||||
: (workState === 'stopped' || workState === 'error' || workState === 'retrying') && runningTask
|
||||
? t('agentTeams.member.stalledOnTask', { state: memberStateLabel(workState, member, t), task: runningTask.id })
|
||||
: memberStateLabel(workState, member, t)
|
||||
// Stopped, retrying and failed members all come back on their own or through
|
||||
// a message; say why they paused and what brings them back.
|
||||
const awaitsRecovery = !isLead && (
|
||||
@@ -433,7 +464,7 @@ export function AgentTeamsMemberInspector({
|
||||
<dt className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">
|
||||
{t('agentTeams.inspector.completedTasks')}
|
||||
</dt>
|
||||
<dd className="mt-0.5 font-extrabold tabular-nums">{completedTasks}</dd>
|
||||
<dd className="mt-0.5 font-extrabold tabular-nums">{completedTasks}/{taskHistory.length}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">
|
||||
@@ -509,40 +540,66 @@ export function AgentTeamsMemberInspector({
|
||||
</span>
|
||||
</h3>
|
||||
{taskHistory.length > 0 ? (
|
||||
<ol className="mt-2" data-testid="agent-teams-member-task-history">
|
||||
{taskHistory.map(entry => {
|
||||
const span = formatTaskSpan(entry)
|
||||
<div data-testid="agent-teams-member-task-history">
|
||||
{TASK_GROUPS.map(({ group, label }) => {
|
||||
const entries = taskHistory.filter(entry => taskGroup(entry.state) === group)
|
||||
if (entries.length === 0) return null
|
||||
// Up next reads in the order the work unblocks, not by when
|
||||
// (never) it started.
|
||||
if (group === 'upcoming') entries.sort((left, right) => left.task.id.localeCompare(right.task.id, undefined, { numeric: true }))
|
||||
return (
|
||||
<li
|
||||
key={entry.task.id}
|
||||
data-testid={`agent-teams-member-task-${entry.task.id}`}
|
||||
data-task-state={entry.state}
|
||||
className="flex min-w-0 items-center gap-2 border-b border-[var(--color-border)] py-1.5 last:border-b-0"
|
||||
>
|
||||
<span className="w-[26px] shrink-0 font-mono text-[10px] font-extrabold text-[var(--color-text-tertiary)]">
|
||||
#{entry.task.id}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[11.5px] leading-[1.3]" title={entry.task.subject}>
|
||||
{entry.task.subject}
|
||||
</span>
|
||||
<Badge
|
||||
data-testid={`agent-teams-member-task-${entry.task.id}-state`}
|
||||
tone={taskTone(entry.state)}
|
||||
size="xs"
|
||||
bordered
|
||||
>
|
||||
{t(`agentTeams.task.${entry.state}` as TranslationKey)}
|
||||
</Badge>
|
||||
<time
|
||||
dateTime={entry.startedAt === null ? undefined : new Date(entry.startedAt).toISOString()}
|
||||
className="w-[82px] shrink-0 text-right font-mono text-[9.5px] tabular-nums text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{span}
|
||||
</time>
|
||||
</li>
|
||||
<section key={group} data-testid={`agent-teams-member-task-group-${group}`} className="mt-2">
|
||||
<h4 className="text-[10px] font-semibold text-[var(--color-text-secondary)]">
|
||||
{t(label)} · {entries.length}
|
||||
</h4>
|
||||
<ol className="mt-0.5">
|
||||
{entries.map(entry => {
|
||||
const stalled = stalledTaskOwnerState(entry.task, snapshot)
|
||||
const openDependencies = entry.state === 'blocked'
|
||||
? entry.task.blockedBy.filter(id => snapshot.tasks.find(task => task.id === id)?.status !== 'completed')
|
||||
: []
|
||||
const aside = openDependencies.length > 0
|
||||
? `${t('agentTeams.task.dependsOn')} ${openDependencies.map(id => `#${id}`).join(' ')}`
|
||||
: formatTaskSpan(entry)
|
||||
return (
|
||||
<li
|
||||
key={entry.task.id}
|
||||
data-testid={`agent-teams-member-task-${entry.task.id}`}
|
||||
data-task-state={entry.state}
|
||||
data-task-stalled={stalled}
|
||||
className="flex min-w-0 items-center gap-2 border-b border-[var(--color-border)] py-1.5 last:border-b-0"
|
||||
>
|
||||
<span className="w-[26px] shrink-0 font-mono text-[10px] font-extrabold text-[var(--color-text-tertiary)]">
|
||||
#{entry.task.id}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[11.5px] leading-[1.3]" title={entry.task.subject}>
|
||||
{entry.task.subject}
|
||||
</span>
|
||||
<Badge
|
||||
data-testid={`agent-teams-member-task-${entry.task.id}-state`}
|
||||
tone={stalled ? stalledTone(stalled) : taskTone(entry.state)}
|
||||
size="xs"
|
||||
bordered
|
||||
>
|
||||
{stalled
|
||||
? memberStateLabel(stalled, member, t)
|
||||
: t(`agentTeams.task.${entry.state}` as TranslationKey)}
|
||||
</Badge>
|
||||
<time
|
||||
dateTime={entry.startedAt === null ? undefined : new Date(entry.startedAt).toISOString()}
|
||||
className="w-[82px] shrink-0 truncate text-right font-mono text-[9.5px] tabular-nums text-[var(--color-text-tertiary)]"
|
||||
title={aside}
|
||||
>
|
||||
{aside}
|
||||
</time>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('agentTeams.noMemberTasks')}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
runningTaskForMember,
|
||||
shortModelLabel,
|
||||
snapshotWithHistoricalMembers,
|
||||
stalledTaskOwnerState,
|
||||
taskOwnedByMember,
|
||||
WORKBENCH_TASK_WIDTH,
|
||||
} from './agentTeamsModel'
|
||||
@@ -446,6 +447,37 @@ describe('Agent Teams workbench model', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent Teams stalled tasks', () => {
|
||||
const member = (name: string, extra: Partial<TeamMember>): TeamMember => ({
|
||||
agentId: `${name}@team-a`, name, role: name, status: 'idle', ...extra,
|
||||
})
|
||||
const withMembers = (tasks: TeamWorkbenchTask[], members: TeamMember[]): TeamWorkbenchSnapshot => {
|
||||
const base = snapshot(tasks)
|
||||
return { ...base, team: { ...base.team, members } }
|
||||
}
|
||||
|
||||
it("reports the owner's state for an in-progress task its owner is no longer working on", () => {
|
||||
// The task list keeps `in_progress` until the member itself updates it,
|
||||
// so a stopped or failed owner would otherwise look busy on the board.
|
||||
const frame = withMembers(
|
||||
[task('1', 'in_progress', [], 'stopper'), task('2', 'in_progress', [], 'failer'), task('3', 'in_progress', [], 'retrier'), task('4', 'in_progress', [], 'worker')],
|
||||
[
|
||||
member('stopper', { activity: 'stopped' }),
|
||||
member('failer', { status: 'error', activity: 'idle', lastError: 'Credit balance is too low' }),
|
||||
member('retrier', { activity: 'idle', lastError: 'overloaded', autoRetry: { attempt: 1, max: 5, nextAt: 1 } }),
|
||||
member('worker', { status: 'running', activity: 'active' }),
|
||||
],
|
||||
)
|
||||
expect(frame.tasks.map(item => stalledTaskOwnerState(item, frame))).toEqual(['stopped', 'error', 'retrying', undefined])
|
||||
})
|
||||
|
||||
it('never marks finished, unstarted or unowned work as stalled', () => {
|
||||
const owner = member('stopper', { activity: 'stopped' })
|
||||
const frame = withMembers([task('1', 'completed', [], 'stopper'), task('2', 'pending', [], 'stopper'), task('3', 'in_progress')], [owner])
|
||||
expect(frame.tasks.map(item => stalledTaskOwnerState(item, frame))).toEqual([undefined, undefined, undefined])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent Teams member model display', () => {
|
||||
const worker: TeamMember = {
|
||||
agentId: 'builder@team-a',
|
||||
|
||||
@@ -314,6 +314,27 @@ export function getMemberWorkState(
|
||||
return member.status === 'running' ? 'working' : 'idle'
|
||||
}
|
||||
|
||||
export type StalledTaskOwnerState = Extract<MemberWorkState, 'stopped' | 'error' | 'retrying'>
|
||||
|
||||
/**
|
||||
* Why a task still marked in progress is not moving: its owner was stopped,
|
||||
* failed, or waits for an automatic retry. Only the member updates its own
|
||||
* task, so the list keeps saying `in_progress`, and the board would animate
|
||||
* work nobody is doing.
|
||||
*/
|
||||
export function stalledTaskOwnerState(
|
||||
task: TeamWorkbenchTask,
|
||||
snapshot: TeamWorkbenchSnapshot,
|
||||
): StalledTaskOwnerState | undefined {
|
||||
if (task.status !== 'in_progress' || snapshot.deletedAt) return undefined
|
||||
const owner = inferTaskOwner(task, snapshot)
|
||||
if (!owner) return undefined
|
||||
const { member, isLead } = resolveTeamMemberIdentity(snapshot.team, owner.identity)
|
||||
if (isLead || !snapshot.team.members.includes(member)) return undefined
|
||||
const state = getMemberWorkState(member)
|
||||
return state === 'stopped' || state === 'error' || state === 'retrying' ? state : undefined
|
||||
}
|
||||
|
||||
export type TaskOwnerAttribution = {
|
||||
identity: string
|
||||
/** True when the name was recovered from the mailbox rather than recorded. */
|
||||
|
||||
@@ -3343,6 +3343,10 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
'agentTeams.member.retryNow': 'Retrying automatically now',
|
||||
'agentTeams.member.stoppedHint': 'Send a message to resume it from its saved conversation',
|
||||
'agentTeams.member.errorHint': 'Once the problem is fixed, send a message to let it continue',
|
||||
'agentTeams.member.stalledOnTask': '{state} · at #{task}',
|
||||
'agentTeams.inspector.groupRunning': 'Now',
|
||||
'agentTeams.inspector.groupUpcoming': 'Up next',
|
||||
'agentTeams.inspector.groupCompleted': 'Done',
|
||||
'agentTeams.openMember': 'Open {name} details',
|
||||
'agentTeams.resizeCommunication': 'Resize communication panel',
|
||||
'agentTeams.memberTranscriptLoading': 'Loading member transcript...',
|
||||
|
||||
@@ -3344,6 +3344,10 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'agentTeams.member.retryNow': 'まもなく自動で再試行',
|
||||
'agentTeams.member.stoppedHint': 'メッセージを送ると、保存された会話から再開します',
|
||||
'agentTeams.member.errorHint': '問題を解決してからメッセージを送ると、作業を再開します',
|
||||
'agentTeams.member.stalledOnTask': '{state} · #{task} で停止',
|
||||
'agentTeams.inspector.groupRunning': '進行中',
|
||||
'agentTeams.inspector.groupUpcoming': '次のタスク',
|
||||
'agentTeams.inspector.groupCompleted': '完了',
|
||||
'agentTeams.openMember': '{name} の詳細を開く',
|
||||
'agentTeams.resizeCommunication': '通信パネルの幅を変更',
|
||||
'agentTeams.memberTranscriptLoading': 'メンバーの transcript を読み込み中...',
|
||||
|
||||
@@ -3346,6 +3346,10 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'agentTeams.member.retryNow': '곧 자동 재시도',
|
||||
'agentTeams.member.stoppedHint': '메시지를 보내면 저장된 대화에서 다시 시작합니다',
|
||||
'agentTeams.member.errorHint': '문제를 해결한 뒤 메시지를 보내면 작업을 이어갑니다',
|
||||
'agentTeams.member.stalledOnTask': '{state} · #{task}에서 멈춤',
|
||||
'agentTeams.inspector.groupRunning': '진행 중',
|
||||
'agentTeams.inspector.groupUpcoming': '다음 작업',
|
||||
'agentTeams.inspector.groupCompleted': '완료',
|
||||
'agentTeams.openMember': '{name} 세부 정보 열기',
|
||||
'agentTeams.resizeCommunication': '통신 패널 너비 조정',
|
||||
'agentTeams.memberTranscriptLoading': '멤버 transcript 불러오는 중...',
|
||||
|
||||
@@ -3343,6 +3343,10 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'agentTeams.member.retryNow': '即將自動重試',
|
||||
'agentTeams.member.stoppedHint': '發訊息即可從已儲存的對話恢復',
|
||||
'agentTeams.member.errorHint': '解決問題後,發訊息即可讓它繼續',
|
||||
'agentTeams.member.stalledOnTask': '{state} · 停在 #{task}',
|
||||
'agentTeams.inspector.groupRunning': '進行中',
|
||||
'agentTeams.inspector.groupUpcoming': '接下來',
|
||||
'agentTeams.inspector.groupCompleted': '已完成',
|
||||
'agentTeams.openMember': '開啟 {name} 的詳細資料',
|
||||
'agentTeams.resizeCommunication': '調整通訊面板寬度',
|
||||
'agentTeams.memberTranscriptLoading': '正在載入成員 transcript...',
|
||||
|
||||
@@ -3342,6 +3342,10 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'agentTeams.member.retryNow': '即将自动重试',
|
||||
'agentTeams.member.stoppedHint': '发消息即可从保存的对话恢复',
|
||||
'agentTeams.member.errorHint': '解决问题后,发消息即可让它继续',
|
||||
'agentTeams.member.stalledOnTask': '{state} · 停在 #{task}',
|
||||
'agentTeams.inspector.groupRunning': '进行中',
|
||||
'agentTeams.inspector.groupUpcoming': '接下来',
|
||||
'agentTeams.inspector.groupCompleted': '已完成',
|
||||
'agentTeams.openMember': '打开 {name} 的详情',
|
||||
'agentTeams.resizeCommunication': '调整通讯面板宽度',
|
||||
'agentTeams.memberTranscriptLoading': '正在加载成员 transcript...',
|
||||
|
||||
Reference in New Issue
Block a user