Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/components/message/content-parts-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ToolCallPart part={solo} />
}

const joiner = t("joiner")
const titleText = phrases.join(joiner)

Expand Down
82 changes: 82 additions & 0 deletions src/components/message/content-parts-tool-group.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<span>{children}</span>
),
useStreamdownLinkSafety: () => ({ enabled: false }),
}))

vi.mock("@/components/ai-elements/code-block", () => ({
CodeBlock: ({ code }: { code: string }) => <pre>{code}</pre>,
}))

vi.mock("@/components/ai-elements/message", () => ({
MessageResponse: ({ children }: { children: string }) => (
<div>{children}</div>
),
}))

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<AdaptedContentPart, { type: "tool-call" }>

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(
<NextIntlClientProvider locale="en" messages={enMessages}>
<ContentPartsRenderer parts={parts} role="assistant" />
</NextIntlClientProvider>
)
}

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("")
})
})
5 changes: 3 additions & 2 deletions src/lib/adapters/ai-elements-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
Loading