fix(agents): group activity tools and hide orphan prompt context

- Update desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts to group consecutive tool calls into transcript tool groups and suppress setup/context-only turn noise when no user prompt bubble exists
- Update desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx and AgentSessionToolItem.tsx to render grouped tool rows with tighter activity-feed spacing
- Expand desktop/src/features/agents/ui/agentSessionToolSummary.ts so Buzz and generic tools use the compact summary presentation with useful previews
- Adjust desktop/src/shared/ui/toggle.tsx to support the ghost prompt-context toggle styling used by the transcript footer
- Add focused unit coverage for grouped tools, Buzz tool summaries, promptless turns, and setup/context-only turns
This commit is contained in:
Taylor Ho
2026-06-15 01:03:23 -07:00
parent fcf010511f
commit 84a8f0cec9
7 changed files with 281 additions and 75 deletions

View File

@@ -11,9 +11,11 @@ import { asRecord, formatCodeValue, formatDuration } from "./agentSessionUtils";
export function ToolItem({
compact = false,
grouped = false,
item,
}: {
compact?: boolean;
grouped?: boolean;
item: Extract<TranscriptItem, { type: "tool" }>;
}) {
const [isExpanded, setIsExpanded] = React.useState(false);
@@ -32,7 +34,11 @@ export function ToolItem({
return (
<div
className={cn("not-prose w-full", compact ? "px-0" : "px-1")}
className={cn(
"not-prose w-full",
compact ? "px-0" : "px-1",
grouped ? "py-0" : compact ? "py-0.5" : "py-0.5",
)}
data-testid="transcript-tool-item"
>
<details
@@ -42,7 +48,7 @@ export function ToolItem({
>
<summary
className={cn(
"inline-flex max-w-full cursor-pointer list-none items-center gap-5 py-px",
"inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 py-px",
compactSummaryTone(),
)}
>

View File

@@ -155,6 +155,14 @@ function TranscriptDisplayBlockView({
profiles?: UserProfileLookup;
}) {
if (block.kind === "single") {
if (block.item.type === "tool") {
return (
<div className={cn("first:mt-0", compact ? "mt-3" : "mt-3.5")}>
<ToolCallGroup compact={compact} items={[block.item]} />
</div>
);
}
return (
<TranscriptItemRow
agentAvatarUrl={agentAvatarUrl}
@@ -169,25 +177,51 @@ function TranscriptDisplayBlockView({
return (
<div
className={cn("first:mt-0", compact ? "mt-2.5" : "mt-4")}
className={cn("first:mt-0", compact ? "mt-2.5" : "mt-3.5")}
data-testid="transcript-turn-group"
data-turn-id={block.turnId}
>
{block.segments.map((segment) => (
<TranscriptTurnSegmentView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
compact={compact}
{block.segments.map((segment, index) => (
<div
className={getTurnSegmentSpacing(
block.segments[index - 1],
segment,
compact,
)}
key={getTurnSegmentKey(block.turnId, segment)}
profiles={profiles}
segment={segment}
/>
>
<TranscriptTurnSegmentView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
compact={compact}
profiles={profiles}
segment={segment}
/>
</div>
))}
</div>
);
}
function getTurnSegmentSpacing(
previous: TranscriptTurnSegment | undefined,
segment: TranscriptTurnSegment,
compact: boolean,
): string | undefined {
if (!previous) {
return undefined;
}
const involvesTool =
previous.kind === "tool_group" || segment.kind === "tool_group";
if (involvesTool) {
return compact ? "mt-3" : "mt-3.5";
}
return compact ? "mt-2" : "mt-2.5";
}
function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) {
if (segment.kind === "setup") {
return `turn:${turnId}:setup`;
@@ -195,6 +229,9 @@ function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) {
if (segment.kind === "prompt") {
return `turn:${turnId}:prompt`;
}
if (segment.kind === "tool_group") {
return `turn:${turnId}:tools:${segment.items.map((item) => item.id).join("+")}`;
}
return segment.item.id;
}
@@ -226,12 +263,17 @@ function TranscriptTurnSegmentView({
return <TurnSetupStatus compact={compact} items={segment.items} />;
}
if (segment.kind === "tool_group") {
return <ToolCallGroup compact={compact} items={segment.items} />;
}
return (
<TranscriptItemRow
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
compact={compact}
embedded
item={segment.item}
profiles={profiles}
/>
@@ -252,10 +294,7 @@ function TurnPromptBlock({
user: Extract<TranscriptItem, { type: "message" }>;
}) {
return (
<div
className={cn("first:mt-0", compact ? "mt-2.5" : "mt-4")}
data-testid="transcript-prompt-bundle"
>
<div data-testid="transcript-prompt-bundle">
{SHOW_TRANSCRIPT_ACP_SOURCE ? (
<div className="mb-1 flex flex-wrap gap-1">
<TranscriptAcpSourceBadge source="session/prompt:user" />
@@ -464,24 +503,42 @@ function TurnSetupFooter({
);
}
function ToolCallGroup({
compact,
items,
}: {
compact: boolean;
items: Extract<TranscriptItem, { type: "tool" }>[];
}) {
return (
<div className="flex flex-col gap-0" data-testid="transcript-tool-group">
{items.map((item) => (
<ToolItem compact={compact} grouped key={item.id} item={item} />
))}
</div>
);
}
function TranscriptItemRow({
agentAvatarUrl,
agentName,
agentPubkey,
compact,
embedded = false,
item,
profiles,
}: AgentTranscriptIdentityProps & {
compact: boolean;
embedded?: boolean;
item: TranscriptItem;
profiles?: UserProfileLookup;
}) {
return (
<div
className={cn(
"first:mt-0",
compact ? "mt-2.5" : "mt-4",
getItemSpacingClass(item),
!embedded && "first:mt-0",
!embedded && (compact ? "mt-2" : "mt-2.5"),
!embedded && getItemSpacingClass(item),
)}
key={item.id}
>
@@ -513,7 +570,7 @@ function TurnSetupStatus({
}
return (
<div className={cn("rounded-md px-2 py-1.5", compact ? "mt-2" : "mt-2.5")}>
<div className={cn("rounded-md px-2 py-1.5", compact ? "mt-1.5" : "mt-2")}>
<TurnSetupFooter items={items} timestamp={timestamp} />
</div>
);
@@ -521,10 +578,10 @@ function TurnSetupStatus({
function getItemSpacingClass(item: TranscriptItem) {
if (item.type === "lifecycle") {
return "mt-2 first:mt-0";
return "mt-1.5 first:mt-0";
}
if (item.type === "metadata" || item.type === "thought") {
return "mt-2 first:mt-0";
return "mt-1.5 first:mt-0";
}
return undefined;
}
@@ -603,7 +660,13 @@ function MessageItem({
className={cn(
"flex animate-in fade-in duration-200 motion-reduce:animate-none",
isAssistant ? "flex-row" : "flex-row items-start justify-end",
compact ? "px-0 py-0.5" : "px-1 py-1",
isAssistant
? compact
? "px-0 py-0"
: "px-1 py-0"
: compact
? "px-0 py-0.5"
: "px-1 py-1",
)}
data-role={isAssistant ? "assistant-message" : "user-message"}
data-testid={

View File

@@ -1,10 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildCompactToolSummary,
isCompactDeveloperTool,
} from "./agentSessionToolSummary.ts";
import { buildCompactToolSummary } from "./agentSessionToolSummary.ts";
const baseTimestamp = "2026-06-14T19:00:00.000Z";
@@ -26,29 +23,19 @@ function makeTool(overrides = {}) {
};
}
test("isCompactDeveloperTool returns false for Buzz relay tools", () => {
assert.equal(
isCompactDeveloperTool(
makeTool({
toolName: "send_message",
buzzToolName: "send_message",
title: "Send Message",
}),
),
false,
test("buildCompactToolSummary formats Buzz send_message preview", () => {
const summary = buildCompactToolSummary(
makeTool({
toolName: "send_message",
buzzToolName: "send_message",
title: "Send Message",
args: { content: "Hello team" },
}),
);
});
test("isCompactDeveloperTool detects buzz-dev-mcp shell tools", () => {
assert.equal(
isCompactDeveloperTool(
makeTool({
toolName: "buzz-dev-mcp__shell",
title: "buzz-dev-mcp__shell",
}),
),
true,
);
assert.equal(summary.kind, "buzz");
assert.equal(summary.label, "Send Message");
assert.equal(summary.preview, "Hello team");
});
test("buildCompactToolSummary formats shell command preview", () => {

View File

@@ -1,9 +1,15 @@
import type { ToolStatus, TranscriptItem } from "./agentSessionTypes";
import {
formatToolTitle,
getBuzzToolInfo,
isGenericToolTitle,
normalizeToolNameText,
} from "./agentSessionToolCatalog";
import { asRecord, getToolString } from "./agentSessionUtils";
import {
asRecord,
getToolString,
getToolStringList,
} from "./agentSessionUtils";
export type CompactToolKind =
| "shell"
@@ -13,7 +19,9 @@ export type CompactToolKind =
| "todo"
| "stop_hook"
| "post_compact_hook"
| "dev_mcp";
| "dev_mcp"
| "buzz"
| "generic";
export type CompactToolSummary = {
kind: CompactToolKind;
@@ -35,26 +43,33 @@ const DEVELOPER_TOOL_BASES = new Set([
type ToolItem = Extract<TranscriptItem, { type: "tool" }>;
/** Whether this tool row should use the muted compact developer summary. */
export function isCompactDeveloperTool(item: ToolItem): boolean {
if (item.buzzToolName && getBuzzToolInfo(item.buzzToolName)) {
return false;
}
return resolveDeveloperToolKind(item) !== null;
}
/** Build the compact summary label and preview for developer MCP tool rows. */
/** Build the muted compact summary label and preview for any tool row. */
export function buildCompactToolSummary(item: ToolItem): CompactToolSummary {
const kind = resolveDeveloperToolKind(item) ?? "dev_mcp";
const kind = resolveCompactToolKind(item);
const { preview, thumbnailSrc } = extractCompactToolPreview(item, kind);
return {
kind,
label: compactToolLabel(kind, item.status, item.isError),
label: compactToolLabel(kind, item, item.status, item.isError),
preview,
thumbnailSrc,
};
}
function resolveCompactToolKind(item: ToolItem): CompactToolKind {
const developerKind = resolveDeveloperToolKind(item);
if (developerKind) {
return developerKind;
}
for (const value of [item.buzzToolName, item.toolName, item.title]) {
if (value && getBuzzToolInfo(value)) {
return "buzz";
}
}
return "generic";
}
function resolveDeveloperToolKind(item: ToolItem): CompactToolKind | null {
for (const value of [item.toolName, item.title, item.buzzToolName]) {
const kind = classifyDeveloperToolName(value);
@@ -98,16 +113,51 @@ function stripMcpServerPrefix(normalized: string): string {
function compactToolLabel(
kind: CompactToolKind,
item: ToolItem,
status: ToolStatus,
isError: boolean,
): string {
const failed = isError || status === "failed";
const running = status === "executing" || status === "pending";
if (kind === "buzz") {
const title = formatToolTitle(
item.buzzToolName ?? item.toolName,
item.title,
);
if (failed) return `${title} failed`;
if (running) return title;
return title;
}
const labels: Record<
CompactToolKind,
{ completed: string; running: string; failed: string }
> = {
generic: {
completed: "Ran tool",
running: "Running tool",
failed: "Tool failed",
},
...developerToolLabels(),
};
const labelsMap = labels as Record<
CompactToolKind,
{ completed: string; running: string; failed: string }
>;
const set = labelsMap[kind];
if (failed) return set.failed;
if (running) return set.running;
return set.completed;
}
function developerToolLabels(): Record<
Exclude<CompactToolKind, "buzz" | "generic">,
{ completed: string; running: string; failed: string }
> {
return {
shell: {
completed: "Ran command",
running: "Running command",
@@ -149,11 +199,6 @@ function compactToolLabel(
failed: "Tool failed",
},
};
const set = labels[kind];
if (failed) return set.failed;
if (running) return set.running;
return set.completed;
}
type CompactToolPreview = {
@@ -181,13 +226,56 @@ function extractCompactToolPreview(
case "post_compact_hook":
return emptyPreview();
case "dev_mcp":
case "generic":
return textPreview(
getToolString(args, ["command", "path", "source", "query", "name"]) ??
null,
getToolString(args, [
"command",
"path",
"source",
"query",
"name",
"content",
"message",
]) ??
(item.title && !isGenericToolTitle(item.title) ? item.title : null),
);
case "buzz":
return textPreview(extractBuzzToolPreview(args));
}
}
function extractBuzzToolPreview(args: Record<string, unknown>): string | null {
const content = getToolString(args, ["content", "message", "text", "body"]);
if (content) {
return content;
}
const query = getToolString(args, ["query", "search"]);
if (query) {
return query;
}
const channelId = getToolString(args, ["channel_id", "channelId"]);
if (channelId) {
return channelId;
}
const workflowId = getToolString(args, ["workflow_id", "workflowId"]);
if (workflowId) {
return workflowId;
}
const pubkeys = getToolStringList(args, ["pubkeys", "pubkey"]);
if (pubkeys.length === 1) {
return pubkeys[0];
}
if (pubkeys.length > 1) {
return `${pubkeys.length} users`;
}
return getToolString(args, ["event_id", "eventId", "name"]);
}
function textPreview(preview: string | null): CompactToolPreview {
return { preview, thumbnailSrc: null };
}

View File

@@ -124,6 +124,32 @@ test("buildTranscriptDisplayBlocks bundles user prompt, setup, and context toget
assert.equal(promptSegment.context?.id, "context");
assert.equal(promptSegment.setup.length, 2);
assert.equal(turnBlock.segments[1]?.kind, "item");
assert.equal(turnBlock.segments[2]?.kind, "tool_group");
});
test("buildTranscriptDisplayBlocks groups consecutive tool calls", () => {
const rawItems = [
userPrompt("prompt", "run these", "turn-1"),
toolCall("tool-1", "turn-1"),
toolCall("tool-2", "turn-1"),
assistantMessage("assistant", "Done.", "turn-1"),
toolCall("tool-3", "turn-1"),
];
const blocks = buildTranscriptDisplayBlocks(rawItems);
const turnBlock = blocks[0];
assert.equal(turnBlock?.kind, "turn");
assert.equal(turnBlock.segments[1]?.kind, "tool_group");
assert.deepEqual(
turnBlock.segments[1]?.items?.map((item) => item.id),
["tool-1", "tool-2"],
);
assert.equal(turnBlock.segments[2]?.kind, "item");
assert.equal(turnBlock.segments[3]?.kind, "tool_group");
assert.deepEqual(
turnBlock.segments[3]?.items?.map((item) => item.id),
["tool-3"],
);
});
test("buildTranscriptDisplayBlocks collapses setup lifecycle inside prompt bundle", () => {
@@ -146,23 +172,25 @@ test("buildTranscriptDisplayBlocks collapses setup lifecycle inside prompt bundl
);
});
test("buildTranscriptDisplayBlocks hides setup lifecycle when prompt is missing", () => {
test("buildTranscriptDisplayBlocks hides setup and context when prompt is missing", () => {
const rawItems = [
lifecycle("turn", "Turn started", "turn_started", "turn-1"),
lifecycle("session", "Session ready", "session_resolved", "turn-1"),
promptContext("context", "turn-1"),
toolCall("tool", "turn-1"),
];
const blocks = buildTranscriptDisplayBlocks(rawItems);
const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id);
assert.deepEqual(displayOrder, ["context"]);
assert.deepEqual(displayOrder, ["tool"]);
});
test("buildTranscriptDisplayBlocks drops setup-only turns", () => {
test("buildTranscriptDisplayBlocks drops setup-and-context-only turns", () => {
const rawItems = [
lifecycle("turn", "Turn started", "turn_started", "turn-1"),
lifecycle("session", "Session ready", "session_resolved", "turn-1"),
promptContext("context", "turn-1"),
];
const blocks = buildTranscriptDisplayBlocks(rawItems);

View File

@@ -2,6 +2,7 @@ import type { TranscriptItem } from "./agentSessionTypes";
export type TranscriptTurnSegment =
| { kind: "item"; item: TranscriptItem }
| { kind: "tool_group"; items: Extract<TranscriptItem, { type: "tool" }>[] }
| { kind: "setup"; items: Extract<TranscriptItem, { type: "lifecycle" }>[] }
| {
kind: "prompt";
@@ -62,12 +63,14 @@ function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] {
if (userPrompt) consumed.add(userPrompt);
for (const item of setupLifecycle) consumed.add(item);
if (userPrompt && promptContext) consumed.add(promptContext);
if (promptContext) consumed.add(promptContext);
const activity = items.filter((item) => !consumed.has(item));
if (!userPrompt) {
return activity.map((item) => ({ kind: "item", item }));
return groupConsecutiveToolSegments(
activity.map((item) => ({ kind: "item", item })),
);
}
const segments: TranscriptTurnSegment[] = [
@@ -90,7 +93,34 @@ function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] {
segments.push({ kind: "item", item });
}
return segments;
return groupConsecutiveToolSegments(segments);
}
function groupConsecutiveToolSegments(
segments: TranscriptTurnSegment[],
): TranscriptTurnSegment[] {
const grouped: TranscriptTurnSegment[] = [];
let toolBuffer: Extract<TranscriptItem, { type: "tool" }>[] = [];
const flushTools = () => {
if (toolBuffer.length === 0) {
return;
}
grouped.push({ kind: "tool_group", items: toolBuffer });
toolBuffer = [];
};
for (const segment of segments) {
if (segment.kind === "item" && segment.item.type === "tool") {
toolBuffer.push(segment.item);
continue;
}
flushTools();
grouped.push(segment);
}
flushTools();
return grouped;
}
/**
@@ -162,6 +192,8 @@ export function flattenDisplayBlocks(
for (const segment of block.segments) {
if (segment.kind === "item") {
result.push(segment.item);
} else if (segment.kind === "tool_group") {
result.push(...segment.items);
} else if (segment.kind === "prompt") {
result.push(segment.user);
result.push(...segment.setup);

View File

@@ -10,12 +10,14 @@ const toggleVariants = cva(
variants: {
variant: {
default: "bg-transparent",
ghost:
"bg-transparent hover:bg-muted/70 hover:text-foreground data-[state=on]:bg-muted data-[state=on]:text-foreground",
outline:
"border border-input/40 bg-background hover:bg-muted/70 data-[state=on]:bg-muted data-[state=on]:text-foreground",
},
size: {
default: "h-9 px-3 min-w-9",
xs: "h-5 min-h-0 min-w-0 gap-1 rounded-md px-1.5 text-xs font-medium",
xs: "h-5 min-h-0 min-w-0 gap-1 rounded-md px-1.5 text-xs font-medium [&_svg]:size-3.5",
sm: "h-8 px-2 min-w-8",
lg: "h-10 px-3 min-w-10",
},