diff --git a/packages/types/src/todo.ts b/packages/types/src/todo.ts index 4e874e1750..0530f92054 100644 --- a/packages/types/src/todo.ts +++ b/packages/types/src/todo.ts @@ -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 diff --git a/src/core/tools/UpdateTodoListTool.ts b/src/core/tools/UpdateTodoListTool.ts index f8b3653b9a..cb2c37cb21 100644 --- a/src/core/tools/UpdateTodoListTool.ts +++ b/src/core/tools/UpdateTodoListTool.ts @@ -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 { 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() + 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 diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 4f09371cb4..9acb26211a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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", diff --git a/webview-ui/src/components/chat/TodoListDisplay.tsx b/webview-ui/src/components/chat/TodoListDisplay.tsx index 06d8185ef8..89eb1151e8 100644 --- a/webview-ui/src/components/chat/TodoListDisplay.tsx +++ b/webview-ui/src/components/chat/TodoListDisplay.tsx @@ -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(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 && (
    - {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 (
  • onSubtaskClick(matchingSubtask.id) : undefined}> + onClick={ + isClickable ? () => onSubtaskClick?.(todo.subtaskId as string) : undefined + }> {todo.content} {/* Token count and cost display */} - {matchingSubtask && ( + {shouldShowCost && ( - {formatLargeNumber(matchingSubtask.tokens)} + {formatLargeNumber(displayTokens)} - ${matchingSubtask.cost.toFixed(2)} + ${displayCost.toFixed(2)} )} diff --git a/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx b/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx index be84b9d28f..74933bbc95 100644 --- a/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TodoListDisplay.spec.tsx @@ -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() // 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() // 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() - - // 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() - - // 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() + render() // 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() + render() // 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() - - // 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( , @@ -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( ,