mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-08 03:07:53 +00:00
feat: link delegated subtasks to todos for cost breakdown
This commit is contained in:
parent
774a8f9471
commit
e18382d9ff
5 changed files with 218 additions and 166 deletions
|
|
@ -14,6 +14,10 @@ export const todoItemSchema = z.object({
|
|||
id: z.string(),
|
||||
content: z.string(),
|
||||
status: todoStatusSchema,
|
||||
// Optional fields for subtask tracking
|
||||
subtaskId: z.string().optional(), // ID of the linked subtask (child task) for direct cost/token attribution
|
||||
tokens: z.number().optional(), // Total tokens (in + out) for linked subtask
|
||||
cost: z.number().optional(), // Total cost for linked subtask
|
||||
})
|
||||
|
||||
export type TodoItem = z.infer<typeof todoItemSchema>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> {
|
|||
const { pushToolResult, handleError, askApproval, toolProtocol } = callbacks
|
||||
|
||||
try {
|
||||
// Pull the previous todo list so we can preserve metadata fields across update_todo_list calls.
|
||||
// Prefer the in-memory task.todoList when available; otherwise fall back to the latest todo list
|
||||
// stored in the conversation history.
|
||||
const previousTodos =
|
||||
getTodoListForTask(task) ?? (getLatestTodo(task.clineMessages) as unknown as TodoItem[])
|
||||
|
||||
const todosRaw = params.todos
|
||||
|
||||
let todos: TodoItem[]
|
||||
|
|
@ -39,6 +45,10 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> {
|
|||
return
|
||||
}
|
||||
|
||||
// Preserve metadata (subtaskId/tokens/cost) for todos whose content matches an existing todo.
|
||||
// Matching is by exact content string; duplicates are matched in order.
|
||||
const todosWithPreservedMetadata = preserveTodoMetadata(todos, previousTodos)
|
||||
|
||||
const { valid, error } = validateTodos(todos)
|
||||
if (!valid) {
|
||||
task.consecutiveMistakeCount++
|
||||
|
|
@ -48,10 +58,13 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> {
|
|||
return
|
||||
}
|
||||
|
||||
let normalizedTodos: TodoItem[] = todos.map((t) => ({
|
||||
let normalizedTodos: TodoItem[] = todosWithPreservedMetadata.map((t) => ({
|
||||
id: t.id,
|
||||
content: t.content,
|
||||
status: normalizeStatus(t.status),
|
||||
subtaskId: t.subtaskId,
|
||||
tokens: t.tokens,
|
||||
cost: t.cost,
|
||||
}))
|
||||
|
||||
const approvalMsg = JSON.stringify({
|
||||
|
|
@ -70,6 +83,11 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> {
|
|||
approvedTodoList !== undefined && JSON.stringify(normalizedTodos) !== JSON.stringify(approvedTodoList)
|
||||
if (isTodoListChanged) {
|
||||
normalizedTodos = approvedTodoList ?? []
|
||||
|
||||
// If the user-edited todo list dropped metadata fields, re-apply metadata preservation against
|
||||
// the previous list (and keep any explicitly provided metadata in the edited list).
|
||||
normalizedTodos = preserveTodoMetadata(normalizedTodos, previousTodos)
|
||||
|
||||
task.say(
|
||||
"user_edit_todos",
|
||||
JSON.stringify({
|
||||
|
|
@ -94,6 +112,7 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> {
|
|||
|
||||
override async handlePartial(task: Task, block: ToolUse<"update_todo_list">): Promise<void> {
|
||||
const todosRaw = block.params.todos
|
||||
const previousTodos = getTodoListForTask(task) ?? (getLatestTodo(task.clineMessages) as unknown as TodoItem[])
|
||||
|
||||
// Parse the markdown checklist to maintain consistent format with execute()
|
||||
let todos: TodoItem[]
|
||||
|
|
@ -104,6 +123,8 @@ export class UpdateTodoListTool extends BaseTool<"update_todo_list"> {
|
|||
todos = []
|
||||
}
|
||||
|
||||
todos = preserveTodoMetadata(todos, previousTodos)
|
||||
|
||||
const approvalMsg = JSON.stringify({
|
||||
tool: "updateTodoList",
|
||||
todos: todos,
|
||||
|
|
@ -181,6 +202,30 @@ function normalizeStatus(status: string | undefined): TodoStatus {
|
|||
return "pending"
|
||||
}
|
||||
|
||||
function preserveTodoMetadata(nextTodos: TodoItem[], previousTodos: TodoItem[]): TodoItem[] {
|
||||
// Build content -> queue mapping so duplicates are matched in order.
|
||||
const previousByContent = new Map<string, TodoItem[]>()
|
||||
for (const prev of previousTodos ?? []) {
|
||||
if (!prev || typeof prev.content !== "string") continue
|
||||
const list = previousByContent.get(prev.content)
|
||||
if (list) list.push(prev)
|
||||
else previousByContent.set(prev.content, [prev])
|
||||
}
|
||||
|
||||
return (nextTodos ?? []).map((next) => {
|
||||
const candidates = previousByContent.get(next.content)
|
||||
const matchedPrev = candidates?.shift()
|
||||
if (!matchedPrev) return next
|
||||
|
||||
return {
|
||||
...next,
|
||||
subtaskId: next.subtaskId ?? matchedPrev.subtaskId,
|
||||
tokens: next.tokens ?? matchedPrev.tokens,
|
||||
cost: next.cost ?? matchedPrev.cost,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function parseMarkdownChecklist(md: string): TodoItem[] {
|
||||
if (typeof md !== "string") return []
|
||||
const lines = md
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ import { webviewMessageHandler } from "./webviewMessageHandler"
|
|||
import type { ClineMessage, TodoItem } from "@roo-code/types"
|
||||
import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-persistence"
|
||||
import { readTaskMessages } from "../task-persistence/taskMessages"
|
||||
import { getLatestTodo } from "../../shared/todo"
|
||||
import { getNonce } from "./getNonce"
|
||||
import { getUri } from "./getUri"
|
||||
import { REQUESTY_BASE_URL } from "../../shared/utils/requesty"
|
||||
|
|
@ -3199,6 +3200,69 @@ export class ClineProvider
|
|||
initialStatus: "active",
|
||||
})
|
||||
|
||||
// 4.5) Direct todo-subtask linking: set todo.subtaskId = childTaskId at delegation-time
|
||||
// Persist by appending an updateTodoList message to the parent's message history.
|
||||
try {
|
||||
const globalStoragePath = this.contextProxy.globalStorageUri.fsPath
|
||||
const parentMessages = await readTaskMessages({ taskId: parentTaskId, globalStoragePath })
|
||||
const todos = getLatestTodo(parentMessages) as unknown as TodoItem[]
|
||||
|
||||
const inProgress = todos.filter((t) => t?.status === "in_progress")
|
||||
const pending = todos.filter((t) => t?.status === "pending")
|
||||
|
||||
// Deterministic selection rule (in_progress > pending): pick the first matching item
|
||||
// in the list order, even if multiple candidates exist.
|
||||
const chosen: TodoItem | undefined = inProgress[0] ?? pending[0]
|
||||
if (!chosen) {
|
||||
this.log(
|
||||
`[delegateParentAndOpenChild] Not linking subtask ${child.taskId}: no in_progress or pending todos found`,
|
||||
)
|
||||
} else {
|
||||
// Log ambiguity (but still link deterministically).
|
||||
if (inProgress.length > 1) {
|
||||
this.log(
|
||||
`[delegateParentAndOpenChild] Multiple in_progress todos (${inProgress.length}); linking first to subtask ${child.taskId}`,
|
||||
)
|
||||
} else if (pending.length > 1 && inProgress.length === 0) {
|
||||
this.log(
|
||||
`[delegateParentAndOpenChild] Multiple pending todos (${pending.length}); linking first to subtask ${child.taskId}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (chosen) {
|
||||
if (chosen.subtaskId && chosen.subtaskId !== child.taskId) {
|
||||
this.log(
|
||||
`[delegateParentAndOpenChild] Overwriting existing todo.subtaskId '${chosen.subtaskId}' -> '${child.taskId}'`,
|
||||
)
|
||||
}
|
||||
chosen.subtaskId = child.taskId
|
||||
|
||||
await saveTaskMessages({
|
||||
messages: [
|
||||
...parentMessages,
|
||||
{
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: "user_edit_todos",
|
||||
text: JSON.stringify({
|
||||
tool: "updateTodoList",
|
||||
todos,
|
||||
}),
|
||||
},
|
||||
],
|
||||
taskId: parentTaskId,
|
||||
globalStoragePath,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`[delegateParentAndOpenChild] Failed to persist delegation-time todo link (non-fatal): ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 5) Persist parent delegation metadata
|
||||
try {
|
||||
const { historyItem } = await this.getTaskWithId(parentTaskId)
|
||||
|
|
@ -3240,6 +3304,19 @@ export class ClineProvider
|
|||
const { parentTaskId, childTaskId, completionResultSummary } = params
|
||||
const globalStoragePath = this.contextProxy.globalStorageUri.fsPath
|
||||
|
||||
// 0) Load child task history to capture tokens/cost for write-back.
|
||||
let childHistoryItem: HistoryItem | undefined
|
||||
try {
|
||||
const { historyItem } = await this.getTaskWithId(childTaskId)
|
||||
childHistoryItem = historyItem
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`[reopenParentFromDelegation] Failed to load child history for ${childTaskId} (non-fatal): ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 1) Load parent from history and current persisted messages
|
||||
const { historyItem } = await this.getTaskWithId(parentTaskId)
|
||||
|
||||
|
|
@ -3277,6 +3354,40 @@ export class ClineProvider
|
|||
ts,
|
||||
}
|
||||
parentClineMessages.push(subtaskUiMessage)
|
||||
|
||||
// 2.5) Persist provider completion write-back: update parent's todo item with tokens/cost.
|
||||
try {
|
||||
const todos = getLatestTodo(parentClineMessages) as unknown as TodoItem[]
|
||||
if (Array.isArray(todos) && todos.length > 0) {
|
||||
const linkedTodo = todos.find((t) => t?.subtaskId === childTaskId)
|
||||
if (!linkedTodo) {
|
||||
this.log(
|
||||
`[reopenParentFromDelegation] No todo found with subtaskId === ${childTaskId}; skipping cost write-back`,
|
||||
)
|
||||
} else {
|
||||
linkedTodo.tokens = (childHistoryItem?.tokensIn || 0) + (childHistoryItem?.tokensOut || 0)
|
||||
linkedTodo.cost = childHistoryItem?.totalCost || 0
|
||||
|
||||
parentClineMessages.push({
|
||||
ts: Date.now(),
|
||||
type: "say",
|
||||
say: "user_edit_todos",
|
||||
text: JSON.stringify({
|
||||
tool: "updateTodoList",
|
||||
todos,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`[reopenParentFromDelegation] Failed to write back todo cost/tokens (non-fatal): ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Persist injected UI records (subtask_result + optional todo write-back)
|
||||
await saveTaskMessages({ messages: parentClineMessages, taskId: parentTaskId, globalStoragePath })
|
||||
|
||||
// Find the tool_use_id from the last assistant message's new_task tool_use
|
||||
|
|
@ -3355,7 +3466,7 @@ export class ClineProvider
|
|||
|
||||
// 3) Update child metadata to "completed" status
|
||||
try {
|
||||
const { historyItem: childHistory } = await this.getTaskWithId(childTaskId)
|
||||
const childHistory = childHistoryItem ?? (await this.getTaskWithId(childTaskId)).historyItem
|
||||
await this.updateTaskHistory({
|
||||
...childHistory,
|
||||
status: "completed",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,18 @@ import type { SubtaskDetail } from "./SubtaskCostList"
|
|||
|
||||
type TodoStatus = "completed" | "in_progress" | "pending"
|
||||
|
||||
interface TodoItem {
|
||||
// Legacy fields
|
||||
id?: string
|
||||
content: string
|
||||
status?: TodoStatus | string | null
|
||||
|
||||
// Direct-linking/cost fields (optional for backward compatibility)
|
||||
subtaskId?: string
|
||||
tokens?: number
|
||||
cost?: number
|
||||
}
|
||||
|
||||
function getTodoIcon(status: TodoStatus | null) {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
|
|
@ -20,53 +32,8 @@ function getTodoIcon(status: TodoStatus | null) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[]
|
||||
todos: TodoItem[]
|
||||
subtaskDetails?: SubtaskDetail[]
|
||||
onSubtaskClick?: (subtaskId: string) => void
|
||||
}
|
||||
|
|
@ -76,16 +43,16 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL
|
|||
const ulRef = useRef<HTMLUListElement>(null)
|
||||
const itemRefs = useRef<(HTMLLIElement | null)[]>([])
|
||||
const scrollIndex = useMemo(() => {
|
||||
const inProgressIdx = todos.findIndex((todo: any) => todo.status === "in_progress")
|
||||
const inProgressIdx = todos.findIndex((todo) => todo.status === "in_progress")
|
||||
if (inProgressIdx !== -1) return inProgressIdx
|
||||
return todos.findIndex((todo: any) => todo.status !== "completed")
|
||||
return todos.findIndex((todo) => todo.status !== "completed")
|
||||
}, [todos])
|
||||
|
||||
// Find the most important todo to display when collapsed
|
||||
const mostImportantTodo = useMemo(() => {
|
||||
const inProgress = todos.find((todo: any) => todo.status === "in_progress")
|
||||
const inProgress = todos.find((todo) => todo.status === "in_progress")
|
||||
if (inProgress) return inProgress
|
||||
return todos.find((todo: any) => todo.status !== "completed")
|
||||
return todos.find((todo) => todo.status !== "completed")
|
||||
}, [todos])
|
||||
useEffect(() => {
|
||||
if (isCollapsed) return
|
||||
|
|
@ -104,7 +71,7 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL
|
|||
if (!Array.isArray(todos) || todos.length === 0) return null
|
||||
|
||||
const totalCount = todos.length
|
||||
const completedCount = todos.filter((todo: any) => todo.status === "completed").length
|
||||
const completedCount = todos.filter((todo) => todo.status === "completed").length
|
||||
|
||||
const allCompleted = completedCount === totalCount && totalCount > 0
|
||||
|
||||
|
|
@ -135,12 +102,16 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL
|
|||
{/* Inline expanded list */}
|
||||
{!isCollapsed && (
|
||||
<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) => {
|
||||
{todos.map((todo, idx: number) => {
|
||||
const icon = getTodoIcon(todo.status as TodoStatus)
|
||||
const matchingSubtask = subtaskDetails
|
||||
? findMatchingSubtask(todo.content, subtaskDetails)
|
||||
: undefined
|
||||
const isClickable = matchingSubtask && onSubtaskClick
|
||||
const isClickable = Boolean(todo.subtaskId && onSubtaskClick)
|
||||
const subtaskById =
|
||||
subtaskDetails && todo.subtaskId
|
||||
? subtaskDetails.find((s) => s.id === todo.subtaskId)
|
||||
: undefined
|
||||
const displayTokens = todo.tokens ?? subtaskById?.tokens
|
||||
const displayCost = todo.cost ?? subtaskById?.cost
|
||||
const shouldShowCost = typeof displayTokens === "number" && typeof displayCost === "number"
|
||||
|
||||
return (
|
||||
<li
|
||||
|
|
@ -154,17 +125,19 @@ export function TodoListDisplay({ todos, subtaskDetails, onSubtaskClick }: TodoL
|
|||
{icon}
|
||||
<span
|
||||
className={cn("flex-1", isClickable && "cursor-pointer hover:underline")}
|
||||
onClick={isClickable ? () => onSubtaskClick(matchingSubtask.id) : undefined}>
|
||||
onClick={
|
||||
isClickable ? () => onSubtaskClick?.(todo.subtaskId as string) : undefined
|
||||
}>
|
||||
{todo.content}
|
||||
</span>
|
||||
{/* Token count and cost display */}
|
||||
{matchingSubtask && (
|
||||
{shouldShowCost && (
|
||||
<span className="flex items-center gap-2 text-xs text-vscode-descriptionForeground shrink-0">
|
||||
<span className="tabular-nums opacity-70">
|
||||
{formatLargeNumber(matchingSubtask.tokens)}
|
||||
{formatLargeNumber(displayTokens)}
|
||||
</span>
|
||||
<span className="tabular-nums min-w-[45px] text-right">
|
||||
${matchingSubtask.cost.toFixed(2)}
|
||||
${displayCost.toFixed(2)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ vi.mock("@src/utils/format", () => ({
|
|||
|
||||
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: "1", content: "Task 1: Change background colour", status: "completed", subtaskId: "subtask-1" },
|
||||
{ id: "2", content: "Task 2: Add timestamp to bottom", status: "completed", subtaskId: "subtask-2" },
|
||||
{ id: "3", content: "Task 3: Pending task", status: "pending" },
|
||||
]
|
||||
|
||||
|
|
@ -81,7 +81,7 @@ describe("TodoListDisplay", () => {
|
|||
})
|
||||
|
||||
describe("subtask cost display", () => {
|
||||
it("should display tokens and cost when subtaskDetails are provided and match", () => {
|
||||
it("should display tokens and cost when subtaskDetails are provided and todo.subtaskId matches", () => {
|
||||
render(<TodoListDisplay todos={baseTodos} subtaskDetails={subtaskDetails} />)
|
||||
|
||||
// Expand to see the items
|
||||
|
|
@ -97,14 +97,14 @@ describe("TodoListDisplay", () => {
|
|||
expect(screen.getByText("$0.24")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should not display tokens/cost for unmatched todos", () => {
|
||||
it("should not display tokens/cost for todos without subtaskId", () => {
|
||||
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
|
||||
// The pending task has no subtaskId, should not show cost
|
||||
const listItems = screen.getAllByRole("listitem")
|
||||
const pendingItem = listItems.find((item) => item.textContent?.includes("Task 3: Pending task"))
|
||||
expect(pendingItem).toBeDefined()
|
||||
|
|
@ -135,131 +135,50 @@ describe("TodoListDisplay", () => {
|
|||
})
|
||||
})
|
||||
|
||||
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[] = [
|
||||
describe("direct subtask linking", () => {
|
||||
it("should use todo.tokens and todo.cost when provided (no subtaskDetails required)", () => {
|
||||
const todosWithDirectCost = [
|
||||
{
|
||||
id: "subtask-1",
|
||||
name: "Change background colour", // Exact partial match
|
||||
tokens: 50000,
|
||||
cost: 0.15,
|
||||
id: "1",
|
||||
content: "Task 1: Change background colour",
|
||||
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...",
|
||||
subtaskId: "subtask-1",
|
||||
tokens: 95400,
|
||||
cost: 0.22,
|
||||
status: "completed",
|
||||
hasNestedChildren: false,
|
||||
},
|
||||
]
|
||||
|
||||
render(<TodoListDisplay todos={todosWithSubtaskPrefix} subtaskDetails={subtaskWithMarkdownPrefix} />)
|
||||
render(<TodoListDisplay todos={todosWithDirectCost} />)
|
||||
|
||||
// Expand
|
||||
const header = screen.getByText("1 to-dos done")
|
||||
fireEvent.click(header)
|
||||
|
||||
// Should find the match despite different prefixes
|
||||
expect(screen.getByText("95.4k")).toBeInTheDocument()
|
||||
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[] = [
|
||||
it("should fall back to subtaskDetails by ID when todo.tokens/cost are missing", () => {
|
||||
const todosMissingCostFields = [
|
||||
{
|
||||
id: "subtask-1",
|
||||
name: "## Task: Add Timestamp to the Bottom of the Pag...",
|
||||
tokens: 95000,
|
||||
cost: 0.24,
|
||||
id: "1",
|
||||
content: "Task 1: Change background colour",
|
||||
status: "completed",
|
||||
hasNestedChildren: false,
|
||||
subtaskId: "subtask-1",
|
||||
},
|
||||
]
|
||||
|
||||
render(<TodoListDisplay todos={todos} subtaskDetails={subtaskWithTruncation} />)
|
||||
render(<TodoListDisplay todos={todosMissingCostFields} subtaskDetails={subtaskDetails} />)
|
||||
|
||||
// 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()
|
||||
expect(screen.getByText("95.4k")).toBeInTheDocument()
|
||||
expect(screen.getByText("$0.22")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe("click handler", () => {
|
||||
it("should call onSubtaskClick when a matched todo is clicked", () => {
|
||||
it("should call onSubtaskClick when a todo with subtaskId is clicked", () => {
|
||||
const onSubtaskClick = vi.fn()
|
||||
render(
|
||||
<TodoListDisplay todos={baseTodos} subtaskDetails={subtaskDetails} onSubtaskClick={onSubtaskClick} />,
|
||||
|
|
@ -276,7 +195,7 @@ describe("TodoListDisplay", () => {
|
|||
expect(onSubtaskClick).toHaveBeenCalledWith("subtask-1")
|
||||
})
|
||||
|
||||
it("should not call onSubtaskClick when an unmatched todo is clicked", () => {
|
||||
it("should not call onSubtaskClick when a todo does not have subtaskId", () => {
|
||||
const onSubtaskClick = vi.fn()
|
||||
render(
|
||||
<TodoListDisplay todos={baseTodos} subtaskDetails={subtaskDetails} onSubtaskClick={onSubtaskClick} />,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue