Преглед изворни кода

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
Hannes Rudolph пре 3 дана
родитељ
комит
7afa43635f
27 измењених фајлова са 847 додато и 139 уклоњено
  1. 6 0
      packages/types/src/vscode-extension-host.ts
  2. 2 2
      webview-ui/src/components/chat/BatchDiffApproval.tsx
  3. 2 2
      webview-ui/src/components/chat/BatchFilePermission.tsx
  4. 45 0
      webview-ui/src/components/chat/BatchListFilesPermission.tsx
  5. 63 51
      webview-ui/src/components/chat/ChatRow.tsx
  6. 143 61
      webview-ui/src/components/chat/ChatView.tsx
  7. 103 0
      webview-ui/src/components/chat/__tests__/BatchListFilesPermission.spec.tsx
  8. 18 1
      webview-ui/src/i18n/locales/ca/chat.json
  9. 18 1
      webview-ui/src/i18n/locales/de/chat.json
  10. 17 0
      webview-ui/src/i18n/locales/en/chat.json
  11. 18 1
      webview-ui/src/i18n/locales/es/chat.json
  12. 18 1
      webview-ui/src/i18n/locales/fr/chat.json
  13. 18 1
      webview-ui/src/i18n/locales/hi/chat.json
  14. 18 1
      webview-ui/src/i18n/locales/id/chat.json
  15. 18 1
      webview-ui/src/i18n/locales/it/chat.json
  16. 18 1
      webview-ui/src/i18n/locales/ja/chat.json
  17. 18 1
      webview-ui/src/i18n/locales/ko/chat.json
  18. 20 3
      webview-ui/src/i18n/locales/nl/chat.json
  19. 20 3
      webview-ui/src/i18n/locales/pl/chat.json
  20. 18 1
      webview-ui/src/i18n/locales/pt-BR/chat.json
  21. 18 1
      webview-ui/src/i18n/locales/ru/chat.json
  22. 18 1
      webview-ui/src/i18n/locales/tr/chat.json
  23. 18 1
      webview-ui/src/i18n/locales/vi/chat.json
  24. 18 1
      webview-ui/src/i18n/locales/zh-CN/chat.json
  25. 20 3
      webview-ui/src/i18n/locales/zh-TW/chat.json
  26. 116 0
      webview-ui/src/utils/__tests__/batchConsecutive.spec.ts
  27. 38 0
      webview-ui/src/utils/batchConsecutive.ts

+ 6 - 0
packages/types/src/vscode-extension-host.ts

@@ -849,6 +849,12 @@ export interface ClineSayTool {
 			startLine?: number
 		}>
 	}>
+	batchDirs?: Array<{
+		path: string
+		recursive: boolean
+		isOutsideWorkspace?: boolean
+		key: string
+	}>
 	question?: string
 	imageData?: string // Base64 encoded image data for generated images
 	// Properties for runSlashCommand tool

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

@@ -35,12 +35,12 @@ export const BatchDiffApproval = memo(({ files = [], ts }: BatchDiffApprovalProp
 	return (
 		<div className="pt-[5px]">
 			<div className="flex flex-col gap-0 border border-border rounded-md p-1">
-				{files.map((file) => {
+				{files.map((file, index) => {
 					// Use backend-provided unified diff only. Stats also provided by backend.
 					const unified = file.content || ""
 
 					return (
-						<div key={`${file.path}-${ts}`}>
+						<div key={`${file.path}-${index}-${ts}`}>
 							<CodeAccordian
 								path={file.path}
 								code={unified}

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

@@ -29,9 +29,9 @@ export const BatchFilePermission = memo(({ files = [], onPermissionResponse, ts
 		<div className="pt-[5px]">
 			{/* Individual files */}
 			<div className="flex flex-col gap-0 border border-border rounded-md p-1">
-				{files.map((file) => {
+				{files.map((file, index) => {
 					return (
-						<div key={`${file.path}-${ts}`} className="flex items-center gap-2">
+						<div key={`${file.path}-${index}-${ts}`} className="flex items-center gap-2">
 							<ToolUseBlock className="flex-1">
 								<ToolUseBlockHeader
 									onClick={() => vscode.postMessage({ type: "openFile", text: file.content })}>

+ 45 - 0
webview-ui/src/components/chat/BatchListFilesPermission.tsx

@@ -0,0 +1,45 @@
+import { memo } from "react"
+
+import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock"
+import { PathTooltip } from "../ui/PathTooltip"
+
+interface DirPermissionItem {
+	path: string
+	key: string
+}
+
+interface BatchListFilesPermissionProps {
+	dirs: DirPermissionItem[]
+	ts: number
+}
+
+export const BatchListFilesPermission = memo(({ dirs = [], ts }: BatchListFilesPermissionProps) => {
+	if (!dirs?.length) {
+		return null
+	}
+
+	return (
+		<div className="pt-[5px]">
+			<div className="flex flex-col gap-0 border border-border rounded-md p-1">
+				{dirs.map((dir, index) => {
+					return (
+						<div key={`${dir.path}-${index}-${ts}`} className="flex items-center gap-2">
+							<ToolUseBlock className="flex-1">
+								<ToolUseBlockHeader>
+									<PathTooltip content={dir.path}>
+										<span className="whitespace-nowrap overflow-hidden text-ellipsis text-left mr-2 rtl">
+											{dir.path}
+										</span>
+									</PathTooltip>
+									<div className="flex-grow"></div>
+								</ToolUseBlockHeader>
+							</ToolUseBlock>
+						</div>
+					)
+				})}
+			</div>
+		</div>
+	)
+})
+
+BatchListFilesPermission.displayName = "BatchListFilesPermission"

+ 63 - 51
webview-ui/src/components/chat/ChatRow.tsx

@@ -40,6 +40,7 @@ import { Mention } from "./Mention"
 import { CheckpointSaved } from "./checkpoints/CheckpointSaved"
 import { FollowUpSuggest } from "./FollowUpSuggest"
 import { BatchFilePermission } from "./BatchFilePermission"
+import { BatchListFilesPermission } from "./BatchListFilesPermission"
 import { BatchDiffApproval } from "./BatchDiffApproval"
 import { ProgressIndicator } from "./ProgressIndicator"
 import { Markdown } from "./Markdown"
@@ -419,24 +420,22 @@ export const ChatRowContent = ({
 				style={{ color: "var(--vscode-foreground)", marginBottom: "-1.5px" }}></span>
 		)
 
+		// Handle batch diffs for any file-edit tool type
+		if (message.type === "ask" && tool.batchDiffs && Array.isArray(tool.batchDiffs)) {
+			return (
+				<>
+					<div style={headerStyle}>
+						<FileDiff className="w-4 shrink-0" aria-label="Batch diff icon" />
+						<span style={{ fontWeight: "bold" }}>{t("chat:fileOperations.wantsToApplyBatchChanges")}</span>
+					</div>
+					<BatchDiffApproval files={tool.batchDiffs} ts={message.ts} />
+				</>
+			)
+		}
+
 		switch (tool.tool as string) {
 			case "editedExistingFile":
 			case "appliedDiff":
-				// Check if this is a batch diff request
-				if (message.type === "ask" && tool.batchDiffs && Array.isArray(tool.batchDiffs)) {
-					return (
-						<>
-							<div style={headerStyle}>
-								<FileDiff className="w-4 shrink-0" aria-label="Batch diff icon" />
-								<span style={{ fontWeight: "bold" }}>
-									{t("chat:fileOperations.wantsToApplyBatchChanges")}
-								</span>
-							</div>
-							<BatchDiffApproval files={tool.batchDiffs} ts={message.ts} />
-						</>
-					)
-				}
-
 				// Regular single file diff
 				return (
 					<>
@@ -742,45 +741,57 @@ export const ChatRowContent = ({
 				)
 			}
 			case "listFilesTopLevel":
+			case "listFilesRecursive": {
+				const isRecursive = tool.tool === "listFilesRecursive"
+
+				// Check if this is a batch directory listing request
+				const isBatchDirRequest = message.type === "ask" && tool.batchDirs && Array.isArray(tool.batchDirs)
+
+				// When batching, check if all dirs share the same recursive value
+				const allTopLevel = tool.batchDirs?.every((d: { recursive: boolean }) => !d.recursive)
+				const DirIcon = isBatchDirRequest && !allTopLevel ? FolderTree : isRecursive ? FolderTree : ListTree
+				const dirIconLabel =
+					isBatchDirRequest && !allTopLevel
+						? "Folder tree icon"
+						: isRecursive
+							? "Folder tree icon"
+							: "List files icon"
+
+				if (isBatchDirRequest) {
+					return (
+						<>
+							<div style={headerStyle}>
+								<DirIcon className="w-4 shrink-0" aria-label={dirIconLabel} />
+								<span style={{ fontWeight: "bold" }}>
+									{t("chat:directoryOperations.wantsToViewMultipleDirectories")}
+								</span>
+							</div>
+							<BatchListFilesPermission dirs={tool.batchDirs || []} ts={message?.ts} />
+						</>
+					)
+				}
+
+				const labelKey = isRecursive
+					? message.type === "ask"
+						? tool.isOutsideWorkspace
+							? "chat:directoryOperations.wantsToViewRecursiveOutsideWorkspace"
+							: "chat:directoryOperations.wantsToViewRecursive"
+						: tool.isOutsideWorkspace
+							? "chat:directoryOperations.didViewRecursiveOutsideWorkspace"
+							: "chat:directoryOperations.didViewRecursive"
+					: message.type === "ask"
+						? tool.isOutsideWorkspace
+							? "chat:directoryOperations.wantsToViewTopLevelOutsideWorkspace"
+							: "chat:directoryOperations.wantsToViewTopLevel"
+						: tool.isOutsideWorkspace
+							? "chat:directoryOperations.didViewTopLevelOutsideWorkspace"
+							: "chat:directoryOperations.didViewTopLevel"
+
 				return (
 					<>
 						<div style={headerStyle}>
-							<ListTree className="w-4 shrink-0" aria-label="List files icon" />
-							<span style={{ fontWeight: "bold" }}>
-								{message.type === "ask"
-									? tool.isOutsideWorkspace
-										? t("chat:directoryOperations.wantsToViewTopLevelOutsideWorkspace")
-										: t("chat:directoryOperations.wantsToViewTopLevel")
-									: tool.isOutsideWorkspace
-										? t("chat:directoryOperations.didViewTopLevelOutsideWorkspace")
-										: t("chat:directoryOperations.didViewTopLevel")}
-							</span>
-						</div>
-						<div className="pl-6">
-							<CodeAccordian
-								path={tool.path}
-								code={tool.content}
-								language="shell-session"
-								isExpanded={isExpanded}
-								onToggleExpand={handleToggleExpand}
-							/>
-						</div>
-					</>
-				)
-			case "listFilesRecursive":
-				return (
-					<>
-						<div style={headerStyle}>
-							<FolderTree className="w-4 shrink-0" aria-label="Folder tree icon" />
-							<span style={{ fontWeight: "bold" }}>
-								{message.type === "ask"
-									? tool.isOutsideWorkspace
-										? t("chat:directoryOperations.wantsToViewRecursiveOutsideWorkspace")
-										: t("chat:directoryOperations.wantsToViewRecursive")
-									: tool.isOutsideWorkspace
-										? t("chat:directoryOperations.didViewRecursiveOutsideWorkspace")
-										: t("chat:directoryOperations.didViewRecursive")}
-							</span>
+							<DirIcon className="w-4 shrink-0" aria-label={dirIconLabel} />
+							<span style={{ fontWeight: "bold" }}>{t(labelKey)}</span>
 						</div>
 						<div className="pl-6">
 							<CodeAccordian
@@ -793,6 +804,7 @@ export const ChatRowContent = ({
 						</div>
 					</>
 				)
+			}
 			case "searchFiles":
 				return (
 					<>

+ 143 - 61
webview-ui/src/components/chat/ChatView.tsx

@@ -11,6 +11,7 @@ import { Trans } from "react-i18next"
 import { useDebounceEffect } from "@src/utils/useDebounceEffect"
 import { appendImages } from "@src/utils/imageUtils"
 import { getCostBreakdownIfNeeded } from "@src/utils/costFormatting"
+import { batchConsecutive } from "@src/utils/batchConsecutive"
 
 import type { ClineAsk, ClineSayTool, ClineMessage, ExtensionMessage, AudioType } from "@roo-code/types"
 
@@ -71,8 +72,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 	const isMountedRef = useRef(true)
 
 	const [audioBaseUri] = useState(() => {
-		const w = window as any
-		return w.AUDIO_BASE_URI || ""
+		return (window as unknown as { AUDIO_BASE_URI?: string }).AUDIO_BASE_URI || ""
 	})
 
 	const { t } = useAppTranslation()
@@ -311,6 +311,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 								case "editedExistingFile":
 								case "appliedDiff":
 								case "newFileCreated":
+									if (tool.batchDiffs && Array.isArray(tool.batchDiffs)) {
+										setPrimaryButtonText(t("chat:edit-batch.approve.title"))
+										setSecondaryButtonText(t("chat:edit-batch.deny.title"))
+									} else {
+										setPrimaryButtonText(t("chat:save.title"))
+										setSecondaryButtonText(t("chat:reject.title"))
+									}
+									break
 								case "generateImage":
 									setPrimaryButtonText(t("chat:save.title"))
 									setSecondaryButtonText(t("chat:reject.title"))
@@ -328,6 +336,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 										setSecondaryButtonText(t("chat:reject.title"))
 									}
 									break
+								case "listFilesTopLevel":
+								case "listFilesRecursive":
+									if (tool.batchDirs && Array.isArray(tool.batchDirs)) {
+										setPrimaryButtonText(t("chat:list-batch.approve.title"))
+										setSecondaryButtonText(t("chat:list-batch.deny.title"))
+									} else {
+										setPrimaryButtonText(t("chat:approve.title"))
+										setSecondaryButtonText(t("chat:reject.title"))
+									}
+									break
 								default:
 									setPrimaryButtonText(t("chat:approve.title"))
 									setSecondaryButtonText(t("chat:reject.title"))
@@ -957,10 +975,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 			if (
 				msg.say === "user_feedback" &&
 				msg.checkpoint &&
-				(msg.checkpoint as any).type === "user_message" &&
-				(msg.checkpoint as any).hash
+				msg.checkpoint["type"] === "user_message" &&
+				msg.checkpoint["hash"]
 			) {
-				userMessageCheckpointHashes.add((msg.checkpoint as any).hash)
+				userMessageCheckpointHashes.add(msg.checkpoint["hash"] as string)
 			}
 		})
 
@@ -1154,71 +1172,137 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 			}
 		}
 
-		// Consolidate consecutive read_file ask messages into batches
-		const result: ClineMessage[] = []
-		let i = 0
-		while (i < filtered.length) {
-			const msg = filtered[i]
-
-			// Check if this starts a sequence of read_file asks
-			if (isReadFileAsk(msg)) {
-				// Collect all consecutive read_file asks
-				const batch: ClineMessage[] = [msg]
-				let j = i + 1
-				while (j < filtered.length && isReadFileAsk(filtered[j])) {
-					batch.push(filtered[j])
-					j++
+		// Helper to check if a message is a list_files ask that should be batched
+		const isListFilesAsk = (msg: ClineMessage): boolean => {
+			if (msg.type !== "ask" || msg.ask !== "tool") return false
+			try {
+				const tool = JSON.parse(msg.text || "{}")
+				return (
+					(tool.tool === "listFilesTopLevel" || tool.tool === "listFilesRecursive") && !tool.batchDirs // Don't re-batch already batched
+				)
+			} catch {
+				return false
+			}
+		}
+
+		// Set of tool names that represent file-editing operations
+		const editFileTools = new Set([
+			"editedExistingFile",
+			"appliedDiff",
+			"newFileCreated",
+			"insertContent",
+			"searchAndReplace",
+		])
+
+		// Helper to check if a message is a file-edit ask that should be batched
+		const isEditFileAsk = (msg: ClineMessage): boolean => {
+			if (msg.type !== "ask" || msg.ask !== "tool") return false
+			try {
+				const tool = JSON.parse(msg.text || "{}")
+				return editFileTools.has(tool.tool) && !tool.batchDiffs // Don't re-batch already batched
+			} catch {
+				return false
+			}
+		}
+
+		// Synthesize a batch of consecutive read_file asks into a single message
+		const synthesizeReadFileBatch = (batch: ClineMessage[]): ClineMessage => {
+			const batchFiles = batch.map((batchMsg) => {
+				try {
+					const tool = JSON.parse(batchMsg.text || "{}")
+					return {
+						path: tool.path || "",
+						lineSnippet: tool.reason || "",
+						isOutsideWorkspace: tool.isOutsideWorkspace || false,
+						key: `${tool.path}${tool.reason ? ` (${tool.reason})` : ""}`,
+						content: tool.content || "",
+					}
+				} catch {
+					return { path: "", lineSnippet: "", key: "", content: "" }
 				}
+			})
 
-				if (batch.length > 1) {
-					// Create a synthetic batch message
-					const batchFiles = batch.map((batchMsg) => {
-						try {
-							const tool = JSON.parse(batchMsg.text || "{}")
-							return {
-								path: tool.path || "",
-								lineSnippet: tool.reason || "",
-								isOutsideWorkspace: tool.isOutsideWorkspace || false,
-								key: `${tool.path}${tool.reason ? ` (${tool.reason})` : ""}`,
-								content: tool.content || "",
-							}
-						} catch {
-							return { path: "", lineSnippet: "", key: "", content: "" }
-						}
-					})
-
-					// Use the first message as the base, but add batchFiles
-					const firstTool = JSON.parse(msg.text || "{}")
-					const syntheticMessage: ClineMessage = {
-						...msg,
-						text: JSON.stringify({
-							...firstTool,
-							batchFiles,
-						}),
-						// Store original messages for response handling
-						_batchedMessages: batch,
-					} as ClineMessage & { _batchedMessages: ClineMessage[] }
-
-					result.push(syntheticMessage)
-					i = j // Skip past all batched messages
-				} else {
-					// Single read_file ask, keep as-is
-					result.push(msg)
-					i++
+			let firstTool
+			try {
+				firstTool = JSON.parse(batch[0].text || "{}")
+			} catch {
+				return batch[0]
+			}
+			return {
+				...batch[0],
+				text: JSON.stringify({ ...firstTool, batchFiles }),
+			}
+		}
+
+		// Synthesize a batch of consecutive list_files asks into a single message
+		const synthesizeListFilesBatch = (batch: ClineMessage[]): ClineMessage => {
+			const batchDirs = batch.map((batchMsg) => {
+				try {
+					const tool = JSON.parse(batchMsg.text || "{}")
+					return {
+						path: tool.path || "",
+						recursive: tool.tool === "listFilesRecursive",
+						isOutsideWorkspace: tool.isOutsideWorkspace || false,
+						key: tool.path || "",
+					}
+				} catch {
+					return { path: "", recursive: false, key: "" }
 				}
-			} else {
-				result.push(msg)
-				i++
+			})
+
+			let firstTool
+			try {
+				firstTool = JSON.parse(batch[0].text || "{}")
+			} catch {
+				return batch[0]
+			}
+			return {
+				...batch[0],
+				text: JSON.stringify({ ...firstTool, batchDirs }),
+			}
+		}
+
+		// Synthesize a batch of consecutive file-edit asks into a single message
+		const synthesizeEditFileBatch = (batch: ClineMessage[]): ClineMessage => {
+			const batchDiffs = batch.map((batchMsg) => {
+				try {
+					const tool = JSON.parse(batchMsg.text || "{}")
+					return {
+						path: tool.path || "",
+						changeCount: 1,
+						key: tool.path || "",
+						content: tool.content || tool.diff || "",
+						diffStats: tool.diffStats,
+					}
+				} catch {
+					return { path: "", changeCount: 0, key: "", content: "" }
+				}
+			})
+
+			let firstTool
+			try {
+				firstTool = JSON.parse(batch[0].text || "{}")
+			} catch {
+				return batch[0]
+			}
+			return {
+				...batch[0],
+				text: JSON.stringify({ ...firstTool, batchDiffs }),
 			}
 		}
 
+		// Consolidate consecutive ask messages into batches
+		const readFileBatched = batchConsecutive(filtered, isReadFileAsk, synthesizeReadFileBatch)
+		const listFilesBatched = batchConsecutive(readFileBatched, isListFilesAsk, synthesizeListFilesBatch)
+		const result = batchConsecutive(listFilesBatched, isEditFileAsk, synthesizeEditFileBatch)
+
 		if (isCondensing) {
 			result.push({
 				type: "say",
 				say: "condense_context",
 				ts: Date.now(),
 				partial: true,
-			} as any)
+			} as ClineMessage)
 		}
 		return result
 	}, [isCondensing, visibleMessages, isBrowserSessionMessage])
@@ -1235,9 +1319,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 
 	useEffect(() => {
 		return () => {
-			if (scrollToBottomSmooth && typeof (scrollToBottomSmooth as any).cancel === "function") {
-				;(scrollToBottomSmooth as any).cancel()
-			}
+			scrollToBottomSmooth.clear()
 		}
 	}, [scrollToBottomSmooth])
 

+ 103 - 0
webview-ui/src/components/chat/__tests__/BatchListFilesPermission.spec.tsx

@@ -0,0 +1,103 @@
+import { render, screen } from "@/utils/test-utils"
+
+import { TranslationProvider } from "@/i18n/__mocks__/TranslationContext"
+
+import { BatchListFilesPermission } from "../BatchListFilesPermission"
+
+describe("BatchListFilesPermission", () => {
+	const mockDirs = [
+		{
+			key: "apps/cli",
+			path: "apps/cli",
+		},
+		{
+			key: "apps/web-roo-code",
+			path: "apps/web-roo-code",
+		},
+		{
+			key: "packages/core",
+			path: "packages/core",
+		},
+	]
+
+	beforeEach(() => {
+		vi.clearAllMocks()
+	})
+
+	it("renders directory list correctly", () => {
+		render(
+			<TranslationProvider>
+				<BatchListFilesPermission dirs={mockDirs} ts={Date.now()} />
+			</TranslationProvider>,
+		)
+
+		expect(screen.getByText("apps/cli")).toBeInTheDocument()
+		expect(screen.getByText("apps/web-roo-code")).toBeInTheDocument()
+		expect(screen.getByText("packages/core")).toBeInTheDocument()
+	})
+
+	it("renders nothing when dirs array is empty", () => {
+		const { container } = render(
+			<TranslationProvider>
+				<BatchListFilesPermission dirs={[]} ts={Date.now()} />
+			</TranslationProvider>,
+		)
+
+		expect(container.firstChild).toBeNull()
+	})
+
+	it("re-renders when timestamp changes", () => {
+		const { rerender } = render(
+			<TranslationProvider>
+				<BatchListFilesPermission dirs={mockDirs} ts={1000} />
+			</TranslationProvider>,
+		)
+
+		expect(screen.getByText("apps/cli")).toBeInTheDocument()
+
+		rerender(
+			<TranslationProvider>
+				<BatchListFilesPermission dirs={mockDirs} ts={2000} />
+			</TranslationProvider>,
+		)
+
+		expect(screen.getByText("apps/cli")).toBeInTheDocument()
+	})
+
+	it("renders all directories in a single container", () => {
+		render(
+			<TranslationProvider>
+				<BatchListFilesPermission dirs={mockDirs} ts={Date.now()} />
+			</TranslationProvider>,
+		)
+
+		// All directories should be within a single bordered container
+		const container = screen.getByText("apps/cli").closest(".border.border-border.rounded-md")
+		expect(container).toBeInTheDocument()
+
+		// All 3 dirs should be inside this container
+		expect(container?.querySelectorAll(".flex.items-center.gap-2")).toHaveLength(mockDirs.length)
+	})
+
+	it("renders a single directory", () => {
+		const singleDir = [
+			{
+				key: "apps/cli",
+				path: "apps/cli",
+			},
+		]
+
+		render(
+			<TranslationProvider>
+				<BatchListFilesPermission dirs={singleDir} ts={Date.now()} />
+			</TranslationProvider>,
+		)
+
+		expect(screen.getByText("apps/cli")).toBeInTheDocument()
+
+		// Single directory should still be rendered inside the container
+		const bordered = screen.getByText("apps/cli").closest(".border.border-border.rounded-md")
+		expect(bordered).toBeInTheDocument()
+		expect(bordered?.querySelectorAll(".flex.items-center.gap-2")).toHaveLength(1)
+	})
+})

+ 18 - 1
webview-ui/src/i18n/locales/ca/chat.json

@@ -220,7 +220,8 @@
 		"wantsToViewTopLevelOutsideWorkspace": "Roo vol veure els fitxers de nivell superior en aquest directori (fora de l'espai de treball)",
 		"didViewTopLevelOutsideWorkspace": "Roo ha vist els fitxers de nivell superior en aquest directori (fora de l'espai de treball)",
 		"wantsToViewRecursiveOutsideWorkspace": "Roo vol veure recursivament tots els fitxers en aquest directori (fora de l'espai de treball)",
-		"didViewRecursiveOutsideWorkspace": "Roo ha vist recursivament tots els fitxers en aquest directori (fora de l'espai de treball)"
+		"didViewRecursiveOutsideWorkspace": "Roo ha vist recursivament tots els fitxers en aquest directori (fora de l'espai de treball)",
+		"wantsToViewMultipleDirectories": "Roo vol veure diversos directoris"
 	},
 	"commandOutput": "Sortida de la comanda",
 	"commandExecution": {
@@ -441,6 +442,22 @@
 			"title": "Denegar tot"
 		}
 	},
+	"list-batch": {
+		"approve": {
+			"title": "Aprovar tot"
+		},
+		"deny": {
+			"title": "Denegar tot"
+		}
+	},
+	"edit-batch": {
+		"approve": {
+			"title": "Desar tot"
+		},
+		"deny": {
+			"title": "Denegar tot"
+		}
+	},
 	"indexingStatus": {
 		"ready": "Índex preparat",
 		"indexing": "Indexant {{percentage}}%",

+ 18 - 1
webview-ui/src/i18n/locales/de/chat.json

@@ -220,7 +220,8 @@
 		"wantsToViewTopLevelOutsideWorkspace": "Roo möchte die Dateien auf oberster Ebene in diesem Verzeichnis (außerhalb des Arbeitsbereichs) anzeigen",
 		"didViewTopLevelOutsideWorkspace": "Roo hat die Dateien auf oberster Ebene in diesem Verzeichnis (außerhalb des Arbeitsbereichs) angezeigt",
 		"wantsToViewRecursiveOutsideWorkspace": "Roo möchte rekursiv alle Dateien in diesem Verzeichnis (außerhalb des Arbeitsbereichs) anzeigen",
-		"didViewRecursiveOutsideWorkspace": "Roo hat rekursiv alle Dateien in diesem Verzeichnis (außerhalb des Arbeitsbereichs) angezeigt"
+		"didViewRecursiveOutsideWorkspace": "Roo hat rekursiv alle Dateien in diesem Verzeichnis (außerhalb des Arbeitsbereichs) angezeigt",
+		"wantsToViewMultipleDirectories": "Roo möchte mehrere Verzeichnisse anzeigen"
 	},
 	"commandOutput": "Befehlsausgabe",
 	"commandExecution": {
@@ -441,6 +442,22 @@
 			"title": "Alle ablehnen"
 		}
 	},
+	"list-batch": {
+		"approve": {
+			"title": "Alle genehmigen"
+		},
+		"deny": {
+			"title": "Alle ablehnen"
+		}
+	},
+	"edit-batch": {
+		"approve": {
+			"title": "Alle speichern"
+		},
+		"deny": {
+			"title": "Alle ablehnen"
+		}
+	},
 	"indexingStatus": {
 		"ready": "Index bereit",
 		"indexing": "Indizierung {{percentage}}%",

+ 17 - 0
webview-ui/src/i18n/locales/en/chat.json

@@ -74,6 +74,22 @@
 			"title": "Deny All"
 		}
 	},
+	"list-batch": {
+		"approve": {
+			"title": "Approve All"
+		},
+		"deny": {
+			"title": "Deny All"
+		}
+	},
+	"edit-batch": {
+		"approve": {
+			"title": "Save All"
+		},
+		"deny": {
+			"title": "Deny All"
+		}
+	},
 	"runCommand": {
 		"title": "Run",
 		"tooltip": "Execute this command"
@@ -237,6 +253,7 @@
 		"didViewRecursive": "Roo recursively viewed all files in this directory",
 		"wantsToViewRecursiveOutsideWorkspace": "Roo wants to recursively view all files in this directory (outside workspace)",
 		"didViewRecursiveOutsideWorkspace": "Roo recursively viewed all files in this directory (outside workspace)",
+		"wantsToViewMultipleDirectories": "Roo wants to view multiple directories",
 		"wantsToSearch": "Roo wants to search this directory for <code>{{regex}}</code>",
 		"didSearch": "Roo searched this directory for <code>{{regex}}</code>",
 		"wantsToSearchOutsideWorkspace": "Roo wants to search this directory (outside workspace) for <code>{{regex}}</code>",

+ 18 - 1
webview-ui/src/i18n/locales/es/chat.json

@@ -220,7 +220,8 @@
 		"wantsToViewTopLevelOutsideWorkspace": "Roo quiere ver los archivos de nivel superior en este directorio (fuera del espacio de trabajo)",
 		"didViewTopLevelOutsideWorkspace": "Roo vio los archivos de nivel superior en este directorio (fuera del espacio de trabajo)",
 		"wantsToViewRecursiveOutsideWorkspace": "Roo quiere ver recursivamente todos los archivos en este directorio (fuera del espacio de trabajo)",
-		"didViewRecursiveOutsideWorkspace": "Roo vio recursivamente todos los archivos en este directorio (fuera del espacio de trabajo)"
+		"didViewRecursiveOutsideWorkspace": "Roo vio recursivamente todos los archivos en este directorio (fuera del espacio de trabajo)",
+		"wantsToViewMultipleDirectories": "Roo quiere ver varios directorios"
 	},
 	"commandOutput": "Salida del comando",
 	"commandExecution": {
@@ -441,6 +442,22 @@
 			"title": "Denegar todo"
 		}
 	},
+	"list-batch": {
+		"approve": {
+			"title": "Aprobar todo"
+		},
+		"deny": {
+			"title": "Denegar todo"
+		}
+	},
+	"edit-batch": {
+		"approve": {
+			"title": "Guardar todo"
+		},
+		"deny": {
+			"title": "Denegar todo"
+		}
+	},
 	"indexingStatus": {
 		"ready": "Índice listo",
 		"indexing": "Indexando {{percentage}}%",

+ 18 - 1
webview-ui/src/i18n/locales/fr/chat.json

@@ -220,7 +220,8 @@
 		"wantsToViewTopLevelOutsideWorkspace": "Roo veut voir les fichiers de premier niveau dans ce répertoire (hors espace de travail)",
 		"didViewTopLevelOutsideWorkspace": "Roo a vu les fichiers de premier niveau dans ce répertoire (hors espace de travail)",
 		"wantsToViewRecursiveOutsideWorkspace": "Roo veut voir récursivement tous les fichiers dans ce répertoire (hors espace de travail)",
-		"didViewRecursiveOutsideWorkspace": "Roo a vu récursivement tous les fichiers dans ce répertoire (hors espace de travail)"
+		"didViewRecursiveOutsideWorkspace": "Roo a vu récursivement tous les fichiers dans ce répertoire (hors espace de travail)",
+		"wantsToViewMultipleDirectories": "Roo veut voir plusieurs répertoires"
 	},
 	"commandOutput": "Sortie de la Commande",
 	"commandExecution": {
@@ -441,6 +442,22 @@
 			"title": "Tout refuser"
 		}
 	},
+	"list-batch": {
+		"approve": {
+			"title": "Tout approuver"
+		},
+		"deny": {
+			"title": "Tout refuser"
+		}
+	},
+	"edit-batch": {
+		"approve": {
+			"title": "Tout enregistrer"
+		},
+		"deny": {
+			"title": "Tout refuser"
+		}
+	},
 	"indexingStatus": {
 		"ready": "Index prêt",
 		"indexing": "Indexation {{percentage}}%",

+ 18 - 1
webview-ui/src/i18n/locales/hi/chat.json

@@ -220,7 +220,8 @@
 		"wantsToViewTopLevelOutsideWorkspace": "Roo इस निर्देशिका (कार्यक्षेत्र के बाहर) में शीर्ष स्तर की फ़ाइलें देखना चाहता है",
 		"didViewTopLevelOutsideWorkspace": "Roo ने इस निर्देशिका (कार्यक्षेत्र के बाहर) में शीर्ष स्तर की फ़ाइलें देखीं",
 		"wantsToViewRecursiveOutsideWorkspace": "Roo इस निर्देशिका (कार्यक्षेत्र के बाहर) में सभी फ़ाइलों को पुनरावर्ती रूप से देखना चाहता है",
-		"didViewRecursiveOutsideWorkspace": "Roo ने इस निर्देशिका (कार्यक्षेत्र के बाहर) में सभी फ़ाइलों को पुनरावर्ती रूप से देखा"
+		"didViewRecursiveOutsideWorkspace": "Roo ने इस निर्देशिका (कार्यक्षेत्र के बाहर) में सभी फ़ाइलों को पुनरावर्ती रूप से देखा",
+		"wantsToViewMultipleDirectories": "Roo कई डायरेक्ट्रीज़ देखना चाहता है"
 	},
 	"commandOutput": "कमांड आउटपुट",
 	"commandExecution": {
@@ -441,6 +442,22 @@
 			"title": "सभी अस्वीकार करें"
 		}
 	},
+	"list-batch": {
+		"approve": {
+			"title": "सभी स्वीकृत करें"
+		},
+		"deny": {
+			"title": "सभी अस्वीकार करें"
+		}
+	},
+	"edit-batch": {
+		"approve": {
+			"title": "सभी सहेजें"
+		},
+		"deny": {
+			"title": "सभी अस्वीकार करें"
+		}
+	},
 	"indexingStatus": {
 		"ready": "इंडेक्स तैयार",
 		"indexing": "इंडेक्सिंग {{percentage}}%",

+ 18 - 1
webview-ui/src/i18n/locales/id/chat.json

@@ -80,6 +80,22 @@
 			"title": "Tolak Semua"
 		}
 	},
+	"list-batch": {
+		"approve": {
+			"title": "Setujui Semua"
+		},
+		"deny": {
+			"title": "Tolak Semua"
+		}
+	},
+	"edit-batch": {
+		"approve": {
+			"title": "Simpan Semua"
+		},
+		"deny": {
+			"title": "Tolak Semua"
+		}
+	},
 	"runCommand": {
 		"title": "Perintah",
 		"tooltip": "Jalankan perintah ini"
@@ -248,7 +264,8 @@
 		"wantsToViewTopLevelOutsideWorkspace": "Roo ingin melihat file tingkat atas di direktori ini (di luar workspace)",
 		"didViewTopLevelOutsideWorkspace": "Roo melihat file tingkat atas di direktori ini (di luar workspace)",
 		"wantsToViewRecursiveOutsideWorkspace": "Roo ingin melihat semua file secara rekursif di direktori ini (di luar workspace)",
-		"didViewRecursiveOutsideWorkspace": "Roo melihat semua file secara rekursif di direktori ini (di luar workspace)"
+		"didViewRecursiveOutsideWorkspace": "Roo melihat semua file secara rekursif di direktori ini (di luar workspace)",
+		"wantsToViewMultipleDirectories": "Roo ingin melihat beberapa direktori"
 	},
 	"codebaseSearch": {
 		"wantsToSearch": "Roo ingin mencari codebase untuk <code>{{query}}</code>",

+ 18 - 1
webview-ui/src/i18n/locales/it/chat.json

@@ -220,7 +220,8 @@
 		"wantsToViewTopLevelOutsideWorkspace": "Roo vuole visualizzare i file di primo livello in questa directory (fuori dall'area di lavoro)",
 		"didViewTopLevelOutsideWorkspace": "Roo ha visualizzato i file di primo livello in questa directory (fuori dall'area di lavoro)",
 		"wantsToViewRecursiveOutsideWorkspace": "Roo vuole visualizzare ricorsivamente tutti i file in questa directory (fuori dall'area di lavoro)",
-		"didViewRecursiveOutsideWorkspace": "Roo ha visualizzato ricorsivamente tutti i file in questa directory (fuori dall'area di lavoro)"
+		"didViewRecursiveOutsideWorkspace": "Roo ha visualizzato ricorsivamente tutti i file in questa directory (fuori dall'area di lavoro)",
+		"wantsToViewMultipleDirectories": "Roo vuole visualizzare più directory"
 	},
 	"commandOutput": "Output del Comando",
 	"commandExecution": {
@@ -441,6 +442,22 @@
 			"title": "Nega tutto"
 		}
 	},
+	"list-batch": {
+		"approve": {
+			"title": "Approva tutto"
+		},
+		"deny": {
+			"title": "Nega tutto"
+		}
+	},
+	"edit-batch": {
+		"approve": {
+			"title": "Salva tutto"
+		},
+		"deny": {
+			"title": "Nega tutto"
+		}
+	},
 	"indexingStatus": {
 		"ready": "Indice pronto",
 		"indexing": "Indicizzazione {{percentage}}%",

+ 18 - 1
webview-ui/src/i18n/locales/ja/chat.json

@@ -220,7 +220,8 @@
 		"wantsToViewTopLevelOutsideWorkspace": "Rooはこのディレクトリ(ワークスペース外)のトップレベルファイルを表示したい",
 		"didViewTopLevelOutsideWorkspace": "Rooはこのディレクトリ(ワークスペース外)のトップレベルファイルを表示しました",
 		"wantsToViewRecursiveOutsideWorkspace": "Rooはこのディレクトリ(ワークスペース外)のすべてのファイルを再帰的に表示したい",
-		"didViewRecursiveOutsideWorkspace": "Rooはこのディレクトリ(ワークスペース外)のすべてのファイルを再帰的に表示しました"
+		"didViewRecursiveOutsideWorkspace": "Rooはこのディレクトリ(ワークスペース外)のすべてのファイルを再帰的に表示しました",
+		"wantsToViewMultipleDirectories": "Roo は複数のディレクトリを表示したい"
 	},
 	"commandOutput": "コマンド出力",
 	"commandExecution": {
@@ -441,6 +442,22 @@
 			"title": "すべて拒否"
 		}
 	},
+	"list-batch": {
+		"approve": {
+			"title": "すべて承認"
+		},
+		"deny": {
+			"title": "すべて拒否"
+		}
+	},
+	"edit-batch": {
+		"approve": {
+			"title": "すべて保存"
+		},
+		"deny": {
+			"title": "すべて拒否"
+		}
+	},
 	"indexingStatus": {
 		"ready": "インデックス準備完了",
 		"indexing": "インデックス作成中 {{percentage}}%",

+ 18 - 1
webview-ui/src/i18n/locales/ko/chat.json

@@ -220,7 +220,8 @@
 		"wantsToViewTopLevelOutsideWorkspace": "Roo가 이 디렉토리(워크스페이스 외부)의 최상위 파일을 보고 싶어합니다",
 		"didViewTopLevelOutsideWorkspace": "Roo가 이 디렉토리(워크스페이스 외부)의 최상위 파일을 보았습니다",
 		"wantsToViewRecursiveOutsideWorkspace": "Roo가 이 디렉토리(워크스페이스 외부)의 모든 파일을 재귀적으로 보고 싶어합니다",
-		"didViewRecursiveOutsideWorkspace": "Roo가 이 디렉토리(워크스페이스 외부)의 모든 파일을 재귀적으로 보았습니다"
+		"didViewRecursiveOutsideWorkspace": "Roo가 이 디렉토리(워크스페이스 외부)의 모든 파일을 재귀적으로 보았습니다",
+		"wantsToViewMultipleDirectories": "Roo가 여러 디렉토리를 보려고 합니다"
 	},
 	"commandOutput": "명령 출력",
 	"commandExecution": {
@@ -441,6 +442,22 @@
 			"title": "모두 거부"
 		}
 	},
+	"list-batch": {
+		"approve": {
+			"title": "모두 승인"
+		},
+		"deny": {
+			"title": "모두 거부"
+		}
+	},
+	"edit-batch": {
+		"approve": {
+			"title": "모두 저장"
+		},
+		"deny": {
+			"title": "모두 거부"
+		}
+	},
 	"indexingStatus": {
 		"ready": "인덱스 준비됨",
 		"indexing": "인덱싱 중 {{percentage}}%",

+ 20 - 3
webview-ui/src/i18n/locales/nl/chat.json

@@ -147,14 +147,14 @@
 		"rateLimitWait": "Snelheidsbeperking",
 		"errorTitle": "Fout van provider {{code}}",
 		"errorMessage": {
-			"docs": "Documentatie",
-			"goToSettings": "Instellingen",
 			"400": "De provider kon het verzoek niet verwerken zoals ingediend. Stop de taak en probeer een ander benadering.",
 			"401": "Kon niet authenticeren met provider. Controleer je API-sleutelconfiguratie.",
 			"402": "Het lijkt erop dat je funds/credits op je account op zijn. Ga naar je provider en voeg meer toe om door te gaan.",
 			"403": "Niet geautoriseerd. Je API-sleutel is geldig, maar de provider weigerde dit verzoek in te willigen.",
 			"429": "Te veel verzoeken. Je bent rate-gelimiteerd door de provider. Wacht alsjeblieft even voor je volgende API-aanroep.",
 			"500": "Provider-serverfout. Er is iets mis aan de kant van de provider, er is niets mis met je verzoek.",
+			"docs": "Documentatie",
+			"goToSettings": "Instellingen",
 			"unknown": "Onbekende API-fout. Neem alsjeblieft contact op met Roo Code-ondersteuning.",
 			"connection": "Verbindingsfout. Zorg ervoor dat je een werkende internetverbinding hebt.",
 			"claudeCodeNotAuthenticated": "Je moet inloggen om Claude Code te gebruiken. Ga naar Instellingen en klik op \"Inloggen bij Claude Code\" om te authenticeren."
@@ -215,7 +215,8 @@
 		"wantsToViewTopLevelOutsideWorkspace": "Roo wil de bovenliggende bestanden in deze map (buiten werkruimte) bekijken",
 		"didViewTopLevelOutsideWorkspace": "Roo heeft de bovenliggende bestanden in deze map (buiten werkruimte) bekeken",
 		"wantsToViewRecursiveOutsideWorkspace": "Roo wil alle bestanden in deze map (buiten werkruimte) recursief bekijken",
-		"didViewRecursiveOutsideWorkspace": "Roo heeft alle bestanden in deze map (buiten werkruimte) recursief bekeken"
+		"didViewRecursiveOutsideWorkspace": "Roo heeft alle bestanden in deze map (buiten werkruimte) recursief bekeken",
+		"wantsToViewMultipleDirectories": "Roo wil meerdere mappen bekijken"
 	},
 	"commandOutput": "Commando-uitvoer",
 	"commandExecution": {
@@ -441,6 +442,22 @@
 			"title": "Alles weigeren"
 		}
 	},
+	"list-batch": {
+		"approve": {
+			"title": "Alles goedkeuren"
+		},
+		"deny": {
+			"title": "Alles weigeren"
+		}
+	},
+	"edit-batch": {
+		"approve": {
+			"title": "Alles opslaan"
+		},
+		"deny": {
+			"title": "Alles weigeren"
+		}
+	},
 	"indexingStatus": {
 		"ready": "Index gereed",
 		"indexing": "Indexeren {{percentage}}%",

+ 20 - 3
webview-ui/src/i18n/locales/pl/chat.json

@@ -152,14 +152,14 @@
 		"rateLimitWait": "Ograniczenie szybkości",
 		"errorTitle": "Błąd dostawcy {{code}}",
 		"errorMessage": {
-			"docs": "Dokumentacja",
-			"goToSettings": "Ustawienia",
 			"400": "Dostawca nie mógł przetworzyć żądania. Zatrzymaj zadanie i spróbuj innego podejścia.",
 			"401": "Nie można uwierzytelnić u dostawcy. Sprawdź konfigurację klucza API.",
 			"402": "Wygląda na to, że wyczerpałeś środki/kredyty na swoim koncie. Przejdź do dostawcy i dodaj więcej, aby kontynuować.",
 			"403": "Brak autoryzacji. Twój klucz API jest ważny, ale dostawca odmówił ukończenia tego żądania.",
 			"429": "Zbyt wiele żądań. Dostawca ogranicza Ci szybkość żądań. Poczekaj chwilę przed następnym wywołaniem API.",
 			"500": "Błąd serwera dostawcy. Po stronie dostawcy coś się nie powiodło, w Twoim żądaniu nie ma nic złego.",
+			"docs": "Dokumentacja",
+			"goToSettings": "Ustawienia",
 			"unknown": "Nieznany błąd API. Skontaktuj się z pomocą techniczną Roo Code.",
 			"connection": "Błąd połączenia. Upewnij się, że masz działające połączenie internetowe.",
 			"claudeCodeNotAuthenticated": "Musisz się zalogować, aby korzystać z Claude Code. Przejdź do Ustawień i kliknij \"Zaloguj się do Claude Code\", aby się uwierzytelnić."
@@ -220,7 +220,8 @@
 		"wantsToViewTopLevelOutsideWorkspace": "Roo chce zobaczyć pliki najwyższego poziomu w tym katalogu (poza obszarem roboczym)",
 		"didViewTopLevelOutsideWorkspace": "Roo zobaczył pliki najwyższego poziomu w tym katalogu (poza obszarem roboczym)",
 		"wantsToViewRecursiveOutsideWorkspace": "Roo chce rekurencyjnie zobaczyć wszystkie pliki w tym katalogu (poza obszarem roboczym)",
-		"didViewRecursiveOutsideWorkspace": "Roo rekurencyjnie zobaczył wszystkie pliki w tym katalogu (poza obszarem roboczym)"
+		"didViewRecursiveOutsideWorkspace": "Roo rekurencyjnie zobaczył wszystkie pliki w tym katalogu (poza obszarem roboczym)",
+		"wantsToViewMultipleDirectories": "Roo chce wyświetlić wiele katalogów"
 	},
 	"commandOutput": "Wyjście polecenia",
 	"commandExecution": {
@@ -441,6 +442,22 @@
 			"title": "Odrzuć wszystko"
 		}
 	},
+	"list-batch": {
+		"approve": {
+			"title": "Zatwierdź wszystko"
+		},
+		"deny": {
+			"title": "Odrzuć wszystko"
+		}
+	},
+	"edit-batch": {
+		"approve": {
+			"title": "Zapisz wszystko"
+		},
+		"deny": {
+			"title": "Odrzuć wszystko"
+		}
+	},
 	"indexingStatus": {
 		"ready": "Indeks gotowy",
 		"indexing": "Indeksowanie {{percentage}}%",

+ 18 - 1
webview-ui/src/i18n/locales/pt-BR/chat.json

@@ -220,7 +220,8 @@
 		"wantsToViewTopLevelOutsideWorkspace": "Roo quer visualizar os arquivos de nível superior neste diretório (fora do espaço de trabalho)",
 		"didViewTopLevelOutsideWorkspace": "Roo visualizou os arquivos de nível superior neste diretório (fora do espaço de trabalho)",
 		"wantsToViewRecursiveOutsideWorkspace": "Roo quer visualizar recursivamente todos os arquivos neste diretório (fora do espaço de trabalho)",
-		"didViewRecursiveOutsideWorkspace": "Roo visualizou recursivamente todos os arquivos neste diretório (fora do espaço de trabalho)"
+		"didViewRecursiveOutsideWorkspace": "Roo visualizou recursivamente todos os arquivos neste diretório (fora do espaço de trabalho)",
+		"wantsToViewMultipleDirectories": "Roo quer visualizar vários diretórios"
 	},
 	"commandOutput": "Saída do comando",
 	"commandExecution": {
@@ -441,6 +442,22 @@
 			"title": "Negar tudo"
 		}
 	},
+	"list-batch": {
+		"approve": {
+			"title": "Aprovar tudo"
+		},
+		"deny": {
+			"title": "Negar tudo"
+		}
+	},
+	"edit-batch": {
+		"approve": {
+			"title": "Salvar tudo"
+		},
+		"deny": {
+			"title": "Negar tudo"
+		}
+	},
 	"indexingStatus": {
 		"ready": "Índice pronto",
 		"indexing": "Indexando {{percentage}}%",

+ 18 - 1
webview-ui/src/i18n/locales/ru/chat.json

@@ -215,7 +215,8 @@
 		"wantsToViewTopLevelOutsideWorkspace": "Roo хочет просмотреть файлы верхнего уровня в этой директории (вне рабочего пространства)",
 		"didViewTopLevelOutsideWorkspace": "Roo просмотрел файлы верхнего уровня в этой директории (вне рабочего пространства)",
 		"wantsToViewRecursiveOutsideWorkspace": "Roo хочет рекурсивно просмотреть все файлы в этой директории (вне рабочего пространства)",
-		"didViewRecursiveOutsideWorkspace": "Roo рекурсивно просмотрел все файлы в этой директории (вне рабочего пространства)"
+		"didViewRecursiveOutsideWorkspace": "Roo рекурсивно просмотрел все файлы в этой директории (вне рабочего пространства)",
+		"wantsToViewMultipleDirectories": "Roo хочет просмотреть несколько директорий"
 	},
 	"commandOutput": "Вывод команды",
 	"commandExecution": {
@@ -442,6 +443,22 @@
 			"title": "Отклонить все"
 		}
 	},
+	"list-batch": {
+		"approve": {
+			"title": "Одобрить все"
+		},
+		"deny": {
+			"title": "Отклонить все"
+		}
+	},
+	"edit-batch": {
+		"approve": {
+			"title": "Сохранить все"
+		},
+		"deny": {
+			"title": "Отклонить все"
+		}
+	},
 	"indexingStatus": {
 		"ready": "Индекс готов",
 		"indexing": "Индексация {{percentage}}%",

+ 18 - 1
webview-ui/src/i18n/locales/tr/chat.json

@@ -220,7 +220,8 @@
 		"wantsToViewTopLevelOutsideWorkspace": "Roo bu dizindeki (çalışma alanı dışında) üst düzey dosyaları görüntülemek istiyor",
 		"didViewTopLevelOutsideWorkspace": "Roo bu dizindeki (çalışma alanı dışında) üst düzey dosyaları görüntüledi",
 		"wantsToViewRecursiveOutsideWorkspace": "Roo bu dizindeki (çalışma alanı dışında) tüm dosyaları özyinelemeli olarak görüntülemek istiyor",
-		"didViewRecursiveOutsideWorkspace": "Roo bu dizindeki (çalışma alanı dışında) tüm dosyaları özyinelemeli olarak görüntüledi"
+		"didViewRecursiveOutsideWorkspace": "Roo bu dizindeki (çalışma alanı dışında) tüm dosyaları özyinelemeli olarak görüntüledi",
+		"wantsToViewMultipleDirectories": "Roo birden fazla dizini görüntülemek istiyor"
 	},
 	"commandOutput": "Komut Çıktısı",
 	"commandExecution": {
@@ -442,6 +443,22 @@
 			"title": "Tümünü Reddet"
 		}
 	},
+	"list-batch": {
+		"approve": {
+			"title": "Tümünü Onayla"
+		},
+		"deny": {
+			"title": "Tümünü Reddet"
+		}
+	},
+	"edit-batch": {
+		"approve": {
+			"title": "Tümünü Kaydet"
+		},
+		"deny": {
+			"title": "Tümünü Reddet"
+		}
+	},
 	"indexingStatus": {
 		"ready": "İndeks hazır",
 		"indexing": "İndeksleniyor {{percentage}}%",

+ 18 - 1
webview-ui/src/i18n/locales/vi/chat.json

@@ -220,7 +220,8 @@
 		"wantsToViewTopLevelOutsideWorkspace": "Roo muốn xem các tệp cấp cao nhất trong thư mục này (ngoài không gian làm việc)",
 		"didViewTopLevelOutsideWorkspace": "Roo đã xem các tệp cấp cao nhất trong thư mục này (ngoài không gian làm việc)",
 		"wantsToViewRecursiveOutsideWorkspace": "Roo muốn xem đệ quy tất cả các tệp trong thư mục này (ngoài không gian làm việc)",
-		"didViewRecursiveOutsideWorkspace": "Roo đã xem đệ quy tất cả các tệp trong thư mục này (ngoài không gian làm việc)"
+		"didViewRecursiveOutsideWorkspace": "Roo đã xem đệ quy tất cả các tệp trong thư mục này (ngoài không gian làm việc)",
+		"wantsToViewMultipleDirectories": "Roo muốn xem nhiều thư mục"
 	},
 	"commandOutput": "Kết quả lệnh",
 	"commandExecution": {
@@ -442,6 +443,22 @@
 			"title": "Từ chối tất cả"
 		}
 	},
+	"list-batch": {
+		"approve": {
+			"title": "Chấp nhận tất cả"
+		},
+		"deny": {
+			"title": "Từ chối tất cả"
+		}
+	},
+	"edit-batch": {
+		"approve": {
+			"title": "Lưu tất cả"
+		},
+		"deny": {
+			"title": "Từ chối tất cả"
+		}
+	},
 	"indexingStatus": {
 		"ready": "Chỉ mục sẵn sàng",
 		"indexing": "Đang lập chỉ mục {{percentage}}%",

+ 18 - 1
webview-ui/src/i18n/locales/zh-CN/chat.json

@@ -220,7 +220,8 @@
 		"wantsToViewTopLevelOutsideWorkspace": "需要查看目录文件列表(工作区外)",
 		"didViewTopLevelOutsideWorkspace": "已查看目录文件列表(工作区外)",
 		"wantsToViewRecursiveOutsideWorkspace": "需要查看目录所有文件(工作区外)",
-		"didViewRecursiveOutsideWorkspace": "已查看目录所有文件(工作区外)"
+		"didViewRecursiveOutsideWorkspace": "已查看目录所有文件(工作区外)",
+		"wantsToViewMultipleDirectories": "Roo 想要查看多个目录"
 	},
 	"commandOutput": "命令输出",
 	"commandExecution": {
@@ -442,6 +443,22 @@
 			"title": "全部拒绝"
 		}
 	},
+	"list-batch": {
+		"approve": {
+			"title": "全部批准"
+		},
+		"deny": {
+			"title": "全部拒绝"
+		}
+	},
+	"edit-batch": {
+		"approve": {
+			"title": "全部保存"
+		},
+		"deny": {
+			"title": "全部拒绝"
+		}
+	},
 	"indexingStatus": {
 		"ready": "索引就绪",
 		"indexing": "索引中 {{percentage}}%",

+ 20 - 3
webview-ui/src/i18n/locales/zh-TW/chat.json

@@ -74,6 +74,22 @@
 			"title": "全部拒絕"
 		}
 	},
+	"list-batch": {
+		"approve": {
+			"title": "全部核准"
+		},
+		"deny": {
+			"title": "全部拒絕"
+		}
+	},
+	"edit-batch": {
+		"approve": {
+			"title": "全部儲存"
+		},
+		"deny": {
+			"title": "全部拒絕"
+		}
+	},
 	"runCommand": {
 		"title": "執行",
 		"tooltip": "執行此命令"
@@ -158,14 +174,14 @@
 		"rateLimitWait": "速率限制",
 		"errorTitle": "供應商錯誤 {{code}}",
 		"errorMessage": {
-			"docs": "說明文件",
-			"goToSettings": "設定",
 			"400": "供應商無法按照此方式處理請求。請停止工作並嘗試其他方法。",
 			"401": "無法向供應商進行身份驗證。請檢查您的 API 金鑰設定。",
 			"402": "您的帳戶資金/額度似乎已用盡。請前往供應商增加額度以繼續。",
 			"403": "無權存取。您的 API 金鑰有效,但供應商拒絕完成此請求。",
 			"429": "請求次數過多。供應商已對您的請求進行速率限制。請在下一次 API 呼叫前稍候。",
 			"500": "供應商伺服器錯誤。伺服器端發生問題,您的請求沒有問題。",
+			"docs": "說明文件",
+			"goToSettings": "設定",
 			"connection": "連線錯誤。請確保您有可用的網際網路連線。",
 			"unknown": "未知 API 錯誤。請聯絡 Roo Code 技術支援。",
 			"claudeCodeNotAuthenticated": "您需要登入才能使用 Claude Code。前往設定並點選「登入 Claude Code」以進行驗證。"
@@ -243,7 +259,8 @@
 		"wantsToSearch": "Roo 想要在此目錄中搜尋 <code>{{regex}}</code>",
 		"didSearch": "Roo 已在此目錄中搜尋 <code>{{regex}}</code>",
 		"wantsToSearchOutsideWorkspace": "Roo 想要在此目錄(工作區外)中搜尋 <code>{{regex}}</code>",
-		"didSearchOutsideWorkspace": "Roo 已在此目錄(工作區外)中搜尋 <code>{{regex}}</code>"
+		"didSearchOutsideWorkspace": "Roo 已在此目錄(工作區外)中搜尋 <code>{{regex}}</code>",
+		"wantsToViewMultipleDirectories": "Roo 想要查看多個目錄"
 	},
 	"codebaseSearch": {
 		"wantsToSearch": "Roo 想要在程式碼庫中搜尋 <code>{{query}}</code>",

+ 116 - 0
webview-ui/src/utils/__tests__/batchConsecutive.spec.ts

@@ -0,0 +1,116 @@
+import { batchConsecutive } from "../batchConsecutive"
+
+interface TestItem {
+	ts: number
+	type: string
+	text: string
+}
+
+/** Helper: create a minimal test item with an identifiable text field. */
+function msg(text: string, type = "say"): TestItem {
+	return { ts: Date.now(), type, text }
+}
+
+/** Predicate: matches items whose text starts with "match". */
+const isMatch = (m: TestItem) => !!m.text?.startsWith("match")
+
+/** Synthesize: merges a batch into a single item with a "BATCH:" marker. */
+const synthesizeBatch = (batch: TestItem[]): TestItem => ({
+	...batch[0],
+	text: `BATCH:${batch.map((m) => m.text).join(",")}`,
+})
+
+describe("batchConsecutive", () => {
+	test("empty input returns empty output", () => {
+		expect(batchConsecutive([], isMatch, synthesizeBatch)).toEqual([])
+	})
+
+	test("no matches returns passthrough", () => {
+		const messages = [msg("a"), msg("b"), msg("c")]
+		const result = batchConsecutive(messages, isMatch, synthesizeBatch)
+		expect(result).toEqual(messages)
+	})
+
+	test("single match is passed through without batching", () => {
+		const messages = [msg("a"), msg("match-1"), msg("b")]
+		const result = batchConsecutive(messages, isMatch, synthesizeBatch)
+		expect(result).toHaveLength(3)
+		expect(result[1].text).toBe("match-1")
+	})
+
+	test("two consecutive matches produce one synthetic message", () => {
+		const messages = [msg("a"), msg("match-1"), msg("match-2"), msg("b")]
+		const result = batchConsecutive(messages, isMatch, synthesizeBatch)
+		expect(result).toHaveLength(3)
+		expect(result[0].text).toBe("a")
+		expect(result[1].text).toBe("BATCH:match-1,match-2")
+		expect(result[2].text).toBe("b")
+	})
+
+	test("non-consecutive matches are not batched", () => {
+		const messages = [msg("match-1"), msg("other"), msg("match-2")]
+		const result = batchConsecutive(messages, isMatch, synthesizeBatch)
+		expect(result).toHaveLength(3)
+		expect(result[0].text).toBe("match-1")
+		expect(result[1].text).toBe("other")
+		expect(result[2].text).toBe("match-2")
+	})
+
+	test("mixed sequences are correctly interleaved", () => {
+		const messages = [
+			msg("match-1"),
+			msg("match-2"),
+			msg("match-3"),
+			msg("other-1"),
+			msg("match-4"),
+			msg("other-2"),
+			msg("match-5"),
+			msg("match-6"),
+		]
+		const result = batchConsecutive(messages, isMatch, synthesizeBatch)
+		expect(result).toHaveLength(5)
+		expect(result[0].text).toBe("BATCH:match-1,match-2,match-3")
+		expect(result[1].text).toBe("other-1")
+		expect(result[2].text).toBe("match-4") // single — not batched
+		expect(result[3].text).toBe("other-2")
+		expect(result[4].text).toBe("BATCH:match-5,match-6")
+	})
+
+	test("all items match → single synthetic message", () => {
+		const items = [msg("match-1"), msg("match-2"), msg("match-3")]
+		const result = batchConsecutive(items, isMatch, synthesizeBatch)
+		expect(result).toHaveLength(1)
+		expect(result[0].text).toBe("BATCH:match-1,match-2,match-3")
+	})
+
+	test("does not mutate the input array", () => {
+		const items = [msg("match-1"), msg("match-2")]
+		const original = [...items]
+		batchConsecutive(items, isMatch, synthesizeBatch)
+		expect(items).toHaveLength(2)
+		expect(items).toEqual(original)
+	})
+
+	test("returns a new array, not the same reference", () => {
+		const items = [msg("a"), msg("b")]
+		const result = batchConsecutive(items, isMatch, synthesizeBatch)
+		expect(result).not.toBe(items)
+	})
+
+	test("synthesize callback receives the correct batches", () => {
+		const spy = vi.fn(synthesizeBatch)
+		const items = [msg("match-1"), msg("match-2"), msg("other"), msg("match-3"), msg("match-4")]
+		batchConsecutive(items, isMatch, spy)
+		expect(spy).toHaveBeenCalledTimes(2)
+		expect(spy.mock.calls[0][0]).toHaveLength(2)
+		expect(spy.mock.calls[1][0]).toHaveLength(2)
+	})
+
+	test("batch at the end of the array", () => {
+		const items = [msg("other"), msg("match-1"), msg("match-2")]
+		const result = batchConsecutive(items, isMatch, synthesizeBatch)
+		expect(result).toHaveLength(2)
+		expect(result[0].text).toBe("other")
+		expect(result[1].text).toBe("BATCH:match-1,match-2")
+	})
+})

+ 38 - 0
webview-ui/src/utils/batchConsecutive.ts

@@ -0,0 +1,38 @@
+/**
+ * Walk an item array and batch runs of consecutive items that match
+ * `predicate` into synthetic items produced by `synthesize`.
+ *
+ * - Runs of length 1 are passed through unchanged.
+ * - Runs of length >= 2 are replaced by a single synthetic item.
+ * - Non-matching items are preserved in-order.
+ */
+export function batchConsecutive<T>(items: T[], predicate: (item: T) => boolean, synthesize: (batch: T[]) => T): T[] {
+	const result: T[] = []
+	let i = 0
+
+	while (i < items.length) {
+		if (predicate(items[i])) {
+			// Collect consecutive matches into a batch
+			const batch: T[] = [items[i]]
+			let j = i + 1
+
+			while (j < items.length && predicate(items[j])) {
+				batch.push(items[j])
+				j++
+			}
+
+			if (batch.length > 1) {
+				result.push(synthesize(batch))
+			} else {
+				result.push(batch[0])
+			}
+
+			i = j
+		} else {
+			result.push(items[i])
+			i++
+		}
+	}
+
+	return result
+}