Skip to content

Commit 7afa436

Browse files
feat: batch consecutive tool calls in chat UI with shared utility (#11245)
* feat: group consecutive list_files tool calls into single UI block Consolidate consecutive listFilesTopLevel/listFilesRecursive ask messages into a single 'Roo wants to view multiple directories' block, matching the existing read_file batching pattern. * chore: add missing translation keys for all locales * refactor: consolidate duplicate listFiles batch-handling blocks in ChatRow Merge the separate listFilesTopLevel and listFilesRecursive case blocks into a single combined case with shared batch-detection logic, selecting the icon and translation key based on the tool type. This removes the duplicated isBatchDirRequest check and BatchListFilesPermission render. * feat: batch consecutive file-edit tool calls into single UI block Add edit-file batching in ChatView groupedMessages that consolidates consecutive editedExistingFile, appliedDiff, newFileCreated, insertContent, and searchAndReplace asks into a single BatchDiffApproval block. Move batchDiffs detection in ChatRow above the switch statement so it applies to any file-edit tool type. * refactor: extract batchConsecutive utility, fix batch UI issues - Extract generic batchConsecutive() utility from 3 identical while-loops - Fix React key collisions in BatchListFilesPermission, BatchFilePermission, BatchDiffApproval - Normalize language prop to "shellsession" (was "shell-session" for top-level) - Remove unused _batchedMessages property from synthetic messages - Remove dead didViewMultipleDirectories i18n key from all 18 locale files - Add batch button text for listFilesTopLevel/listFilesRecursive - Add batchConsecutive utility tests (6 cases) * fix: audit improvements for batch tool-call UI - Make batchConsecutive() generic instead of ClineMessage-specific - Add batch-aware button text for edit-file batches ("Save All"/"Deny All") - Add dedicated list-batch/edit-batch i18n keys (stop reusing read-batch) - Add JSON.parse defense-in-depth in all three synthesizers - Fix mixed list_files batch icon to default to FolderTree - Add 6 missing test cases (all-match, immutability, spy, single-dir) * chore: minor type cleanup (out-of-scope housekeeping) - Trim unused recursive/isOutsideWorkspace from DirPermissionItem interface - Remove 4 pre-existing `as any` casts in ChatView.tsx: - window cast → precise inline type - checkpoint bracket access → removed unnecessary casts - condensing message → `as ClineMessage` - debounce cancel → `.clear()` (correct API) - Update BatchListFilesPermission test data to match trimmed interface * i18n: add list-batch and edit-batch translations for all locales
1 parent 5313cb5 commit 7afa436

27 files changed

Lines changed: 847 additions & 139 deletions

packages/types/src/vscode-extension-host.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,12 @@ export interface ClineSayTool {
849849
startLine?: number
850850
}>
851851
}>
852+
batchDirs?: Array<{
853+
path: string
854+
recursive: boolean
855+
isOutsideWorkspace?: boolean
856+
key: string
857+
}>
852858
question?: string
853859
imageData?: string // Base64 encoded image data for generated images
854860
// Properties for runSlashCommand tool

webview-ui/src/components/chat/BatchDiffApproval.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,12 @@ export const BatchDiffApproval = memo(({ files = [], ts }: BatchDiffApprovalProp
3535
return (
3636
<div className="pt-[5px]">
3737
<div className="flex flex-col gap-0 border border-border rounded-md p-1">
38-
{files.map((file) => {
38+
{files.map((file, index) => {
3939
// Use backend-provided unified diff only. Stats also provided by backend.
4040
const unified = file.content || ""
4141

4242
return (
43-
<div key={`${file.path}-${ts}`}>
43+
<div key={`${file.path}-${index}-${ts}`}>
4444
<CodeAccordian
4545
path={file.path}
4646
code={unified}

webview-ui/src/components/chat/BatchFilePermission.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ export const BatchFilePermission = memo(({ files = [], onPermissionResponse, ts
2929
<div className="pt-[5px]">
3030
{/* Individual files */}
3131
<div className="flex flex-col gap-0 border border-border rounded-md p-1">
32-
{files.map((file) => {
32+
{files.map((file, index) => {
3333
return (
34-
<div key={`${file.path}-${ts}`} className="flex items-center gap-2">
34+
<div key={`${file.path}-${index}-${ts}`} className="flex items-center gap-2">
3535
<ToolUseBlock className="flex-1">
3636
<ToolUseBlockHeader
3737
onClick={() => vscode.postMessage({ type: "openFile", text: file.content })}>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { memo } from "react"
2+
3+
import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock"
4+
import { PathTooltip } from "../ui/PathTooltip"
5+
6+
interface DirPermissionItem {
7+
path: string
8+
key: string
9+
}
10+
11+
interface BatchListFilesPermissionProps {
12+
dirs: DirPermissionItem[]
13+
ts: number
14+
}
15+
16+
export const BatchListFilesPermission = memo(({ dirs = [], ts }: BatchListFilesPermissionProps) => {
17+
if (!dirs?.length) {
18+
return null
19+
}
20+
21+
return (
22+
<div className="pt-[5px]">
23+
<div className="flex flex-col gap-0 border border-border rounded-md p-1">
24+
{dirs.map((dir, index) => {
25+
return (
26+
<div key={`${dir.path}-${index}-${ts}`} className="flex items-center gap-2">
27+
<ToolUseBlock className="flex-1">
28+
<ToolUseBlockHeader>
29+
<PathTooltip content={dir.path}>
30+
<span className="whitespace-nowrap overflow-hidden text-ellipsis text-left mr-2 rtl">
31+
{dir.path}
32+
</span>
33+
</PathTooltip>
34+
<div className="flex-grow"></div>
35+
</ToolUseBlockHeader>
36+
</ToolUseBlock>
37+
</div>
38+
)
39+
})}
40+
</div>
41+
</div>
42+
)
43+
})
44+
45+
BatchListFilesPermission.displayName = "BatchListFilesPermission"

webview-ui/src/components/chat/ChatRow.tsx

Lines changed: 63 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { Mention } from "./Mention"
4040
import { CheckpointSaved } from "./checkpoints/CheckpointSaved"
4141
import { FollowUpSuggest } from "./FollowUpSuggest"
4242
import { BatchFilePermission } from "./BatchFilePermission"
43+
import { BatchListFilesPermission } from "./BatchListFilesPermission"
4344
import { BatchDiffApproval } from "./BatchDiffApproval"
4445
import { ProgressIndicator } from "./ProgressIndicator"
4546
import { Markdown } from "./Markdown"
@@ -419,24 +420,22 @@ export const ChatRowContent = ({
419420
style={{ color: "var(--vscode-foreground)", marginBottom: "-1.5px" }}></span>
420421
)
421422

423+
// Handle batch diffs for any file-edit tool type
424+
if (message.type === "ask" && tool.batchDiffs && Array.isArray(tool.batchDiffs)) {
425+
return (
426+
<>
427+
<div style={headerStyle}>
428+
<FileDiff className="w-4 shrink-0" aria-label="Batch diff icon" />
429+
<span style={{ fontWeight: "bold" }}>{t("chat:fileOperations.wantsToApplyBatchChanges")}</span>
430+
</div>
431+
<BatchDiffApproval files={tool.batchDiffs} ts={message.ts} />
432+
</>
433+
)
434+
}
435+
422436
switch (tool.tool as string) {
423437
case "editedExistingFile":
424438
case "appliedDiff":
425-
// Check if this is a batch diff request
426-
if (message.type === "ask" && tool.batchDiffs && Array.isArray(tool.batchDiffs)) {
427-
return (
428-
<>
429-
<div style={headerStyle}>
430-
<FileDiff className="w-4 shrink-0" aria-label="Batch diff icon" />
431-
<span style={{ fontWeight: "bold" }}>
432-
{t("chat:fileOperations.wantsToApplyBatchChanges")}
433-
</span>
434-
</div>
435-
<BatchDiffApproval files={tool.batchDiffs} ts={message.ts} />
436-
</>
437-
)
438-
}
439-
440439
// Regular single file diff
441440
return (
442441
<>
@@ -742,45 +741,57 @@ export const ChatRowContent = ({
742741
)
743742
}
744743
case "listFilesTopLevel":
744+
case "listFilesRecursive": {
745+
const isRecursive = tool.tool === "listFilesRecursive"
746+
747+
// Check if this is a batch directory listing request
748+
const isBatchDirRequest = message.type === "ask" && tool.batchDirs && Array.isArray(tool.batchDirs)
749+
750+
// When batching, check if all dirs share the same recursive value
751+
const allTopLevel = tool.batchDirs?.every((d: { recursive: boolean }) => !d.recursive)
752+
const DirIcon = isBatchDirRequest && !allTopLevel ? FolderTree : isRecursive ? FolderTree : ListTree
753+
const dirIconLabel =
754+
isBatchDirRequest && !allTopLevel
755+
? "Folder tree icon"
756+
: isRecursive
757+
? "Folder tree icon"
758+
: "List files icon"
759+
760+
if (isBatchDirRequest) {
761+
return (
762+
<>
763+
<div style={headerStyle}>
764+
<DirIcon className="w-4 shrink-0" aria-label={dirIconLabel} />
765+
<span style={{ fontWeight: "bold" }}>
766+
{t("chat:directoryOperations.wantsToViewMultipleDirectories")}
767+
</span>
768+
</div>
769+
<BatchListFilesPermission dirs={tool.batchDirs || []} ts={message?.ts} />
770+
</>
771+
)
772+
}
773+
774+
const labelKey = isRecursive
775+
? message.type === "ask"
776+
? tool.isOutsideWorkspace
777+
? "chat:directoryOperations.wantsToViewRecursiveOutsideWorkspace"
778+
: "chat:directoryOperations.wantsToViewRecursive"
779+
: tool.isOutsideWorkspace
780+
? "chat:directoryOperations.didViewRecursiveOutsideWorkspace"
781+
: "chat:directoryOperations.didViewRecursive"
782+
: message.type === "ask"
783+
? tool.isOutsideWorkspace
784+
? "chat:directoryOperations.wantsToViewTopLevelOutsideWorkspace"
785+
: "chat:directoryOperations.wantsToViewTopLevel"
786+
: tool.isOutsideWorkspace
787+
? "chat:directoryOperations.didViewTopLevelOutsideWorkspace"
788+
: "chat:directoryOperations.didViewTopLevel"
789+
745790
return (
746791
<>
747792
<div style={headerStyle}>
748-
<ListTree className="w-4 shrink-0" aria-label="List files icon" />
749-
<span style={{ fontWeight: "bold" }}>
750-
{message.type === "ask"
751-
? tool.isOutsideWorkspace
752-
? t("chat:directoryOperations.wantsToViewTopLevelOutsideWorkspace")
753-
: t("chat:directoryOperations.wantsToViewTopLevel")
754-
: tool.isOutsideWorkspace
755-
? t("chat:directoryOperations.didViewTopLevelOutsideWorkspace")
756-
: t("chat:directoryOperations.didViewTopLevel")}
757-
</span>
758-
</div>
759-
<div className="pl-6">
760-
<CodeAccordian
761-
path={tool.path}
762-
code={tool.content}
763-
language="shell-session"
764-
isExpanded={isExpanded}
765-
onToggleExpand={handleToggleExpand}
766-
/>
767-
</div>
768-
</>
769-
)
770-
case "listFilesRecursive":
771-
return (
772-
<>
773-
<div style={headerStyle}>
774-
<FolderTree className="w-4 shrink-0" aria-label="Folder tree icon" />
775-
<span style={{ fontWeight: "bold" }}>
776-
{message.type === "ask"
777-
? tool.isOutsideWorkspace
778-
? t("chat:directoryOperations.wantsToViewRecursiveOutsideWorkspace")
779-
: t("chat:directoryOperations.wantsToViewRecursive")
780-
: tool.isOutsideWorkspace
781-
? t("chat:directoryOperations.didViewRecursiveOutsideWorkspace")
782-
: t("chat:directoryOperations.didViewRecursive")}
783-
</span>
793+
<DirIcon className="w-4 shrink-0" aria-label={dirIconLabel} />
794+
<span style={{ fontWeight: "bold" }}>{t(labelKey)}</span>
784795
</div>
785796
<div className="pl-6">
786797
<CodeAccordian
@@ -793,6 +804,7 @@ export const ChatRowContent = ({
793804
</div>
794805
</>
795806
)
807+
}
796808
case "searchFiles":
797809
return (
798810
<>

0 commit comments

Comments
 (0)