feat: show total lines added/removed in task header (#11213)

This commit is contained in:
Roo Code 2026-02-06 04:10:11 +00:00
parent 5b0897beb9
commit 524c8d557a
5 changed files with 351 additions and 1 deletions

View file

@ -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 = ({
</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()}>
@ -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>

View file

@ -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()
})
})
})

View file

@ -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 })
})
})

View file

@ -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 }
}

View file

@ -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",