diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index ce8f02d75c..949bc12bd9 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -3047,6 +3047,15 @@ const ToolGroupPart = memo(function ToolGroupPart({ if (part.items.length === 0) return null + // A lone call has nothing to summarize — the pill would only read + // "Ran 1 command" and hide the card one click away. Unwrap it back to the + // direct tool card (the same one the expanded list below renders), so a + // single call sitting between two reasoning blocks reads as a plain card. + const [solo] = part.items + if (part.items.length === 1 && solo) { + return + } + const joiner = t("joiner") const titleText = phrases.join(joiner) diff --git a/src/components/message/content-parts-tool-group.test.tsx b/src/components/message/content-parts-tool-group.test.tsx new file mode 100644 index 0000000000..e2733c011e --- /dev/null +++ b/src/components/message/content-parts-tool-group.test.tsx @@ -0,0 +1,82 @@ +import { type ReactNode } from "react" +import { render } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { describe, expect, it, vi } from "vitest" + +/** + * A collapsed tool-group pill exists to summarize a RUN of calls. When a + * reasoning block splits a turn into runs of one, the pill adds nothing but a + * click — "Ran 1 command" hiding the very card the user wants to read. The + * adapter still wraps every run uniformly; the renderer unwraps a one-item + * group back into the direct tool card. + */ + +vi.mock("@/components/ai-elements/link-safety", () => ({ + FilePathLink: ({ children }: { children: ReactNode }) => ( + {children} + ), + useStreamdownLinkSafety: () => ({ enabled: false }), +})) + +vi.mock("@/components/ai-elements/code-block", () => ({ + CodeBlock: ({ code }: { code: string }) =>
{code}
, +})) + +vi.mock("@/components/ai-elements/message", () => ({ + MessageResponse: ({ children }: { children: string }) => ( +
{children}
+ ), +})) + +import { ContentPartsRenderer } from "./content-parts-renderer" +import enMessages from "@/i18n/messages/en.json" +import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter" + +type ToolCallPartType = Extract + +function settledBash(toolCallId: string, command: string): ToolCallPartType { + return { + type: "tool-call", + toolCallId, + toolName: "bash", + displayTitle: command, + input: JSON.stringify({ command, cwd: "/tmp/work" }), + state: "output-available", + output: "done", + toolStatus: "completed", + meta: null, + } +} + +function groupOf(...items: ToolCallPartType[]): AdaptedContentPart { + return { type: "tool-group", items, isStreaming: false } +} + +function renderParts(parts: AdaptedContentPart[]) { + return render( + + + + ) +} + +describe("tool-group renderer — lone call unwrapping", () => { + it("renders a single call directly instead of a collapsed pill", () => { + const { container } = renderParts([groupOf(settledBash("tc-1", "ls -la"))]) + expect(container.textContent).not.toContain("Ran 1 command") + // The card itself is on screen without the click the pill would require. + expect(container.textContent).toContain("ls -la") + }) + + it("keeps the collapsed pill once a run has two or more calls", () => { + const { container } = renderParts([ + groupOf(settledBash("tc-1", "ls -la"), settledBash("tc-2", "pwd")), + ]) + expect(container.textContent).toContain("Ran 2 commands") + }) + + it("still renders nothing for an empty group", () => { + const { container } = renderParts([groupOf()]) + expect(container.textContent).toBe("") + }) +}) diff --git a/src/lib/adapters/ai-elements-adapter.ts b/src/lib/adapters/ai-elements-adapter.ts index 84015fc6a7..a2a6cfe754 100644 --- a/src/lib/adapters/ai-elements-adapter.ts +++ b/src/lib/adapters/ai-elements-adapter.ts @@ -1577,8 +1577,9 @@ export function mergeAdjacentToolGroups( /** * Wrap any consecutive run of tool-call parts into a single tool-group. * Text, reasoning, tool-result and any other part types break the run. - * Even a single tool call is wrapped, so the renderer can present a uniform - * collapsed summary across history. + * Even a single tool call is wrapped — grouping stays uniform at this layer — + * but the renderer unwraps a one-item group back into a direct tool card, so + * lone calls never hide behind a "Ran 1 command" pill. */ export function groupConsecutiveToolCalls( parts: AdaptedContentPart[]