Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1bac383
feat(chat): improve diff appearance in main chat view
hannesrudolph Oct 30, 2025
18ab7b2
ui(chat): constrain expanded diff height with scroll (max-h 300px) to…
hannesrudolph Oct 30, 2025
1fb2da7
fix(chat): rely on computeDiffStats for new file unified diffs; remov…
hannesrudolph Oct 30, 2025
56882e4
ui(diff): VSCode-style highlighting in [DiffView](webview-ui/src/comp…
hannesrudolph Oct 30, 2025
e3f09e3
feat(diff): enhance DiffView with syntax highlighting and improved st…
hannesrudolph Oct 30, 2025
cebd646
feat(webview-ui): normalize diffs to unified format and enhance DiffV…
hannesrudolph Oct 30, 2025
2c39a93
fix(diff): update regex in convertSearchReplaceToUnifiedDiff for impr…
hannesrudolph Oct 30, 2025
1b86bab
fix(webview-ui): use theme foreground for +/- indicators in DiffView …
hannesrudolph Oct 30, 2025
5f17d50
fix(webview-ui): resolve CodeQL warning in stripCData by handling HTM…
hannesrudolph Oct 30, 2025
15589b0
fix(webview-ui): stripCData removes raw and HTML-encoded CDATA marker…
hannesrudolph Oct 30, 2025
960fe90
fix(webview-ui): correct new-file diff line counting by ignoring trai…
hannesrudolph Oct 30, 2025
408e9fb
webview-ui: remove redundant CDATA replacements; improve unified diff…
hannesrudolph Oct 30, 2025
a4fac50
apply_diff: stream unified diffs to UI during batch preview; include …
hannesrudolph Nov 3, 2025
5256aed
refactor(diffUtils): remove SEARCH/REPLACE handling and normalize dif…
hannesrudolph Nov 4, 2025
356a3e0
fix(webview): accept 'searchAndReplace' in ChatRow switch via string …
hannesrudolph Nov 4, 2025
f7b99d5
fix(webview-ui): Address PR review findings for diff view improvements
hannesrudolph Nov 4, 2025
4693132
fix(tools): Generate unified diff for write_to_file in background edi…
hannesrudolph Nov 4, 2025
7906320
fix(tools): Load original content before generating diff in backgroun…
hannesrudolph Nov 4, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/core/tools/applyDiffTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ export async function applyDiffToolLegacy(
cline.consecutiveMistakeCount = 0
cline.consecutiveMistakeCountForApplyDiff.delete(relPath)

// Generate backend-unified diff for display in chat/webview
const unifiedPatch = formatResponse.createPrettyPatch(relPath, originalContent, diffResult.content)

// Check if preventFocusDisruption experiment is enabled
const provider = cline.providerRef.deref()
const state = await provider?.getState()
Expand All @@ -158,6 +161,7 @@ export async function applyDiffToolLegacy(
const completeMessage = JSON.stringify({
...sharedMessageProps,
diff: diffContent,
content: unifiedPatch,
isProtected: isWriteProtected,
} satisfies ClineSayTool)

Expand Down Expand Up @@ -194,6 +198,7 @@ export async function applyDiffToolLegacy(
const completeMessage = JSON.stringify({
...sharedMessageProps,
diff: diffContent,
content: unifiedPatch,
isProtected: isWriteProtected,
} satisfies ClineSayTool)

Expand Down
52 changes: 45 additions & 7 deletions src/core/tools/multiApplyDiffTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,31 +282,66 @@ Original error: ${errorMessage}`
(opResult) => cline.rooProtectedController?.isWriteProtected(opResult.path) || false,
)

// Prepare batch diff data
const batchDiffs = operationsToApprove.map((opResult) => {
// Stream batch diffs progressively for better UX
const batchDiffs: Array<{
path: string
changeCount: number
key: string
content: string
diffs?: Array<{ content: string; startLine?: number }>
}> = []

for (const opResult of operationsToApprove) {
const readablePath = getReadablePath(cline.cwd, opResult.path)
const changeCount = opResult.diffItems?.length || 0
const changeText = changeCount === 1 ? "1 change" : `${changeCount} changes`

return {
let unified = ""
try {
const original = await fs.readFile(opResult.absolutePath!, "utf-8")
const processed = !cline.api.getModel().id.includes("claude")
? (opResult.diffItems || []).map((item) => ({
...item,
content: item.content ? unescapeHtmlEntities(item.content) : item.content,
}))
: opResult.diffItems || []

const applyRes =
(await cline.diffStrategy?.applyDiff(original, processed)) ?? ({ success: false } as any)
const newContent = applyRes.success && applyRes.content ? applyRes.content : original
unified = formatResponse.createPrettyPatch(opResult.path, original, newContent)
} catch {
unified = ""
}

batchDiffs.push({
path: readablePath,
changeCount,
key: `${readablePath} (${changeText})`,
content: opResult.path, // Full relative path
content: unified,
diffs: opResult.diffItems?.map((item) => ({
content: item.content,
startLine: item.startLine,
})),
}
})
})

// Send a partial update after each file preview is ready
const partialMessage = JSON.stringify({
tool: "appliedDiff",
batchDiffs,
isProtected: hasProtectedFiles,
} satisfies ClineSayTool)
await cline.ask("tool", partialMessage, true).catch(() => {})
}

// Final approval message (non-partial)
const completeMessage = JSON.stringify({
tool: "appliedDiff",
batchDiffs,
isProtected: hasProtectedFiles,
} satisfies ClineSayTool)

const { response, text, images } = await cline.ask("tool", completeMessage, hasProtectedFiles)
const { response, text, images } = await cline.ask("tool", completeMessage, false)

// Process batch response
if (response === "yesButtonClicked") {
Expand Down Expand Up @@ -418,6 +453,7 @@ Original error: ${errorMessage}`

try {
let originalContent: string | null = await fs.readFile(absolutePath, "utf-8")
let beforeContent: string | null = originalContent
let successCount = 0
let formattedError = ""

Expand Down Expand Up @@ -540,9 +576,11 @@ ${errorDetails ? `\nTechnical details:\n${errorDetails}\n` : ""}
if (operationsToApprove.length === 1) {
// Prepare common data for single file operation
const diffContents = diffItems.map((item) => item.content).join("\n\n")
const unifiedPatch = formatResponse.createPrettyPatch(relPath, beforeContent!, originalContent!)
const operationMessage = JSON.stringify({
...sharedMessageProps,
diff: diffContents,
content: unifiedPatch,
} satisfies ClineSayTool)

let toolProgressStatus
Expand Down
23 changes: 13 additions & 10 deletions src/core/tools/writeToFileTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,15 @@ export async function writeToFileTool(

if (isPreventFocusDisruptionEnabled) {
// Direct file write without diff view
// Set up diffViewProvider properties needed for diff generation and saveDirectly
cline.diffViewProvider.editType = fileExists ? "modify" : "create"
if (fileExists) {
const absolutePath = path.resolve(cline.cwd, relPath)
cline.diffViewProvider.originalContent = await fs.readFile(absolutePath, "utf-8")
} else {
cline.diffViewProvider.originalContent = ""
}

// Check for code omissions before proceeding
if (detectCodeOmission(cline.diffViewProvider.originalContent || "", newContent, predictedLineCount)) {
if (cline.diffStrategy) {
Expand Down Expand Up @@ -204,7 +213,10 @@ export async function writeToFileTool(

const completeMessage = JSON.stringify({
...sharedMessageProps,
content: newContent,
content: fileExists ? undefined : newContent,
diff: fileExists
? formatResponse.createPrettyPatch(relPath, cline.diffViewProvider.originalContent, newContent)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When editing an existing file, the completeMessage now omits 'content' in favor of a 'diff' field generated from cline.diffViewProvider.originalContent. Ensure that originalContent is reliably loaded before this call (especially in the preventFocusDisruption branch) so the diff patch is accurate. Consider moving the original file read before constructing completeMessage if needed.

: undefined,
} satisfies ClineSayTool)
Comment on lines +216 to 220
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The originalContent is accessed before it's loaded. In the preventFocusDisruption flow, cline.diffViewProvider.originalContent is undefined at line 209, but isn't populated until line 223 (after the askApproval call). This causes createPrettyPatch to generate a diff against empty content, showing all lines as additions instead of the actual changes. The original content needs to be loaded before creating the diff message.

Fix it with Roo Code or mention @roomote and request a fix.


const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected)
Expand All @@ -213,15 +225,6 @@ export async function writeToFileTool(
return
}

// Set up diffViewProvider properties needed for saveDirectly
cline.diffViewProvider.editType = fileExists ? "modify" : "create"
if (fileExists) {
const absolutePath = path.resolve(cline.cwd, relPath)
cline.diffViewProvider.originalContent = await fs.readFile(absolutePath, "utf-8")
} else {
cline.diffViewProvider.originalContent = ""
}

// Save directly without showing diff view or opening the file
await cline.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs)
} else {
Expand Down
2 changes: 2 additions & 0 deletions webview-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"cmdk": "^1.0.0",
"date-fns": "^4.1.0",
"debounce": "^2.1.1",
"diff": "^5.2.0",
"fast-deep-equal": "^3.1.3",
"fzf": "^0.5.2",
"hast-util-to-jsx-runtime": "^2.3.6",
Expand Down Expand Up @@ -87,6 +88,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/diff": "^5.2.1",
"@types/jest": "^29.0.0",
"@types/katex": "^0.16.7",
"@types/node": "20.x",
Expand Down
9 changes: 6 additions & 3 deletions webview-ui/src/components/chat/BatchDiffApproval.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { memo, useState } from "react"
import CodeAccordian from "../common/CodeAccordian"
import { computeUnifiedDiffStats } from "../../utils/diffStats"

interface FileDiff {
path: string
Expand Down Expand Up @@ -35,17 +36,19 @@ export const BatchDiffApproval = memo(({ files = [], ts }: BatchDiffApprovalProp
<div className="pt-[5px]">
<div className="flex flex-col gap-0 border border-border rounded-md p-1">
{files.map((file) => {
// Combine all diffs into a single diff string for this file
const combinedDiff = file.diffs?.map((diff) => diff.content).join("\n\n") || file.content
// Use backend-provided unified diff only. No client-side fallback for apply_diff batches.
const unified = file.content || ""
const stats = computeUnifiedDiffStats(unified)

return (
<div key={`${file.path}-${ts}`}>
<CodeAccordian
path={file.path}
code={combinedDiff}
code={unified}
language="diff"
isExpanded={expandedFiles[file.path] || false}
onToggleExpand={() => handleToggleExpand(file.path)}
diffStats={stats ?? undefined}
/>
</div>
)
Expand Down
79 changes: 73 additions & 6 deletions webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { useExtensionState } from "@src/context/ExtensionStateContext"
import { findMatchingResourceOrTemplate } from "@src/utils/mcp"
import { vscode } from "@src/utils/vscode"
import { removeLeadingNonAlphanumeric } from "@src/utils/removeLeadingNonAlphanumeric"
import { getLanguageFromPath } from "@src/utils/getLanguageFromPath"

import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock"
import UpdateTodoListToolBlock from "./UpdateTodoListToolBlock"
Expand All @@ -25,6 +24,8 @@ import { ReasoningBlock } from "./ReasoningBlock"
import Thumbnails from "../common/Thumbnails"
import ImageBlock from "../common/ImageBlock"
import ErrorRow from "./ErrorRow"
import { extractUnifiedDiff } from "../../utils/diffUtils"
import { computeDiffStats } from "../../utils/diffStats"

import McpResourceRow from "../mcp/McpResourceRow"

Expand Down Expand Up @@ -335,6 +336,35 @@ export const ChatRowContent = ({
[message.ask, message.text],
)

// Inline diff stats for edit/apply_diff/insert/search-replace/newFile asks
const diffTextForStats = useMemo(() => {
if (!tool) return ""
return (
extractUnifiedDiff({
toolName: tool.tool as string,
path: tool.path,
diff: (tool as any).content ?? (tool as any).diff,
content: (tool as any).diff,
}) || ""
)
}, [tool])

const diffStatsForInline = useMemo(() => {
return computeDiffStats(diffTextForStats)
}, [diffTextForStats])

// Clean diff content for display (normalize to unified diff)
const cleanDiffContent = useMemo(() => {
if (!tool) return undefined
const unified = extractUnifiedDiff({
toolName: tool.tool as string,
path: tool.path,
diff: (tool as any).content ?? (tool as any).diff,
content: (tool as any).diff,
})
return unified || undefined
}, [tool])

const followUpData = useMemo(() => {
if (message.type === "ask" && message.ask === "followup" && !message.partial) {
return safeJsonParse<FollowUpData>(message.text)
Expand All @@ -349,7 +379,7 @@ export const ChatRowContent = ({
style={{ color: "var(--vscode-foreground)", marginBottom: "-1.5px" }}></span>
)

switch (tool.tool) {
switch (tool.tool as string) {
case "editedExistingFile":
case "appliedDiff":
// Check if this is a batch diff request
Expand Down Expand Up @@ -390,12 +420,13 @@ export const ChatRowContent = ({
<div className="pl-6">
<CodeAccordian
path={tool.path}
code={tool.content ?? tool.diff}
code={cleanDiffContent ?? tool.content ?? tool.diff}
language="diff"
progressStatus={message.progressStatus}
isLoading={message.partial}
isExpanded={isExpanded}
onToggleExpand={handleToggleExpand}
diffStats={diffStatsForInline ?? undefined}
/>
</div>
</>
Expand Down Expand Up @@ -427,12 +458,47 @@ export const ChatRowContent = ({
<div className="pl-6">
<CodeAccordian
path={tool.path}
code={tool.diff}
code={cleanDiffContent ?? tool.diff}
language="diff"
progressStatus={message.progressStatus}
isLoading={message.partial}
isExpanded={isExpanded}
onToggleExpand={handleToggleExpand}
diffStats={diffStatsForInline ?? undefined}
/>
</div>
</>
)
case "searchAndReplace":
return (
<>
<div style={headerStyle}>
{tool.isProtected ? (
<span
className="codicon codicon-lock"
style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }}
/>
) : (
toolIcon("replace")
)}
<span style={{ fontWeight: "bold" }}>
{tool.isProtected && message.type === "ask"
? t("chat:fileOperations.wantsToEditProtected")
: message.type === "ask"
? t("chat:fileOperations.wantsToSearchReplace")
: t("chat:fileOperations.didSearchReplace")}
</span>
</div>
<div className="pl-6">
<CodeAccordian
path={tool.path}
code={cleanDiffContent ?? tool.diff}
language="diff"
progressStatus={message.progressStatus}
isLoading={message.partial}
isExpanded={isExpanded}
onToggleExpand={handleToggleExpand}
diffStats={diffStatsForInline ?? undefined}
/>
</div>
</>
Expand Down Expand Up @@ -495,12 +561,13 @@ export const ChatRowContent = ({
<div className="pl-6">
<CodeAccordian
path={tool.path}
code={tool.content}
language={getLanguageFromPath(tool.path || "") || "log"}
code={cleanDiffContent ?? ""}
language="diff"
isLoading={message.partial}
isExpanded={isExpanded}
onToggleExpand={handleToggleExpand}
onJumpToFile={() => vscode.postMessage({ type: "openFile", text: "./" + tool.path })}
diffStats={diffStatsForInline ?? undefined}
/>
</div>
</>
Expand Down
2 changes: 1 addition & 1 deletion webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}, [messages.length])

useEffect(() => {
// Reset UI states
// Reset UI states only when task changes
setExpandedRows({})
everVisibleMessagesTsRef.current.clear() // Clear for new task
setCurrentFollowUpTs(null) // Clear follow-up answered state for new task
Expand Down
Loading
Loading