Просмотр исходного кода

Add file changes panel per conversation (#11494)

* Add file changes panel per conversation

Closes #11493

* Add unit tests for file changes and consolidate specs in src/__tests__

Closes #11493

* fix(chat): only show approved file diffs in conversation panel
Chiranjeevisantosh Madugundi 1 день назад
Родитель
Сommit
d7359ff228
27 измененных файлов с 710 добавлено и 99 удалено
  1. 15 0
      src/core/task/Task.ts
  2. 1 1
      webview-ui/src/__tests__/App.spec.tsx
  3. 175 0
      webview-ui/src/__tests__/FileChangesPanel.spec.tsx
  4. 280 0
      webview-ui/src/__tests__/fileChangesFromMessages.spec.ts
  5. 0 97
      webview-ui/src/components/__tests__/ErrorBoundary.spec.tsx
  6. 2 0
      webview-ui/src/components/chat/ChatView.tsx
  7. 118 0
      webview-ui/src/components/chat/FileChangesPanel.tsx
  8. 64 0
      webview-ui/src/components/chat/utils/fileChangesFromMessages.ts
  9. 1 1
      webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx
  10. 3 0
      webview-ui/src/i18n/locales/ca/chat.json
  11. 3 0
      webview-ui/src/i18n/locales/de/chat.json
  12. 3 0
      webview-ui/src/i18n/locales/en/chat.json
  13. 3 0
      webview-ui/src/i18n/locales/es/chat.json
  14. 3 0
      webview-ui/src/i18n/locales/fr/chat.json
  15. 3 0
      webview-ui/src/i18n/locales/hi/chat.json
  16. 3 0
      webview-ui/src/i18n/locales/id/chat.json
  17. 3 0
      webview-ui/src/i18n/locales/it/chat.json
  18. 3 0
      webview-ui/src/i18n/locales/ja/chat.json
  19. 3 0
      webview-ui/src/i18n/locales/ko/chat.json
  20. 3 0
      webview-ui/src/i18n/locales/nl/chat.json
  21. 3 0
      webview-ui/src/i18n/locales/pl/chat.json
  22. 3 0
      webview-ui/src/i18n/locales/pt-BR/chat.json
  23. 3 0
      webview-ui/src/i18n/locales/ru/chat.json
  24. 3 0
      webview-ui/src/i18n/locales/tr/chat.json
  25. 3 0
      webview-ui/src/i18n/locales/vi/chat.json
  26. 3 0
      webview-ui/src/i18n/locales/zh-CN/chat.json
  27. 3 0
      webview-ui/src/i18n/locales/zh-TW/chat.json

+ 15 - 0
src/core/task/Task.ts

@@ -1532,6 +1532,21 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 				})
 			}
 		}
+
+		// Mark the last tool-approval ask as answered when user approves (or auto-approval)
+		if (askResponse === "yesButtonClicked") {
+			const lastToolAskIndex = findLastIndex(
+				this.clineMessages,
+				(msg) => msg.type === "ask" && msg.ask === "tool" && !msg.isAnswered,
+			)
+			if (lastToolAskIndex !== -1) {
+				this.clineMessages[lastToolAskIndex].isAnswered = true
+				void this.updateClineMessage(this.clineMessages[lastToolAskIndex])
+				this.saveClineMessages().catch((error) => {
+					console.error("Failed to save answered tool-ask state:", error)
+				})
+			}
+		}
 	}
 
 	/**

+ 1 - 1
webview-ui/src/__tests__/App.spec.tsx

@@ -193,7 +193,7 @@ describe("App", () => {
 		const chatView = screen.getByTestId("chat-view")
 		expect(chatView).toBeInTheDocument()
 		expect(chatView.getAttribute("data-hidden")).toBe("false")
-	})
+	}, 10000)
 
 	it("switches to settings view when receiving settingsButtonClicked action", async () => {
 		render(<AppWithProviders />)

+ 175 - 0
webview-ui/src/__tests__/FileChangesPanel.spec.tsx

@@ -0,0 +1,175 @@
+import React from "react"
+import { fireEvent, render, screen } from "@/utils/test-utils"
+import type { ClineMessage } from "@roo-code/types"
+import { TranslationProvider } from "@/i18n/__mocks__/TranslationContext"
+import FileChangesPanel from "../components/chat/FileChangesPanel"
+
+const mockPostMessage = vi.fn()
+
+vi.mock("@src/utils/vscode", () => ({
+	vscode: {
+		postMessage: (...args: unknown[]) => mockPostMessage(...args),
+	},
+}))
+
+// Mock i18n to return readable header with count
+vi.mock("react-i18next", () => ({
+	useTranslation: () => ({
+		t: (key: string, opts?: { count?: number }) => {
+			if (key === "chat:fileChangesInConversation.header" && opts?.count != null) {
+				return `${opts.count} file(s) changed in this conversation`
+			}
+			return key
+		},
+	}),
+}))
+
+// Lightweight mock so we don't pull in CodeBlock/DiffView
+vi.mock("@src/components/common/CodeAccordian", () => ({
+	default: ({
+		path,
+		isExpanded,
+		onToggleExpand,
+	}: {
+		path?: string
+		isExpanded: boolean
+		onToggleExpand: () => void
+	}) => (
+		<div data-testid="code-accordian">
+			<span data-testid="accordian-path">{path}</span>
+			<button type="button" onClick={onToggleExpand} data-testid="accordian-toggle">
+				{isExpanded ? "expanded" : "collapsed"}
+			</button>
+		</div>
+	),
+}))
+
+function createFileEditMessage(path: string, diff: string): ClineMessage {
+	return {
+		type: "ask",
+		ask: "tool",
+		ts: Date.now(),
+		partial: false,
+		isAnswered: true,
+		text: JSON.stringify({
+			tool: "appliedDiff",
+			path,
+			diff,
+		}),
+	}
+}
+
+function renderPanel(messages: ClineMessage[] | undefined) {
+	return render(
+		<TranslationProvider>
+			<FileChangesPanel clineMessages={messages} />
+		</TranslationProvider>,
+	)
+}
+
+describe("FileChangesPanel", () => {
+	beforeEach(() => {
+		vi.clearAllMocks()
+	})
+
+	it("renders nothing when clineMessages is undefined", () => {
+		const { container } = renderPanel(undefined)
+		expect(container.firstChild).toBeNull()
+	})
+
+	it("renders nothing when clineMessages is empty", () => {
+		const { container } = renderPanel([])
+		expect(container.firstChild).toBeNull()
+	})
+
+	it("renders nothing when there are no file-edit messages", () => {
+		const messages: ClineMessage[] = [
+			{
+				type: "say",
+				say: "text",
+				ts: Date.now(),
+				partial: false,
+				text: "hello",
+			},
+			{
+				type: "ask",
+				ask: "tool",
+				ts: Date.now(),
+				partial: false,
+				text: JSON.stringify({ tool: "read_file", path: "x.ts" }),
+			},
+		]
+		const { container } = renderPanel(messages)
+		expect(container.firstChild).toBeNull()
+	})
+
+	it("renders nothing when file-edit ask tool is not approved (isAnswered false or missing)", () => {
+		const messages: ClineMessage[] = [
+			{
+				type: "ask",
+				ask: "tool",
+				ts: Date.now(),
+				partial: false,
+				text: JSON.stringify({
+					tool: "appliedDiff",
+					path: "src/foo.ts",
+					diff: "+line",
+				}),
+			},
+		]
+		const { container } = renderPanel(messages)
+		expect(container.firstChild).toBeNull()
+	})
+
+	it("renders panel with header when there is one file edit", () => {
+		const messages = [createFileEditMessage("src/foo.ts", "@@ -1 +1 @@\n+line")]
+		renderPanel(messages)
+
+		expect(screen.getByText("1 file(s) changed in this conversation")).toBeInTheDocument()
+		// Expand panel so file row is in DOM (CollapsibleContent may not render when closed in some setups)
+		fireEvent.click(screen.getByText("1 file(s) changed in this conversation").closest("button")!)
+		expect(screen.getByTestId("accordian-path")).toHaveTextContent("src/foo.ts")
+	})
+
+	it("renders one row per unique path when multiple files edited", () => {
+		const messages = [createFileEditMessage("src/a.ts", "diff a"), createFileEditMessage("src/b.ts", "diff b")]
+		renderPanel(messages)
+
+		expect(screen.getByText("2 file(s) changed in this conversation")).toBeInTheDocument()
+		// Expand panel so file rows are rendered
+		fireEvent.click(screen.getByText("2 file(s) changed in this conversation").closest("button")!)
+		const paths = screen.getAllByTestId("accordian-path")
+		expect(paths).toHaveLength(2)
+		expect(paths.map((el) => el.textContent)).toEqual(expect.arrayContaining(["src/a.ts", "src/b.ts"]))
+	})
+
+	it("collapsed by default: panel trigger shows chevron and expanding reveals file rows", () => {
+		const messages = [createFileEditMessage("src/foo.ts", "diff")]
+		renderPanel(messages)
+
+		// Header visible
+		const headerText = screen.getByText("1 file(s) changed in this conversation")
+		expect(headerText).toBeInTheDocument()
+		// Trigger is the button that contains the header text
+		const trigger = headerText.closest("button")
+		expect(trigger).toBeInTheDocument()
+
+		// Expand panel
+		fireEvent.click(trigger!)
+		expect(screen.getByTestId("accordian-path")).toHaveTextContent("src/foo.ts")
+	})
+
+	it("toggling a file row expand calls onToggleExpand", () => {
+		const messages = [createFileEditMessage("src/foo.ts", "diff")]
+		renderPanel(messages)
+
+		// Expand panel first so the file row is rendered
+		const headerText = screen.getByText("1 file(s) changed in this conversation")
+		fireEvent.click(headerText.closest("button")!)
+
+		const accordianToggle = screen.getByTestId("accordian-toggle")
+		expect(accordianToggle).toHaveTextContent("collapsed")
+		fireEvent.click(accordianToggle)
+		expect(accordianToggle).toHaveTextContent("expanded")
+	})
+})

+ 280 - 0
webview-ui/src/__tests__/fileChangesFromMessages.spec.ts

@@ -0,0 +1,280 @@
+import type { ClineMessage } from "@roo-code/types"
+import { fileChangesFromMessages } from "../components/chat/utils/fileChangesFromMessages"
+
+function msg(overrides: Partial<ClineMessage> & { text: string }): ClineMessage {
+	return {
+		type: "say",
+		say: "tool",
+		ts: Date.now(),
+		partial: false,
+		...overrides,
+	}
+}
+
+describe("fileChangesFromMessages", () => {
+	it("returns empty array for undefined messages", () => {
+		expect(fileChangesFromMessages(undefined)).toEqual([])
+	})
+
+	it("returns empty array for empty messages", () => {
+		expect(fileChangesFromMessages([])).toEqual([])
+	})
+
+	it("ignores non-tool messages", () => {
+		const messages: ClineMessage[] = [
+			msg({ type: "say", say: "text", text: "hello" }),
+			msg({ type: "ask", ask: "followup", text: "world" }),
+		]
+		expect(fileChangesFromMessages(messages)).toEqual([])
+	})
+
+	it("ignores tool messages with non-file-edit tool type", () => {
+		const messages: ClineMessage[] = [
+			msg({
+				type: "ask",
+				ask: "tool",
+				text: JSON.stringify({ tool: "read_file", path: "a.ts" }),
+			}),
+		]
+		expect(fileChangesFromMessages(messages)).toEqual([])
+	})
+
+	it("skips partial messages", () => {
+		const messages: ClineMessage[] = [
+			msg({
+				type: "ask",
+				ask: "tool",
+				partial: true,
+				text: JSON.stringify({
+					tool: "appliedDiff",
+					path: "src/file.ts",
+					diff: "+x",
+				}),
+			}),
+		]
+		expect(fileChangesFromMessages(messages)).toEqual([])
+	})
+
+	it("excludes ask tool file-edit when isAnswered is false or undefined", () => {
+		const payload = JSON.stringify({
+			tool: "appliedDiff",
+			path: "src/foo.ts",
+			diff: "+line",
+		})
+		expect(fileChangesFromMessages([msg({ type: "ask", ask: "tool", text: payload, isAnswered: false })])).toEqual(
+			[],
+		)
+		expect(fileChangesFromMessages([msg({ type: "ask", ask: "tool", text: payload })])).toEqual([])
+	})
+
+	it("includes ask tool file-edit when isAnswered is true", () => {
+		const messages: ClineMessage[] = [
+			msg({
+				type: "ask",
+				ask: "tool",
+				isAnswered: true,
+				text: JSON.stringify({
+					tool: "appliedDiff",
+					path: "src/foo.ts",
+					diff: "+line",
+				}),
+			}),
+		]
+		const result = fileChangesFromMessages(messages)
+		expect(result).toHaveLength(1)
+		expect(result[0].path).toBe("src/foo.ts")
+	})
+
+	it("extracts single-file edit from ask tool message", () => {
+		const messages: ClineMessage[] = [
+			msg({
+				type: "ask",
+				ask: "tool",
+				isAnswered: true,
+				text: JSON.stringify({
+					tool: "appliedDiff",
+					path: "src/foo.ts",
+					diff: "@@ -1 +1 @@\n+line",
+					diffStats: { added: 1, removed: 0 },
+				}),
+			}),
+		]
+		const result = fileChangesFromMessages(messages)
+		expect(result).toHaveLength(1)
+		expect(result[0]).toEqual({
+			path: "src/foo.ts",
+			diff: "@@ -1 +1 @@\n+line",
+			diffStats: { added: 1, removed: 0 },
+		})
+	})
+
+	it("extracts single-file edit from say tool message", () => {
+		const messages: ClineMessage[] = [
+			msg({
+				type: "say",
+				say: "tool",
+				text: JSON.stringify({
+					tool: "editedExistingFile",
+					path: "lib/bar.ts",
+					diff: "-old\n+new",
+				}),
+			}),
+		]
+		const result = fileChangesFromMessages(messages)
+		expect(result).toHaveLength(1)
+		expect(result[0].path).toBe("lib/bar.ts")
+		expect(result[0].diff).toBe("-old\n+new")
+	})
+
+	it("uses content when diff is missing for single-file", () => {
+		const messages: ClineMessage[] = [
+			msg({
+				type: "ask",
+				ask: "tool",
+				isAnswered: true,
+				text: JSON.stringify({
+					tool: "newFileCreated",
+					path: "new.ts",
+					content: "full file content",
+				}),
+			}),
+		]
+		const result = fileChangesFromMessages(messages)
+		expect(result).toHaveLength(1)
+		expect(result[0].diff).toBe("full file content")
+	})
+
+	it("ignores single-file tool when path is missing", () => {
+		const messages: ClineMessage[] = [
+			msg({
+				type: "ask",
+				ask: "tool",
+				text: JSON.stringify({
+					tool: "appliedDiff",
+					diff: "something",
+				}),
+			}),
+		]
+		expect(fileChangesFromMessages(messages)).toEqual([])
+	})
+
+	it("ignores single-file tool when diff and content are empty", () => {
+		const messages: ClineMessage[] = [
+			msg({
+				type: "ask",
+				ask: "tool",
+				text: JSON.stringify({
+					tool: "appliedDiff",
+					path: "x.ts",
+				}),
+			}),
+		]
+		expect(fileChangesFromMessages(messages)).toEqual([])
+	})
+
+	it("extracts from batchDiffs", () => {
+		const messages: ClineMessage[] = [
+			msg({
+				type: "ask",
+				ask: "tool",
+				isAnswered: true,
+				text: JSON.stringify({
+					tool: "appliedDiff",
+					batchDiffs: [
+						{ path: "a.ts", content: "content a" },
+						{ path: "b.ts", diffs: [{ content: "content b" }] },
+						{ path: "c.ts" }, // no content
+					],
+				}),
+			}),
+		]
+		const result = fileChangesFromMessages(messages)
+		expect(result).toHaveLength(2)
+		expect(result[0]).toEqual({ path: "a.ts", diff: "content a" })
+		expect(result[1].path).toBe("b.ts")
+		expect(result[1].diff).toBe("content b")
+	})
+
+	it("includes diffStats from batchDiffs when present", () => {
+		const messages: ClineMessage[] = [
+			msg({
+				type: "ask",
+				ask: "tool",
+				isAnswered: true,
+				text: JSON.stringify({
+					tool: "appliedDiff",
+					batchDiffs: [
+						{
+							path: "f.ts",
+							content: "x",
+							diffStats: { added: 2, removed: 1 },
+						},
+					],
+				}),
+			}),
+		]
+		const result = fileChangesFromMessages(messages)
+		expect(result[0].diffStats).toEqual({ added: 2, removed: 1 })
+	})
+
+	it("recognizes all ClineSayTool file-edit tool names (editedExistingFile, appliedDiff, newFileCreated)", () => {
+		const tools = ["editedExistingFile", "appliedDiff", "newFileCreated"]
+		for (const tool of tools) {
+			const messages: ClineMessage[] = [
+				msg({
+					type: "ask",
+					ask: "tool",
+					isAnswered: true,
+					text: JSON.stringify({
+						tool,
+						path: "f.ts",
+						diff: "d",
+					}),
+				}),
+			]
+			const result = fileChangesFromMessages(messages)
+			expect(result).toHaveLength(1)
+			expect(result[0].path).toBe("f.ts")
+		}
+	})
+
+	it("returns multiple entries for multiple file-edit messages", () => {
+		const messages: ClineMessage[] = [
+			msg({
+				type: "ask",
+				ask: "tool",
+				isAnswered: true,
+				text: JSON.stringify({
+					tool: "appliedDiff",
+					path: "first.ts",
+					diff: "a",
+				}),
+			}),
+			msg({
+				type: "ask",
+				ask: "tool",
+				isAnswered: true,
+				text: JSON.stringify({
+					tool: "editedExistingFile",
+					path: "second.ts",
+					diff: "b",
+				}),
+			}),
+		]
+		const result = fileChangesFromMessages(messages)
+		expect(result).toHaveLength(2)
+		expect(result[0].path).toBe("first.ts")
+		expect(result[1].path).toBe("second.ts")
+	})
+
+	it("skips invalid JSON in message text", () => {
+		const messages: ClineMessage[] = [
+			msg({
+				type: "ask",
+				ask: "tool",
+				text: "not json",
+			}),
+		]
+		expect(fileChangesFromMessages(messages)).toEqual([])
+	})
+})

+ 0 - 97
webview-ui/src/components/__tests__/ErrorBoundary.spec.tsx

@@ -1,97 +0,0 @@
-import React from "react"
-import { render, screen } from "@testing-library/react"
-
-import ErrorBoundary from "../ErrorBoundary"
-
-// Mock telemetryClient
-vi.mock("@src/utils/TelemetryClient", () => ({
-	telemetryClient: {
-		capture: vi.fn(),
-	},
-}))
-
-// Mock translation
-vi.mock("react-i18next", () => ({
-	withTranslation: () => (Component: any) => {
-		Component.defaultProps = {
-			...Component.defaultProps,
-			t: (key: string) => {
-				// Mock translations for tests
-				const translations: Record<string, string> = {
-					"errorBoundary.title": "Something went wrong",
-					"errorBoundary.reportText": "Please help us improve by reporting this error on",
-					"errorBoundary.githubText": "GitHub",
-					"errorBoundary.copyInstructions": "Please copy and paste the following error message:",
-				}
-				return translations[key] || key
-			},
-		}
-		return Component
-	},
-}))
-
-// Test component that throws an error
-const ErrorThrowingComponent = ({ shouldThrow = false }) => {
-	if (shouldThrow) {
-		throw new Error("Test error")
-	}
-	return <div data-testid="normal-render">Content rendered normally</div>
-}
-
-describe("ErrorBoundary", () => {
-	// Suppress console errors during tests
-	const originalConsoleError = console.error
-	beforeAll(() => {
-		console.error = vi.fn()
-	})
-	afterAll(() => {
-		console.error = originalConsoleError
-	})
-
-	test("renders children when no error occurs", () => {
-		render(
-			<ErrorBoundary>
-				<ErrorThrowingComponent shouldThrow={false} />
-			</ErrorBoundary>,
-		)
-
-		expect(screen.getByTestId("normal-render")).toBeInTheDocument()
-	})
-
-	test("renders error UI when an error occurs", () => {
-		// React will log the error to the console - we're just testing the UI behavior
-		render(
-			<ErrorBoundary>
-				<ErrorThrowingComponent shouldThrow={true} />
-			</ErrorBoundary>,
-		)
-
-		// Verify error message is displayed using a more flexible approach
-		const errorTitle = screen.getByRole("heading", { level: 2 })
-		expect(errorTitle.textContent).toContain("Something went wrong")
-		expect(screen.getByText(/please copy and paste the following error message/i)).toBeInTheDocument()
-	})
-
-	test("error boundary renders error UI when component changes but still in error state", () => {
-		const { rerender } = render(
-			<ErrorBoundary>
-				<ErrorThrowingComponent shouldThrow={true} />
-			</ErrorBoundary>,
-		)
-
-		// Verify error message is displayed using a more flexible approach
-		const errorTitle = screen.getByRole("heading", { level: 2 })
-		expect(errorTitle.textContent).toContain("Something went wrong")
-
-		// Update the component to not throw
-		rerender(
-			<ErrorBoundary>
-				<ErrorThrowingComponent shouldThrow={false} />
-			</ErrorBoundary>,
-		)
-
-		// The error boundary should still show the error since it doesn't automatically reset
-		const errorTitleAfterRerender = screen.getByRole("heading", { level: 2 })
-		expect(errorTitleAfterRerender.textContent).toContain("Something went wrong")
-	})
-})

+ 2 - 0
webview-ui/src/components/chat/ChatView.tsx

@@ -46,6 +46,7 @@ import ProfileViolationWarning from "./ProfileViolationWarning"
 import { CheckpointWarning } from "./CheckpointWarning"
 import { QueuedMessages } from "./QueuedMessages"
 import { WorktreeSelector } from "./WorktreeSelector"
+import FileChangesPanel from "./FileChangesPanel"
 import DismissibleUpsell from "../common/DismissibleUpsell"
 import { useCloudUpsell } from "@src/hooks/useCloudUpsell"
 import { Cloud } from "lucide-react"
@@ -1700,6 +1701,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 							initialTopMostItemIndex={groupedMessages.length - 1}
 						/>
 					</div>
+					<FileChangesPanel clineMessages={messages} />
 					{areButtonsVisible && (
 						<div
 							className={`flex h-9 items-center mb-1 px-[15px] ${

+ 118 - 0
webview-ui/src/components/chat/FileChangesPanel.tsx

@@ -0,0 +1,118 @@
+import { memo, useEffect, useMemo, useState, useCallback } from "react"
+import { useTranslation } from "react-i18next"
+import { ChevronDown, ChevronRight, FileDiff } from "lucide-react"
+
+import type { ClineMessage } from "@roo-code/types"
+
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui"
+import { cn } from "@/lib/utils"
+import { vscode } from "@src/utils/vscode"
+
+import { fileChangesFromMessages, type FileChangeEntry } from "./utils/fileChangesFromMessages"
+import CodeAccordian from "../common/CodeAccordian"
+
+interface FileChangesPanelProps {
+	clineMessages: ClineMessage[] | undefined
+	className?: string
+}
+
+const FileChangesPanel = memo(({ clineMessages, className }: FileChangesPanelProps) => {
+	const { t } = useTranslation()
+	const [panelExpanded, setPanelExpanded] = useState(false)
+	const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
+
+	// Reset expanded file rows when switching to a different task (clineMessages identity change)
+	useEffect(() => {
+		setExpandedPaths(new Set())
+	}, [clineMessages])
+
+	const fileChanges = useMemo(() => fileChangesFromMessages(clineMessages), [clineMessages])
+
+	// Group by path so we show one row per file (multiple edits to same file combined for display)
+	const byPath = useMemo(() => {
+		const map = new Map<string, FileChangeEntry[]>()
+		for (const entry of fileChanges) {
+			const key = entry.path
+			const list = map.get(key) ?? []
+			list.push(entry)
+			map.set(key, list)
+		}
+		return map
+	}, [fileChanges])
+
+	const togglePath = useCallback((path: string) => {
+		setExpandedPaths((prev) => {
+			const next = new Set(prev)
+			if (next.has(path)) next.delete(path)
+			else next.add(path)
+			return next
+		})
+	}, [])
+
+	if (fileChanges.length === 0) return null
+
+	const fileCount = byPath.size
+
+	return (
+		<Collapsible open={panelExpanded} onOpenChange={setPanelExpanded} className={cn("px-3", className)}>
+			<CollapsibleTrigger
+				className={cn(
+					"flex items-center gap-2 w-full py-2 rounded-md text-left text-vscode-foreground",
+					"hover:bg-vscode-list-hoverBackground",
+				)}>
+				{panelExpanded ? (
+					<ChevronDown className="size-4 shrink-0" aria-hidden />
+				) : (
+					<ChevronRight className="size-4 shrink-0" aria-hidden />
+				)}
+				<FileDiff className="size-4 shrink-0" aria-hidden />
+				<span className="text-sm font-medium">
+					{t("chat:fileChangesInConversation.header", { count: fileCount })}
+				</span>
+			</CollapsibleTrigger>
+			<CollapsibleContent>
+				<div className="flex flex-col gap-1 pb-2 pl-6">
+					{Array.from(byPath.entries()).map(([path, entries]) => {
+						// If multiple edits to same file, concatenate diffs with a separator
+						const combinedDiff = entries.map((e) => e.diff).join("\n\n")
+						const combinedStats = entries.reduce(
+							(acc, e) => ({
+								added: acc.added + (e.diffStats?.added ?? 0),
+								removed: acc.removed + (e.diffStats?.removed ?? 0),
+							}),
+							{ added: 0, removed: 0 },
+						)
+						const isExpanded = expandedPaths.has(path)
+						return (
+							<div key={path} className="rounded border border-vscode-panel-border overflow-hidden">
+								<CodeAccordian
+									path={path}
+									code={combinedDiff}
+									language="diff"
+									isExpanded={isExpanded}
+									onToggleExpand={() => togglePath(path)}
+									diffStats={
+										combinedStats.added > 0 || combinedStats.removed > 0 ? combinedStats : undefined
+									}
+									onJumpToFile={
+										path
+											? () =>
+													vscode.postMessage({
+														type: "openFile",
+														text: path.startsWith("./") ? path : "./" + path,
+													})
+											: undefined
+									}
+								/>
+							</div>
+						)
+					})}
+				</div>
+			</CollapsibleContent>
+		</Collapsible>
+	)
+})
+
+FileChangesPanel.displayName = "FileChangesPanel"
+
+export default FileChangesPanel

+ 64 - 0
webview-ui/src/components/chat/utils/fileChangesFromMessages.ts

@@ -0,0 +1,64 @@
+import type { ClineMessage, ClineSayTool } from "@roo-code/types"
+import { safeJsonParse } from "@roo/core"
+
+/** File-edit tool names from ClineSayTool["tool"] (packages/types). */
+const FILE_EDIT_TOOLS = new Set<string>(["editedExistingFile", "appliedDiff", "newFileCreated"])
+
+export interface FileChangeEntry {
+	path: string
+	diff: string
+	diffStats?: { added: number; removed: number }
+}
+
+/**
+ * Derives a list of file changes from clineMessages for the current conversation.
+ * Includes:
+ * - type "say" + say "tool" (applied tool results, if any are ever pushed that way)
+ * - type "ask" + ask "tool" (tool approval messages; after approval the message stays as ask, so this is where file edits appear in the UI)
+ */
+export function fileChangesFromMessages(messages: ClineMessage[] | undefined): FileChangeEntry[] {
+	if (!messages?.length) return []
+
+	const entries: FileChangeEntry[] = []
+
+	for (const msg of messages) {
+		// Tool payload can be in say "tool" (rare) or ask "tool" (how file edits are stored after approval)
+		const isSayTool = msg.type === "say" && msg.say === "tool"
+		const isAskTool = msg.type === "ask" && msg.ask === "tool"
+		if ((!isSayTool && !isAskTool) || !msg.text || msg.partial) continue
+		// Only include ask "tool" file edits that the user (or auto-approval) has approved
+		if (isAskTool && !msg.isAnswered) continue
+
+		const tool = safeJsonParse<ClineSayTool>(msg.text)
+		if (!tool || !FILE_EDIT_TOOLS.has(tool.tool as string)) continue
+
+		// Batch diffs
+		if (tool.batchDiffs && Array.isArray(tool.batchDiffs)) {
+			for (const file of tool.batchDiffs) {
+				if (!file.path) continue
+				const content = file.content ?? file.diffs?.map((d) => d.content).join("\n") ?? ""
+				if (content) {
+					entries.push({
+						path: file.path,
+						diff: content,
+						diffStats: file.diffStats,
+					})
+				}
+			}
+			continue
+		}
+
+		// Single file
+		if (!tool.path) continue
+		const diff = tool.diff ?? tool.content ?? ""
+		if (diff) {
+			entries.push({
+				path: tool.path,
+				diff,
+				diffStats: tool.diffStats,
+			})
+		}
+	}
+
+	return entries
+}

+ 1 - 1
webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx

@@ -34,7 +34,7 @@ describe("MarkdownBlock", () => {
 		// Check that the period is outside the link
 		const paragraph = container.querySelector("p")
 		expect(paragraph?.textContent).toBe("Check out this link: https://example.com.")
-	})
+	}, 10000)
 
 	it("should render unordered lists with proper styling", async () => {
 		const markdown = `Here are some items:

+ 3 - 0
webview-ui/src/i18n/locales/ca/chat.json

@@ -33,6 +33,9 @@
 	},
 	"unpin": "Desfixar",
 	"pin": "Fixar",
+	"fileChangesInConversation": {
+		"header": "{{count}} fitxer(s) canviat(s) en aquesta conversa"
+	},
 	"tokenProgress": {
 		"availableSpace": "Espai disponible: {{amount}} tokens",
 		"tokensUsed": "Tokens utilitzats: {{used}} de {{total}}",

+ 3 - 0
webview-ui/src/i18n/locales/de/chat.json

@@ -33,6 +33,9 @@
 	},
 	"unpin": "Lösen von oben",
 	"pin": "Anheften",
+	"fileChangesInConversation": {
+		"header": "{{count}} Datei(en) in dieser Unterhaltung geändert"
+	},
 	"tokenProgress": {
 		"availableSpace": "Verfügbarer Speicher: {{amount}} Tokens",
 		"tokensUsed": "Verwendete Tokens: {{used}} von {{total}}",

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

@@ -33,6 +33,9 @@
 	},
 	"unpin": "Unpin",
 	"pin": "Pin",
+	"fileChangesInConversation": {
+		"header": "{{count}} file(s) changed in this conversation"
+	},
 	"retry": {
 		"title": "Retry",
 		"tooltip": "Try the operation again"

+ 3 - 0
webview-ui/src/i18n/locales/es/chat.json

@@ -33,6 +33,9 @@
 	},
 	"unpin": "Desfijar",
 	"pin": "Fijar",
+	"fileChangesInConversation": {
+		"header": "{{count}} archivo(s) modificado(s) en esta conversación"
+	},
 	"retry": {
 		"title": "Reintentar",
 		"tooltip": "Intenta la operación de nuevo"

+ 3 - 0
webview-ui/src/i18n/locales/fr/chat.json

@@ -33,6 +33,9 @@
 	},
 	"unpin": "Désépingler",
 	"pin": "Épingler",
+	"fileChangesInConversation": {
+		"header": "{{count}} fichier(s) modifié(s) dans cette conversation"
+	},
 	"tokenProgress": {
 		"availableSpace": "Espace disponible : {{amount}} tokens",
 		"tokensUsed": "Tokens utilisés : {{used}} sur {{total}}",

+ 3 - 0
webview-ui/src/i18n/locales/hi/chat.json

@@ -33,6 +33,9 @@
 	},
 	"unpin": "पिन करें",
 	"pin": "अवपिन करें",
+	"fileChangesInConversation": {
+		"header": "इस वार्तालाप में {{count}} फ़ाइल(ें) बदली गईं"
+	},
 	"tokenProgress": {
 		"availableSpace": "उपलब्ध स्थान: {{amount}} tokens",
 		"tokensUsed": "प्रयुक्त tokens: {{used}} / {{total}}",

+ 3 - 0
webview-ui/src/i18n/locales/id/chat.json

@@ -36,6 +36,9 @@
 	},
 	"unpin": "Lepas Pin",
 	"pin": "Pin",
+	"fileChangesInConversation": {
+		"header": "{{count}} file diubah dalam percakapan ini"
+	},
 	"retry": {
 		"title": "Coba Lagi",
 		"tooltip": "Coba operasi lagi"

+ 3 - 0
webview-ui/src/i18n/locales/it/chat.json

@@ -33,6 +33,9 @@
 	},
 	"unpin": "Rilascia",
 	"pin": "Fissa",
+	"fileChangesInConversation": {
+		"header": "{{count}} file modificati in questa conversazione"
+	},
 	"tokenProgress": {
 		"availableSpace": "Spazio disponibile: {{amount}} tokens",
 		"tokensUsed": "Tokens utilizzati: {{used}} di {{total}}",

+ 3 - 0
webview-ui/src/i18n/locales/ja/chat.json

@@ -33,6 +33,9 @@
 	},
 	"unpin": "ピン留めを解除",
 	"pin": "ピン留め",
+	"fileChangesInConversation": {
+		"header": "この会話で {{count}} 個のファイルが変更されました"
+	},
 	"tokenProgress": {
 		"availableSpace": "利用可能な空き容量: {{amount}} トークン",
 		"tokensUsed": "使用トークン: {{used}} / {{total}}",

+ 3 - 0
webview-ui/src/i18n/locales/ko/chat.json

@@ -33,6 +33,9 @@
 	},
 	"unpin": "고정 해제하기",
 	"pin": "고정하기",
+	"fileChangesInConversation": {
+		"header": "이 대화에서 {{count}}개 파일이 변경됨"
+	},
 	"tokenProgress": {
 		"availableSpace": "사용 가능한 공간: {{amount}} 토큰",
 		"tokensUsed": "사용된 토큰: {{used}} / {{total}}",

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

@@ -33,6 +33,9 @@
 	},
 	"unpin": "Losmaken",
 	"pin": "Vastmaken",
+	"fileChangesInConversation": {
+		"header": "{{count}} bestand(en) gewijzigd in dit gesprek"
+	},
 	"retry": {
 		"title": "Opnieuw proberen",
 		"tooltip": "Probeer de bewerking opnieuw"

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

@@ -33,6 +33,9 @@
 	},
 	"unpin": "Odepnij",
 	"pin": "Przypnij",
+	"fileChangesInConversation": {
+		"header": "{{count}} plik(ów) zmienionych w tej rozmowie"
+	},
 	"tokenProgress": {
 		"availableSpace": "Dostępne miejsce: {{amount}} tokenów",
 		"tokensUsed": "Wykorzystane tokeny: {{used}} z {{total}}",

+ 3 - 0
webview-ui/src/i18n/locales/pt-BR/chat.json

@@ -33,6 +33,9 @@
 	},
 	"unpin": "Desfixar",
 	"pin": "Fixar",
+	"fileChangesInConversation": {
+		"header": "{{count}} arquivo(s) alterado(s) nesta conversa"
+	},
 	"tokenProgress": {
 		"availableSpace": "Espaço disponível: {{amount}} tokens",
 		"tokensUsed": "Tokens usados: {{used}} de {{total}}",

+ 3 - 0
webview-ui/src/i18n/locales/ru/chat.json

@@ -33,6 +33,9 @@
 	},
 	"unpin": "Открепить",
 	"pin": "Закрепить",
+	"fileChangesInConversation": {
+		"header": "{{count}} файл(ов) изменено в этом разговоре"
+	},
 	"retry": {
 		"title": "Повторить",
 		"tooltip": "Попробовать выполнить операцию снова"

+ 3 - 0
webview-ui/src/i18n/locales/tr/chat.json

@@ -33,6 +33,9 @@
 	},
 	"unpin": "Sabitlemeyi iptal et",
 	"pin": "Sabitle",
+	"fileChangesInConversation": {
+		"header": "Bu sohbette {{count}} dosya değiştirildi"
+	},
 	"tokenProgress": {
 		"availableSpace": "Kullanılabilir alan: {{amount}} token",
 		"tokensUsed": "Kullanılan tokenlar: {{used}} / {{total}}",

+ 3 - 0
webview-ui/src/i18n/locales/vi/chat.json

@@ -33,6 +33,9 @@
 	},
 	"unpin": "Bỏ ghim khỏi đầu",
 	"pin": "Ghim lên đầu",
+	"fileChangesInConversation": {
+		"header": "{{count}} tệp đã thay đổi trong cuộc hội thoại này"
+	},
 	"tokenProgress": {
 		"availableSpace": "Không gian khả dụng: {{amount}} tokens",
 		"tokensUsed": "Tokens đã sử dụng: {{used}} trong {{total}}",

+ 3 - 0
webview-ui/src/i18n/locales/zh-CN/chat.json

@@ -33,6 +33,9 @@
 	},
 	"unpin": "取消置顶",
 	"pin": "置顶",
+	"fileChangesInConversation": {
+		"header": "此对话中已更改 {{count}} 个文件"
+	},
 	"tokenProgress": {
 		"availableSpace": "可用: {{amount}}",
 		"tokensUsed": "已使用: {{used}} / {{total}}",

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

@@ -33,6 +33,9 @@
 	},
 	"unpin": "取消釘選",
 	"pin": "釘選",
+	"fileChangesInConversation": {
+		"header": "此對話中已變更 {{count}} 個檔案"
+	},
 	"retry": {
 		"title": "重試",
 		"tooltip": "再次嘗試操作"