mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-08 03:07:53 +00:00
feat: display per-subtask cost breakdown in todos list
- Add SubtaskDetail interface with id, name, tokens, cost, status fields - Add buildSubtaskDetails() function to aggregate task costs - Pass childDetails in taskWithAggregatedCosts message - Integrate subtask cost display into TodoListDisplay component - Add fuzzy matching to match todo items with subtask details - Display token count and cost next to matching todo items - Add 16 tests for TodoListDisplay with subtask cost functionality Related to: #5376
This commit is contained in:
parent
f48ea389df
commit
774a8f9471
9 changed files with 830 additions and 6 deletions
|
|
@ -188,6 +188,14 @@ export interface ExtensionMessage {
|
|||
totalCost: number
|
||||
ownCost: number
|
||||
childrenCost: number
|
||||
childDetails?: {
|
||||
id: string
|
||||
name: string
|
||||
tokens: number
|
||||
cost: number
|
||||
status: "active" | "completed" | "delegated"
|
||||
hasNestedChildren: boolean
|
||||
}[]
|
||||
}
|
||||
historyItem?: HistoryItem
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,12 @@ import {
|
|||
DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
|
||||
getModelId,
|
||||
} from "@roo-code/types"
|
||||
import { aggregateTaskCostsRecursive, type AggregatedCosts } from "./aggregateTaskCosts"
|
||||
import {
|
||||
aggregateTaskCostsRecursive,
|
||||
buildSubtaskDetails,
|
||||
type AggregatedCosts,
|
||||
type SubtaskDetail,
|
||||
} from "./aggregateTaskCosts"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
import { CloudService, BridgeOrchestrator, getRooCodeApiUrl } from "@roo-code/cloud"
|
||||
|
||||
|
|
@ -1717,7 +1722,24 @@ export class ClineProvider
|
|||
return result.historyItem
|
||||
})
|
||||
|
||||
return { historyItem, aggregatedCosts }
|
||||
// Build subtask details if there are children
|
||||
let childDetails: SubtaskDetail[] | undefined
|
||||
if (aggregatedCosts.childBreakdown && Object.keys(aggregatedCosts.childBreakdown).length > 0) {
|
||||
childDetails = await buildSubtaskDetails(aggregatedCosts.childBreakdown, async (id: string) => {
|
||||
const result = await this.getTaskWithId(id)
|
||||
return result.historyItem
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
historyItem,
|
||||
aggregatedCosts: {
|
||||
totalCost: aggregatedCosts.totalCost,
|
||||
ownCost: aggregatedCosts.ownCost,
|
||||
childrenCost: aggregatedCosts.childrenCost,
|
||||
childDetails,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async showTaskWithId(id: string) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { aggregateTaskCostsRecursive } from "../aggregateTaskCosts.js"
|
||||
import { aggregateTaskCostsRecursive, buildSubtaskDetails } from "../aggregateTaskCosts.js"
|
||||
import type { HistoryItem } from "@roo-code/types"
|
||||
import type { AggregatedCosts } from "../aggregateTaskCosts.js"
|
||||
|
||||
describe("aggregateTaskCostsRecursive", () => {
|
||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
|
@ -324,3 +325,211 @@ describe("aggregateTaskCostsRecursive", () => {
|
|||
expect(result.totalCost).toBe(2.0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildSubtaskDetails", () => {
|
||||
it("should build subtask details from child breakdown", async () => {
|
||||
const childBreakdown: { [childId: string]: AggregatedCosts } = {
|
||||
"child-1": {
|
||||
ownCost: 0.5,
|
||||
childrenCost: 0,
|
||||
totalCost: 0.5,
|
||||
},
|
||||
"child-2": {
|
||||
ownCost: 0.3,
|
||||
childrenCost: 0.2,
|
||||
totalCost: 0.5,
|
||||
},
|
||||
}
|
||||
|
||||
const mockHistory: Record<string, HistoryItem> = {
|
||||
"child-1": {
|
||||
id: "child-1",
|
||||
task: "First subtask",
|
||||
tokensIn: 100,
|
||||
tokensOut: 50,
|
||||
status: "completed",
|
||||
} as unknown as HistoryItem,
|
||||
"child-2": {
|
||||
id: "child-2",
|
||||
task: "Second subtask with nested children",
|
||||
tokensIn: 200,
|
||||
tokensOut: 100,
|
||||
status: "active",
|
||||
} as unknown as HistoryItem,
|
||||
}
|
||||
|
||||
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
|
||||
|
||||
const result = await buildSubtaskDetails(childBreakdown, getTaskHistory)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
|
||||
const child1 = result.find((d) => d.id === "child-1")
|
||||
expect(child1).toBeDefined()
|
||||
expect(child1!.name).toBe("First subtask")
|
||||
expect(child1!.tokens).toBe(150) // 100 + 50
|
||||
expect(child1!.cost).toBe(0.5)
|
||||
expect(child1!.status).toBe("completed")
|
||||
expect(child1!.hasNestedChildren).toBe(false)
|
||||
|
||||
const child2 = result.find((d) => d.id === "child-2")
|
||||
expect(child2).toBeDefined()
|
||||
expect(child2!.name).toBe("Second subtask with nested children")
|
||||
expect(child2!.tokens).toBe(300) // 200 + 100
|
||||
expect(child2!.cost).toBe(0.5)
|
||||
expect(child2!.status).toBe("active")
|
||||
expect(child2!.hasNestedChildren).toBe(true) // childrenCost > 0
|
||||
})
|
||||
|
||||
it("should truncate long task names to 50 characters", async () => {
|
||||
const longTaskName =
|
||||
"This is a very long task name that exceeds fifty characters and should be truncated with ellipsis"
|
||||
const childBreakdown: { [childId: string]: AggregatedCosts } = {
|
||||
"child-1": {
|
||||
ownCost: 1.0,
|
||||
childrenCost: 0,
|
||||
totalCost: 1.0,
|
||||
},
|
||||
}
|
||||
|
||||
const mockHistory: Record<string, HistoryItem> = {
|
||||
"child-1": {
|
||||
id: "child-1",
|
||||
task: longTaskName,
|
||||
tokensIn: 100,
|
||||
tokensOut: 50,
|
||||
status: "completed",
|
||||
} as unknown as HistoryItem,
|
||||
}
|
||||
|
||||
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
|
||||
|
||||
const result = await buildSubtaskDetails(childBreakdown, getTaskHistory)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe("This is a very long task name that exceeds fift...")
|
||||
expect(result[0].name.length).toBe(50)
|
||||
})
|
||||
|
||||
it("should not truncate task names at or under 50 characters", async () => {
|
||||
const exactlyFiftyChars = "12345678901234567890123456789012345678901234567890" // exactly 50 chars
|
||||
const childBreakdown: { [childId: string]: AggregatedCosts } = {
|
||||
"child-1": {
|
||||
ownCost: 1.0,
|
||||
childrenCost: 0,
|
||||
totalCost: 1.0,
|
||||
},
|
||||
}
|
||||
|
||||
const mockHistory: Record<string, HistoryItem> = {
|
||||
"child-1": {
|
||||
id: "child-1",
|
||||
task: exactlyFiftyChars,
|
||||
tokensIn: 100,
|
||||
tokensOut: 50,
|
||||
status: "completed",
|
||||
} as unknown as HistoryItem,
|
||||
}
|
||||
|
||||
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
|
||||
|
||||
const result = await buildSubtaskDetails(childBreakdown, getTaskHistory)
|
||||
|
||||
expect(result[0].name).toBe(exactlyFiftyChars)
|
||||
expect(result[0].name.length).toBe(50)
|
||||
})
|
||||
|
||||
it("should skip children with missing history", async () => {
|
||||
const childBreakdown: { [childId: string]: AggregatedCosts } = {
|
||||
"child-1": {
|
||||
ownCost: 0.5,
|
||||
childrenCost: 0,
|
||||
totalCost: 0.5,
|
||||
},
|
||||
"missing-child": {
|
||||
ownCost: 0.3,
|
||||
childrenCost: 0,
|
||||
totalCost: 0.3,
|
||||
},
|
||||
}
|
||||
|
||||
const mockHistory: Record<string, HistoryItem> = {
|
||||
"child-1": {
|
||||
id: "child-1",
|
||||
task: "Existing subtask",
|
||||
tokensIn: 100,
|
||||
tokensOut: 50,
|
||||
status: "completed",
|
||||
} as unknown as HistoryItem,
|
||||
// missing-child has no history
|
||||
}
|
||||
|
||||
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
|
||||
|
||||
const result = await buildSubtaskDetails(childBreakdown, getTaskHistory)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("child-1")
|
||||
})
|
||||
|
||||
it("should handle empty child breakdown", async () => {
|
||||
const childBreakdown: { [childId: string]: AggregatedCosts } = {}
|
||||
|
||||
const getTaskHistory = vi.fn(async () => undefined)
|
||||
|
||||
const result = await buildSubtaskDetails(childBreakdown, getTaskHistory)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should default status to completed when undefined", async () => {
|
||||
const childBreakdown: { [childId: string]: AggregatedCosts } = {
|
||||
"child-1": {
|
||||
ownCost: 0.5,
|
||||
childrenCost: 0,
|
||||
totalCost: 0.5,
|
||||
},
|
||||
}
|
||||
|
||||
const mockHistory: Record<string, HistoryItem> = {
|
||||
"child-1": {
|
||||
id: "child-1",
|
||||
task: "Subtask without status",
|
||||
tokensIn: 100,
|
||||
tokensOut: 50,
|
||||
// status is undefined
|
||||
} as unknown as HistoryItem,
|
||||
}
|
||||
|
||||
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
|
||||
|
||||
const result = await buildSubtaskDetails(childBreakdown, getTaskHistory)
|
||||
|
||||
expect(result[0].status).toBe("completed")
|
||||
})
|
||||
|
||||
it("should handle undefined token values", async () => {
|
||||
const childBreakdown: { [childId: string]: AggregatedCosts } = {
|
||||
"child-1": {
|
||||
ownCost: 0.5,
|
||||
childrenCost: 0,
|
||||
totalCost: 0.5,
|
||||
},
|
||||
}
|
||||
|
||||
const mockHistory: Record<string, HistoryItem> = {
|
||||
"child-1": {
|
||||
id: "child-1",
|
||||
task: "Subtask without tokens",
|
||||
// tokensIn and tokensOut are undefined
|
||||
status: "completed",
|
||||
} as unknown as HistoryItem,
|
||||
}
|
||||
|
||||
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
|
||||
|
||||
const result = await buildSubtaskDetails(childBreakdown, getTaskHistory)
|
||||
|
||||
expect(result[0].tokens).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
import type { HistoryItem } from "@roo-code/types"
|
||||
|
||||
/**
|
||||
* Detailed information about a subtask for UI display
|
||||
*/
|
||||
export interface SubtaskDetail {
|
||||
id: string // Task ID
|
||||
name: string // First 50 chars of task description
|
||||
tokens: number // tokensIn + tokensOut
|
||||
cost: number // Aggregated total cost
|
||||
status: "active" | "completed" | "delegated"
|
||||
hasNestedChildren: boolean // Has its own subtasks
|
||||
}
|
||||
|
||||
export interface AggregatedCosts {
|
||||
ownCost: number // This task's own API costs
|
||||
childrenCost: number // Sum of all direct children costs (recursive)
|
||||
|
|
@ -8,6 +20,7 @@ export interface AggregatedCosts {
|
|||
// Optional detailed breakdown
|
||||
[childId: string]: AggregatedCosts
|
||||
}
|
||||
childDetails?: SubtaskDetail[] // Detailed subtask info for UI display
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -63,3 +76,39 @@ export async function aggregateTaskCostsRecursive(
|
|||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a task name to a maximum length, adding ellipsis if needed
|
||||
*/
|
||||
function truncateTaskName(task: string, maxLength: number): string {
|
||||
if (task.length <= maxLength) return task
|
||||
return task.substring(0, maxLength - 3) + "..."
|
||||
}
|
||||
|
||||
/**
|
||||
* Build subtask details from child breakdown and history items
|
||||
* for displaying in the UI's expandable subtask list
|
||||
*/
|
||||
export async function buildSubtaskDetails(
|
||||
childBreakdown: { [childId: string]: AggregatedCosts },
|
||||
getTaskHistory: (id: string) => Promise<HistoryItem | undefined>,
|
||||
): Promise<SubtaskDetail[]> {
|
||||
const details: SubtaskDetail[] = []
|
||||
|
||||
for (const [childId, costs] of Object.entries(childBreakdown)) {
|
||||
const history = await getTaskHistory(childId)
|
||||
|
||||
if (history) {
|
||||
details.push({
|
||||
id: childId,
|
||||
name: truncateTaskName(history.task, 50),
|
||||
tokens: (history.tokensIn || 0) + (history.tokensOut || 0),
|
||||
cost: costs.totalCost,
|
||||
status: history.status || "completed",
|
||||
hasNestedChildren: costs.childrenCost > 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,19 @@ import DismissibleUpsell from "../common/DismissibleUpsell"
|
|||
import { useCloudUpsell } from "@src/hooks/useCloudUpsell"
|
||||
import { Cloud } from "lucide-react"
|
||||
|
||||
/**
|
||||
* Detailed information about a subtask for UI display.
|
||||
* Matches the SubtaskDetail interface from backend aggregateTaskCosts.ts
|
||||
*/
|
||||
interface SubtaskDetail {
|
||||
id: string // Task ID
|
||||
name: string // First 50 chars of task description
|
||||
tokens: number // tokensIn + tokensOut
|
||||
cost: number // Aggregated total cost
|
||||
status: "active" | "completed" | "delegated"
|
||||
hasNestedChildren: boolean // Has its own subtasks
|
||||
}
|
||||
|
||||
export interface ChatViewProps {
|
||||
isHidden: boolean
|
||||
showAnnouncement: boolean
|
||||
|
|
@ -174,6 +187,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
totalCost: number
|
||||
ownCost: number
|
||||
childrenCost: number
|
||||
childDetails?: SubtaskDetail[]
|
||||
}
|
||||
>
|
||||
>(new Map())
|
||||
|
|
@ -1490,6 +1504,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
})
|
||||
: undefined
|
||||
}
|
||||
subtaskDetails={
|
||||
currentTaskItem?.id && aggregatedCostsMap.has(currentTaskItem.id)
|
||||
? aggregatedCostsMap.get(currentTaskItem.id)!.childDetails
|
||||
: undefined
|
||||
}
|
||||
contextTokens={apiMetrics.contextTokens}
|
||||
buttonsDisabled={sendingDisabled}
|
||||
handleCondenseContext={handleCondenseContext}
|
||||
|
|
|
|||
123
webview-ui/src/components/chat/SubtaskCostList.tsx
Normal file
123
webview-ui/src/components/chat/SubtaskCostList.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { memo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ChevronRight, ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { formatLargeNumber } from "@/utils/format"
|
||||
|
||||
export interface SubtaskDetail {
|
||||
id: string
|
||||
name: string
|
||||
tokens: number
|
||||
cost: number
|
||||
status: "active" | "completed" | "delegated"
|
||||
hasNestedChildren: boolean
|
||||
}
|
||||
|
||||
export interface SubtaskCostListProps {
|
||||
subtasks: SubtaskDetail[]
|
||||
onSubtaskClick?: (subtaskId: string) => void
|
||||
}
|
||||
|
||||
interface SubtaskRowProps {
|
||||
subtask: SubtaskDetail
|
||||
isLast: boolean
|
||||
onClick?: () => void
|
||||
t: (key: string, options?: Record<string, unknown>) => string
|
||||
}
|
||||
|
||||
const statusColors: Record<SubtaskDetail["status"], string> = {
|
||||
active: "bg-vscode-testing-iconQueued",
|
||||
completed: "bg-vscode-testing-iconPassed",
|
||||
delegated: "bg-vscode-testing-iconSkipped",
|
||||
}
|
||||
|
||||
const SubtaskRow = memo(({ subtask, isLast, onClick, t }: SubtaskRowProps) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full text-left py-1 px-2 rounded",
|
||||
"text-xs text-vscode-descriptionForeground",
|
||||
"hover:bg-vscode-list-hoverBackground transition-colors",
|
||||
)}>
|
||||
{/* Tree indicator */}
|
||||
<span className="text-vscode-descriptionForeground opacity-50 font-mono">{isLast ? "└─" : "├─"}</span>
|
||||
|
||||
{/* Status indicator */}
|
||||
<span
|
||||
className={cn("size-2 rounded-full shrink-0", statusColors[subtask.status])}
|
||||
title={t(`subtasks.status.${subtask.status}`)}
|
||||
/>
|
||||
|
||||
{/* Subtask name */}
|
||||
<span className="flex-1 truncate" title={subtask.name}>
|
||||
"{subtask.name}"
|
||||
</span>
|
||||
|
||||
{/* Nested indicator */}
|
||||
{subtask.hasNestedChildren && (
|
||||
<span className="text-vscode-descriptionForeground opacity-50" title={t("subtasks.hasNestedChildren")}>
|
||||
+
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Token count */}
|
||||
<span className="text-vscode-descriptionForeground opacity-70 tabular-nums">
|
||||
{formatLargeNumber(subtask.tokens)}
|
||||
</span>
|
||||
|
||||
{/* Cost */}
|
||||
<span className="min-w-[60px] text-right tabular-nums">${subtask.cost.toFixed(2)}</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
SubtaskRow.displayName = "SubtaskRow"
|
||||
|
||||
export const SubtaskCostList = memo(({ subtasks, onSubtaskClick }: SubtaskCostListProps) => {
|
||||
const { t } = useTranslation("chat")
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
if (!subtasks || subtasks.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 border-t border-vscode-sideBar-background pt-2">
|
||||
{/* Collapsible Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={cn(
|
||||
"flex items-center gap-1 w-full text-left",
|
||||
"text-sm text-vscode-descriptionForeground",
|
||||
"hover:text-vscode-foreground transition-colors",
|
||||
)}
|
||||
aria-expanded={isExpanded}>
|
||||
{isExpanded ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
|
||||
<span>{t("subtasks.count", { count: subtasks.length })}</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded Subtask List */}
|
||||
{isExpanded && (
|
||||
<div className="mt-2 ml-2 space-y-1">
|
||||
{subtasks.map((subtask, index) => (
|
||||
<SubtaskRow
|
||||
key={subtask.id}
|
||||
subtask={subtask}
|
||||
isLast={index === subtasks.length - 1}
|
||||
onClick={() => onSubtaskClick?.(subtask.id)}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
SubtaskCostList.displayName = "SubtaskCostList"
|
||||
|
||||
export default SubtaskCostList
|
||||
|
|
@ -34,6 +34,7 @@ import { ContextWindowProgress } from "./ContextWindowProgress"
|
|||
import { Mention } from "./Mention"
|
||||
import { TodoListDisplay } from "./TodoListDisplay"
|
||||
import { LucideIconButton } from "./LucideIconButton"
|
||||
import type { SubtaskDetail } from "./SubtaskCostList"
|
||||
|
||||
export interface TaskHeaderProps {
|
||||
task: ClineMessage
|
||||
|
|
@ -45,6 +46,7 @@ export interface TaskHeaderProps {
|
|||
aggregatedCost?: number
|
||||
hasSubtasks?: boolean
|
||||
costBreakdown?: string
|
||||
subtaskDetails?: SubtaskDetail[]
|
||||
contextTokens: number
|
||||
buttonsDisabled: boolean
|
||||
handleCondenseContext: (taskId: string) => void
|
||||
|
|
@ -61,6 +63,7 @@ const TaskHeader = ({
|
|||
aggregatedCost,
|
||||
hasSubtasks,
|
||||
costBreakdown,
|
||||
subtaskDetails,
|
||||
contextTokens,
|
||||
buttonsDisabled,
|
||||
handleCondenseContext,
|
||||
|
|
@ -472,7 +475,15 @@ const TaskHeader = ({
|
|||
</>
|
||||
)}
|
||||
{/* Todo list - always shown at bottom when todos exist */}
|
||||
{hasTodos && <TodoListDisplay todos={todos ?? (task as any)?.tool?.todos ?? []} />}
|
||||
{hasTodos && (
|
||||
<TodoListDisplay
|
||||
todos={todos ?? (task as any)?.tool?.todos ?? []}
|
||||
subtaskDetails={subtaskDetails}
|
||||
onSubtaskClick={(subtaskId) => {
|
||||
vscode.postMessage({ type: "showTaskWithId", text: subtaskId })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<CloudUpsellDialog open={isOpen} onOpenChange={closeUpsell} onConnect={handleConnect} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ import { t } from "i18next"
|
|||
import { ArrowRight, Check, ListChecks, SquareDashed } from "lucide-react"
|
||||
import { useState, useRef, useMemo, useEffect } from "react"
|
||||
|
||||
import { formatLargeNumber } from "@src/utils/format"
|
||||
|
||||
import type { SubtaskDetail } from "./SubtaskCostList"
|
||||
|
||||
type TodoStatus = "completed" | "in_progress" | "pending"
|
||||
|
||||
function getTodoIcon(status: TodoStatus | null) {
|
||||
|
|
@ -16,7 +20,58 @@ function getTodoIcon(status: TodoStatus | null) {
|
|||
}
|
||||
}
|
||||
|
||||
export function TodoListDisplay({ todos }: { todos: any[] }) {
|
||||
/**
|
||||
* Normalizes a string for comparison by:
|
||||
* - Converting to lowercase
|
||||
* - Removing extra whitespace
|
||||
* - Trimming quotes
|
||||
* - Stripping common task prefixes (Subtask N:, ## Task:, Task N:)
|
||||
* - Removing trailing ellipsis from truncated strings
|
||||
*/
|
||||
function normalizeForComparison(str: string): string {
|
||||
return (
|
||||
str
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, "")
|
||||
// Strip common task prefixes: "Subtask N:", "## Task:", "Task N:", etc.
|
||||
.replace(/^(subtask\s*\d*\s*:|##\s*task\s*:|task\s*\d*\s*:)\s*/i, "")
|
||||
// Remove trailing ellipsis from truncated strings
|
||||
.replace(/\.{3}$/, "")
|
||||
.trim()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a todo content string to a subtask detail using fuzzy matching.
|
||||
* Returns the matching SubtaskDetail if found, undefined otherwise.
|
||||
*/
|
||||
function findMatchingSubtask(todoContent: string, subtaskDetails: SubtaskDetail[]): SubtaskDetail | undefined {
|
||||
const normalizedTodo = normalizeForComparison(todoContent)
|
||||
|
||||
// Try exact match first
|
||||
const exactMatch = subtaskDetails.find((s) => normalizeForComparison(s.name) === normalizedTodo)
|
||||
if (exactMatch) {
|
||||
return exactMatch
|
||||
}
|
||||
|
||||
// Try partial match - check if one contains the other
|
||||
const partialMatch = subtaskDetails.find((s) => {
|
||||
const normalizedSubtask = normalizeForComparison(s.name)
|
||||
return normalizedTodo.includes(normalizedSubtask) || normalizedSubtask.includes(normalizedTodo)
|
||||
})
|
||||
|
||||
return partialMatch
|
||||
}
|
||||
|
||||
export interface TodoListDisplayProps {
|
||||
todos: any[]
|
||||
subtaskDetails?: SubtaskDetail[]
|
||||
onSubtaskClick?: (subtaskId: string) => void
|
||||
}
|
||||
|
||||
export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoListDisplayProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(true)
|
||||
const ulRef = useRef<HTMLUListElement>(null)
|
||||
const itemRefs = useRef<(HTMLLIElement | null)[]>([])
|
||||
|
|
@ -82,6 +137,11 @@ export function TodoListDisplay({ todos }: { todos: any[] }) {
|
|||
<ul ref={ulRef} className="list-none max-h-[300px] overflow-y-auto mt-2 -mb-1 pb-0 px-2 cursor-default">
|
||||
{todos.map((todo: any, idx: number) => {
|
||||
const icon = getTodoIcon(todo.status as TodoStatus)
|
||||
const matchingSubtask = subtaskDetails
|
||||
? findMatchingSubtask(todo.content, subtaskDetails)
|
||||
: undefined
|
||||
const isClickable = matchingSubtask && onSubtaskClick
|
||||
|
||||
return (
|
||||
<li
|
||||
key={todo.id || todo.content}
|
||||
|
|
@ -92,7 +152,22 @@ export function TodoListDisplay({ todos }: { todos: any[] }) {
|
|||
todo.status !== "in_progress" && todo.status !== "completed" && "opacity-60",
|
||||
)}>
|
||||
{icon}
|
||||
<span>{todo.content}</span>
|
||||
<span
|
||||
className={cn("flex-1", isClickable && "cursor-pointer hover:underline")}
|
||||
onClick={isClickable ? () => onSubtaskClick(matchingSubtask.id) : undefined}>
|
||||
{todo.content}
|
||||
</span>
|
||||
{/* Token count and cost display */}
|
||||
{matchingSubtask && (
|
||||
<span className="flex items-center gap-2 text-xs text-vscode-descriptionForeground shrink-0">
|
||||
<span className="tabular-nums opacity-70">
|
||||
{formatLargeNumber(matchingSubtask.tokens)}
|
||||
</span>
|
||||
<span className="tabular-nums min-w-[45px] text-right">
|
||||
${matchingSubtask.cost.toFixed(2)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,308 @@
|
|||
import { describe, it, expect, vi } from "vitest"
|
||||
import { render, screen, fireEvent } from "@testing-library/react"
|
||||
|
||||
import { TodoListDisplay } from "../TodoListDisplay"
|
||||
import type { SubtaskDetail } from "../SubtaskCostList"
|
||||
|
||||
// Mock i18next
|
||||
vi.mock("i18next", () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
if (key === "chat:todo.complete") return `${options?.total} to-dos done`
|
||||
if (key === "chat:todo.partial") return `${options?.completed} of ${options?.total} to-dos done`
|
||||
return key
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock format utility
|
||||
vi.mock("@src/utils/format", () => ({
|
||||
formatLargeNumber: (num: number) => {
|
||||
if (num >= 1e3) return `${(num / 1e3).toFixed(1)}k`
|
||||
return num.toString()
|
||||
},
|
||||
}))
|
||||
|
||||
describe("TodoListDisplay", () => {
|
||||
const baseTodos = [
|
||||
{ id: "1", content: "Task 1: Change background colour", status: "completed" },
|
||||
{ id: "2", content: "Task 2: Add timestamp to bottom", status: "completed" },
|
||||
{ id: "3", content: "Task 3: Pending task", status: "pending" },
|
||||
]
|
||||
|
||||
const subtaskDetails: SubtaskDetail[] = [
|
||||
{
|
||||
id: "subtask-1",
|
||||
name: "Task 1: Change background colour",
|
||||
tokens: 95400,
|
||||
cost: 0.22,
|
||||
status: "completed",
|
||||
hasNestedChildren: false,
|
||||
},
|
||||
{
|
||||
id: "subtask-2",
|
||||
name: "Task 2: Add timestamp to bottom",
|
||||
tokens: 95000,
|
||||
cost: 0.24,
|
||||
status: "completed",
|
||||
hasNestedChildren: false,
|
||||
},
|
||||
]
|
||||
|
||||
describe("basic rendering", () => {
|
||||
it("should render nothing when todos is empty", () => {
|
||||
const { container } = render(<TodoListDisplay todos={[]} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it("should render collapsed view by default", () => {
|
||||
render(<TodoListDisplay todos={baseTodos} />)
|
||||
// Should show the first incomplete task in collapsed view
|
||||
expect(screen.getByText("Task 3: Pending task")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should expand when header is clicked", () => {
|
||||
render(<TodoListDisplay todos={baseTodos} />)
|
||||
const header = screen.getByText("Task 3: Pending task")
|
||||
fireEvent.click(header)
|
||||
|
||||
// After expanding, should show all tasks
|
||||
expect(screen.getByText("Task 1: Change background colour")).toBeInTheDocument()
|
||||
expect(screen.getByText("Task 2: Add timestamp to bottom")).toBeInTheDocument()
|
||||
expect(screen.getByText("Task 3: Pending task")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should show completion count when all tasks are complete", () => {
|
||||
const completedTodos = [
|
||||
{ id: "1", content: "Task 1", status: "completed" },
|
||||
{ id: "2", content: "Task 2", status: "completed" },
|
||||
]
|
||||
render(<TodoListDisplay todos={completedTodos} />)
|
||||
expect(screen.getByText("2 to-dos done")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe("subtask cost display", () => {
|
||||
it("should display tokens and cost when subtaskDetails are provided and match", () => {
|
||||
render(<TodoListDisplay todos={baseTodos} subtaskDetails={subtaskDetails} />)
|
||||
|
||||
// Expand to see the items
|
||||
const header = screen.getByText("Task 3: Pending task")
|
||||
fireEvent.click(header)
|
||||
|
||||
// Check for formatted token counts
|
||||
expect(screen.getByText("95.4k")).toBeInTheDocument()
|
||||
expect(screen.getByText("95.0k")).toBeInTheDocument()
|
||||
|
||||
// Check for costs
|
||||
expect(screen.getByText("$0.22")).toBeInTheDocument()
|
||||
expect(screen.getByText("$0.24")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should not display tokens/cost for unmatched todos", () => {
|
||||
render(<TodoListDisplay todos={baseTodos} subtaskDetails={subtaskDetails} />)
|
||||
|
||||
// Expand to see the items
|
||||
const header = screen.getByText("Task 3: Pending task")
|
||||
fireEvent.click(header)
|
||||
|
||||
// The pending task has no matching subtask, should not show cost
|
||||
const listItems = screen.getAllByRole("listitem")
|
||||
const pendingItem = listItems.find((item) => item.textContent?.includes("Task 3: Pending task"))
|
||||
expect(pendingItem).toBeDefined()
|
||||
expect(pendingItem?.textContent).not.toContain("$")
|
||||
})
|
||||
|
||||
it("should not display tokens/cost when subtaskDetails is undefined", () => {
|
||||
render(<TodoListDisplay todos={baseTodos} />)
|
||||
|
||||
// Expand to see the items
|
||||
const header = screen.getByText("Task 3: Pending task")
|
||||
fireEvent.click(header)
|
||||
|
||||
// No cost should be displayed
|
||||
expect(screen.queryByText("$0.22")).not.toBeInTheDocument()
|
||||
expect(screen.queryByText("$0.24")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should not display tokens/cost when subtaskDetails is empty array", () => {
|
||||
render(<TodoListDisplay todos={baseTodos} subtaskDetails={[]} />)
|
||||
|
||||
// Expand to see the items
|
||||
const header = screen.getByText("Task 3: Pending task")
|
||||
fireEvent.click(header)
|
||||
|
||||
// No cost should be displayed
|
||||
expect(screen.queryByText("$0.22")).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe("fuzzy matching", () => {
|
||||
it("should match todos with slightly different names (partial match)", () => {
|
||||
const todosWithSlightlyDifferentNames = [
|
||||
{ id: "1", content: "Change background colour", status: "completed" }, // Missing "Task 1:" prefix
|
||||
]
|
||||
const subtaskWithFullName: SubtaskDetail[] = [
|
||||
{
|
||||
id: "subtask-1",
|
||||
name: "Change background colour", // Exact partial match
|
||||
tokens: 50000,
|
||||
cost: 0.15,
|
||||
status: "completed",
|
||||
hasNestedChildren: false,
|
||||
},
|
||||
]
|
||||
|
||||
render(<TodoListDisplay todos={todosWithSlightlyDifferentNames} subtaskDetails={subtaskWithFullName} />)
|
||||
|
||||
// Expand
|
||||
const header = screen.getByText("1 to-dos done")
|
||||
fireEvent.click(header)
|
||||
|
||||
// Should find the match
|
||||
expect(screen.getByText("$0.15")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should handle case-insensitive matching", () => {
|
||||
const todosLowercase = [{ id: "1", content: "change background colour", status: "completed" }]
|
||||
const subtaskUppercase: SubtaskDetail[] = [
|
||||
{
|
||||
id: "subtask-1",
|
||||
name: "Change Background Colour",
|
||||
tokens: 50000,
|
||||
cost: 0.15,
|
||||
status: "completed",
|
||||
hasNestedChildren: false,
|
||||
},
|
||||
]
|
||||
|
||||
render(<TodoListDisplay todos={todosLowercase} subtaskDetails={subtaskUppercase} />)
|
||||
|
||||
// Expand
|
||||
const header = screen.getByText("1 to-dos done")
|
||||
fireEvent.click(header)
|
||||
|
||||
// Should find the match despite case difference
|
||||
expect(screen.getByText("$0.15")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should match when todo has 'Subtask N:' prefix and subtask has '## Task:' prefix", () => {
|
||||
const todosWithSubtaskPrefix = [
|
||||
{ id: "1", content: "Subtask 1: Change background colour to light purple", status: "completed" },
|
||||
]
|
||||
const subtaskWithMarkdownPrefix: SubtaskDetail[] = [
|
||||
{
|
||||
id: "subtask-1",
|
||||
name: "## Task: Change Background Colour to Light Purp...",
|
||||
tokens: 95400,
|
||||
cost: 0.22,
|
||||
status: "completed",
|
||||
hasNestedChildren: false,
|
||||
},
|
||||
]
|
||||
|
||||
render(<TodoListDisplay todos={todosWithSubtaskPrefix} subtaskDetails={subtaskWithMarkdownPrefix} />)
|
||||
|
||||
// Expand
|
||||
const header = screen.getByText("1 to-dos done")
|
||||
fireEvent.click(header)
|
||||
|
||||
// Should find the match despite different prefixes
|
||||
expect(screen.getByText("$0.22")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should match when subtask name is truncated with ellipsis", () => {
|
||||
const todos = [{ id: "1", content: "Task 1: Add timestamp to the bottom of the page", status: "completed" }]
|
||||
const subtaskWithTruncation: SubtaskDetail[] = [
|
||||
{
|
||||
id: "subtask-1",
|
||||
name: "## Task: Add Timestamp to the Bottom of the Pag...",
|
||||
tokens: 95000,
|
||||
cost: 0.24,
|
||||
status: "completed",
|
||||
hasNestedChildren: false,
|
||||
},
|
||||
]
|
||||
|
||||
render(<TodoListDisplay todos={todos} subtaskDetails={subtaskWithTruncation} />)
|
||||
|
||||
// Expand
|
||||
const header = screen.getByText("1 to-dos done")
|
||||
fireEvent.click(header)
|
||||
|
||||
// Should find the match despite truncation
|
||||
expect(screen.getByText("$0.24")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should strip 'Subtask N:' prefix from todo content", () => {
|
||||
const todosWithNumberedPrefix = [
|
||||
{ id: "1", content: "Subtask 2: Do something important", status: "completed" },
|
||||
]
|
||||
const subtaskWithoutPrefix: SubtaskDetail[] = [
|
||||
{
|
||||
id: "subtask-1",
|
||||
name: "Do something important",
|
||||
tokens: 50000,
|
||||
cost: 0.15,
|
||||
status: "completed",
|
||||
hasNestedChildren: false,
|
||||
},
|
||||
]
|
||||
|
||||
render(<TodoListDisplay todos={todosWithNumberedPrefix} subtaskDetails={subtaskWithoutPrefix} />)
|
||||
|
||||
// Expand
|
||||
const header = screen.getByText("1 to-dos done")
|
||||
fireEvent.click(header)
|
||||
|
||||
// Should find the match after stripping prefix
|
||||
expect(screen.getByText("$0.15")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe("click handler", () => {
|
||||
it("should call onSubtaskClick when a matched todo is clicked", () => {
|
||||
const onSubtaskClick = vi.fn()
|
||||
render(
|
||||
<TodoListDisplay todos={baseTodos} subtaskDetails={subtaskDetails} onSubtaskClick={onSubtaskClick} />,
|
||||
)
|
||||
|
||||
// Expand
|
||||
const header = screen.getByText("Task 3: Pending task")
|
||||
fireEvent.click(header)
|
||||
|
||||
// Click on first matched todo
|
||||
const task1 = screen.getByText("Task 1: Change background colour")
|
||||
fireEvent.click(task1)
|
||||
|
||||
expect(onSubtaskClick).toHaveBeenCalledWith("subtask-1")
|
||||
})
|
||||
|
||||
it("should not call onSubtaskClick when an unmatched todo is clicked", () => {
|
||||
const onSubtaskClick = vi.fn()
|
||||
render(
|
||||
<TodoListDisplay todos={baseTodos} subtaskDetails={subtaskDetails} onSubtaskClick={onSubtaskClick} />,
|
||||
)
|
||||
|
||||
// Expand
|
||||
const header = screen.getByText("Task 3: Pending task")
|
||||
fireEvent.click(header)
|
||||
|
||||
// Click on unmatched todo
|
||||
const task3 = screen.getByText("Task 3: Pending task")
|
||||
fireEvent.click(task3)
|
||||
|
||||
expect(onSubtaskClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not be clickable when onSubtaskClick is not provided", () => {
|
||||
render(<TodoListDisplay todos={baseTodos} subtaskDetails={subtaskDetails} />)
|
||||
|
||||
// Expand
|
||||
const header = screen.getByText("Task 3: Pending task")
|
||||
fireEvent.click(header)
|
||||
|
||||
// Task should be present but not have hover:underline class behavior
|
||||
const task1 = screen.getByText("Task 1: Change background colour")
|
||||
expect(task1.className).not.toContain("cursor-pointer")
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue