diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage.multi-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage.multi-tool.spec.ts new file mode 100644 index 0000000000..85b916bdeb --- /dev/null +++ b/src/core/assistant-message/__tests__/presentAssistantMessage.multi-tool.spec.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { presentAssistantMessage } from "../presentAssistantMessage" +import { Task } from "../../task/Task" +import { updateTodoListTool } from "../../tools/updateTodoListTool" +import { readFileTool } from "../../tools/readFileTool" + +vi.mock("../../tools/updateTodoListTool") +vi.mock("../../tools/readFileTool") +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureToolUsage: vi.fn(), + captureConsecutiveMistakeError: vi.fn(), + }, + hasInstance: () => true, + }, +})) + +describe("presentAssistantMessage - Multi-tool execution", () => { + let mockTask: Task + let mockAskApproval: any + let mockHandleError: any + let mockPushToolResult: any + + beforeEach(() => { + vi.clearAllMocks() + + // Create a minimal mock task + mockTask = { + taskId: "test-task", + instanceId: "test-instance", + abort: false, + presentAssistantMessageLocked: false, + presentAssistantMessageHasPendingUpdates: false, + currentStreamingContentIndex: 0, + assistantMessageContent: [], + didCompleteReadingStream: false, + userMessageContentReady: false, + didRejectTool: false, + didAlreadyUseTool: false, + userMessageContent: [], + say: vi.fn(), + ask: vi.fn(), + recordToolUsage: vi.fn(), + recordToolError: vi.fn(), + consecutiveMistakeCount: 0, + clineMessages: [], + apiConversationHistory: [], + todoList: [], + checkpointSave: vi.fn(), + currentStreamingDidCheckpoint: false, + browserSession: { closeBrowser: vi.fn() }, + toolRepetitionDetector: { check: vi.fn(() => ({ allowExecution: true })) }, + providerRef: { deref: vi.fn(() => ({ getState: vi.fn(() => ({ mode: "code" })) })) }, + api: { getModel: vi.fn(() => ({ id: "test-model" })) }, + } as any + + mockAskApproval = vi.fn(() => Promise.resolve(true)) + mockHandleError = vi.fn() + mockPushToolResult = vi.fn() + }) + + it("should allow update_todo_list to execute alongside other tools", async () => { + // Set up assistant message content with two tools + mockTask.assistantMessageContent = [ + { + type: "tool_use", + name: "read_file", + params: { path: "test.txt" }, + partial: false, + }, + { + type: "tool_use", + name: "update_todo_list", + params: { todos: "[ ] Test todo" }, + partial: false, + }, + ] + + // Mock the tool implementations + vi.mocked(readFileTool).mockImplementation(async (cline, block, askApproval, handleError, pushToolResult) => { + pushToolResult("File content") + }) + + vi.mocked(updateTodoListTool).mockImplementation( + async (cline, block, askApproval, handleError, pushToolResult) => { + pushToolResult("Todo list updated") + }, + ) + + // Process first tool + mockTask.currentStreamingContentIndex = 0 + await presentAssistantMessage(mockTask) + + // After first tool, didAlreadyUseTool should be true + expect(mockTask.didAlreadyUseTool).toBe(true) + + // Process second tool (update_todo_list) + mockTask.currentStreamingContentIndex = 1 + await presentAssistantMessage(mockTask) + + // Both tools should have been executed + expect(readFileTool).toHaveBeenCalledTimes(1) + expect(updateTodoListTool).toHaveBeenCalledTimes(1) + + // Check that both tool results were pushed + // The first two entries should be for read_file + expect(mockTask.userMessageContent[0]).toEqual({ + type: "text", + text: expect.stringContaining("Result:"), + }) + expect(mockTask.userMessageContent[1]).toEqual({ + type: "text", + text: "File content", + }) + // The next two entries should be for update_todo_list + expect(mockTask.userMessageContent[2]).toEqual({ + type: "text", + text: "[update_todo_list] Result:", + }) + expect(mockTask.userMessageContent[3]).toEqual({ + type: "text", + text: "Todo list updated", + }) + }) + + it("should block non-update_todo_list tools after a tool has been used", async () => { + // Set up assistant message content with two non-update_todo_list tools + mockTask.assistantMessageContent = [ + { + type: "tool_use", + name: "read_file", + params: { path: "test.txt" }, + partial: false, + }, + { + type: "tool_use", + name: "write_to_file", + params: { path: "test.txt", content: "new content" }, + partial: false, + }, + ] + + // Mock the read_file tool + vi.mocked(readFileTool).mockImplementation(async (cline, block, askApproval, handleError, pushToolResult) => { + pushToolResult("File content") + }) + + // Process first tool + mockTask.currentStreamingContentIndex = 0 + await presentAssistantMessage(mockTask) + + // After first tool, didAlreadyUseTool should be true + expect(mockTask.didAlreadyUseTool).toBe(true) + + // Process second tool (should be blocked) + mockTask.currentStreamingContentIndex = 1 + await presentAssistantMessage(mockTask) + + // Only the first tool should have been executed + expect(readFileTool).toHaveBeenCalledTimes(1) + + // Check that the second tool was blocked + expect(mockTask.userMessageContent).toContainEqual( + expect.objectContaining({ + type: "text", + text: expect.stringContaining( + "Tool [write_to_file] was not executed because a tool has already been used", + ), + }), + ) + }) + + it("should not set didAlreadyUseTool when update_todo_list is executed", async () => { + // Set up assistant message content with update_todo_list first + mockTask.assistantMessageContent = [ + { + type: "tool_use", + name: "update_todo_list", + params: { todos: "[ ] Test todo" }, + partial: false, + }, + ] + + // Mock the tool implementation + vi.mocked(updateTodoListTool).mockImplementation( + async (cline, block, askApproval, handleError, pushToolResult) => { + pushToolResult("Todo list updated") + }, + ) + + // Process the update_todo_list tool + await presentAssistantMessage(mockTask) + + // didAlreadyUseTool should remain false for update_todo_list + expect(mockTask.didAlreadyUseTool).toBe(false) + expect(updateTodoListTool).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index a8b90728b1..01f1e0eefb 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -242,8 +242,11 @@ export async function presentAssistantMessage(cline: Task) { break } - if (cline.didAlreadyUseTool) { - // Ignore any content after a tool has already been used. + // Special handling for update_todo_list - it can be used alongside other tools + const isUpdateTodoList = block.name === "update_todo_list" + + if (cline.didAlreadyUseTool && !isUpdateTodoList) { + // Ignore any content after a tool has already been used (except update_todo_list). cline.userMessageContent.push({ type: "text", text: `Tool [${block.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message. You must assess the first tool's result before proceeding to use the next tool.`, @@ -263,8 +266,10 @@ export async function presentAssistantMessage(cline: Task) { // Once a tool result has been collected, ignore all other tool // uses since we should only ever present one tool result per - // message. - cline.didAlreadyUseTool = true + // message. Exception: update_todo_list can be used alongside other tools. + if (!isUpdateTodoList) { + cline.didAlreadyUseTool = true + } } const askApproval = async ( diff --git a/src/core/tools/__tests__/updateTodoListTool.diff.spec.ts b/src/core/tools/__tests__/updateTodoListTool.diff.spec.ts new file mode 100644 index 0000000000..2c16306209 --- /dev/null +++ b/src/core/tools/__tests__/updateTodoListTool.diff.spec.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { updateTodoListTool } from "../updateTodoListTool" +import { Task } from "../../task/Task" +import { formatResponse } from "../../prompts/responses" + +vi.mock("../../prompts/responses", () => ({ + formatResponse: { + toolError: vi.fn((msg) => `Error: ${msg}`), + toolResult: vi.fn((msg) => msg), + }, +})) + +describe("updateTodoListTool - Diff Generation", () => { + let mockTask: Task + let mockAskApproval: any + let mockHandleError: any + let mockPushToolResult: any + let mockRemoveClosingTag: any + + beforeEach(() => { + vi.clearAllMocks() + + mockTask = { + taskId: "test-task", + todoList: [ + { id: "1", content: "Existing task 1", status: "pending" }, + { id: "2", content: "Existing task 2", status: "completed" }, + { id: "3", content: "Existing task 3", status: "in_progress" }, + ], + say: vi.fn(), + ask: vi.fn(), + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + } as any + + mockAskApproval = vi.fn(() => Promise.resolve(true)) + mockHandleError = vi.fn() + mockPushToolResult = vi.fn() + mockRemoveClosingTag = vi.fn((tag, text) => text) + }) + + it("should generate diff for added todos", async () => { + const block = { + name: "update_todo_list", + params: { + todos: `[x] Existing task 2 +[-] Existing task 3 +[ ] Existing task 1 +[ ] New task 4`, + }, + partial: false, + } as any + + await updateTodoListTool( + mockTask, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Check that the diff was generated and included in the result + expect(mockPushToolResult).toHaveBeenCalled() + const result = mockPushToolResult.mock.calls[0][0] + expect(result).toContain("Added:") + expect(result).toContain("+ [ ] New task 4") + }) + + it("should generate diff for removed todos", async () => { + const block = { + name: "update_todo_list", + params: { + todos: `[x] Existing task 2 +[-] Existing task 3`, + }, + partial: false, + } as any + + await updateTodoListTool( + mockTask, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Check that the diff was generated and included in the result + expect(mockPushToolResult).toHaveBeenCalled() + const result = mockPushToolResult.mock.calls[0][0] + expect(result).toContain("Removed:") + expect(result).toContain("- [ ] Existing task 1") + }) + + it("should generate diff for modified todos", async () => { + const block = { + name: "update_todo_list", + params: { + todos: `[ ] Modified task 1 +[x] Existing task 2 +[ ] Existing task 3`, + }, + partial: false, + } as any + + await updateTodoListTool( + mockTask, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Check that the diff was generated and included in the result + expect(mockPushToolResult).toHaveBeenCalled() + const result = mockPushToolResult.mock.calls[0][0] + expect(result).toContain("Modified:") + // Should show content change + expect(result).toMatch(/"Existing task 1".*→.*"Modified task 1"/) + // Should show status change + expect(result).toMatch(/Status:.*in_progress.*→.*pending/) + }) + + it("should handle no changes", async () => { + const block = { + name: "update_todo_list", + params: { + todos: `[ ] Existing task 1 +[x] Existing task 2 +[-] Existing task 3`, + }, + partial: false, + } as any + + await updateTodoListTool( + mockTask, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Check that the result indicates no changes + expect(mockPushToolResult).toHaveBeenCalled() + const result = mockPushToolResult.mock.calls[0][0] + expect(result).toContain("no changes") + }) + + it("should include diff text in approval message", async () => { + const block = { + name: "update_todo_list", + params: { + todos: `[ ] Existing task 1 +[x] Existing task 2 +[-] Existing task 3 +[ ] New task 4`, + }, + partial: false, + } as any + + await updateTodoListTool( + mockTask, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Check that askApproval was called with diff text + expect(mockAskApproval).toHaveBeenCalled() + const approvalMsg = mockAskApproval.mock.calls[0][1] + const parsed = JSON.parse(approvalMsg) + expect(parsed.diffText).toBeDefined() + expect(parsed.diffText).toContain("Added:") + expect(parsed.diffText).toContain("+ [ ] New task 4") + }) + + it("should handle empty initial todo list", async () => { + mockTask.todoList = [] + + const block = { + name: "update_todo_list", + params: { + todos: `[ ] First task +[ ] Second task`, + }, + partial: false, + } as any + + await updateTodoListTool( + mockTask, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Check that the diff shows all items as added + expect(mockPushToolResult).toHaveBeenCalled() + const result = mockPushToolResult.mock.calls[0][0] + expect(result).toContain("Added:") + expect(result).toContain("+ [ ] First task") + expect(result).toContain("+ [ ] Second task") + }) + + it("should handle clearing all todos", async () => { + const block = { + name: "update_todo_list", + params: { + todos: "", + }, + partial: false, + } as any + + await updateTodoListTool( + mockTask, + block, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Check that the diff shows all items as removed + expect(mockPushToolResult).toHaveBeenCalled() + const result = mockPushToolResult.mock.calls[0][0] + expect(result).toContain("Removed:") + expect(result).toContain("- [ ] Existing task 1") + expect(result).toContain("- [x] Existing task 2") + expect(result).toContain("- [-] Existing task 3") + }) +}) diff --git a/src/core/tools/updateTodoListTool.ts b/src/core/tools/updateTodoListTool.ts index de96c3cc76..a827fcc80f 100644 --- a/src/core/tools/updateTodoListTool.ts +++ b/src/core/tools/updateTodoListTool.ts @@ -7,6 +7,12 @@ import crypto from "crypto" import { TodoItem, TodoStatus, todoStatusSchema } from "@roo-code/types" import { getLatestTodo } from "../../shared/todo" +interface TodoDiff { + added: TodoItem[] + removed: TodoItem[] + modified: { old: TodoItem; new: TodoItem }[] +} + let approvedTodoList: TodoItem[] | undefined = undefined /** @@ -143,6 +149,151 @@ function validateTodos(todos: any[]): { valid: boolean; error?: string } { return { valid: true } } +/** + * Generate a diff between two todo lists + * @param oldTodos Previous todo list + * @param newTodos New todo list + * @returns TodoDiff object containing added, removed, and modified items + */ +function generateTodoDiff(oldTodos: TodoItem[], newTodos: TodoItem[]): TodoDiff { + const diff: TodoDiff = { + added: [], + removed: [], + modified: [], + } + + // Create maps to track which items have been matched + const oldMatched = new Set() + const newMatched = new Set() + + // First pass: Find exact content matches (may have status changes) + for (let i = 0; i < oldTodos.length; i++) { + if (oldMatched.has(i)) continue + const oldItem = oldTodos[i] + + for (let j = 0; j < newTodos.length; j++) { + if (newMatched.has(j)) continue + const newItem = newTodos[j] + + if (oldItem.content === newItem.content) { + oldMatched.add(i) + newMatched.add(j) + + // Check if status changed + if (oldItem.status !== newItem.status) { + diff.modified.push({ old: oldItem, new: newItem }) + } + // If status is same, it's unchanged (not added to diff) + break + } + } + } + + // Second pass: Find similar content (modifications) + for (let i = 0; i < oldTodos.length; i++) { + if (oldMatched.has(i)) continue + const oldItem = oldTodos[i] + + // Look for similar items at the same position first + if (i < newTodos.length && !newMatched.has(i)) { + const newItem = newTodos[i] + const similarity = calculateSimilarity(oldItem.content, newItem.content) + + // If items are at same position and somewhat similar, consider them modified + if (similarity > 0.3) { + oldMatched.add(i) + newMatched.add(i) + diff.modified.push({ old: oldItem, new: newItem }) + } + } + } + + // Third pass: Mark remaining items as removed/added + for (let i = 0; i < oldTodos.length; i++) { + if (!oldMatched.has(i)) { + diff.removed.push(oldTodos[i]) + } + } + + for (let j = 0; j < newTodos.length; j++) { + if (!newMatched.has(j)) { + diff.added.push(newTodos[j]) + } + } + + return diff +} + +/** + * Calculate similarity between two strings (0 to 1) + */ +function calculateSimilarity(str1: string, str2: string): number { + if (str1 === str2) return 1.0 + if (str1.length === 0 || str2.length === 0) return 0.0 + + // Simple character-based similarity + const longer = str1.length > str2.length ? str1 : str2 + const shorter = str1.length > str2.length ? str2 : str1 + + let matches = 0 + for (let i = 0; i < shorter.length; i++) { + if (shorter[i] === longer[i]) { + matches++ + } + } + + return matches / longer.length +} + +/** + * Format todo diff as a concise string + * @param diff TodoDiff object + * @returns Formatted diff string + */ +function formatTodoDiff(diff: TodoDiff): string { + const lines: string[] = [] + + if (diff.added.length > 0) { + lines.push("Added:") + for (const item of diff.added) { + const status = item.status === "completed" ? "[x]" : item.status === "in_progress" ? "[-]" : "[ ]" + lines.push(` + ${status} ${item.content}`) + } + } + + if (diff.removed.length > 0) { + if (lines.length > 0) lines.push("") + lines.push("Removed:") + for (const item of diff.removed) { + const status = item.status === "completed" ? "[x]" : item.status === "in_progress" ? "[-]" : "[ ]" + lines.push(` - ${status} ${item.content}`) + } + } + + if (diff.modified.length > 0) { + if (lines.length > 0) lines.push("") + lines.push("Modified:") + for (const { old, new: newItem } of diff.modified) { + if (old.content !== newItem.content) { + lines.push(` ~ "${old.content}" → "${newItem.content}"`) + } + if (old.status !== newItem.status) { + const oldStatus = + old.status === "completed" ? "completed" : old.status === "in_progress" ? "in_progress" : "pending" + const newStatus = + newItem.status === "completed" + ? "completed" + : newItem.status === "in_progress" + ? "in_progress" + : "pending" + lines.push(` ~ Status: ${oldStatus} → ${newStatus}`) + } + } + } + + return lines.length > 0 ? lines.join("\n") : "No changes" +} + /** * Update the todo list for a task. * @param cline Task instance @@ -194,9 +345,15 @@ export async function updateTodoListTool( status: normalizeStatus(t.status), })) + // Get the previous todo list for diff generation + const previousTodos = cline.todoList || [] + const diff = generateTodoDiff(previousTodos, normalizedTodos) + const diffText = formatTodoDiff(diff) + const approvalMsg = JSON.stringify({ tool: "updateTodoList", todos: normalizedTodos, + diffText: diffText, }) if (block.partial) { await cline.ask("tool", approvalMsg, block.partial).catch(() => {}) @@ -217,18 +374,24 @@ export async function updateTodoListTool( JSON.stringify({ tool: "updateTodoList", todos: normalizedTodos, + diffText: diffText, }), ) } await setTodoListForTask(cline, normalizedTodos) - // If todo list changed, output new todo list in markdown format + // Regenerate diff if todos were changed by user + const finalDiff = isTodoListChanged ? generateTodoDiff(previousTodos, normalizedTodos) : diff + const finalDiffText = isTodoListChanged ? formatTodoDiff(finalDiff) : diffText + + // If todo list changed, output the diff if (isTodoListChanged) { - const md = todoListToMarkdown(normalizedTodos) - pushToolResult(formatResponse.toolResult("User edits todo:\n\n" + md)) + pushToolResult(formatResponse.toolResult("User edited todo list:\n\n" + finalDiffText)) + } else if (finalDiffText !== "No changes") { + pushToolResult(formatResponse.toolResult("Todo list updated:\n\n" + finalDiffText)) } else { - pushToolResult(formatResponse.toolResult("Todo list updated successfully.")) + pushToolResult(formatResponse.toolResult("Todo list updated (no changes).")) } } catch (error) { await handleError("update todo list", error) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 4fa921f443..8a703b4b29 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -495,10 +495,13 @@ export const ChatRowContent = ({ } case "updateTodoList" as any: { const todos = (tool as any).todos || [] + // Extract diff text from the tool result if available + const diffText = (tool as any).diffText || undefined return ( { if (typeof vscode !== "undefined" && vscode?.postMessage) { vscode.postMessage({ type: "updateTodoList", payload: { todos: updatedTodos } }) diff --git a/webview-ui/src/components/chat/TodoListDiff.tsx b/webview-ui/src/components/chat/TodoListDiff.tsx new file mode 100644 index 0000000000..48212ad7ae --- /dev/null +++ b/webview-ui/src/components/chat/TodoListDiff.tsx @@ -0,0 +1,230 @@ +import React, { useState } from "react" + +interface TodoDiffItem { + type: "added" | "removed" | "modified" + content: string + oldContent?: string + status?: string + oldStatus?: string +} + +interface TodoListDiffProps { + diffText: string + isCollapsed?: boolean +} + +/** + * Parse diff text into structured diff items + */ +function parseDiffText(diffText: string): TodoDiffItem[] { + const items: TodoDiffItem[] = [] + const lines = diffText.split("\n") + let currentSection: "added" | "removed" | "modified" | null = null + + for (const line of lines) { + if (line === "Added:") { + currentSection = "added" + } else if (line === "Removed:") { + currentSection = "removed" + } else if (line === "Modified:") { + currentSection = "modified" + } else if (line.startsWith(" + ") && currentSection === "added") { + // Parse added item: + [status] content + const match = line.match(/^\s+\+\s+(\[.\])\s+(.+)$/) + if (match) { + items.push({ + type: "added", + content: match[2], + status: match[1], + }) + } + } else if (line.startsWith(" - ") && currentSection === "removed") { + // Parse removed item: - [status] content + const match = line.match(/^\s+-\s+(\[.\])\s+(.+)$/) + if (match) { + items.push({ + type: "removed", + content: match[2], + status: match[1], + }) + } + } else if (line.startsWith(" ~ ") && currentSection === "modified") { + // Parse modified item + if (line.includes("→")) { + if (line.includes("Status:")) { + // Status change: ~ Status: old → new + const match = line.match(/^\s+~\s+Status:\s+(\w+)\s+→\s+(\w+)$/) + if (match) { + items.push({ + type: "modified", + content: "", + oldStatus: match[1], + status: match[2], + }) + } + } else { + // Content change: ~ "old" → "new" + const match = line.match(/^\s+~\s+"(.+)"\s+→\s+"(.+)"$/) + if (match) { + items.push({ + type: "modified", + oldContent: match[1], + content: match[2], + }) + } + } + } + } + } + + return items +} + +export const TodoListDiff: React.FC = ({ diffText, isCollapsed: initialCollapsed = true }) => { + const [isCollapsed, setIsCollapsed] = useState(initialCollapsed) + const diffItems = parseDiffText(diffText) + + // Count changes by type + const addedCount = diffItems.filter((item) => item.type === "added").length + const removedCount = diffItems.filter((item) => item.type === "removed").length + const modifiedCount = diffItems.filter((item) => item.type === "modified").length + + // Generate summary text + const summaryParts: string[] = [] + if (addedCount > 0) summaryParts.push(`+${addedCount} added`) + if (removedCount > 0) summaryParts.push(`-${removedCount} removed`) + if (modifiedCount > 0) summaryParts.push(`~${modifiedCount} modified`) + const summary = summaryParts.length > 0 ? summaryParts.join(", ") : "No changes" + + return ( +
+
setIsCollapsed(!isCollapsed)}> + + Todo list updated + + ({summary}) + +
+ + {!isCollapsed && ( +
+ {diffItems.length === 0 ? ( +
No changes detected
+ ) : ( +
+ {diffItems.map((item, index) => ( +
+ {item.type === "added" && ( + <> + + + + + + {item.status} {item.content} + + + )} + {item.type === "removed" && ( + <> + + - + + + {item.status} {item.content} + + + )} + {item.type === "modified" && ( + <> + + ~ + + + {item.oldContent && item.content && ( + <> + + {item.oldContent} + + {" → "} + {item.content} + + )} + {item.oldStatus && item.status && ( + <> + Status: {item.oldStatus} → {item.status} + + )} + + + )} +
+ ))} +
+ )} +
+ )} +
+ ) +} diff --git a/webview-ui/src/components/chat/UpdateTodoListToolBlock.tsx b/webview-ui/src/components/chat/UpdateTodoListToolBlock.tsx index e851284505..89510be231 100644 --- a/webview-ui/src/components/chat/UpdateTodoListToolBlock.tsx +++ b/webview-ui/src/components/chat/UpdateTodoListToolBlock.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef } from "react" import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock" import MarkdownBlock from "../common/MarkdownBlock" +import { TodoListDiff } from "./TodoListDiff" interface TodoItem { id?: string @@ -24,6 +25,8 @@ interface UpdateTodoListToolBlockProps { /** Whether editing is allowed (controlled externally) */ editable?: boolean userEdited?: boolean + /** Diff text to display instead of full list */ + diffText?: string } const STATUS_OPTIONS = [ @@ -52,6 +55,7 @@ const UpdateTodoListToolBlock: React.FC = ({ onChange, editable = true, userEdited = false, + diffText, }) => { const [editTodos, setEditTodos] = useState( todos.length > 0 ? todos.map((todo) => ({ ...todo, id: todo.id || genId() })) : [], @@ -166,6 +170,43 @@ const UpdateTodoListToolBlock: React.FC = ({ ) } + // If we have diff text, show the collapsible diff view + if (diffText && !isEditing) { + return ( + + +
+ + + Todo List + +
+ {editable && ( + + )} +
+ + + + ) + } + return ( <> @@ -176,7 +217,7 @@ const UpdateTodoListToolBlock: React.FC = ({ style={{ color: "var(--vscode-foreground)" }} /> - Todo List Updated + Todo List {diffText ? "(Full View)" : "Updated"}
{editable && ( @@ -198,7 +239,7 @@ const UpdateTodoListToolBlock: React.FC = ({ fontSize: 13, marginLeft: 8, }}> - {isEditing ? "Done" : "Edit"} + {isEditing ? "Done" : diffText ? "Back to Diff" : "Edit"} )}