feat(ui): AgentTeamPanel and team activity helpers

Move the agent team panel into @cline/ui so any client can show a live
team: AgentTeamPanel renders one row per teammate with its phase, step, and
elapsed time and folds to a summary when the team finishes, and
buildAgentTeamMembers derives members from a host's teammate-session roster
and team_progress lifecycle events. Includes tests and Storybook stories,
among them a live replay of a team run.
This commit is contained in:
Saoud Rizwan
2026-10-21 04:39:58 +00:00
parent eabe493402
commit dc816bbe8b
10 changed files with 1038 additions and 366 deletions
@@ -1,246 +0,0 @@
"use client";
import {
AlertCircle,
Check,
ChevronDown,
ChevronRight,
CircleSlash,
Clock3,
FileSearch,
Lightbulb,
Loader2,
MessagesSquare,
Pencil,
SquareTerminal,
Users,
} from "lucide-react";
import { memo, useEffect, useState } from "react";
import {
isTeamSettled,
phaseBucket,
summarizeTeam,
type TeamMemberActivity,
type TeamMemberPhase,
} from "@/lib/team-activity";
import { cn } from "@/lib/utils";
const PHASE_LABEL: Record<TeamMemberPhase, string> = {
thinking: "Thinking",
reading: "Reading",
editing: "Editing",
running: "Running",
coordinating: "Coordinating",
waiting: "Waiting",
done: "Done",
failed: "Failed",
stopped: "Stopped",
};
const PHASE_ICON: Record<TeamMemberPhase, typeof Check> = {
thinking: Lightbulb,
reading: FileSearch,
editing: Pencil,
running: SquareTerminal,
coordinating: MessagesSquare,
waiting: Clock3,
done: Check,
failed: AlertCircle,
stopped: CircleSlash,
};
function formatElapsed(ms: number): string {
const seconds = Math.max(0, Math.round(ms / 1000));
const minutes = Math.floor(seconds / 60);
if (minutes >= 60) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
return minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
}
/** Re-renders once a second while anything is still running, for live timers. */
function useNow(active: boolean): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
setNow(Date.now());
if (!active) return;
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [active]);
return now;
}
/**
* Live view of an agent team, attached to the top of the chat input in the
* slot the pull request bar uses. One row per teammate says what it is doing
* right now, so planning, building, and coordinating are visible at a glance;
* selecting a row opens that teammate's session. Once every teammate has
* finished, the panel folds to a one-line summary.
*/
function TeamPanelImpl({
teamName,
members,
activeSessionId,
onOpenSession,
}: {
teamName?: string;
members: TeamMemberActivity[];
/** The teammate session on screen, highlighted in the list. */
activeSessionId?: string | null;
onOpenSession: (sessionId: string) => void;
}) {
const settled = isTeamSettled(members);
const now = useNow(!settled && members.length > 0);
const [open, setOpen] = useState(!settled);
useEffect(() => {
setOpen(!settled);
}, [settled]);
if (members.length === 0) return null;
const startedAt = Math.min(...members.map((member) => member.startedAt));
const endedAt = settled
? Math.max(...members.map((member) => member.endedAt ?? now))
: now;
return (
<section
aria-label="Agent team"
className="border-b border-border/70 text-xs"
id="team-panel"
>
<button
aria-expanded={open}
className="flex w-full min-w-0 items-center gap-2 px-4 py-2 text-left transition-colors hover:bg-surface-hover/60"
onClick={() => setOpen((value) => !value)}
type="button"
>
<Users
aria-hidden="true"
className="size-3.5 shrink-0 text-muted-foreground"
/>
<span className="shrink-0 font-medium text-foreground">Team</span>
{teamName ? (
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground">
{teamName}
</span>
) : null}
<span className="ml-auto shrink-0 text-muted-foreground">
{summarizeTeam(members)}
</span>
<span className="shrink-0 font-mono text-[11px] tabular-nums text-muted-foreground/80">
{formatElapsed(endedAt - startedAt)}
</span>
<ChevronDown
aria-hidden="true"
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform",
!open && "-rotate-90",
)}
/>
</button>
{open ? (
<ul className="max-h-52 overflow-y-auto overscroll-contain pb-1.5">
{members.map((member) => (
<TeamPanelRow
active={member.sessionId === activeSessionId}
key={member.agentId}
member={member}
now={now}
onOpen={() => onOpenSession(member.sessionId)}
/>
))}
</ul>
) : null}
</section>
);
}
export const TeamPanel = memo(TeamPanelImpl);
function TeamPanelRow({
member,
now,
active,
onOpen,
}: {
member: TeamMemberActivity;
now: number;
active: boolean;
onOpen: () => void;
}) {
const { phase } = member;
const bucket = phaseBucket(phase);
const PhaseIcon = PHASE_ICON[phase];
const working = bucket === "planning" || bucket === "acting";
const detail = phase === "done" ? member.task : (member.step ?? member.task);
return (
<li>
<button
aria-current={active || undefined}
className={cn(
"grid w-full grid-cols-[0.875rem_minmax(0,8rem)_6.5rem_minmax(0,1fr)_3.5rem_0.875rem] items-center gap-2 px-4 py-1 text-left transition-colors hover:bg-surface-hover",
active && "bg-surface-hover",
)}
onClick={onOpen}
title={
member.task ? `${member.agentId}: ${member.task}` : member.agentId
}
type="button"
>
{working ? (
<Loader2
aria-hidden="true"
className={cn(
"size-3.5 animate-spin",
bucket === "acting" ? "text-foreground" : "text-muted-foreground",
)}
/>
) : bucket === "done" ? (
<Check aria-hidden="true" className="size-3.5 text-chart-2" />
) : bucket === "failed" ? (
<AlertCircle
aria-hidden="true"
className="size-3.5 text-destructive"
/>
) : (
<Clock3
aria-hidden="true"
className="size-3.5 text-muted-foreground"
/>
)}
<span className="truncate font-medium text-foreground">
{member.agentId}
</span>
<span
className={cn(
"flex min-w-0 items-center gap-1",
bucket === "done"
? "text-chart-2"
: bucket === "failed"
? "text-destructive"
: bucket === "acting"
? "text-foreground"
: "text-muted-foreground",
)}
>
<PhaseIcon aria-hidden="true" className="size-3 shrink-0" />
<span className="truncate">{PHASE_LABEL[phase]}</span>
</span>
<span
className={cn(
"truncate text-muted-foreground",
phase === "waiting" && "italic",
)}
>
{detail}
</span>
<span className="text-right font-mono text-[11px] tabular-nums text-muted-foreground/80">
{formatElapsed((member.endedAt ?? now) - member.startedAt)}
</span>
<ChevronRight
aria-hidden="true"
className="size-3.5 text-muted-foreground/60"
/>
</button>
</li>
);
}
+1
View File
@@ -8,6 +8,7 @@
@import "./components/agent-welcome-hero.css";
@import "./components/agent-prompt-queue.css";
@import "./components/agent-quick-actions.css";
@import "./components/agent-team-panel.css";
@import "./components/search-combobox.css";
@import "./components/session-status.css";
@import "./components/switch.css";
@@ -1,15 +1,35 @@
import { agentEntryState, type SessionAgentEntry } from "@/lib/session-agents";
import type { TeamProgressLifecycleEvent } from "@cline/shared";
/**
* What a teammate is doing right now, for the team panel on the chat input.
* What each teammate of an agent team is doing right now, for
* {@link AgentTeamPanel}.
*
* Two sources feed it. The persisted roster (`list_session_agents`) knows each
* Two sources feed it. The persisted roster of child sessions knows each
* teammate's runs, their real status, and the last thing each transcript did,
* but it is polled. `team_progress` events arrive as the team works and name
* the tool a run is executing, so they take over whenever they are newer.
* but hosts usually poll it. `team_progress` lifecycle events arrive as the
* team works and name the tool a run is executing, so they take over whenever
* they are newer.
*/
export type TeamMemberPhase =
/** One persisted teammate run, as a host's session roster reports it. */
export type AgentTeamRun = {
agentId: string;
/** The run's own child session, which a member row opens. */
sessionId: string;
/** Team-task runs are teammates; anything else (e.g. subagents) is ignored. */
kind: "teamtask" | "subagent";
/** Persisted session status: running, pending, completed, failed, cancelled, idle. */
status: string;
/** The task the run was given, usually its first prompt. */
prompt?: string;
/** Most recent step: assistant text, or `Running <tool>` for a tool call. */
lastAction?: string;
/** ISO timestamps. */
startedAt: string;
endedAt?: string;
};
export type AgentTeamMemberPhase =
| "thinking"
| "reading"
| "editing"
@@ -20,25 +40,33 @@ export type TeamMemberPhase =
| "failed"
| "stopped";
export type TeamMemberActivity = {
export type AgentTeamMember = {
agentId: string;
/** The teammate's latest run, which is the session its row opens. */
sessionId: string;
task?: string;
phase: TeamMemberPhase;
phase: AgentTeamMemberPhase;
step?: string;
/** Epoch ms. */
startedAt: number;
endedAt?: number;
};
/** Latest live activity per agent, from `team_progress` lifecycle events. */
export type TeamLiveActivity = {
/** Latest live activity for one agent, from a `team_progress` lifecycle event. */
export type AgentTeamLiveActivity = {
eventType: string;
message?: string;
/** Epoch ms the event was observed. */
at: number;
};
export type AgentTeamPhaseBucket =
| "planning"
| "acting"
| "waiting"
| "done"
| "failed";
const READ_TOOLS = new Set([
"read_files",
"read_file",
@@ -84,7 +112,7 @@ const TOOL_STEP: Record<string, string> = {
spawn_agent: "Delegating to a subagent",
};
export function phaseForTool(toolName: string): TeamMemberPhase {
export function agentTeamPhaseForTool(toolName: string): AgentTeamMemberPhase {
const name = toolName.trim().toLowerCase();
if (READ_TOOLS.has(name)) return "reading";
if (EDIT_TOOLS.has(name)) return "editing";
@@ -94,14 +122,14 @@ export function phaseForTool(toolName: string): TeamMemberPhase {
return "running";
}
export function stepForTool(toolName: string): string {
export function agentTeamStepForTool(toolName: string): string {
const name = toolName.trim().toLowerCase();
return TOOL_STEP[name] ?? `Running ${name}`;
}
const LAST_ACTION_TOOL_PREFIX = "Running ";
// Tool starts and ends are both reported, and the throttled stream often only
// delivers the end, so either names what the teammate is working on.
// Tool starts and ends are both reported, and a throttled stream may only
// deliver the end, so either names what the teammate is working on.
const TOOL_ACTIVITY =
/^(?:running_tool_|finished_tool_)([\w.-]+)$|^tool_([\w.-]+?)_error/;
@@ -119,10 +147,27 @@ function toolFromLastAction(lastAction?: string): string | undefined {
return /^[\w.-]+$/.test(tool) ? tool : undefined;
}
/** Live activity for a `team_progress` lifecycle event, or undefined without an agent. */
export function agentTeamLiveActivityFromLifecycle(
lifecycle: Pick<
TeamProgressLifecycleEvent,
"agentId" | "eventType" | "message"
>,
at: number = Date.now(),
): (AgentTeamLiveActivity & { agentId: string }) | undefined {
if (!lifecycle.agentId || !lifecycle.eventType) return undefined;
return {
agentId: lifecycle.agentId,
eventType: lifecycle.eventType,
message: lifecycle.message,
at,
};
}
/** Phase implied by a live event, or undefined when it says nothing new. */
export function phaseFromLiveActivity(
live: TeamLiveActivity,
): { phase: TeamMemberPhase; step?: string } | undefined {
export function agentTeamPhaseFromLiveActivity(
live: AgentTeamLiveActivity,
): { phase: AgentTeamMemberPhase; step?: string } | undefined {
switch (live.eventType) {
case "run_queued":
return { phase: "waiting", step: "Queued" };
@@ -142,7 +187,12 @@ export function phaseFromLiveActivity(
};
case "run_progress": {
const tool = toolFromActivity(live.message);
if (tool) return { phase: phaseForTool(tool), step: stepForTool(tool) };
if (tool) {
return {
phase: agentTeamPhaseForTool(tool),
step: agentTeamStepForTool(tool),
};
}
if (live.message?.startsWith("iteration_")) return { phase: "thinking" };
if (live.message === "finalizing_response") {
return { phase: "thinking", step: "Wrapping up" };
@@ -167,11 +217,11 @@ const ROSTER_EVENTS = new Set([
/**
* Whether a live event means the persisted roster changed: a teammate joined or
* left, or one of its runs started or ended. The roster is otherwise only
* polled while the lead looks busy, which misses runs a lead starts while its
* own turn is idle.
* left, or one of its runs started or ended. Hosts that poll the roster only
* while the lead looks busy should re-read it on these, or they miss runs a
* lead starts while its own turn is idle.
*/
export function changesTeamRoster(eventType: string): boolean {
export function changesAgentTeamRoster(eventType: string): boolean {
return ROSTER_EVENTS.has(eventType);
}
@@ -183,11 +233,11 @@ export function changesTeamRoster(eventType: string): boolean {
* kind of work until it does something else, and anything uninterpretable is
* ignored.
*/
export function shouldReplaceLiveActivity(
previous: TeamLiveActivity | undefined,
next: TeamLiveActivity,
export function shouldReplaceAgentTeamLiveActivity(
previous: AgentTeamLiveActivity | undefined,
next: AgentTeamLiveActivity,
): boolean {
if (!phaseFromLiveActivity(next)) return false;
if (!agentTeamPhaseFromLiveActivity(next)) return false;
const isIteration =
next.eventType === "run_progress" &&
Boolean(next.message?.startsWith("iteration_"));
@@ -210,8 +260,8 @@ function parseTime(value?: string): number | undefined {
return Number.isFinite(time) ? time : undefined;
}
function settledPhase(status: string): TeamMemberPhase | undefined {
switch (agentEntryState(status)) {
function settledPhase(status: string): AgentTeamMemberPhase | undefined {
switch (status.trim().toLowerCase()) {
case "completed":
return "done";
case "failed":
@@ -224,29 +274,29 @@ function settledPhase(status: string): TeamMemberPhase | undefined {
}
/**
* One row per teammate, built from its team-task runs. A teammate that ran
* One member per teammate, built from its team-task runs. A teammate that ran
* several tasks is shown by its latest run, timed from its first.
*/
export function buildTeamMembers(
agents: SessionAgentEntry[],
live: ReadonlyMap<string, TeamLiveActivity> = new Map(),
): TeamMemberActivity[] {
const runsByAgent = new Map<string, SessionAgentEntry[]>();
for (const agent of agents) {
if (agent.kind !== "teamtask") continue;
const runs = runsByAgent.get(agent.agentId) ?? [];
runs.push(agent);
runsByAgent.set(agent.agentId, runs);
export function buildAgentTeamMembers(
runs: readonly AgentTeamRun[],
live: ReadonlyMap<string, AgentTeamLiveActivity> = new Map(),
): AgentTeamMember[] {
const runsByAgent = new Map<string, AgentTeamRun[]>();
for (const run of runs) {
if (run.kind !== "teamtask") continue;
const agentRuns = runsByAgent.get(run.agentId) ?? [];
agentRuns.push(run);
runsByAgent.set(run.agentId, agentRuns);
}
const members: TeamMemberActivity[] = [];
for (const [agentId, runs] of runsByAgent) {
runs.sort(
const members: AgentTeamMember[] = [];
for (const [agentId, agentRuns] of runsByAgent) {
agentRuns.sort(
(a, b) => (parseTime(a.startedAt) ?? 0) - (parseTime(b.startedAt) ?? 0),
);
const latest = runs[runs.length - 1];
const latest = agentRuns[agentRuns.length - 1];
if (!latest) continue;
const startedAt = parseTime(runs[0]?.startedAt) ?? Date.now();
const startedAt = parseTime(agentRuns[0]?.startedAt) ?? Date.now();
const settled = settledPhase(latest.status);
const lastAction = latest.lastAction?.trim() || undefined;
const lastActionTool = toolFromLastAction(lastAction);
@@ -254,14 +304,16 @@ export function buildTeamMembers(
// when a newer live event decides the phase.
const narration = lastAction && !lastActionTool ? lastAction : undefined;
let phase: TeamMemberPhase =
settled ?? (lastActionTool ? phaseForTool(lastActionTool) : "thinking");
let step = narration ?? (lastActionTool && stepForTool(lastActionTool));
let phase: AgentTeamMemberPhase =
settled ??
(lastActionTool ? agentTeamPhaseForTool(lastActionTool) : "thinking");
let step =
narration ?? (lastActionTool && agentTeamStepForTool(lastActionTool));
const liveActivity = live.get(agentId);
const latestStartedAt = parseTime(latest.startedAt) ?? startedAt;
if (!settled && liveActivity && liveActivity.at >= latestStartedAt) {
const fromLive = phaseFromLiveActivity(liveActivity);
const fromLive = agentTeamPhaseFromLiveActivity(liveActivity);
if (fromLive) {
phase = fromLive.phase;
step = narration ?? fromLive.step ?? step;
@@ -281,14 +333,9 @@ export function buildTeamMembers(
return members.sort((a, b) => a.startedAt - b.startedAt);
}
export type TeamPhaseBucket =
| "planning"
| "acting"
| "waiting"
| "done"
| "failed";
export function phaseBucket(phase: TeamMemberPhase): TeamPhaseBucket {
export function agentTeamPhaseBucket(
phase: AgentTeamMemberPhase,
): AgentTeamPhaseBucket {
switch (phase) {
case "thinking":
case "reading":
@@ -308,17 +355,19 @@ export function phaseBucket(phase: TeamMemberPhase): TeamPhaseBucket {
}
/** "2 planning · 3 acting · 1 done", omitting empty buckets. */
export function summarizeTeam(members: TeamMemberActivity[]): string {
const order: TeamPhaseBucket[] = [
export function summarizeAgentTeam(
members: readonly AgentTeamMember[],
): string {
const order: AgentTeamPhaseBucket[] = [
"planning",
"acting",
"waiting",
"done",
"failed",
];
const counts = new Map<TeamPhaseBucket, number>();
const counts = new Map<AgentTeamPhaseBucket, number>();
for (const member of members) {
const bucket = phaseBucket(member.phase);
const bucket = agentTeamPhaseBucket(member.phase);
counts.set(bucket, (counts.get(bucket) ?? 0) + 1);
}
return order
@@ -327,11 +376,13 @@ export function summarizeTeam(members: TeamMemberActivity[]): string {
.join(" · ");
}
export function isTeamSettled(members: TeamMemberActivity[]): boolean {
export function isAgentTeamSettled(
members: readonly AgentTeamMember[],
): boolean {
return (
members.length > 0 &&
members.every((member) => {
const bucket = phaseBucket(member.phase);
const bucket = agentTeamPhaseBucket(member.phase);
return bucket === "done" || bucket === "failed";
})
);
@@ -0,0 +1,11 @@
.cline-ui-agent-team-panel__header,
.cline-ui-agent-team-panel__row {
font: inherit;
color: inherit;
}
.cline-ui-agent-team-panel__header:focus-visible,
.cline-ui-agent-team-panel__row:focus-visible {
outline: 2px solid var(--cline-ui-ring);
outline-offset: -2px;
}
@@ -0,0 +1,268 @@
"use client";
import { clsx } from "clsx";
import {
AlertCircle,
Check,
ChevronDown,
ChevronRight,
CircleSlash,
Clock3,
FileSearch,
Lightbulb,
Loader2,
MessagesSquare,
Pencil,
SquareTerminal,
Users,
} from "lucide-react";
import { memo, useEffect, useState } from "react";
import {
type AgentTeamMember,
type AgentTeamMemberPhase,
agentTeamPhaseBucket,
isAgentTeamSettled,
summarizeAgentTeam,
} from "./agent-team-activity.js";
export type AgentTeamPanelProps = {
teamName?: string;
members: readonly AgentTeamMember[];
/** The teammate session on screen, highlighted in the list. */
activeSessionId?: string | null;
onOpenSession: (sessionId: string) => void;
/**
* Clock for elapsed times, in epoch ms. Omit to tick every second while any
* teammate is still working; pass it to drive the panel from outside.
*/
now?: number;
className?: string;
};
const PHASE_LABEL: Record<AgentTeamMemberPhase, string> = {
thinking: "Thinking",
reading: "Reading",
editing: "Editing",
running: "Running",
coordinating: "Coordinating",
waiting: "Waiting",
done: "Done",
failed: "Failed",
stopped: "Stopped",
};
const PHASE_ICON: Record<AgentTeamMemberPhase, typeof Check> = {
thinking: Lightbulb,
reading: FileSearch,
editing: Pencil,
running: SquareTerminal,
coordinating: MessagesSquare,
waiting: Clock3,
done: Check,
failed: AlertCircle,
stopped: CircleSlash,
};
export function formatAgentTeamElapsed(ms: number): string {
const seconds = Math.max(0, Math.round(ms / 1000));
const minutes = Math.floor(seconds / 60);
if (minutes >= 60) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
return minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
}
/** Re-renders once a second while `active`, for live timers. */
function useTickingNow(active: boolean, fixed: number | undefined): number {
const [now, setNow] = useState(() => fixed ?? Date.now());
useEffect(() => {
if (fixed !== undefined) return;
setNow(Date.now());
if (!active) return;
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [active, fixed]);
return fixed ?? now;
}
/**
* Live view of an agent team, made to sit at the top of the chat composer in
* the slot the pull request bar uses. One row per teammate says what it is
* doing right now, so planning, building, and coordinating are visible at a
* glance; selecting a row opens that teammate's session. Once every teammate
* has finished the panel folds to a one-line summary, and it opens again if
* the team resumes.
*/
function AgentTeamPanelImpl({
teamName,
members,
activeSessionId,
onOpenSession,
now: fixedNow,
className,
}: AgentTeamPanelProps) {
const settled = isAgentTeamSettled(members);
const now = useTickingNow(!settled && members.length > 0, fixedNow);
const [open, setOpen] = useState(!settled);
useEffect(() => {
setOpen(!settled);
}, [settled]);
if (members.length === 0) return null;
const startedAt = Math.min(...members.map((member) => member.startedAt));
const endedAt = settled
? Math.max(...members.map((member) => member.endedAt ?? now))
: now;
return (
<section
aria-label="Agent team"
className={clsx(
"cline-ui-agent-team-panel border-b border-cline-ui-border text-cline-ui-xs",
className,
)}
>
<button
aria-expanded={open}
className="cline-ui-agent-team-panel__header flex w-full min-w-0 cursor-pointer items-center gap-2 border-0 bg-transparent px-4 py-2 text-left text-inherit transition-colors hover:bg-cline-ui-surface-hover"
onClick={() => setOpen((value) => !value)}
type="button"
>
<Users
aria-hidden="true"
className="size-3.5 shrink-0 text-cline-ui-muted-foreground"
/>
<span className="shrink-0 font-cline-ui-medium text-cline-ui-foreground">
Team
</span>
{teamName ? (
<span className="min-w-0 truncate font-cline-ui-mono text-[11px] text-cline-ui-muted-foreground">
{teamName}
</span>
) : null}
<span className="ml-auto shrink-0 text-cline-ui-muted-foreground">
{summarizeAgentTeam(members)}
</span>
<span className="shrink-0 font-cline-ui-mono text-[11px] tabular-nums text-cline-ui-muted-foreground">
{formatAgentTeamElapsed(endedAt - startedAt)}
</span>
<ChevronDown
aria-hidden="true"
className={clsx(
"size-3.5 shrink-0 text-cline-ui-muted-foreground transition-transform",
!open && "-rotate-90",
)}
/>
</button>
{open ? (
<ul className="m-0 max-h-52 list-none overflow-y-auto overscroll-contain p-0 pb-1.5">
{members.map((member) => (
<AgentTeamPanelRow
active={member.sessionId === activeSessionId}
key={member.agentId}
member={member}
now={now}
onOpen={() => onOpenSession(member.sessionId)}
/>
))}
</ul>
) : null}
</section>
);
}
export const AgentTeamPanel = memo(AgentTeamPanelImpl);
function AgentTeamPanelRow({
member,
now,
active,
onOpen,
}: {
member: AgentTeamMember;
now: number;
active: boolean;
onOpen: () => void;
}) {
const { phase } = member;
const bucket = agentTeamPhaseBucket(phase);
const PhaseIcon = PHASE_ICON[phase];
const working = bucket === "planning" || bucket === "acting";
const detail = phase === "done" ? member.task : (member.step ?? member.task);
return (
<li>
<button
aria-current={active || undefined}
className={clsx(
"cline-ui-agent-team-panel__row grid w-full cursor-pointer grid-cols-[0.875rem_minmax(0,8rem)_6.5rem_minmax(0,1fr)_3.5rem_0.875rem] items-center gap-2 border-0 px-4 py-1 text-left text-inherit transition-colors hover:bg-cline-ui-surface-hover",
active ? "bg-cline-ui-surface-hover" : "bg-transparent",
)}
data-phase={phase}
onClick={onOpen}
title={
member.task ? `${member.agentId}: ${member.task}` : member.agentId
}
type="button"
>
{working ? (
<Loader2
aria-hidden="true"
className={clsx(
"size-3.5 animate-spin",
bucket === "acting"
? "text-cline-ui-foreground"
: "text-cline-ui-muted-foreground",
)}
/>
) : bucket === "done" ? (
<Check
aria-hidden="true"
className="size-3.5 text-cline-ui-success-text"
/>
) : bucket === "failed" ? (
<AlertCircle
aria-hidden="true"
className="size-3.5 text-cline-ui-error-text"
/>
) : (
<Clock3
aria-hidden="true"
className="size-3.5 text-cline-ui-muted-foreground"
/>
)}
<span className="truncate font-cline-ui-medium text-cline-ui-foreground">
{member.agentId}
</span>
<span
className={clsx(
"flex min-w-0 items-center gap-1",
bucket === "done"
? "text-cline-ui-success-text"
: bucket === "failed"
? "text-cline-ui-error-text"
: bucket === "acting"
? "text-cline-ui-foreground"
: "text-cline-ui-muted-foreground",
)}
>
<PhaseIcon aria-hidden="true" className="size-3 shrink-0" />
<span className="truncate">{PHASE_LABEL[phase]}</span>
</span>
<span
className={clsx(
"truncate text-cline-ui-muted-foreground",
phase === "waiting" && "italic",
)}
>
{detail}
</span>
<span className="text-right font-cline-ui-mono text-[11px] tabular-nums text-cline-ui-muted-foreground">
{formatAgentTeamElapsed((member.endedAt ?? now) - member.startedAt)}
</span>
<ChevronRight
aria-hidden="true"
className="size-3.5 text-cline-ui-muted-foreground"
/>
</button>
</li>
);
}
+22
View File
@@ -92,6 +92,28 @@ export {
type AgentSessionRowEditorProps,
type AgentSessionRowProps,
} from "./agent-session-row.js";
export {
type AgentTeamLiveActivity,
type AgentTeamMember,
type AgentTeamMemberPhase,
type AgentTeamPhaseBucket,
type AgentTeamRun,
agentTeamLiveActivityFromLifecycle,
agentTeamPhaseBucket,
agentTeamPhaseForTool,
agentTeamPhaseFromLiveActivity,
agentTeamStepForTool,
buildAgentTeamMembers,
changesAgentTeamRoster,
isAgentTeamSettled,
shouldReplaceAgentTeamLiveActivity,
summarizeAgentTeam,
} from "./agent-team-activity.js";
export {
AgentTeamPanel,
type AgentTeamPanelProps,
formatAgentTeamElapsed,
} from "./agent-team-panel.js";
export {
AgentWelcomeHero,
type AgentWelcomeHeroLayout,
+3
View File
@@ -92,6 +92,9 @@
"components/agent-quick-actions.css",
"components/agent-quick-actions.tsx",
"components/agent-session-row.tsx",
"components/agent-team-activity.ts",
"components/agent-team-panel.css",
"components/agent-team-panel.tsx",
"components/github-icon.tsx",
"components/markdown.css",
"components/markdown.ts",
@@ -0,0 +1,368 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type ReactElement, useEffect, useMemo, useState } from "react";
import {
AgentComposer,
AgentComposerActions,
AgentComposerBody,
AgentComposerField,
AgentComposerSettings,
AgentComposerSettingsEnd,
AgentComposerSettingsGroup,
AgentComposerStopButton,
AgentComposerTextarea,
type AgentTeamLiveActivity,
type AgentTeamMember,
AgentTeamPanel,
type AgentTeamRun,
buildAgentTeamMembers,
} from "../components/index.js";
const START = Date.parse("2026-10-20T20:00:00.000Z");
const at = (seconds: number) => START + seconds * 1000;
function member(
overrides: Partial<AgentTeamMember> & { agentId: string },
): AgentTeamMember {
return {
sessionId: `session-${overrides.agentId}`,
phase: "thinking",
startedAt: START,
...overrides,
};
}
const workingTeam: AgentTeamMember[] = [
member({
agentId: "architect",
task: "Design the sync protocol and ordering model",
phase: "done",
endedAt: at(66),
}),
member({
agentId: "server-dev",
task: "Build the WebSocket hub and event persistence",
phase: "running",
step: "Running commands",
}),
member({
agentId: "client-dev",
task: "Replace polling with an optimistic sync store",
phase: "editing",
step: "Editing files",
}),
member({
agentId: "presence-dev",
task: "Show who is viewing the board",
phase: "reading",
step: "Reading files",
startedAt: at(12),
}),
member({
agentId: "reviewer",
task: "Review each change as it lands",
phase: "waiting",
step: "Waiting for the first change",
startedAt: at(20),
}),
];
const meta = {
title: "Agent/Team panel",
component: AgentTeamPanel,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component:
"Live view of an agent team, made to sit at the top of the composer. Build `members` with `buildAgentTeamMembers` from the host's teammate-session roster and `team_progress` lifecycle events; selecting a row asks the host to open that teammate's session.",
},
},
},
args: {
teamName: "team-Shp9t",
members: workingTeam,
now: at(95),
onOpenSession: () => {},
},
} satisfies Meta<typeof AgentTeamPanel>;
export default meta;
type Story = StoryObj<typeof meta>;
function frame(story: ReactElement) {
return (
<div className="w-[min(100%,48rem)] overflow-hidden rounded-cline-ui-lg border border-cline-ui-border bg-cline-ui-background">
{story}
</div>
);
}
export const Working: Story = {
render: (args) => frame(<AgentTeamPanel {...args} />),
};
export const EveryonePlanning: Story = {
args: {
members: [
member({
agentId: "ui-dev",
task: "Add a light/dark theme toggle that persists",
step: "Reading the task",
}),
member({
agentId: "search-dev",
task: "Filter cards by title as you type",
phase: "reading",
step: "Searching the codebase",
}),
member({
agentId: "reviewer",
task: "Review both changes",
phase: "waiting",
step: "Queued",
}),
],
now: at(8),
},
render: Working.render,
};
export const TeammateOpen: Story = {
args: { activeSessionId: "session-client-dev" },
render: Working.render,
};
export const NeedsAttention: Story = {
args: {
members: [
member({
agentId: "server-dev",
task: "Build the WebSocket hub",
phase: "failed",
step: "Tests failed: 2 of 9",
endedAt: at(140),
}),
member({
agentId: "client-dev",
task: "Optimistic sync store",
phase: "stopped",
endedAt: at(90),
}),
member({
agentId: "reviewer",
task: "Review each change",
phase: "coordinating",
step: "Sent “Hub tests are failing”",
}),
],
now: at(150),
},
render: Working.render,
};
export const Finished: Story = {
args: {
members: workingTeam.map((teammate) => ({
...teammate,
phase: "done",
step: undefined,
endedAt: at(135),
})),
now: at(400),
},
render: Working.render,
};
export const LargeTeam: Story = {
args: {
members: Array.from({ length: 10 }, (_, index) =>
member({
agentId: `worker-${index + 1}`,
task: `Migrate package ${index + 1} to the new API`,
phase: (["reading", "editing", "running", "done"] as const)[index % 4],
step: ["Reading files", "Editing files", "Running commands", undefined][
index % 4
],
startedAt: at(index * 3),
endedAt: index % 4 === 3 ? at(60 + index) : undefined,
}),
),
now: at(120),
},
render: Working.render,
};
export const InComposer: Story = {
render: (args) => (
<div className="w-[min(100%,48rem)]">
<AgentComposer variant="conversation">
<AgentTeamPanel {...args} />
<AgentComposerBody variant="conversation">
<AgentComposerField variant="conversation">
<AgentComposerTextarea
aria-label="Prompt"
placeholder="Agent is working... submit to queue another message"
rows={3}
variant="conversation"
/>
<AgentComposerActions variant="conversation">
<AgentComposerStopButton
aria-label="Stop agent"
type="button"
variant="conversation"
>
<span aria-hidden>■</span>
</AgentComposerStopButton>
</AgentComposerActions>
</AgentComposerField>
</AgentComposerBody>
<AgentComposerSettings>
<AgentComposerSettingsGroup>
<button type="button">Host model controls</button>
</AgentComposerSettingsGroup>
<AgentComposerSettingsEnd>Host workspace</AgentComposerSettingsEnd>
</AgentComposerSettings>
</AgentComposer>
</div>
),
};
type TimelineStep = {
at: number;
run?: Partial<AgentTeamRun> & { agentId: string };
live?: Omit<AgentTeamLiveActivity, "at"> & { agentId: string };
};
const run = (agentId: string, prompt: string, startedAt: number) => ({
agentId,
prompt,
kind: "teamtask" as const,
status: "running",
sessionId: `session-${agentId}`,
startedAt: new Date(at(startedAt)).toISOString(),
});
const progress = (agentId: string, message: string) => ({
agentId,
eventType: "run_progress",
message,
});
/** A small team's run, as roster updates and `team_progress` events. */
const TIMELINE: TimelineStep[] = [
{ at: 0, run: run("ui-dev", "Add a theme toggle that persists", 0) },
{ at: 0, run: run("search-dev", "Filter cards by title as you type", 0) },
{ at: 1, live: progress("ui-dev", "running_tool_read_files") },
{ at: 2, live: progress("search-dev", "running_tool_search_codebase") },
{ at: 4, live: progress("ui-dev", "running_tool_editor") },
{ at: 6, live: progress("search-dev", "running_tool_editor") },
{ at: 8, live: progress("ui-dev", "running_tool_run_commands") },
{
at: 10,
run: {
agentId: "ui-dev",
status: "completed",
endedAt: new Date(at(10)).toISOString(),
},
},
{ at: 11, run: run("reviewer", "Review both changes", 11) },
{ at: 12, live: progress("reviewer", "running_tool_read_files") },
{ at: 13, live: progress("search-dev", "running_tool_run_commands") },
{
at: 15,
run: {
agentId: "search-dev",
status: "completed",
endedAt: new Date(at(15)).toISOString(),
},
},
{
at: 17,
live: {
agentId: "reviewer",
eventType: "team_message",
message: "Search input needs styles",
},
},
{
at: 19,
run: {
agentId: "reviewer",
status: "completed",
endedAt: new Date(at(19)).toISOString(),
},
},
];
function LiveTeam() {
const [elapsed, setElapsed] = useState(0);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
useEffect(() => {
if (elapsed > 22) return;
const timer = setTimeout(() => setElapsed((value) => value + 1), 1000);
return () => clearTimeout(timer);
}, [elapsed]);
const members = useMemo(() => {
const runs = new Map<string, AgentTeamRun>();
const live = new Map<string, AgentTeamLiveActivity>();
for (const step of TIMELINE) {
if (step.at > elapsed) break;
if (step.run) {
const previous = runs.get(step.run.agentId);
runs.set(step.run.agentId, {
...previous,
...step.run,
} as AgentTeamRun);
}
if (step.live) {
const { agentId, ...activity } = step.live;
live.set(agentId, { ...activity, at: at(step.at) });
}
}
return buildAgentTeamMembers([...runs.values()], live);
}, [elapsed]);
return (
<div className="flex w-[min(100%,48rem)] flex-col gap-3">
{frame(
<AgentTeamPanel
activeSessionId={activeSessionId}
members={members}
now={at(elapsed)}
onOpenSession={setActiveSessionId}
teamName="team-live"
/>,
)}
<div className="flex items-center gap-3 text-cline-ui-xs text-cline-ui-muted-foreground">
<button
className="cursor-pointer rounded-cline-ui-md border border-cline-ui-border bg-transparent px-2 py-1 text-cline-ui-foreground"
onClick={() => {
setElapsed(0);
setActiveSessionId(null);
}}
type="button"
>
Replay
</button>
<span>
{activeSessionId
? `Host would open ${activeSessionId}`
: "Select a teammate to open its session"}
</span>
</div>
</div>
);
}
export const Live: Story = {
render: () => <LiveTeam />,
parameters: {
docs: {
description: {
story:
"Replays a small team through `buildAgentTeamMembers`: roster updates and `team_progress` events drive each row's phase, the panel folds when everyone is done.",
},
},
},
};
@@ -1,42 +1,45 @@
import { describe, expect, it } from "vitest";
import type { SessionAgentEntry } from "@/lib/session-agents";
import {
buildTeamMembers,
changesTeamRoster,
isTeamSettled,
phaseForTool,
phaseFromLiveActivity,
shouldReplaceLiveActivity,
summarizeTeam,
type TeamLiveActivity,
} from "@/lib/team-activity";
type AgentTeamLiveActivity,
type AgentTeamRun,
agentTeamLiveActivityFromLifecycle,
agentTeamPhaseForTool,
agentTeamPhaseFromLiveActivity,
buildAgentTeamMembers,
changesAgentTeamRoster,
isAgentTeamSettled,
shouldReplaceAgentTeamLiveActivity,
summarizeAgentTeam,
} from "../components/agent-team-activity.js";
function entry(
overrides: Partial<SessionAgentEntry> & { agentId: string },
): SessionAgentEntry {
overrides: Partial<AgentTeamRun> & { agentId: string },
): AgentTeamRun {
return {
sessionId: `root__teamtask__${overrides.agentId}_1`,
kind: "teamtask",
status: "running",
startedAt: "2026-10-20T20:00:00.000Z",
hasMessages: true,
...overrides,
};
}
describe("phaseForTool", () => {
describe("agentTeamPhaseForTool", () => {
it("separates reading, editing, running, and coordinating", () => {
expect(phaseForTool("read_files")).toBe("reading");
expect(phaseForTool("search_codebase")).toBe("reading");
expect(phaseForTool("editor")).toBe("editing");
expect(phaseForTool("run_commands")).toBe("running");
expect(phaseForTool("team_send_message")).toBe("coordinating");
expect(phaseForTool("team_await_runs")).toBe("waiting");
expect(agentTeamPhaseForTool("read_files")).toBe("reading");
expect(agentTeamPhaseForTool("search_codebase")).toBe("reading");
expect(agentTeamPhaseForTool("editor")).toBe("editing");
expect(agentTeamPhaseForTool("run_commands")).toBe("running");
expect(agentTeamPhaseForTool("team_send_message")).toBe("coordinating");
expect(agentTeamPhaseForTool("team_await_runs")).toBe("waiting");
});
});
describe("phaseFromLiveActivity", () => {
const live = (eventType: string, message?: string): TeamLiveActivity => ({
describe("agentTeamPhaseFromLiveActivity", () => {
const live = (
eventType: string,
message?: string,
): AgentTeamLiveActivity => ({
eventType,
message,
at: 0,
@@ -44,42 +47,53 @@ describe("phaseFromLiveActivity", () => {
it("reads the tool out of a run_progress activity", () => {
expect(
phaseFromLiveActivity(live("run_progress", "running_tool_editor")),
agentTeamPhaseFromLiveActivity(
live("run_progress", "running_tool_editor"),
),
).toEqual({ phase: "editing", step: "Editing files" });
});
it("reads the tool out of a finished-tool activity too", () => {
expect(
phaseFromLiveActivity(live("run_progress", "finished_tool_run_commands")),
agentTeamPhaseFromLiveActivity(
live("run_progress", "finished_tool_run_commands"),
),
).toEqual({ phase: "running", step: "Running commands" });
});
it("treats a new iteration as thinking", () => {
expect(
phaseFromLiveActivity(live("run_progress", "iteration_3_started")),
agentTeamPhaseFromLiveActivity(
live("run_progress", "iteration_3_started"),
),
).toEqual({ phase: "thinking" });
});
it("ignores activity it cannot interpret", () => {
expect(phaseFromLiveActivity(live("run_progress", "heartbeat"))).toBe(
expect(
agentTeamPhaseFromLiveActivity(live("run_progress", "heartbeat")),
).toBe(undefined);
expect(agentTeamPhaseFromLiveActivity(live("outcome_created"))).toBe(
undefined,
);
expect(phaseFromLiveActivity(live("outcome_created"))).toBe(undefined);
});
});
describe("changesTeamRoster", () => {
describe("changesAgentTeamRoster", () => {
it("flags joins and run starts and ends, not activity", () => {
expect(changesTeamRoster("teammate_spawned")).toBe(true);
expect(changesTeamRoster("run_started")).toBe(true);
expect(changesTeamRoster("run_completed")).toBe(true);
expect(changesTeamRoster("run_progress")).toBe(false);
expect(changesTeamRoster("agent_event")).toBe(false);
expect(changesAgentTeamRoster("teammate_spawned")).toBe(true);
expect(changesAgentTeamRoster("run_started")).toBe(true);
expect(changesAgentTeamRoster("run_completed")).toBe(true);
expect(changesAgentTeamRoster("run_progress")).toBe(false);
expect(changesAgentTeamRoster("agent_event")).toBe(false);
});
});
describe("shouldReplaceLiveActivity", () => {
const live = (eventType: string, message?: string): TeamLiveActivity => ({
describe("shouldReplaceAgentTeamLiveActivity", () => {
const live = (
eventType: string,
message?: string,
): AgentTeamLiveActivity => ({
eventType,
message,
at: 0,
@@ -87,7 +101,7 @@ describe("shouldReplaceLiveActivity", () => {
it("keeps the latest tool when the next iteration starts", () => {
expect(
shouldReplaceLiveActivity(
shouldReplaceAgentTeamLiveActivity(
live("run_progress", "finished_tool_editor"),
live("run_progress", "iteration_4_started"),
),
@@ -96,13 +110,13 @@ describe("shouldReplaceLiveActivity", () => {
it("lets a first iteration or a new tool through", () => {
expect(
shouldReplaceLiveActivity(
shouldReplaceAgentTeamLiveActivity(
live("run_started"),
live("run_progress", "iteration_1_started"),
),
).toBe(true);
expect(
shouldReplaceLiveActivity(
shouldReplaceAgentTeamLiveActivity(
live("run_progress", "finished_tool_editor"),
live("run_progress", "running_tool_run_commands"),
),
@@ -111,17 +125,20 @@ describe("shouldReplaceLiveActivity", () => {
it("ignores events it cannot interpret", () => {
expect(
shouldReplaceLiveActivity(undefined, live("run_progress", "heartbeat")),
shouldReplaceAgentTeamLiveActivity(
undefined,
live("run_progress", "heartbeat"),
),
).toBe(false);
expect(
shouldReplaceAgentTeamLiveActivity(undefined, live("agent_event")),
).toBe(false);
expect(shouldReplaceLiveActivity(undefined, live("agent_event"))).toBe(
false,
);
});
});
describe("buildTeamMembers", () => {
describe("buildAgentTeamMembers", () => {
it("keeps team-task runs and drops subagents", () => {
const members = buildTeamMembers([
const members = buildAgentTeamMembers([
entry({ agentId: "architect" }),
entry({ agentId: "helper", kind: "subagent" }),
]);
@@ -129,7 +146,7 @@ describe("buildTeamMembers", () => {
});
it("shows a teammate by its latest run, timed from its first", () => {
const [member] = buildTeamMembers([
const [member] = buildAgentTeamMembers([
entry({
agentId: "server-dev",
sessionId: "run_a",
@@ -153,7 +170,7 @@ describe("buildTeamMembers", () => {
});
it("names the task by the first line of a multi-line brief", () => {
const [member] = buildTeamMembers([
const [member] = buildAgentTeamMembers([
entry({
agentId: "search-dev",
prompt:
@@ -164,7 +181,7 @@ describe("buildTeamMembers", () => {
});
it("prefers narration over a tool name for the step", () => {
const [member] = buildTeamMembers([
const [member] = buildAgentTeamMembers([
entry({
agentId: "architect",
lastAction: "Comparing ordering strategies before writing the doc.",
@@ -177,7 +194,7 @@ describe("buildTeamMembers", () => {
});
it("lets newer live activity decide the phase", () => {
const live = new Map<string, TeamLiveActivity>([
const live = new Map<string, AgentTeamLiveActivity>([
[
"client-dev",
{
@@ -187,7 +204,7 @@ describe("buildTeamMembers", () => {
},
],
]);
const [member] = buildTeamMembers(
const [member] = buildAgentTeamMembers(
[entry({ agentId: "client-dev", lastAction: "Running read_files" })],
live,
);
@@ -195,7 +212,7 @@ describe("buildTeamMembers", () => {
});
it("ignores live activity from before the latest run started", () => {
const live = new Map<string, TeamLiveActivity>([
const live = new Map<string, AgentTeamLiveActivity>([
[
"client-dev",
{
@@ -204,7 +221,7 @@ describe("buildTeamMembers", () => {
},
],
]);
const [member] = buildTeamMembers(
const [member] = buildAgentTeamMembers(
[entry({ agentId: "client-dev", lastAction: "Running editor" })],
live,
);
@@ -212,7 +229,7 @@ describe("buildTeamMembers", () => {
});
it("trusts a settled status over live activity", () => {
const live = new Map<string, TeamLiveActivity>([
const live = new Map<string, AgentTeamLiveActivity>([
[
"reviewer",
{
@@ -222,7 +239,7 @@ describe("buildTeamMembers", () => {
},
],
]);
const [member] = buildTeamMembers(
const [member] = buildAgentTeamMembers(
[
entry({
agentId: "reviewer",
@@ -240,7 +257,7 @@ describe("buildTeamMembers", () => {
});
describe("team summary", () => {
const members = buildTeamMembers([
const members = buildAgentTeamMembers([
entry({ agentId: "a", lastAction: "Running read_files" }),
entry({ agentId: "b", lastAction: "Running editor" }),
entry({ agentId: "c", lastAction: "Running run_commands" }),
@@ -248,14 +265,14 @@ describe("team summary", () => {
]);
it("counts planning, acting, and done teammates", () => {
expect(summarizeTeam(members)).toBe("1 planning · 2 acting · 1 done");
expect(summarizeAgentTeam(members)).toBe("1 planning · 2 acting · 1 done");
});
it("is settled only when every teammate has finished", () => {
expect(isTeamSettled(members)).toBe(false);
expect(isAgentTeamSettled(members)).toBe(false);
expect(
isTeamSettled(
buildTeamMembers([
isAgentTeamSettled(
buildAgentTeamMembers([
entry({ agentId: "a", status: "completed" }),
entry({ agentId: "b", status: "failed" }),
]),
@@ -263,3 +280,29 @@ describe("team summary", () => {
).toBe(true);
});
});
describe("agentTeamLiveActivityFromLifecycle", () => {
it("keeps the agent, event, and message of a lifecycle event", () => {
expect(
agentTeamLiveActivityFromLifecycle(
{
agentId: "reviewer",
eventType: "run_progress",
message: "running_tool_editor",
},
42,
),
).toEqual({
agentId: "reviewer",
eventType: "run_progress",
message: "running_tool_editor",
at: 42,
});
});
it("drops events without an agent", () => {
expect(
agentTeamLiveActivityFromLifecycle({ eventType: "outcome_created" }, 0),
).toBeUndefined();
});
});
@@ -0,0 +1,151 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { AgentTeamMember } from "../components/agent-team-activity.js";
import { AgentTeamPanel } from "../components/agent-team-panel.js";
let container: HTMLDivElement;
let root: Root;
const START = Date.parse("2026-10-20T20:00:00.000Z");
const running: AgentTeamMember[] = [
{
agentId: "ui-dev",
sessionId: "s-ui",
task: "Add a theme toggle",
phase: "editing",
step: "Editing files",
startedAt: START,
},
{
agentId: "reviewer",
sessionId: "s-review",
task: "Review both changes",
phase: "reading",
step: "Reading files",
startedAt: START + 5_000,
},
];
const finished: AgentTeamMember[] = running.map((member) => ({
...member,
phase: "done",
step: undefined,
endedAt: START + 135_000,
}));
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
function rows() {
return [...container.querySelectorAll<HTMLButtonElement>("li button")];
}
it("lists each teammate with its phase, step, and elapsed time", async () => {
await act(async () =>
root.render(
<AgentTeamPanel
members={running}
now={START + 65_000}
onOpenSession={() => {}}
teamName="team-Shp9t"
/>,
),
);
const header = container.querySelector("button[aria-expanded]");
expect(header?.textContent).toContain("team-Shp9t");
expect(header?.textContent).toContain("1 planning · 1 acting");
expect(header?.textContent).toContain("1m 5s");
expect(rows().map((row) => row.textContent)).toEqual([
"ui-devEditingEditing files1m 5s",
"reviewerReadingReading files1m 0s",
]);
});
it("opens a teammate's session and highlights the one on screen", async () => {
const onOpenSession = vi.fn();
await act(async () =>
root.render(
<AgentTeamPanel
activeSessionId="s-review"
members={running}
now={START}
onOpenSession={onOpenSession}
/>,
),
);
expect(rows().map((row) => row.getAttribute("aria-current"))).toEqual([
null,
"true",
]);
await act(async () => rows()[0]?.click());
expect(onOpenSession).toHaveBeenCalledWith("s-ui");
});
it("folds to a summary when the team finishes and opens when it resumes", async () => {
await act(async () =>
root.render(
<AgentTeamPanel members={running} now={START} onOpenSession={() => {}} />,
),
);
await act(async () =>
root.render(
<AgentTeamPanel
members={finished}
now={START + 200_000}
onOpenSession={() => {}}
/>,
),
);
const header = container.querySelector("button[aria-expanded]");
expect(header?.getAttribute("aria-expanded")).toBe("false");
expect(header?.textContent).toContain("2 done");
expect(header?.textContent).toContain("2m 15s");
expect(rows()).toHaveLength(0);
await act(async () => (header as HTMLButtonElement).click());
expect(rows()).toHaveLength(2);
await act(async () =>
root.render(
<AgentTeamPanel
members={[
...finished,
{
agentId: "fixer",
sessionId: "s-fix",
phase: "editing",
startedAt: START + 205_000,
},
]}
now={START + 210_000}
onOpenSession={() => {}}
/>,
),
);
expect(
container
.querySelector("button[aria-expanded]")
?.getAttribute("aria-expanded"),
).toBe("true");
});
it("renders nothing without teammates", async () => {
await act(async () =>
root.render(<AgentTeamPanel members={[]} onOpenSession={() => {}} />),
);
expect(container.innerHTML).toBe("");
});