diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index fa2f04c0e5..a0aa6f2e52 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -845,6 +845,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 diff --git a/webview-ui/src/components/chat/BatchListFilesPermission.tsx b/webview-ui/src/components/chat/BatchListFilesPermission.tsx new file mode 100644 index 0000000000..a1d909a524 --- /dev/null +++ b/webview-ui/src/components/chat/BatchListFilesPermission.tsx @@ -0,0 +1,47 @@ +import { memo } from "react" + +import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock" +import { PathTooltip } from "../ui/PathTooltip" + +interface DirPermissionItem { + path: string + recursive: boolean + isOutsideWorkspace?: boolean + key: string +} + +interface BatchListFilesPermissionProps { + dirs: DirPermissionItem[] + ts: number +} + +export const BatchListFilesPermission = memo(({ dirs = [], ts }: BatchListFilesPermissionProps) => { + if (!dirs?.length) { + return null + } + + return ( +
+
+ {dirs.map((dir) => { + return ( +
+ + + + + {dir.path} + + +
+
+
+
+ ) + })} +
+
+ ) +}) + +BatchListFilesPermission.displayName = "BatchListFilesPermission" diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 74639ff5ac..b01b3a2775 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/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" @@ -741,7 +742,24 @@ export const ChatRowContent = ({ ) } - case "listFilesTopLevel": + case "listFilesTopLevel": { + // Check if this is a batch directory listing request + const isBatchDirRequest = message.type === "ask" && tool.batchDirs && Array.isArray(tool.batchDirs) + + if (isBatchDirRequest) { + return ( + <> +
+ + + {t("chat:directoryOperations.wantsToViewMultipleDirectories")} + +
+ + + ) + } + return ( <>
@@ -767,7 +785,25 @@ export const ChatRowContent = ({
) - case "listFilesRecursive": + } + case "listFilesRecursive": { + // Check if this is a batch directory listing request + const isBatchDirRequest = message.type === "ask" && tool.batchDirs && Array.isArray(tool.batchDirs) + + if (isBatchDirRequest) { + return ( + <> +
+ + + {t("chat:directoryOperations.wantsToViewMultipleDirectories")} + +
+ + + ) + } + return ( <>
@@ -793,6 +829,7 @@ export const ChatRowContent = ({
) + } case "searchFiles": return ( <> diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 5c377eb1f5..9920ce15a9 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1154,8 +1154,21 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + 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 + } + } + // Consolidate consecutive read_file ask messages into batches - const result: ClineMessage[] = [] + const readFileBatched: ClineMessage[] = [] let i = 0 while (i < filtered.length) { const msg = filtered[i] @@ -1199,10 +1212,67 @@ const ChatViewComponent: React.ForwardRefRenderFunction 1) { + // Create a synthetic batch message + 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: "" } + } + }) + + // Use the first message as the base, but add batchDirs + const firstTool = JSON.parse(msg.text || "{}") + const syntheticMessage: ClineMessage = { + ...msg, + text: JSON.stringify({ + ...firstTool, + batchDirs, + }), + // Store original messages for response handling + _batchedMessages: batch, + } as ClineMessage & { _batchedMessages: ClineMessage[] } + + result.push(syntheticMessage) + i = j // Skip past all batched messages + } else { + // Single list_files ask, keep as-is result.push(msg) i++ } diff --git a/webview-ui/src/components/chat/__tests__/BatchListFilesPermission.spec.tsx b/webview-ui/src/components/chat/__tests__/BatchListFilesPermission.spec.tsx new file mode 100644 index 0000000000..d26205e3fc --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/BatchListFilesPermission.spec.tsx @@ -0,0 +1,87 @@ +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", + recursive: false, + isOutsideWorkspace: false, + }, + { + key: "apps/web-roo-code", + path: "apps/web-roo-code", + recursive: false, + isOutsideWorkspace: false, + }, + { + key: "packages/core", + path: "packages/core", + recursive: true, + isOutsideWorkspace: false, + }, + ] + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders directory list correctly", () => { + render( + + + , + ) + + 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( + + + , + ) + + expect(container.firstChild).toBeNull() + }) + + it("re-renders when timestamp changes", () => { + const { rerender } = render( + + + , + ) + + expect(screen.getByText("apps/cli")).toBeInTheDocument() + + rerender( + + + , + ) + + expect(screen.getByText("apps/cli")).toBeInTheDocument() + }) + + it("renders all directories in a single container", () => { + render( + + + , + ) + + // 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) + }) +}) diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index b9652cfce5..b60ad59620 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -235,6 +235,8 @@ "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", + "didViewMultipleDirectories": "Roo viewed multiple directories", "wantsToSearch": "Roo wants to search this directory for {{regex}}", "didSearch": "Roo searched this directory for {{regex}}", "wantsToSearchOutsideWorkspace": "Roo wants to search this directory (outside workspace) for {{regex}}",