◐ Shell
clean mode source ↗

feat(site/src/pages/AgentsPage/components): collapse sequential read file events by DanielleMaywood · Pull Request #25075 · coder/coder

Collapses sequential read_file tool activity in the Agents chat timeline into a single UI event while leaving the tool API and persisted transcript shape unchanged.

The grouping handles both multiple tool blocks within one assistant message and the real persisted shape where read_file calls are split across adjacent assistant and tool-result messages. Refs CODAGT-337.

Implementation plan

Collapse Sequential Read File Events Implementation Plan

For agentic workers: use subagent-driven-development for independent
tasks, or executing-plans for inline execution. Track steps with checkbox
syntax.

Goal: Collapse consecutive read_file tool calls into a single conversation timeline UI entry such as Read 3 files while keeping each tool call singular.

Architecture: This is a render-time frontend grouping change. BlockList will derive a grouped view from existing RenderBlock[] and MergedTool[], leaving message parsing, streaming state, persisted transcript shape, and the read_file tool protocol unchanged. A new grouped read-file component will reuse the same file viewer behavior as ReadFileTool so expanded groups still expose every file read.

Tech Stack: React, TypeScript, Vite, Vitest, Storybook interaction tests, Biome formatting, pnpm commands from site/.


Requirements

  • Consecutive rendered tool blocks whose resolved tool name is read_file collapse into one UI event.
  • Single read_file blocks continue to render as the existing single-file ReadFileTool.
  • Non-consecutive read_file blocks do not collapse across text, reasoning, source, file, or other tool blocks.
  • The read_file tool API stays singular. Do not change tool schemas or backend code.
  • Grouped UI shows a summary such as Read N files and can expand to show each file's content.
  • If any grouped read is running, the group uses a running label and spinner. If any grouped read errors, the group shows an error indicator.

File Map

  • Modify: site/src/pages/AgentsPage/components/ChatConversation/blockUtils.ts
    • Add pure render-block grouping helpers.
  • Modify: site/src/pages/AgentsPage/components/ChatConversation/blockUtils.test.ts
    • Add unit tests for grouping behavior.
  • Modify: site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx
    • Use grouped blocks in BlockList, account for grouped IDs in remaining-tool filtering, and render grouped read files.
  • Create: site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx
    • Render grouped read_file calls using ToolCollapsible and file viewers.
  • Modify: site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx
    • Export a reusable file viewer body if needed by ReadFilesTool.
  • Modify: site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx
    • Add a story and play assertions for collapsed sequential reads.

No database, generated API, backend, or docs changes are expected.

Implementation Details

Grouping model

Do not add a persisted RenderBlock union member. Keep grouping as a local render derivation so other code that consumes parsed blocks does not need to understand grouped reads.

Add these types and helpers to blockUtils.ts:

export type ToolGroupRenderBlock = {
	type: "tool-group";
	toolName: "read_file";
	ids: string[];
};

export type TimelineRenderBlock = RenderBlock | ToolGroupRenderBlock;

export const groupSequentialReadFileBlocks = (
	blocks: readonly RenderBlock[],
	tools: readonly MergedTool[],
): TimelineRenderBlock[] => {
	const toolByID = new Map(tools.map((tool) => [tool.id, tool]));
	const grouped: TimelineRenderBlock[] = [];
	let currentReadFileIDs: string[] = [];

	const flushReadFileIDs = () => {
		if (currentReadFileIDs.length === 0) {
			return;
		}
		if (currentReadFileIDs.length === 1) {
			grouped.push({ type: "tool", id: currentReadFileIDs[0] });
		} else {
			grouped.push({
				type: "tool-group",
				toolName: "read_file",
				ids: currentReadFileIDs,
			});
		}
		currentReadFileIDs = [];
	};

	for (const block of blocks) {
		if (block.type === "tool") {
			const tool = toolByID.get(block.id);
			if (tool?.name === "read_file") {
				currentReadFileIDs = [...currentReadFileIDs, block.id];
				continue;
			}
		}

		flushReadFileIDs();
		grouped.push(block);
	}

	flushReadFileIDs();
	return grouped;
};

export const getToolIDsForBlock = (
	block: TimelineRenderBlock,
): readonly string[] => {
	if (block.type === "tool") {
		return [block.id];
	}
	if (block.type === "tool-group") {
		return block.ids;
	}
	return [];
};

groupSequentialReadFileBlocks should:

  • Build toolByID = new Map(tools.map((tool) => [tool.id, tool])).
  • Iterate through blocks in order.
  • For each block.type === "tool", resolve the tool from toolByID.
  • Accumulate consecutive tool blocks only when the resolved tool exists and tool.name === "read_file".
  • When a non-read block or unresolved tool block appears, flush the current run.
  • Flush a run as:
    • one original tool block when the run length is 1,
    • one { type: "tool-group", toolName: "read_file", ids } block when the run length is greater than 1.
  • Preserve original block order and object identity for non-grouped blocks where practical.

Grouped component behavior

Create ReadFilesTool.tsx with props:

import type { MergedTool } from "../../ChatConversation/types";

export const ReadFilesTool: React.FC<{
	tools: readonly MergedTool[];
}> = ({ tools }) => {
	// Implementation described in Task 3.
};

Inside ReadFilesTool:

  • Derive each file item from its tool with parseArgs, asRecord, and asString, matching ReadFileRenderer in Tool.tsx.
  • Use path || "file" as the display path fallback.
  • Use asString(rec.content).trim() for content to match existing single-file behavior.
  • Compute status:
    • running if any tool has status === "running",
    • error if no running tools and any tool has isError,
    • completed otherwise.
  • Header label:
    • Reading ${tools.length} files… when running,
    • Read ${tools.length} files otherwise.
  • hasContent is true if any grouped item has non-empty content.
  • Use ToolCollapsible with className="w-full".
  • In expanded content, render a vertical list of file sections. Each section shows the path and a scrollable file viewer body.
  • Preserve per-file errors in the expanded list by showing the error message near that file if present.

To avoid duplicating file viewer setup, extract a small exported component from ReadFileTool.tsx:

export const ReadFileContent: React.FC<{
	path: string;
	content: string;
}> = ({ path, content }) => {
	// Existing ScrollArea plus FileViewer body.
};

Then ReadFileTool renders <ReadFileContent path={path} content={content} />, and ReadFilesTool reuses it for each file.

BlockList integration

In ConversationTimeline.tsx:

  • Import getToolIDsForBlock, groupSequentialReadFileBlocks, and ReadFilesTool.
  • Keep toolByID creation.
  • Compute const displayBlocks = groupSequentialReadFileBlocks(blocks, tools); after toolByID.
  • Build blockToolIDs from displayBlocks.flatMap(getToolIDsForBlock) and retain the existing streaming check behavior for unresolved single tool placeholders.
  • Render displayBlocks.map instead of blocks.map.
  • Add case "tool-group" before case "tool":
case "tool-group": {
	const groupTools = block.ids
		.map((id) => toolByID.get(id))
		.filter((tool): tool is MergedTool => Boolean(tool));
	if (groupTools.length === 0) {
		return null;
	}
	return <ReadFilesTool key={`${keyPrefix}-tool-group-${index}`} tools={groupTools} />;
}
  • Keep the existing single-tool render path unchanged.
  • For lastBlockIsThinking, continue using the original blocks input because grouping only affects tool blocks.

Tasks

Task 1: Add pure grouping helper tests

Files:

  • Test: site/src/pages/AgentsPage/components/ChatConversation/blockUtils.test.ts

  • Modify later: site/src/pages/AgentsPage/components/ChatConversation/blockUtils.ts

  • Step 1: Add failing tests for grouped read blocks.

Add MergedTool to the type imports and add a new suite after appendTextBlock:

describe("groupSequentialReadFileBlocks", () => {
	const tools: MergedTool[] = [
		{
			id: "read-1",
			name: "read_file",
			args: { path: "a.ts" },
			result: { content: "a" },
			isError: false,
			status: "completed",
		},
		{
			id: "read-2",
			name: "read_file",
			args: { path: "b.ts" },
			result: { content: "b" },
			isError: false,
			status: "completed",
		},
		{
			id: "execute-1",
			name: "execute",
			args: { command: "pwd" },
			result: { output: "/home/coder" },
			isError: false,
			status: "completed",
		},
	];

	it("collapses consecutive read_file tool blocks", () => {
		const result = groupSequentialReadFileBlocks(
			[
				{ type: "tool", id: "read-1" },
				{ type: "tool", id: "read-2" },
			],
			tools,
		);

		expect(result).toEqual([
			{ type: "tool-group", toolName: "read_file", ids: ["read-1", "read-2"] },
		]);
	});

	it("leaves a single read_file tool block ungrouped", () => {
		const result = groupSequentialReadFileBlocks(
			[{ type: "tool", id: "read-1" }],
			tools,
		);

		expect(result).toEqual([{ type: "tool", id: "read-1" }]);
	});

	it("does not collapse read_file blocks across other content", () => {
		const result = groupSequentialReadFileBlocks(
			[
				{ type: "tool", id: "read-1" },
				{ type: "response", text: "middle" },
				{ type: "tool", id: "read-2" },
			],
			tools,
		);

		expect(result).toEqual([
			{ type: "tool", id: "read-1" },
			{ type: "response", text: "middle" },
			{ type: "tool", id: "read-2" },
		]);
	});

	it("does not collapse read_file blocks across another tool", () => {
		const result = groupSequentialReadFileBlocks(
			[
				{ type: "tool", id: "read-1" },
				{ type: "tool", id: "execute-1" },
				{ type: "tool", id: "read-2" },
			],
			tools,
		);

		expect(result).toEqual([
			{ type: "tool", id: "read-1" },
			{ type: "tool", id: "execute-1" },
			{ type: "tool", id: "read-2" },
		]);
	});

	it("keeps unresolved tool blocks ungrouped", () => {
		const result = groupSequentialReadFileBlocks(
			[
				{ type: "tool", id: "read-1" },
				{ type: "tool", id: "missing" },
				{ type: "tool", id: "read-2" },
			],
			tools,
		);

		expect(result).toEqual([
			{ type: "tool", id: "read-1" },
			{ type: "tool", id: "missing" },
			{ type: "tool", id: "read-2" },
		]);
	});
});
  • Step 2: Run the focused test and verify it fails because the helper is missing.
cd site && pnpm test -- src/pages/AgentsPage/components/ChatConversation/blockUtils.test.ts -t groupSequentialReadFileBlocks

Expected: Vitest fails because groupSequentialReadFileBlocks is not exported from blockUtils.ts.

Task 2: Implement grouping helpers

Files:

  • Modify: site/src/pages/AgentsPage/components/ChatConversation/blockUtils.ts

  • Test: site/src/pages/AgentsPage/components/ChatConversation/blockUtils.test.ts

  • Step 1: Add the helper types and implementation.

Update imports:

import type { MergedTool, RenderBlock } from "./types";

Add the types and helpers after appendTextBlock:

export type ToolGroupRenderBlock = {
	type: "tool-group";
	toolName: "read_file";
	ids: string[];
};

export type TimelineRenderBlock = RenderBlock | ToolGroupRenderBlock;

export const groupSequentialReadFileBlocks = (
	blocks: readonly RenderBlock[],
	tools: readonly MergedTool[],
): TimelineRenderBlock[] => {
	const toolByID = new Map(tools.map((tool) => [tool.id, tool]));
	const grouped: TimelineRenderBlock[] = [];
	let currentReadFileIDs: string[] = [];

	const flushReadFileIDs = () => {
		if (currentReadFileIDs.length === 0) {
			return;
		}
		if (currentReadFileIDs.length === 1) {
			grouped.push({ type: "tool", id: currentReadFileIDs[0] });
		} else {
			grouped.push({
				type: "tool-group",
				toolName: "read_file",
				ids: currentReadFileIDs,
			});
		}
		currentReadFileIDs = [];
	};

	for (const block of blocks) {
		if (block.type === "tool") {
			const tool = toolByID.get(block.id);
			if (tool?.name === "read_file") {
				currentReadFileIDs = [...currentReadFileIDs, block.id];
				continue;
			}
		}

		flushReadFileIDs();
		grouped.push(block);
	}

	flushReadFileIDs();
	return grouped;
};

export const getToolIDsForBlock = (
	block: TimelineRenderBlock,
): readonly string[] => {
	if (block.type === "tool") {
		return [block.id];
	}
	if (block.type === "tool-group") {
		return block.ids;
	}
	return [];
};
  • Step 2: Import the helper in the test file.

Update the blockUtils.test.ts import to include groupSequentialReadFileBlocks and import MergedTool:

import {
	appendTextBlock,
	asNonEmptyString,
	groupSequentialReadFileBlocks,
} from "./blockUtils";
import type { MergedTool, RenderBlock } from "./types";
  • Step 3: Run the focused helper tests and verify they pass.
cd site && pnpm test -- src/pages/AgentsPage/components/ChatConversation/blockUtils.test.ts -t groupSequentialReadFileBlocks

Expected: the new grouping tests pass.

  • Step 4: Run all block utility tests.
cd site && pnpm test -- src/pages/AgentsPage/components/ChatConversation/blockUtils.test.ts

Expected: all blockUtils.test.ts tests pass.

Task 3: Add grouped read-file component with reusable file viewer body

Files:

  • Modify: site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx

  • Create: site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx

  • Step 1: Extract ReadFileContent from ReadFileTool.tsx.

Add this exported component before ReadFileTool:

export const ReadFileContent: React.FC<{
	path: string;
	content: string;
}> = ({ path, content }) => {
	const theme = useTheme();
	const isDark = theme.palette.mode === "dark";

	return (
		<ScrollArea
			className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
			viewportClassName="max-h-64"
			scrollBarClassName="w-1.5"
		>
			<FileViewer
				file={{
					name: path,
					contents: content,
				}}
				options={getFileViewerOptionsMinimal(isDark)}
				style={DIFFS_FONT_STYLE}
			/>
		</ScrollArea>
	);
};

Then replace the existing inline ScrollArea in ReadFileTool with:

<ReadFileContent path={path} content={content} />
  • Step 2: Create ReadFilesTool.tsx.

Use this shape:

import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
import type React from "react";
import {
	Tooltip,
	TooltipContent,
	TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import type { MergedTool } from "../../ChatConversation/types";
import { asRecord, asString } from "../runtimeTypeUtils";
import { ReadFileContent } from "./ReadFileTool";
import { ToolCollapsible } from "./ToolCollapsible";
import { parseArgs } from "./utils";

type ReadFileItem = {
	id: string;
	path: string;
	content: string;
	isError: boolean;
	errorMessage?: string;
};

const getReadFileItem = (tool: MergedTool): ReadFileItem => {
	const parsedArgs = parseArgs(tool.args);
	const path = parsedArgs ? asString(parsedArgs.path).trim() : "";
	const rec = asRecord(tool.result);
	return {
		id: tool.id,
		path: path || "file",
		content: rec ? asString(rec.content).trim() : "",
		isError: tool.isError,
		errorMessage: rec ? asString(rec.error || rec.message) : undefined,
	};
};

export const ReadFilesTool: React.FC<{
	tools: readonly MergedTool[];
}> = ({ tools }) => {
	const items = tools.map(getReadFileItem);
	const isRunning = tools.some((tool) => tool.status === "running");
	const isError = tools.some((tool) => tool.isError);
	const hasContent = items.some((item) => item.content.length > 0);
	const label = isRunning
		? `Reading ${tools.length} files…`
		: `Read ${tools.length} files`;
	const errorMessage = items.find((item) => item.errorMessage)?.errorMessage;

	return (
		<ToolCollapsible
			className="w-full"
			hasContent={hasContent}
			header={
				<>
					<span className="text-[13px]">{label}</span>
					{isError && (
						<Tooltip>
							<TooltipTrigger asChild>
								<TriangleAlertIcon className="h-3.5 w-3.5 shrink-0 text-current" />
							</TooltipTrigger>
							<TooltipContent>
								{errorMessage || "Failed to read one or more files"}
							</TooltipContent>
						</Tooltip>
					)}
					{isRunning && (
						<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-current" />
					)}
				</>
			}
		>
			<div className="space-y-3">
				{items.map((item) => (
					<section key={item.id} className="min-w-0">
						<div className="mt-2 truncate text-xs text-content-secondary">
							{item.path}
						</div>
						{item.isError && (
							<div className="mt-1 text-xs text-content-destructive">
								{item.errorMessage || "Failed to read file"}
							</div>
						)}
						{item.content.length > 0 && (
							<ReadFileContent path={item.path} content={item.content} />
						)}
					</section>
				))}
			</div>
		</ToolCollapsible>
	);
};
  • Step 3: Run TypeScript check for immediate errors.

Expected: no TypeScript errors from the new component and extracted viewer body.

Task 4: Integrate grouped blocks into BlockList

Files:

  • Modify: site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx

  • Step 1: Import the helper and grouped component.

Add ReadFilesTool near the existing tool imports:

import { ReadFilesTool } from "../ChatElements/tools/ReadFilesTool";

Update the block utility import:

import {
	getToolIDsForBlock,
	groupSequentialReadFileBlocks,
} from "./blockUtils";

If ConversationTimeline.tsx already imports from blockUtils, merge these names into that import instead of adding a duplicate.

  • Step 2: Derive displayBlocks and update remaining-tool filtering.

In BlockList, after toolByID:

const displayBlocks = groupSequentialReadFileBlocks(blocks, tools);

Replace the existing blockToolIDs computation with:

const blockToolIDs = new Set(
	displayBlocks.flatMap((block) => {
		if (
			block.type === "tool" &&
			!(toolByID.has(block.id) || isStreaming)
		) {
			return [];
		}
		return [...getToolIDsForBlock(block)];
	}),
);

This keeps unresolved streaming placeholders eligible for single-tool rendering while also excluding grouped tool IDs from remainingTools.

  • Step 3: Render displayBlocks and add the grouped case.

Change:

{blocks.map((block, index) => {

to:

{displayBlocks.map((block, index) => {

Add this switch case before case "tool":

case "tool-group": {
	const groupTools = block.ids
		.map((id) => toolByID.get(id))
		.filter((tool): tool is MergedTool => Boolean(tool));
	if (groupTools.length === 0) {
		return null;
	}
	return (
		<ReadFilesTool
			key={`${keyPrefix}-tool-group-${index}`}
			tools={groupTools}
		/>
	);
}
  • Step 4: Run focused tests and typecheck.
cd site && pnpm test -- src/pages/AgentsPage/components/ChatConversation/blockUtils.test.ts
cd site && pnpm check

Expected: tests pass and TypeScript accepts the new TimelineRenderBlock switch case.

Task 5: Add Storybook coverage for sequential reads

Files:

  • Modify: site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx

  • Step 1: Add a story with play assertions.

Add a story near the other tool/rendering regression stories:

export const SequentialReadFilesCollapsed: Story = {
	args: {
		...defaultArgs,
		parsedMessages: buildMessages([
			{
				...baseMessage,
				id: 1,
				role: "assistant",
				content: [
					{ type: "text", text: "I'll inspect the relevant files." },
					{
						type: "tool-call",
						tool_call_id: "read-1",
						tool_name: "read_file",
						args: { path: "site/src/a.ts" },
					},
					{
						type: "tool-result",
						tool_call_id: "read-1",
						tool_name: "read_file",
						result: { content: "export const a = 1;" },
					},
					{
						type: "tool-call",
						tool_call_id: "read-2",
						tool_name: "read_file",
						args: { path: "site/src/b.ts" },
					},
					{
						type: "tool-result",
						tool_call_id: "read-2",
						tool_name: "read_file",
						result: { content: "export const b = 2;" },
					},
					{
						type: "tool-call",
						tool_call_id: "read-3",
						tool_name: "read_file",
						args: { path: "site/src/c.ts" },
					},
					{
						type: "tool-result",
						tool_call_id: "read-3",
						tool_name: "read_file",
						result: { content: "export const c = 3;" },
					},
				],
			},
		]),
	},
	play: async ({ canvasElement }) => {
		const canvas = within(canvasElement);
		const groupButton = canvas.getByRole("button", { name: /read 3 files/i });
		expect(groupButton).toBeInTheDocument();
		expect(
			canvas.queryByRole("button", { name: /read a\.ts/i }),
		).not.toBeInTheDocument();
		await userEvent.click(groupButton);
		await waitFor(() => {
			expect(canvas.getByText("site/src/a.ts")).toBeVisible();
			expect(canvas.getByText("site/src/b.ts")).toBeVisible();
			expect(canvas.getByText("site/src/c.ts")).toBeVisible();
		});
	},
};

The exact text queries for code contents may need adjustment because @pierre/diffs/react may split code into decorated spans. Prefer asserting visible file paths and the group button.

  • Step 2: Not applicable because Task 4 was already completed before the story test run.

If Task 4 is complete, this should pass. If running this before Task 4, expected failure is that Read 3 files is not found.

cd site && pnpm test:storybook src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx -- --grep SequentialReadFilesCollapsed
  • Step 3: Run the story test after integration.
cd site && pnpm test:storybook src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx -- --grep SequentialReadFilesCollapsed

Expected: the story passes and confirms one grouped UI event.

Task 6: Format and verify the frontend change

Files:

  • All modified frontend files from previous tasks.

  • Step 1: Format frontend files.

Expected: Biome formats changed files.

  • Step 2: Run focused unit tests.
cd site && pnpm test -- src/pages/AgentsPage/components/ChatConversation/blockUtils.test.ts

Expected: all block utility tests pass.

  • Step 3: Run the focused Storybook interaction test.
cd site && pnpm test:storybook src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx -- --grep SequentialReadFilesCollapsed

Expected: the sequential read-file story passes.

  • Step 4: Run TypeScript check.

Expected: no TypeScript errors.

  • Step 5: Inspect the diff for unrelated changes.
git diff -- site/src/pages/AgentsPage/components/ChatConversation/blockUtils.ts site/src/pages/AgentsPage/components/ChatConversation/blockUtils.test.ts site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx

Expected: the diff only contains grouping helpers, grouped UI, integration, and tests for CODAGT-337.

Follow-up: Real transcript shape fix

After manual testing showed the initial implementation did not collapse reads in
real chats, the reproduced transcript shape was updated to match persisted chat
messages: each read_file tool call can appear in its own assistant message,
with a hidden tool role result message between calls. The original
block-level grouping only handled multiple tool blocks inside one assistant
message, so those real sequential reads stayed separate.

Additional changes:

  • Added groupSequentialReadFileMessages in messageHelpers.ts to collapse
    adjacent read-file-only assistant messages before rendering the timeline.
  • Hidden tool-result-only messages are transparent for grouping and remain
    omitted from the rendered timeline.
  • Visible assistant text, reasoning, files, sources, user messages, and other
    visible tools still break a read-file group.
  • Updated SequentialReadFilesCollapsed to use the real multi-message shape.
  • Added messageHelpers.test.ts coverage for cross-message grouping.