mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
feat: show total lines added/removed in task header (#11213)
This commit is contained in:
parent
5b0897beb9
commit
524c8d557a
5 changed files with 351 additions and 1 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
webview-ui/src/components/chat/hooks/useDiffStats.ts
Normal file
73
webview-ui/src/components/chat/hooks/useDiffStats.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue