mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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
This commit is contained in:
parent
00075684fd
commit
d7359ff228
27 changed files with 710 additions and 99 deletions
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
webview-ui/src/__tests__/FileChangesPanel.spec.tsx
Normal file
175
webview-ui/src/__tests__/FileChangesPanel.spec.tsx
Normal file
|
|
@ -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
webview-ui/src/__tests__/fileChangesFromMessages.spec.ts
Normal file
280
webview-ui/src/__tests__/fileChangesFromMessages.spec.ts
Normal file
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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
webview-ui/src/components/chat/FileChangesPanel.tsx
Normal file
118
webview-ui/src/components/chat/FileChangesPanel.tsx
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
webview-ui/src/i18n/locales/ca/chat.json
generated
3
webview-ui/src/i18n/locales/ca/chat.json
generated
|
|
@ -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
webview-ui/src/i18n/locales/de/chat.json
generated
3
webview-ui/src/i18n/locales/de/chat.json
generated
|
|
@ -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}}",
|
||||
|
|
|
|||
|
|
@ -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
webview-ui/src/i18n/locales/es/chat.json
generated
3
webview-ui/src/i18n/locales/es/chat.json
generated
|
|
@ -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
webview-ui/src/i18n/locales/fr/chat.json
generated
3
webview-ui/src/i18n/locales/fr/chat.json
generated
|
|
@ -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
webview-ui/src/i18n/locales/hi/chat.json
generated
3
webview-ui/src/i18n/locales/hi/chat.json
generated
|
|
@ -33,6 +33,9 @@
|
|||
},
|
||||
"unpin": "पिन करें",
|
||||
"pin": "अवपिन करें",
|
||||
"fileChangesInConversation": {
|
||||
"header": "इस वार्तालाप में {{count}} फ़ाइल(ें) बदली गईं"
|
||||
},
|
||||
"tokenProgress": {
|
||||
"availableSpace": "उपलब्ध स्थान: {{amount}} tokens",
|
||||
"tokensUsed": "प्रयुक्त tokens: {{used}} / {{total}}",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/id/chat.json
generated
3
webview-ui/src/i18n/locales/id/chat.json
generated
|
|
@ -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
webview-ui/src/i18n/locales/it/chat.json
generated
3
webview-ui/src/i18n/locales/it/chat.json
generated
|
|
@ -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
webview-ui/src/i18n/locales/ja/chat.json
generated
3
webview-ui/src/i18n/locales/ja/chat.json
generated
|
|
@ -33,6 +33,9 @@
|
|||
},
|
||||
"unpin": "ピン留めを解除",
|
||||
"pin": "ピン留め",
|
||||
"fileChangesInConversation": {
|
||||
"header": "この会話で {{count}} 個のファイルが変更されました"
|
||||
},
|
||||
"tokenProgress": {
|
||||
"availableSpace": "利用可能な空き容量: {{amount}} トークン",
|
||||
"tokensUsed": "使用トークン: {{used}} / {{total}}",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/ko/chat.json
generated
3
webview-ui/src/i18n/locales/ko/chat.json
generated
|
|
@ -33,6 +33,9 @@
|
|||
},
|
||||
"unpin": "고정 해제하기",
|
||||
"pin": "고정하기",
|
||||
"fileChangesInConversation": {
|
||||
"header": "이 대화에서 {{count}}개 파일이 변경됨"
|
||||
},
|
||||
"tokenProgress": {
|
||||
"availableSpace": "사용 가능한 공간: {{amount}} 토큰",
|
||||
"tokensUsed": "사용된 토큰: {{used}} / {{total}}",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/nl/chat.json
generated
3
webview-ui/src/i18n/locales/nl/chat.json
generated
|
|
@ -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
webview-ui/src/i18n/locales/pl/chat.json
generated
3
webview-ui/src/i18n/locales/pl/chat.json
generated
|
|
@ -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
webview-ui/src/i18n/locales/pt-BR/chat.json
generated
3
webview-ui/src/i18n/locales/pt-BR/chat.json
generated
|
|
@ -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
webview-ui/src/i18n/locales/ru/chat.json
generated
3
webview-ui/src/i18n/locales/ru/chat.json
generated
|
|
@ -33,6 +33,9 @@
|
|||
},
|
||||
"unpin": "Открепить",
|
||||
"pin": "Закрепить",
|
||||
"fileChangesInConversation": {
|
||||
"header": "{{count}} файл(ов) изменено в этом разговоре"
|
||||
},
|
||||
"retry": {
|
||||
"title": "Повторить",
|
||||
"tooltip": "Попробовать выполнить операцию снова"
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/tr/chat.json
generated
3
webview-ui/src/i18n/locales/tr/chat.json
generated
|
|
@ -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
webview-ui/src/i18n/locales/vi/chat.json
generated
3
webview-ui/src/i18n/locales/vi/chat.json
generated
|
|
@ -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
webview-ui/src/i18n/locales/zh-CN/chat.json
generated
3
webview-ui/src/i18n/locales/zh-CN/chat.json
generated
|
|
@ -33,6 +33,9 @@
|
|||
},
|
||||
"unpin": "取消置顶",
|
||||
"pin": "置顶",
|
||||
"fileChangesInConversation": {
|
||||
"header": "此对话中已更改 {{count}} 个文件"
|
||||
},
|
||||
"tokenProgress": {
|
||||
"availableSpace": "可用: {{amount}}",
|
||||
"tokensUsed": "已使用: {{used}} / {{total}}",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/zh-TW/chat.json
generated
3
webview-ui/src/i18n/locales/zh-TW/chat.json
generated
|
|
@ -33,6 +33,9 @@
|
|||
},
|
||||
"unpin": "取消釘選",
|
||||
"pin": "釘選",
|
||||
"fileChangesInConversation": {
|
||||
"header": "此對話中已變更 {{count}} 個檔案"
|
||||
},
|
||||
"retry": {
|
||||
"title": "重試",
|
||||
"tooltip": "再次嘗試操作"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue