From d25491b2d86c8c31e388bf3dbd44ff33c4ee3638 Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Sat, 28 Mar 2026 02:22:38 +0200 Subject: [PATCH] feat(work-log): display agent task groups in timeline Groups task.started, task.progress, and task.completed events into expandable agent group entries showing: - Individual task descriptions and status (running, completed, failed, stopped) - Token usage and tool use counts - Status indicators and collapsible task list Adds AgentTaskSummary and AgentGroup types, AgentTaskRow and AgentGroupRow components, and formatting functions for metrics display. Replaces previous filtering behavior that omitted task lifecycle events. --- .../src/components/chat/MessagesTimeline.tsx | 144 ++++++++- apps/web/src/session-logic.test.ts | 289 +++++++++++++++++- apps/web/src/session-logic.ts | 172 ++++++++++- 3 files changed, 588 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 677d8128d676..7354b0b4a5d9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -14,7 +14,14 @@ import { type VirtualItem, useVirtualizer, } from "@tanstack/react-virtual"; -import { deriveTimelineEntries, formatElapsed } from "../../session-logic"; +import { + deriveTimelineEntries, + formatElapsed, + formatTokenCount, + formatToolUseCount, + type AgentGroup, + type AgentTaskSummary, +} from "../../session-logic"; import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; import { type TurnDiffSummary } from "../../types"; import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; @@ -22,6 +29,8 @@ import ChatMarkdown from "../ChatMarkdown"; import { BotIcon, CheckIcon, + ChevronDownIcon, + ChevronRightIcon, CircleAlertIcon, EyeIcon, GlobeIcon, @@ -64,6 +73,7 @@ const WORK_ROW_BASE_HEIGHT = 112; const DIFF_PREVIEW_HEADER_HEIGHT = 32; const DIFF_PREVIEW_LINE_HEIGHT = 18; const DIFF_PREVIEW_MAX_HEIGHT = 260; +const AGENT_GROUP_HEADER_HEIGHT = 32; function estimateWorkRowHeight( groupedEntries: ReadonlyArray, @@ -71,13 +81,17 @@ function estimateWorkRowHeight( ): number { if (!showInlineDiffs) return WORK_ROW_BASE_HEIGHT; - let diffHeight = 0; + let extraHeight = 0; const visibleEntries = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : groupedEntries; for (const entry of visibleEntries) { + if (entry.agentGroup) { + extraHeight += AGENT_GROUP_HEADER_HEIGHT; + continue; + } const previews = entry.diffPreviews; if (!previews || previews.length === 0) continue; for (const hunk of previews) { @@ -85,11 +99,11 @@ function estimateWorkRowHeight( hunk.lines.length * DIFF_PREVIEW_LINE_HEIGHT, DIFF_PREVIEW_MAX_HEIGHT, ); - diffHeight += DIFF_PREVIEW_HEADER_HEIGHT + bodyHeight + 8; + extraHeight += DIFF_PREVIEW_HEADER_HEIGHT + bodyHeight + 8; } } - return WORK_ROW_BASE_HEIGHT + diffHeight; + return WORK_ROW_BASE_HEIGHT + extraHeight; } interface MessagesTimelineProps { @@ -381,13 +395,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )}
- {visibleEntries.map((workEntry) => ( - - ))} + {visibleEntries.map((workEntry) => + workEntry.agentGroup ? ( + + ) : ( + + ), + )}
); @@ -896,6 +918,106 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); } +function agentTaskStatusLabel(status: AgentTaskSummary["status"]): string { + switch (status) { + case "completed": + return "Done"; + case "failed": + return "Failed"; + case "stopped": + return "Stopped"; + case "running": + return "Running..."; + } +} + +function agentTaskStatusClass(status: AgentTaskSummary["status"]): string { + switch (status) { + case "failed": + return "text-rose-400/70"; + case "running": + return "text-amber-400/70"; + default: + return "text-muted-foreground/50"; + } +} + +const AgentTaskRow = memo(function AgentTaskRow(props: { + task: AgentTaskSummary; + isLast: boolean; +}) { + const { task, isLast } = props; + const connector = isLast ? "└─" : "├─"; + const statusConnectorPrefix = isLast ? "\u00A0\u00A0 " : "│\u00A0 "; + + const metaParts: string[] = []; + if (task.toolUses !== null) metaParts.push(formatToolUseCount(task.toolUses)); + if (task.totalTokens !== null) metaParts.push(formatTokenCount(task.totalTokens)); + const metaString = metaParts.length > 0 ? ` · ${metaParts.join(" · ")}` : ""; + + return ( +
+

+ {" "} + {task.description} + {metaString && {metaString}} +

+

+ {" "} + {agentTaskStatusLabel(task.status)} +

+
+ ); +}); + +const AgentGroupRow = memo(function AgentGroupRow(props: { + agentGroup: AgentGroup; + label: string; +}) { + const { agentGroup, label } = props; + const [expanded, setExpanded] = useState(false); + const tasks = agentGroup.tasks; + const allSettled = tasks.every( + (t) => t.status === "completed" || t.status === "failed" || t.status === "stopped", + ); + + const ExpandIcon = expanded ? ChevronDownIcon : ChevronRightIcon; + + return ( +
+ + + {expanded && ( +
+ {tasks.map((task, index) => ( + + ))} +
+ )} +
+ ); +}); + const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; showInlineDiffs: boolean; diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index e7fde80b7e1e..4e71a15557c6 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -17,6 +17,8 @@ import { deriveWorkLogEntries, findLatestProposedPlan, findSidebarProposedPlan, + formatTokenCount, + formatToolUseCount, hasActionableProposedPlan, hasToolActivityForTurn, isLatestTurnSettled, @@ -563,7 +565,7 @@ describe("deriveWorkLogEntries", () => { expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); }); - it("omits task start and completion lifecycle entries", () => { + it("groups task lifecycle entries into a single agent group entry", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ id: "task-start", @@ -571,6 +573,7 @@ describe("deriveWorkLogEntries", () => { kind: "task.started", summary: "default task started", tone: "info", + payload: { taskId: "t1", detail: "Explore codebase" }, }), makeActivity({ id: "task-progress", @@ -578,6 +581,11 @@ describe("deriveWorkLogEntries", () => { kind: "task.progress", summary: "Updating files", tone: "info", + payload: { + taskId: "t1", + detail: "Exploring", + usage: { total_tokens: 12000, tool_uses: 5 }, + }, }), makeActivity({ id: "task-complete", @@ -585,11 +593,262 @@ describe("deriveWorkLogEntries", () => { kind: "task.completed", summary: "Task completed", tone: "info", + payload: { + taskId: "t1", + status: "completed", + usage: { total_tokens: 15000, tool_uses: 8 }, + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + expect(entries).toHaveLength(1); + expect(entries[0]!.agentGroup).toBeDefined(); + expect(entries[0]!.agentGroup!.tasks).toHaveLength(1); + expect(entries[0]!.agentGroup!.tasks[0]!.taskId).toBe("t1"); + expect(entries[0]!.agentGroup!.tasks[0]!.description).toBe("Explore codebase"); + expect(entries[0]!.agentGroup!.tasks[0]!.status).toBe("completed"); + expect(entries[0]!.agentGroup!.tasks[0]!.toolUses).toBe(8); + expect(entries[0]!.agentGroup!.tasks[0]!.totalTokens).toBe(15000); + expect(entries[0]!.label).toBe("1 agent finished"); + }); + + it("groups multiple tasks into a single agent group entry", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "t1-start", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "task.started", + tone: "info", + payload: { taskId: "t1", detail: "Explore CSS" }, + }), + makeActivity({ + id: "t2-start", + createdAt: "2026-02-23T00:00:01.500Z", + kind: "task.started", + tone: "info", + payload: { taskId: "t2", detail: "Explore sidebar" }, + }), + makeActivity({ + id: "t1-complete", + createdAt: "2026-02-23T00:00:05.000Z", + kind: "task.completed", + tone: "info", + payload: { + taskId: "t1", + status: "completed", + usage: { total_tokens: 77900, tool_uses: 35 }, + }, + }), + makeActivity({ + id: "t2-complete", + createdAt: "2026-02-23T00:00:06.000Z", + kind: "task.completed", + tone: "info", + payload: { + taskId: "t2", + status: "completed", + usage: { total_tokens: 54700, tool_uses: 19 }, + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + expect(entries).toHaveLength(1); + expect(entries[0]!.agentGroup!.tasks).toHaveLength(2); + expect(entries[0]!.label).toBe("2 agents finished"); + expect(entries[0]!.agentGroup!.tasks[0]!.taskId).toBe("t1"); + expect(entries[0]!.agentGroup!.tasks[1]!.taskId).toBe("t2"); + }); + + it("shows running status when task has no completed event", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "t1-start", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "task.started", + tone: "info", + payload: { taskId: "t1", detail: "Running analysis" }, + }), + makeActivity({ + id: "t1-progress", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "task.progress", + tone: "info", + payload: { taskId: "t1", detail: "Analyzing", usage: { total_tokens: 3000, tool_uses: 2 } }, + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + expect(entries).toHaveLength(1); + expect(entries[0]!.agentGroup!.tasks[0]!.status).toBe("running"); + expect(entries[0]!.label).toBe("1 agent running"); + }); + + it("shows failed status and error tone in agent group", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "t1-start", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "task.started", + tone: "info", + payload: { taskId: "t1", detail: "Run tests" }, + }), + makeActivity({ + id: "t1-complete", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "task.completed", + tone: "error", + payload: { taskId: "t1", status: "failed" }, + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + expect(entries).toHaveLength(1); + expect(entries[0]!.tone).toBe("error"); + expect(entries[0]!.agentGroup!.tasks[0]!.status).toBe("failed"); + expect(entries[0]!.label).toBe("1 agent finished (1 failed)"); + }); + + it("handles missing usage data gracefully", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "t1-start", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "task.started", + tone: "info", + payload: { taskId: "t1", detail: "Quick check" }, + }), + makeActivity({ + id: "t1-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "task.completed", + tone: "info", + payload: { taskId: "t1", status: "completed" }, + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + expect(entries[0]!.agentGroup!.tasks[0]!.toolUses).toBeNull(); + expect(entries[0]!.agentGroup!.tasks[0]!.totalTokens).toBeNull(); + }); + + it("preserves non-task work entries alongside agent group", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "tool-1", + createdAt: "2026-02-23T00:00:00.500Z", + kind: "tool.completed", + summary: "File edit", + tone: "tool", + }), + makeActivity({ + id: "t1-start", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "task.started", + tone: "info", + payload: { taskId: "t1", detail: "Explore" }, + }), + makeActivity({ + id: "t1-complete", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "task.completed", + tone: "info", + payload: { taskId: "t1", status: "completed" }, + }), + makeActivity({ + id: "tool-2", + createdAt: "2026-02-23T00:00:04.000Z", + kind: "tool.completed", + summary: "Command run", + tone: "tool", }), ]; const entries = deriveWorkLogEntries(activities, undefined); - expect(entries.map((entry) => entry.id)).toEqual(["task-progress"]); + const ids = entries.map((e) => e.id); + expect(ids[0]).toBe("tool-1"); + expect(entries[1]!.agentGroup).toBeDefined(); + expect(ids[2]).toBe("tool-2"); + }); + + it("shows mixed label when some tasks are running and some completed", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "t1-start", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "task.started", + tone: "info", + payload: { taskId: "t1", detail: "First task" }, + }), + makeActivity({ + id: "t2-start", + createdAt: "2026-02-23T00:00:01.500Z", + kind: "task.started", + tone: "info", + payload: { taskId: "t2", detail: "Second task" }, + }), + makeActivity({ + id: "t1-complete", + createdAt: "2026-02-23T00:00:05.000Z", + kind: "task.completed", + tone: "info", + payload: { taskId: "t1", status: "completed" }, + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + expect(entries[0]!.label).toBe("2 agents (1 running)"); + }); + + it("handles task.progress without preceding task.started", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "t1-progress", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "task.progress", + tone: "info", + payload: { + taskId: "t1", + detail: "Checking files", + usage: { total_tokens: 5000, tool_uses: 3 }, + }, + }), + makeActivity({ + id: "t1-complete", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "task.completed", + tone: "info", + payload: { taskId: "t1", status: "completed" }, + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + expect(entries).toHaveLength(1); + expect(entries[0]!.agentGroup!.tasks[0]!.description).toBe("Checking files"); + expect(entries[0]!.agentGroup!.tasks[0]!.status).toBe("completed"); + }); + + it("handles stopped task status", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "t1-start", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "task.started", + tone: "info", + payload: { taskId: "t1", detail: "Long task" }, + }), + makeActivity({ + id: "t1-complete", + createdAt: "2026-02-23T00:00:05.000Z", + kind: "task.completed", + tone: "info", + payload: { taskId: "t1", status: "stopped" }, + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + expect(entries[0]!.agentGroup!.tasks[0]!.status).toBe("stopped"); }); it("filters by turn id when provided", () => { @@ -1128,6 +1387,32 @@ describe("deriveActiveWorkStartedAt", () => { }); }); +describe("formatTokenCount", () => { + it("formats small counts without abbreviation", () => { + expect(formatTokenCount(500)).toBe("500 tokens"); + }); + + it("formats thousands with one decimal", () => { + expect(formatTokenCount(12000)).toBe("12.0k tokens"); + expect(formatTokenCount(77900)).toBe("77.9k tokens"); + }); + + it("formats large counts rounded", () => { + expect(formatTokenCount(150000)).toBe("150k tokens"); + }); +}); + +describe("formatToolUseCount", () => { + it("uses singular for count of 1", () => { + expect(formatToolUseCount(1)).toBe("1 tool use"); + }); + + it("uses plural for other counts", () => { + expect(formatToolUseCount(0)).toBe("0 tool uses"); + expect(formatToolUseCount(35)).toBe("35 tool uses"); + }); +}); + describe("PROVIDER_OPTIONS", () => { it("advertises Claude as available while keeping Cursor as a placeholder", () => { const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeAgent"); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index f5917fc02032..358ebf9eed78 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -34,6 +34,19 @@ export const PROVIDER_OPTIONS: Array<{ { value: "cursor", label: "Cursor", available: false }, ]; +export interface AgentTaskSummary { + taskId: string; + description: string; + status: "running" | "completed" | "failed" | "stopped"; + toolUses: number | null; + totalTokens: number | null; + createdAt: string; +} + +export interface AgentGroup { + tasks: ReadonlyArray; +} + export interface WorkLogEntry { id: string; createdAt: string; @@ -46,6 +59,7 @@ export interface WorkLogEntry { itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; diffPreviews?: ReadonlyArray; + agentGroup?: AgentGroup; } interface DerivedWorkLogEntry extends WorkLogEntry { @@ -462,14 +476,33 @@ export function deriveWorkLogEntries( latestTurnId: TurnId | undefined, ): WorkLogEntry[] { const ordered = [...activities].toSorted(compareActivitiesByOrder); - const entries = ordered + const filtered = ordered .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true)) .filter((activity) => activity.kind !== "tool.started") - .filter((activity) => activity.kind !== "task.started" && activity.kind !== "task.completed") .filter((activity) => activity.kind !== "context-window.updated") .filter((activity) => activity.summary !== "Checkpoint captured") - .filter((activity) => !isPlanBoundaryToolActivity(activity)) - .map(toDerivedWorkLogEntry); + .filter((activity) => !isPlanBoundaryToolActivity(activity)); + + const taskGroups = buildTaskGroups(filtered); + const taskActivityIds = new Set([...taskGroups.values()].flatMap((group) => group.activityIds)); + + let agentGroupEmitted = false; + const entries: DerivedWorkLogEntry[] = []; + + for (const activity of filtered) { + if (taskActivityIds.has(activity.id)) { + if (!agentGroupEmitted) { + agentGroupEmitted = true; + const groupEntry = buildAgentGroupEntry(activity, taskGroups); + if (groupEntry) { + entries.push(groupEntry); + } + } + continue; + } + entries.push(toDerivedWorkLogEntry(activity)); + } + return collapseDerivedWorkLogEntries(entries).map( ({ activityKind: _activityKind, collapseKey: _collapseKey, ...entry }) => entry, ); @@ -487,6 +520,137 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); } +interface TaskActivityGroup { + started: OrchestrationThreadActivity | null; + progressEntries: OrchestrationThreadActivity[]; + completed: OrchestrationThreadActivity | null; + activityIds: string[]; +} + +const TASK_ACTIVITY_KINDS = new Set(["task.started", "task.progress", "task.completed"]); + +function isTaskActivity(activity: OrchestrationThreadActivity): boolean { + return TASK_ACTIVITY_KINDS.has(activity.kind); +} + +function extractTaskId(activity: OrchestrationThreadActivity): string | null { + const payload = asRecord(activity.payload); + const taskId = payload?.taskId; + return typeof taskId === "string" && taskId.length > 0 ? taskId : null; +} + +function buildTaskGroups( + activities: ReadonlyArray, +): Map { + const groups = new Map(); + for (const activity of activities) { + if (!isTaskActivity(activity)) continue; + const taskId = extractTaskId(activity); + if (!taskId) continue; + + let group = groups.get(taskId); + if (!group) { + group = { started: null, progressEntries: [], completed: null, activityIds: [] }; + groups.set(taskId, group); + } + group.activityIds.push(activity.id); + + if (activity.kind === "task.started") group.started = activity; + else if (activity.kind === "task.progress") group.progressEntries.push(activity); + else if (activity.kind === "task.completed") group.completed = activity; + } + return groups; +} + +function extractTaskUsage(payload: Record | null): { + totalTokens: number | null; + toolUses: number | null; +} { + const usage = asRecord(payload?.usage); + if (!usage) return { totalTokens: null, toolUses: null }; + return { + totalTokens: typeof usage.total_tokens === "number" ? usage.total_tokens : null, + toolUses: typeof usage.tool_uses === "number" ? usage.tool_uses : null, + }; +} + +function buildAgentTaskSummary(taskId: string, group: TaskActivityGroup): AgentTaskSummary { + const completedPayload = asRecord(group.completed?.payload); + const latestProgress = group.progressEntries.at(-1); + const latestProgressPayload = asRecord(latestProgress?.payload); + const startedPayload = asRecord(group.started?.payload); + + const description = + asTrimmedString(startedPayload?.detail) ?? + asTrimmedString(startedPayload?.description) ?? + asTrimmedString(latestProgressPayload?.detail) ?? + asTrimmedString(latestProgressPayload?.summary) ?? + "Agent"; + + let status: AgentTaskSummary["status"] = "running"; + if (completedPayload) { + const rawStatus = completedPayload.status; + if (rawStatus === "failed") status = "failed"; + else if (rawStatus === "stopped") status = "stopped"; + else status = "completed"; + } + + const usageSource = completedPayload ?? latestProgressPayload; + const { totalTokens, toolUses } = extractTaskUsage(usageSource); + + const createdAt = + group.started?.createdAt ?? latestProgress?.createdAt ?? group.completed?.createdAt ?? ""; + + return { taskId, description, status, toolUses, totalTokens, createdAt }; +} + +export function formatTokenCount(tokens: number): string { + if (tokens < 1_000) return `${tokens} tokens`; + if (tokens < 100_000) return `${(tokens / 1_000).toFixed(1)}k tokens`; + return `${Math.round(tokens / 1_000)}k tokens`; +} + +export function formatToolUseCount(count: number): string { + return `${count} tool use${count !== 1 ? "s" : ""}`; +} + +function buildAgentGroupLabel(tasks: ReadonlyArray): string { + const total = tasks.length; + const runningCount = tasks.filter((t) => t.status === "running").length; + const failedCount = tasks.filter((t) => t.status === "failed").length; + const noun = total === 1 ? "agent" : "agents"; + + if (runningCount > 0) { + if (runningCount === total) return `${total} ${noun} running`; + return `${total} ${noun} (${runningCount} running)`; + } + if (failedCount > 0) { + return `${total} ${noun} finished (${failedCount} failed)`; + } + return `${total} ${noun} finished`; +} + +function buildAgentGroupEntry( + firstActivity: OrchestrationThreadActivity, + taskGroups: Map, +): DerivedWorkLogEntry | null { + if (taskGroups.size === 0) return null; + + const tasks = [...taskGroups.entries()].map(([taskId, group]) => + buildAgentTaskSummary(taskId, group), + ); + const hasFailed = tasks.some((t) => t.status === "failed"); + + return { + id: `agent-group:${firstActivity.id}`, + createdAt: firstActivity.createdAt, + label: buildAgentGroupLabel(tasks), + tone: hasFailed ? "error" : "info", + activityKind: "task.started", + agentGroup: { tasks }, + }; +} + function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry { const payload = activity.payload && typeof activity.payload === "object"