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.
Collapse Sequential Read File Events Implementation Plan
For agentic workers: use
subagent-driven-developmentfor independent
tasks, orexecuting-plansfor 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_filecollapse into one UI event. - Single
read_fileblocks continue to render as the existing single-fileReadFileTool. - Non-consecutive
read_fileblocks do not collapse across text, reasoning, source, file, or other tool blocks. - The
read_filetool API stays singular. Do not change tool schemas or backend code. - Grouped UI shows a summary such as
Read N filesand 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.
- Use grouped blocks in
- Create:
site/src/pages/AgentsPage/components/ChatElements/tools/ReadFilesTool.tsx- Render grouped
read_filecalls usingToolCollapsibleand file viewers.
- Render grouped
- Modify:
site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx- Export a reusable file viewer body if needed by
ReadFilesTool.
- Export a reusable file viewer body if needed by
- 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
blocksin order. - For each
block.type === "tool", resolve the tool fromtoolByID. - 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, andasString, matchingReadFileRendererinTool.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.
- running if any tool has
- Header label:
Reading ${tools.length} files…when running,Read ${tools.length} filesotherwise.
hasContentis true if any grouped item has non-empty content.- Use
ToolCollapsiblewithclassName="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, andReadFilesTool. - Keep
toolByIDcreation. - Compute
const displayBlocks = groupSequentialReadFileBlocks(blocks, tools);aftertoolByID. - Build
blockToolIDsfromdisplayBlocks.flatMap(getToolIDsForBlock)and retain the existing streaming check behavior for unresolved single tool placeholders. - Render
displayBlocks.mapinstead ofblocks.map. - Add
case "tool-group"beforecase "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 originalblocksinput 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
ReadFileContentfromReadFileTool.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
displayBlocksand 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
displayBlocksand 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
groupSequentialReadFileMessagesinmessageHelpers.tsto 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
SequentialReadFilesCollapsedto use the real multi-message shape. - Added
messageHelpers.test.tscoverage for cross-message grouping.