From 524c8d557a61146cfb529be0d178ac44585879a7 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 6 Feb 2026 04:10:11 +0000 Subject: [PATCH] feat: show total lines added/removed in task header (#11213) --- webview-ui/src/components/chat/TaskHeader.tsx | 33 +++++ .../chat/__tests__/TaskHeader.spec.tsx | 115 ++++++++++++++++ .../chat/hooks/__tests__/useDiffStats.spec.ts | 128 ++++++++++++++++++ .../src/components/chat/hooks/useDiffStats.ts | 73 ++++++++++ webview-ui/src/i18n/locales/en/chat.json | 3 +- 5 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 webview-ui/src/components/chat/hooks/__tests__/useDiffStats.spec.ts create mode 100644 webview-ui/src/components/chat/hooks/useDiffStats.ts diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index d5424b74221..801cd7d191d 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -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 @@ -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) @@ -335,6 +338,15 @@ const TaskHeader = ({ )} + {hasDiffStats && ( + <> + ยท + + +{diffStats.totalAdded} + -{diffStats.totalRemoved} + + + )} {showBrowserGlobe && (
e.stopPropagation()}> @@ -500,6 +512,27 @@ const TaskHeader = ({ )} + {/* Lines changed display */} + {hasDiffStats && ( + + + {t("chat:task.linesChanged")} + + +
+ + +{diffStats.totalAdded} + + + -{diffStats.totalRemoved} + +
+ + + )} + {/* Size display */} {!!currentTaskItem?.size && currentTaskItem.size > 0 && ( diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index c4ebe06973a..6099f949820 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -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() + }) + }) }) diff --git a/webview-ui/src/components/chat/hooks/__tests__/useDiffStats.spec.ts b/webview-ui/src/components/chat/hooks/__tests__/useDiffStats.spec.ts new file mode 100644 index 00000000000..a8b6a2831a8 --- /dev/null +++ b/webview-ui/src/components/chat/hooks/__tests__/useDiffStats.spec.ts @@ -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, 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 }) + }) +}) diff --git a/webview-ui/src/components/chat/hooks/useDiffStats.ts b/webview-ui/src/components/chat/hooks/useDiffStats.ts new file mode 100644 index 00000000000..9acb063d091 --- /dev/null +++ b/webview-ui/src/components/chat/hooks/useDiffStats.ts @@ -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(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 } +} diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index b9652cfce5c..217fc5d7228 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -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",