Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 33 additions & 0 deletions webview-ui/src/components/chat/TaskHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { ContextWindowProgress } from "./ContextWindowProgress"
import { Mention } from "./Mention"
import { TodoListDisplay } from "./TodoListDisplay"
import { LucideIconButton } from "./LucideIconButton"
import { useDiffStats } from "./hooks/useDiffStats"

export interface TaskHeaderProps {
task: ClineMessage
Expand Down Expand Up @@ -69,6 +70,8 @@ const TaskHeader = ({
}: TaskHeaderProps) => {
const { t } = useTranslation()
const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive } = useExtensionState()
const diffStats = useDiffStats(clineMessages)
const hasDiffStats = diffStats.totalAdded > 0 || diffStats.totalRemoved > 0
const { id: modelId, info: model } = useSelectedModel(apiConfiguration)
const [isTaskExpanded, setIsTaskExpanded] = useState(false)
const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false)
Expand Down Expand Up @@ -335,6 +338,15 @@ const TaskHeader = ({
</StandardTooltip>
</>
)}
{hasDiffStats && (
<>
<span>·</span>
<span className="flex items-center gap-1.5" data-testid="compact-diff-stats">
<span className="text-vscode-charts-green">+{diffStats.totalAdded}</span>
<span className="text-vscode-charts-red">-{diffStats.totalRemoved}</span>
</span>
</>
)}
</div>
{showBrowserGlobe && (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
Expand Down Expand Up @@ -500,6 +512,27 @@ const TaskHeader = ({
</tr>
)}

{/* Lines changed display */}
{hasDiffStats && (
<tr>
<th
className="font-medium text-left align-top w-1 whitespace-nowrap pr-3 h-[24px]"
data-testid="lines-changed-label">
{t("chat:task.linesChanged")}
</th>
<td className="font-light align-top" data-testid="lines-changed-value">
<div className="flex items-center gap-2">
<span className="text-vscode-charts-green">
+{diffStats.totalAdded}
</span>
<span className="text-vscode-charts-red">
-{diffStats.totalRemoved}
</span>
</div>
</td>
</tr>
)}

{/* Size display */}
{!!currentTaskItem?.size && currentTaskItem.size > 0 && (
<tr>
Expand Down
115 changes: 115 additions & 0 deletions webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -470,4 +470,119 @@ describe("TaskHeader", () => {
expect(screen.getByText("0%")).toBeInTheDocument()
})
})

describe("Diff stats display", () => {
beforeEach(() => {
mockExtensionState = {
apiConfiguration: {
apiProvider: "anthropic",
apiKey: "test-api-key",
apiModelId: "claude-3-opus-20240229",
} as ProviderSettings,
currentTaskItem: { id: "test-task-id" },
clineMessages: [],
}
mockModelInfo = undefined
mockMaxOutputTokens = 0
})

it("should not show diff stats when there are no file edits", () => {
mockExtensionState.clineMessages = [{ type: "say", say: "text", ts: Date.now(), text: "hello" }]
renderTaskHeader()
expect(screen.queryByTestId("compact-diff-stats")).not.toBeInTheDocument()
})

it("should show compact diff stats in collapsed view when file edits exist", () => {
mockExtensionState.clineMessages = [
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({
tool: "editedExistingFile",
path: "test.ts",
diffStats: { added: 10, removed: 3 },
}),
},
]
renderTaskHeader()
const compactStats = screen.getByTestId("compact-diff-stats")
expect(compactStats).toBeInTheDocument()
expect(compactStats).toHaveTextContent("+10")
expect(compactStats).toHaveTextContent("-3")
})

it("should show lines changed row in expanded view when file edits exist", () => {
mockExtensionState.clineMessages = [
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({
tool: "editedExistingFile",
path: "test.ts",
diffStats: { added: 25, removed: 8 },
}),
},
]
renderTaskHeader()

// Expand the task header
const taskText = screen.getByText("Test task")
fireEvent.click(taskText)

// Check for the lines changed label and values
expect(screen.getByTestId("lines-changed-label")).toBeInTheDocument()
expect(screen.getByTestId("lines-changed-label")).toHaveTextContent("chat:task.linesChanged")
const value = screen.getByTestId("lines-changed-value")
expect(value).toHaveTextContent("+25")
expect(value).toHaveTextContent("-8")
})

it("should aggregate diff stats across multiple tool messages", () => {
mockExtensionState.clineMessages = [
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({
tool: "editedExistingFile",
path: "a.ts",
diffStats: { added: 10, removed: 2 },
}),
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({
tool: "newFileCreated",
path: "b.ts",
diffStats: { added: 30, removed: 0 },
}),
},
]
renderTaskHeader()
const compactStats = screen.getByTestId("compact-diff-stats")
expect(compactStats).toHaveTextContent("+40")
expect(compactStats).toHaveTextContent("-2")
})

it("should not show diff stats when all diffStats are zero", () => {
mockExtensionState.clineMessages = [
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({
tool: "editedExistingFile",
path: "test.ts",
diffStats: { added: 0, removed: 0 },
}),
},
]
renderTaskHeader()
expect(screen.queryByTestId("compact-diff-stats")).not.toBeInTheDocument()
})
})
})
128 changes: 128 additions & 0 deletions webview-ui/src/components/chat/hooks/__tests__/useDiffStats.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// npx vitest src/components/chat/hooks/__tests__/useDiffStats.spec.ts

import type { ClineMessage, ClineSayTool } from "@roo-code/types"

import { aggregateDiffStats } from "../useDiffStats"

/**
* Helper to build a ClineMessage that mimics an ask="tool" message carrying
* a serialised ClineSayTool payload.
*/
function toolMessage(tool: Partial<ClineSayTool>, type: "ask" | "say" = "ask"): ClineMessage {
const base: ClineMessage = {
ts: Date.now(),
type,
text: JSON.stringify(tool),
} as any

if (type === "ask") {
;(base as any).ask = "tool"
} else {
;(base as any).say = "tool"
}

return base
}

describe("aggregateDiffStats", () => {
it("returns zeros for undefined messages", () => {
expect(aggregateDiffStats(undefined)).toEqual({ totalAdded: 0, totalRemoved: 0 })
})

it("returns zeros for empty messages array", () => {
expect(aggregateDiffStats([])).toEqual({ totalAdded: 0, totalRemoved: 0 })
})

it("returns zeros when no tool messages exist", () => {
const messages: ClineMessage[] = [{ ts: Date.now(), type: "say", say: "text", text: "hello" } as any]
expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 0, totalRemoved: 0 })
})

it("aggregates diffStats from ask=tool editedExistingFile messages", () => {
const messages = [
toolMessage({ tool: "editedExistingFile", path: "a.ts", diffStats: { added: 10, removed: 3 } }),
toolMessage({ tool: "editedExistingFile", path: "b.ts", diffStats: { added: 5, removed: 2 } }),
]
expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 15, totalRemoved: 5 })
})

it("aggregates diffStats from say=tool messages", () => {
const messages = [
toolMessage({ tool: "editedExistingFile", path: "a.ts", diffStats: { added: 7, removed: 1 } }, "say"),
]
expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 7, totalRemoved: 1 })
})

it("aggregates diffStats from appliedDiff tool", () => {
const messages = [toolMessage({ tool: "appliedDiff", path: "c.ts", diffStats: { added: 20, removed: 10 } })]
expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 20, totalRemoved: 10 })
})

it("aggregates diffStats from newFileCreated tool", () => {
const messages = [toolMessage({ tool: "newFileCreated", path: "d.ts", diffStats: { added: 50, removed: 0 } })]
expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 50, totalRemoved: 0 })
})

it("ignores non-file-edit tools (readFile, searchFiles, etc.)", () => {
const messages = [
toolMessage({ tool: "readFile", path: "e.ts", diffStats: { added: 100, removed: 100 } } as any),
toolMessage({ tool: "searchFiles" } as any),
]
expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 0, totalRemoved: 0 })
})

it("handles messages with missing diffStats gracefully", () => {
const messages = [
toolMessage({ tool: "editedExistingFile", path: "f.ts" }), // no diffStats
]
expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 0, totalRemoved: 0 })
})

it("aggregates per-file stats from batchDiffs", () => {
const messages = [
toolMessage({
tool: "appliedDiff",
batchDiffs: [
{ path: "g.ts", changeCount: 2, key: "1", content: "...", diffStats: { added: 8, removed: 2 } },
{ path: "h.ts", changeCount: 1, key: "2", content: "...", diffStats: { added: 3, removed: 1 } },
],
}),
]
expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 11, totalRemoved: 3 })
})

it("aggregates both top-level and batchDiffs stats", () => {
const messages = [
toolMessage({
tool: "editedExistingFile",
path: "i.ts",
diffStats: { added: 5, removed: 2 },
}),
toolMessage({
tool: "appliedDiff",
batchDiffs: [
{ path: "j.ts", changeCount: 1, key: "1", content: "...", diffStats: { added: 10, removed: 0 } },
],
}),
]
expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 15, totalRemoved: 2 })
})

it("handles malformed JSON text gracefully", () => {
const messages: ClineMessage[] = [{ ts: Date.now(), type: "ask", ask: "tool", text: "not valid json" } as any]
expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 0, totalRemoved: 0 })
})

it("handles null text gracefully", () => {
const messages: ClineMessage[] = [{ ts: Date.now(), type: "ask", ask: "tool", text: null } as any]
expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 0, totalRemoved: 0 })
})

it("mixes ask and say tool messages", () => {
const messages = [
toolMessage({ tool: "editedExistingFile", path: "a.ts", diffStats: { added: 3, removed: 1 } }, "ask"),
toolMessage({ tool: "newFileCreated", path: "b.ts", diffStats: { added: 20, removed: 0 } }, "say"),
]
expect(aggregateDiffStats(messages)).toEqual({ totalAdded: 23, totalRemoved: 1 })
})
})
73 changes: 73 additions & 0 deletions webview-ui/src/components/chat/hooks/useDiffStats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { useMemo } from "react"

import type { ClineMessage, ClineSayTool } from "@roo-code/types"

import { safeJsonParse } from "@roo/core"

/** Tools that produce file-editing diffs with diffStats. */
const FILE_EDIT_TOOLS = new Set(["editedExistingFile", "appliedDiff", "newFileCreated"])

export interface AggregatedDiffStats {
totalAdded: number
totalRemoved: number
}

/**
* Extracts and aggregates diffStats from all file-editing tool messages in the
* provided clineMessages array. Both `ask === "tool"` and `say === "tool"`
* messages are inspected because the backend emits diffStats in both paths.
*
* Handles:
* - Individual file tools with a top-level `diffStats` property.
* - Batch diff tools with per-file `batchDiffs[].diffStats` entries.
*
* The result is memoized so it only recomputes when the messages array identity
* or length changes.
*/
export function useDiffStats(clineMessages: ClineMessage[] | undefined): AggregatedDiffStats {
return useMemo(() => aggregateDiffStats(clineMessages), [clineMessages])
}

/**
* Pure function (no hooks) that performs the aggregation. Useful in tests and
* in non-React contexts.
*/
export function aggregateDiffStats(clineMessages: ClineMessage[] | undefined): AggregatedDiffStats {
let totalAdded = 0
let totalRemoved = 0

if (!clineMessages || clineMessages.length === 0) {
return { totalAdded, totalRemoved }
}

for (const msg of clineMessages) {
// Tool messages can appear as ask="tool" or say="tool"
const isTool = msg.ask === "tool" || (msg as any).say === "tool"
if (!isTool || !msg.text) {
continue
}

const parsed = safeJsonParse<ClineSayTool>(msg.text)
if (!parsed) {
continue
}

// Individual file tool
if (FILE_EDIT_TOOLS.has(parsed.tool) && parsed.diffStats) {
totalAdded += parsed.diffStats.added || 0
totalRemoved += parsed.diffStats.removed || 0
}

// Batch diffs (e.g. appliedDiff with multiple files)
if (parsed.batchDiffs && Array.isArray(parsed.batchDiffs)) {
for (const entry of parsed.batchDiffs) {
if (entry.diffStats) {
totalAdded += entry.diffStats.added || 0
totalRemoved += entry.diffStats.removed || 0
}
}
}
}

return { totalAdded, totalRemoved }
}
3 changes: 2 additions & 1 deletion webview-ui/src/i18n/locales/en/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"openInCloudIntro": "Keep monitoring or interacting with Roo from anywhere. Scan, click or copy to open.",
"openApiHistory": "Open API History",
"openUiHistory": "Open UI History",
"backToParentTask": "Parent task"
"backToParentTask": "Parent task",
"linesChanged": "Lines Changed"
},
"unpin": "Unpin",
"pin": "Pin",
Expand Down
Loading