From d7359ff2288227c4a259f2663ea910ccac8154bd Mon Sep 17 00:00:00 2001 From: Chiranjeevisantosh Madugundi Date: Thu, 19 Feb 2026 00:08:45 -0600 Subject: [PATCH] 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 --- src/core/task/Task.ts | 15 + webview-ui/src/__tests__/App.spec.tsx | 2 +- .../src/__tests__/FileChangesPanel.spec.tsx | 175 +++++++++++ .../__tests__/fileChangesFromMessages.spec.ts | 280 ++++++++++++++++++ .../__tests__/ErrorBoundary.spec.tsx | 97 ------ webview-ui/src/components/chat/ChatView.tsx | 2 + .../src/components/chat/FileChangesPanel.tsx | 118 ++++++++ .../chat/utils/fileChangesFromMessages.ts | 64 ++++ .../common/__tests__/MarkdownBlock.spec.tsx | 2 +- webview-ui/src/i18n/locales/ca/chat.json | 3 + webview-ui/src/i18n/locales/de/chat.json | 3 + webview-ui/src/i18n/locales/en/chat.json | 3 + webview-ui/src/i18n/locales/es/chat.json | 3 + webview-ui/src/i18n/locales/fr/chat.json | 3 + webview-ui/src/i18n/locales/hi/chat.json | 3 + webview-ui/src/i18n/locales/id/chat.json | 3 + webview-ui/src/i18n/locales/it/chat.json | 3 + webview-ui/src/i18n/locales/ja/chat.json | 3 + webview-ui/src/i18n/locales/ko/chat.json | 3 + webview-ui/src/i18n/locales/nl/chat.json | 3 + webview-ui/src/i18n/locales/pl/chat.json | 3 + webview-ui/src/i18n/locales/pt-BR/chat.json | 3 + webview-ui/src/i18n/locales/ru/chat.json | 3 + webview-ui/src/i18n/locales/tr/chat.json | 3 + webview-ui/src/i18n/locales/vi/chat.json | 3 + webview-ui/src/i18n/locales/zh-CN/chat.json | 3 + webview-ui/src/i18n/locales/zh-TW/chat.json | 3 + 27 files changed, 710 insertions(+), 99 deletions(-) create mode 100644 webview-ui/src/__tests__/FileChangesPanel.spec.tsx create mode 100644 webview-ui/src/__tests__/fileChangesFromMessages.spec.ts delete mode 100644 webview-ui/src/components/__tests__/ErrorBoundary.spec.tsx create mode 100644 webview-ui/src/components/chat/FileChangesPanel.tsx create mode 100644 webview-ui/src/components/chat/utils/fileChangesFromMessages.ts diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 6ba57e98ac..16c6b36dce 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1532,6 +1532,21 @@ export class Task extends EventEmitter 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) + }) + } + } } /** diff --git a/webview-ui/src/__tests__/App.spec.tsx b/webview-ui/src/__tests__/App.spec.tsx index e8e08782da..e04bc14200 100644 --- a/webview-ui/src/__tests__/App.spec.tsx +++ b/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() diff --git a/webview-ui/src/__tests__/FileChangesPanel.spec.tsx b/webview-ui/src/__tests__/FileChangesPanel.spec.tsx new file mode 100644 index 0000000000..b28102b1fe --- /dev/null +++ b/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 + }) => ( +
+ {path} + +
+ ), +})) + +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( + + + , + ) +} + +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") + }) +}) diff --git a/webview-ui/src/__tests__/fileChangesFromMessages.spec.ts b/webview-ui/src/__tests__/fileChangesFromMessages.spec.ts new file mode 100644 index 0000000000..8fab8b14d5 --- /dev/null +++ b/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 & { 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([]) + }) +}) diff --git a/webview-ui/src/components/__tests__/ErrorBoundary.spec.tsx b/webview-ui/src/components/__tests__/ErrorBoundary.spec.tsx deleted file mode 100644 index 1fbb6774f2..0000000000 --- a/webview-ui/src/components/__tests__/ErrorBoundary.spec.tsx +++ /dev/null @@ -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 = { - "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
Content rendered normally
-} - -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( - - - , - ) - - 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( - - - , - ) - - // 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( - - - , - ) - - // 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( - - - , - ) - - // 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") - }) -}) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index fbd7db0743..c070f8764e 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/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 + {areButtonsVisible && (
{ + const { t } = useTranslation() + const [panelExpanded, setPanelExpanded] = useState(false) + const [expandedPaths, setExpandedPaths] = useState>(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() + 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 ( + + + {panelExpanded ? ( + + ) : ( + + )} + + + {t("chat:fileChangesInConversation.header", { count: fileCount })} + + + +
+ {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 ( +
+ togglePath(path)} + diffStats={ + combinedStats.added > 0 || combinedStats.removed > 0 ? combinedStats : undefined + } + onJumpToFile={ + path + ? () => + vscode.postMessage({ + type: "openFile", + text: path.startsWith("./") ? path : "./" + path, + }) + : undefined + } + /> +
+ ) + })} +
+
+
+ ) +}) + +FileChangesPanel.displayName = "FileChangesPanel" + +export default FileChangesPanel diff --git a/webview-ui/src/components/chat/utils/fileChangesFromMessages.ts b/webview-ui/src/components/chat/utils/fileChangesFromMessages.ts new file mode 100644 index 0000000000..6b77833e9d --- /dev/null +++ b/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(["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(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 +} diff --git a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx index a0b6857a37..8e41eefa14 100644 --- a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx +++ b/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: diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 16e999efd7..230d730b13 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/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}}", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 339083038a..480d3197cd 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/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}}", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 71f08bb504..10ef7c8811 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/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" diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index cee47210b7..b758c4eae2 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/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" diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 592a85dbcd..9080c3d29a 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/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}}", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 897d6b2db1..e4226e5ad5 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/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}}", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index f78cbc0d47..10b13bcb5b 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/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" diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index a6cafe1bd1..9d4b03bd73 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/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}}", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 78721a6f5c..a648393df7 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -33,6 +33,9 @@ }, "unpin": "ピン留めを解除", "pin": "ピン留め", + "fileChangesInConversation": { + "header": "この会話で {{count}} 個のファイルが変更されました" + }, "tokenProgress": { "availableSpace": "利用可能な空き容量: {{amount}} トークン", "tokensUsed": "使用トークン: {{used}} / {{total}}", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 86aa58e522..8d686b9496 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -33,6 +33,9 @@ }, "unpin": "고정 해제하기", "pin": "고정하기", + "fileChangesInConversation": { + "header": "이 대화에서 {{count}}개 파일이 변경됨" + }, "tokenProgress": { "availableSpace": "사용 가능한 공간: {{amount}} 토큰", "tokensUsed": "사용된 토큰: {{used}} / {{total}}", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 57dd6e5499..eb134cfca4 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/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" diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index bcf3317882..d2ee83879f 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/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}}", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 4429fa2962..2c339defd4 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/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}}", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 091175f0bb..fd3718f28a 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -33,6 +33,9 @@ }, "unpin": "Открепить", "pin": "Закрепить", + "fileChangesInConversation": { + "header": "{{count}} файл(ов) изменено в этом разговоре" + }, "retry": { "title": "Повторить", "tooltip": "Попробовать выполнить операцию снова" diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 26a975d797..2a6b776f78 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/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}}", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 70637db9c6..8e3841f334 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/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}}", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index d5f945da18..012797cc51 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -33,6 +33,9 @@ }, "unpin": "取消置顶", "pin": "置顶", + "fileChangesInConversation": { + "header": "此对话中已更改 {{count}} 个文件" + }, "tokenProgress": { "availableSpace": "可用: {{amount}}", "tokensUsed": "已使用: {{used}} / {{total}}", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 0693a7d96f..4acb77715a 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -33,6 +33,9 @@ }, "unpin": "取消釘選", "pin": "釘選", + "fileChangesInConversation": { + "header": "此對話中已變更 {{count}} 個檔案" + }, "retry": { "title": "重試", "tooltip": "再次嘗試操作"