mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
+113
-62
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
+101
-58
@@ -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("");
|
||||
});
|
||||
Reference in New Issue
Block a user