diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index b6eb67e171..ad25dd2da4 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -205,6 +205,7 @@ export const clineMessageSchema = z.object({ ask: clineAskSchema.optional(), say: clineSaySchema.optional(), text: z.string().optional(), + title: z.string().optional(), // Custom title for error messages and other displays images: z.array(z.string()).optional(), partial: z.boolean().optional(), reasoning: z.string().optional(), diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 689675999f..57b2e379cd 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -323,9 +323,14 @@ export async function presentAssistantMessage(cline: Task) { await cline.say( "error", `Error ${action}:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`, + undefined, // images + undefined, // partial + undefined, // checkpoint + undefined, // progressStatus + { title: `Tool Call Error: ${block.name}` }, // Custom title with tool name ) - pushToolResult(formatResponse.toolError(errorString)) + pushToolResult(formatResponse.toolError(errorString, block.name)) } // If block is partial, remove partial closing tag so its not @@ -377,7 +382,7 @@ export async function presentAssistantMessage(cline: Task) { ) } catch (error) { cline.consecutiveMistakeCount++ - pushToolResult(formatResponse.toolError(error.message)) + pushToolResult(formatResponse.toolError(error.message, block.name)) break } @@ -416,6 +421,7 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult( formatResponse.toolError( `Tool call repetition limit reached for ${block.name}. Please try a different approach.`, + block.name, ), ) break diff --git a/src/core/prompts/__tests__/responses-tool-error.spec.ts b/src/core/prompts/__tests__/responses-tool-error.spec.ts new file mode 100644 index 0000000000..127e4bf921 --- /dev/null +++ b/src/core/prompts/__tests__/responses-tool-error.spec.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest" +import { formatResponse } from "../responses" + +describe("formatResponse.toolError", () => { + it("should format error without tool name when not provided", () => { + const error = "Something went wrong" + const result = formatResponse.toolError(error) + + expect(result).toBe("Tool Execution Error\n\nSomething went wrong\n") + }) + + it("should format error with tool name when provided", () => { + const error = "Invalid mode: test_mode" + const toolName = "switch_mode" + const result = formatResponse.toolError(error, toolName) + + expect(result).toBe("Tool Call Error: switch_mode\n\nInvalid mode: test_mode\n") + }) + + it("should handle undefined error message", () => { + const result = formatResponse.toolError(undefined, "new_task") + + expect(result).toBe("Tool Call Error: new_task\n\nundefined\n") + }) + + it("should work with various tool names", () => { + const testCases = [ + { toolName: "write_to_file", expected: "Tool Call Error: write_to_file" }, + { toolName: "execute_command", expected: "Tool Call Error: execute_command" }, + { toolName: "apply_diff", expected: "Tool Call Error: apply_diff" }, + { toolName: "new_task", expected: "Tool Call Error: new_task" }, + { toolName: "use_mcp_tool", expected: "Tool Call Error: use_mcp_tool" }, + ] + + testCases.forEach(({ toolName, expected }) => { + const result = formatResponse.toolError("Test error", toolName) + expect(result).toContain(expected) + }) + }) + + it("should maintain backward compatibility when tool name is not provided", () => { + // This ensures existing code that doesn't pass toolName still works + const error = "Legacy error" + const result = formatResponse.toolError(error) + + // Should not contain "Tool Call Error:" prefix + expect(result).not.toContain("Tool Call Error:") + // Should contain generic title + expect(result).toContain("Tool Execution Error") + }) +}) diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index fd51b18fed..7d93522a51 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -13,7 +13,10 @@ export const formatResponse = { toolApprovedWithFeedback: (feedback?: string) => `The user approved this operation and provided the following context:\n\n${feedback}\n`, - toolError: (error?: string) => `The tool execution failed with the following error:\n\n${error}\n`, + toolError: (error?: string, toolName?: string) => { + const title = toolName ? `Tool Call Error: ${toolName}` : "Tool Execution Error" + return `${title}\n\n${error}\n` + }, rooIgnoreError: (path: string) => `Access to ${path} is blocked by the .rooignore file settings. You must try to continue in the task without using this file, or ask the user to update the .rooignore file.`, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index c5be865731..fbe71bf818 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1052,6 +1052,7 @@ export class Task extends EventEmitter implements TaskLike { options: { isNonInteractive?: boolean metadata?: Record + title?: string // Optional custom title for error messages } = {}, contextCondense?: ContextCondense, ): Promise { @@ -1086,6 +1087,7 @@ export class Task extends EventEmitter implements TaskLike { type: "say", say: type, text, + title: options.title, // Include custom title if provided images, partial, contextCondense, @@ -1133,6 +1135,7 @@ export class Task extends EventEmitter implements TaskLike { type: "say", say: type, text, + title: options.title, // Include custom title if provided images, contextCondense, metadata: options.metadata, @@ -1156,6 +1159,7 @@ export class Task extends EventEmitter implements TaskLike { type: "say", say: type, text, + title: options.title, // Include custom title if provided images, checkpoint, contextCondense, @@ -1169,8 +1173,13 @@ export class Task extends EventEmitter implements TaskLike { `Roo tried to use ${toolName}${ relPath ? ` for '${relPath.toPosix()}'` : "" } without value for required parameter '${paramName}'. Retrying...`, + undefined, // images + undefined, // partial + undefined, // checkpoint + undefined, // progressStatus + { title: `Tool Call Error: ${toolName}` }, // Custom title for the error ) - return formatResponse.toolError(formatResponse.missingToolParameterError(paramName)) + return formatResponse.toolError(formatResponse.missingToolParameterError(paramName), toolName) } // Lifecycle @@ -2314,6 +2323,11 @@ export class Task extends EventEmitter implements TaskLike { await this.say( "error", "Unexpected API Response: The language model did not provide any assistant messages. This may indicate an issue with the API or the model's output.", + undefined, + undefined, + undefined, + undefined, + { title: "API Response Error" }, ) await this.addToApiConversationHistory({ diff --git a/src/core/tools/__tests__/insertContentTool.spec.ts b/src/core/tools/__tests__/insertContentTool.spec.ts index 5f055fb29a..27c8e74d7f 100644 --- a/src/core/tools/__tests__/insertContentTool.spec.ts +++ b/src/core/tools/__tests__/insertContentTool.spec.ts @@ -226,7 +226,15 @@ describe("insertContentTool", () => { expect(mockedFsReadFile).not.toHaveBeenCalled() expect(mockCline.consecutiveMistakeCount).toBe(1) expect(mockCline.recordToolError).toHaveBeenCalledWith("insert_content") - expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("non-existent file")) + expect(mockCline.say).toHaveBeenCalledWith( + "error", + expect.stringContaining("non-existent file"), + undefined, + undefined, + undefined, + undefined, + { title: "Invalid Line Number" }, + ) expect(mockCline.diffViewProvider.update).not.toHaveBeenCalled() expect(mockCline.diffViewProvider.pushToolWriteResult).not.toHaveBeenCalled() }) diff --git a/src/core/tools/applyDiffTool.ts b/src/core/tools/applyDiffTool.ts index 903e3c846e..abea061ba9 100644 --- a/src/core/tools/applyDiffTool.ts +++ b/src/core/tools/applyDiffTool.ts @@ -13,6 +13,7 @@ import { fileExistsAtPath } from "../../utils/fs" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { unescapeHtmlEntities } from "../../utils/text-normalization" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" +import { t } from "../../i18n" export async function applyDiffToolLegacy( cline: Task, @@ -72,7 +73,7 @@ export async function applyDiffToolLegacy( if (!accessAllowed) { await cline.say("rooignore_error", relPath) - pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath))) + pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath), "apply_diff")) return } @@ -83,7 +84,9 @@ export async function applyDiffToolLegacy( cline.consecutiveMistakeCount++ cline.recordToolError("apply_diff") const formattedError = `File does not exist at path: ${absolutePath}\n\n\nThe specified file could not be found. Please verify the file path and try again.\n` - await cline.say("error", formattedError) + await cline.say("error", formattedError, undefined, undefined, undefined, undefined, { + title: t("tools:errors.fileNotFound"), + }) pushToolResult(formattedError) return } diff --git a/src/core/tools/askFollowupQuestionTool.ts b/src/core/tools/askFollowupQuestionTool.ts index e736936887..62c707c0ee 100644 --- a/src/core/tools/askFollowupQuestionTool.ts +++ b/src/core/tools/askFollowupQuestionTool.ts @@ -48,8 +48,16 @@ export async function askFollowupQuestionTool( } catch (error) { cline.consecutiveMistakeCount++ cline.recordToolError("ask_followup_question") - await cline.say("error", `Failed to parse operations: ${error.message}`) - pushToolResult(formatResponse.toolError("Invalid operations xml format")) + await cline.say( + "error", + `Failed to parse operations: ${error.message}`, + undefined, + undefined, + undefined, + undefined, + { title: "Parse Error" }, + ) + pushToolResult(formatResponse.toolError("Invalid operations xml format", "ask_followup_question")) return } diff --git a/src/core/tools/attemptCompletionTool.ts b/src/core/tools/attemptCompletionTool.ts index 5074d7f4e8..b831d68b5a 100644 --- a/src/core/tools/attemptCompletionTool.ts +++ b/src/core/tools/attemptCompletionTool.ts @@ -46,6 +46,7 @@ export async function attemptCompletionTool( pushToolResult( formatResponse.toolError( "Cannot complete task while there are incomplete todos. Please finish all todos before attempting completion.", + "attempt_completion", ), ) diff --git a/src/core/tools/executeCommandTool.ts b/src/core/tools/executeCommandTool.ts index 2c7ce0d023..e3a9637214 100644 --- a/src/core/tools/executeCommandTool.ts +++ b/src/core/tools/executeCommandTool.ts @@ -47,7 +47,12 @@ export async function executeCommandTool( if (ignoredFileAttemptedToAccess) { await task.say("rooignore_error", ignoredFileAttemptedToAccess) - pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(ignoredFileAttemptedToAccess))) + pushToolResult( + formatResponse.toolError( + formatResponse.rooIgnoreError(ignoredFileAttemptedToAccess), + "execute_command", + ), + ) return } @@ -271,7 +276,15 @@ export async function executeCommand( if (isTimedOut) { const status: CommandExecutionStatus = { executionId, status: "timeout" } provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) - await task.say("error", t("common:errors:command_timeout", { seconds: commandExecutionTimeoutSeconds })) + await task.say( + "error", + t("common:errors:command_timeout", { seconds: commandExecutionTimeoutSeconds }), + undefined, + undefined, + undefined, + undefined, + { title: t("tools:errors.commandTimeout") }, + ) task.terminalProcess = undefined return [ diff --git a/src/core/tools/fetchInstructionsTool.ts b/src/core/tools/fetchInstructionsTool.ts index 5325f98fbf..412101a7f2 100644 --- a/src/core/tools/fetchInstructionsTool.ts +++ b/src/core/tools/fetchInstructionsTool.ts @@ -49,7 +49,7 @@ export async function fetchInstructionsTool( const content = await fetchInstructions(task, { mcpHub, diffStrategy, context }) if (!content) { - pushToolResult(formatResponse.toolError(`Invalid instructions request: ${task}`)) + pushToolResult(formatResponse.toolError(`Invalid instructions request: ${task}`, "fetch_instructions")) return } diff --git a/src/core/tools/insertContentTool.ts b/src/core/tools/insertContentTool.ts index e22a368167..8aa2ba65c0 100644 --- a/src/core/tools/insertContentTool.ts +++ b/src/core/tools/insertContentTool.ts @@ -64,7 +64,7 @@ export async function insertContentTool( if (!accessAllowed) { await cline.say("rooignore_error", relPath) - pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath))) + pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath), "insert_content")) return } @@ -76,7 +76,9 @@ export async function insertContentTool( if (isNaN(lineNumber) || lineNumber < 0) { cline.consecutiveMistakeCount++ cline.recordToolError("insert_content") - pushToolResult(formatResponse.toolError("Invalid line number. Must be a non-negative integer.")) + pushToolResult( + formatResponse.toolError("Invalid line number. Must be a non-negative integer.", "insert_content"), + ) return } @@ -87,7 +89,9 @@ export async function insertContentTool( cline.consecutiveMistakeCount++ cline.recordToolError("insert_content") const formattedError = `Cannot insert content at line ${lineNumber} into a non-existent file. For new files, 'line' must be 0 (to append) or 1 (to insert at the beginning).` - await cline.say("error", formattedError) + await cline.say("error", formattedError, undefined, undefined, undefined, undefined, { + title: "Invalid Line Number", + }) pushToolResult(formattedError) return } diff --git a/src/core/tools/newTaskTool.ts b/src/core/tools/newTaskTool.ts index aeb0c8393b..d4a45212f4 100644 --- a/src/core/tools/newTaskTool.ts +++ b/src/core/tools/newTaskTool.ts @@ -53,7 +53,7 @@ export async function newTaskTool( const provider = task.providerRef.deref() if (!provider) { - pushToolResult(formatResponse.toolError("Provider reference lost")) + pushToolResult(formatResponse.toolError("Provider reference lost", "new_task")) return } @@ -82,7 +82,9 @@ export async function newTaskTool( } catch (error) { task.consecutiveMistakeCount++ task.recordToolError("new_task") - pushToolResult(formatResponse.toolError("Invalid todos format: must be a markdown checklist")) + pushToolResult( + formatResponse.toolError("Invalid todos format: must be a markdown checklist", "new_task"), + ) return } } @@ -97,7 +99,7 @@ export async function newTaskTool( const targetMode = getModeBySlug(mode, state?.customModes) if (!targetMode) { - pushToolResult(formatResponse.toolError(`Invalid mode: ${mode}`)) + pushToolResult(formatResponse.toolError(`Invalid mode: ${mode}`, "new_task")) return } diff --git a/src/core/tools/searchAndReplaceTool.ts b/src/core/tools/searchAndReplaceTool.ts index 4912934415..36bfa3745b 100644 --- a/src/core/tools/searchAndReplaceTool.ts +++ b/src/core/tools/searchAndReplaceTool.ts @@ -121,7 +121,7 @@ export async function searchAndReplaceTool( if (!accessAllowed) { await cline.say("rooignore_error", validRelPath) - pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(validRelPath))) + pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(validRelPath), "search_and_replace")) return } @@ -137,7 +137,9 @@ export async function searchAndReplaceTool( const formattedError = formatResponse.toolError( `File does not exist at path: ${absolutePath}\nThe specified file could not be found. Please verify the file path and try again.`, ) - await cline.say("error", formattedError) + await cline.say("error", formattedError, undefined, undefined, undefined, undefined, { + title: "File Not Found", + }) pushToolResult(formattedError) return } @@ -156,7 +158,9 @@ export async function searchAndReplaceTool( error instanceof Error ? error.message : String(error) }\nPlease verify file permissions and try again.` const formattedError = formatResponse.toolError(errorMessage) - await cline.say("error", formattedError) + await cline.say("error", formattedError, undefined, undefined, undefined, undefined, { + title: "File Read Error", + }) pushToolResult(formattedError) return } diff --git a/src/core/tools/switchModeTool.ts b/src/core/tools/switchModeTool.ts index 8ce906b41f..e2cf3b61d0 100644 --- a/src/core/tools/switchModeTool.ts +++ b/src/core/tools/switchModeTool.ts @@ -41,7 +41,7 @@ export async function switchModeTool( if (!targetMode) { cline.recordToolError("switch_mode") - pushToolResult(formatResponse.toolError(`Invalid mode: ${mode_slug}`)) + pushToolResult(formatResponse.toolError(`Invalid mode: ${mode_slug}`, "switch_mode")) return } diff --git a/src/core/tools/updateTodoListTool.ts b/src/core/tools/updateTodoListTool.ts index de96c3cc76..09ef2fc670 100644 --- a/src/core/tools/updateTodoListTool.ts +++ b/src/core/tools/updateTodoListTool.ts @@ -176,7 +176,12 @@ export async function updateTodoListTool( } catch { cline.consecutiveMistakeCount++ cline.recordToolError("update_todo_list") - pushToolResult(formatResponse.toolError("The todos parameter is not valid markdown checklist or JSON")) + pushToolResult( + formatResponse.toolError( + "The todos parameter is not valid markdown checklist or JSON", + "update_todo_list", + ), + ) return } @@ -184,7 +189,7 @@ export async function updateTodoListTool( if (!valid && !block.partial) { cline.consecutiveMistakeCount++ cline.recordToolError("update_todo_list") - pushToolResult(formatResponse.toolError(error || "todos parameter validation failed")) + pushToolResult(formatResponse.toolError(error || "todos parameter validation failed", "update_todo_list")) return } diff --git a/src/core/tools/useMcpToolTool.ts b/src/core/tools/useMcpToolTool.ts index 41697ab979..3d7e608f67 100644 --- a/src/core/tools/useMcpToolTool.ts +++ b/src/core/tools/useMcpToolTool.ts @@ -62,11 +62,20 @@ async function validateParams( } catch (error) { cline.consecutiveMistakeCount++ cline.recordToolError("use_mcp_tool") - await cline.say("error", t("mcp:errors.invalidJsonArgument", { toolName: params.tool_name })) + await cline.say( + "error", + t("mcp:errors.invalidJsonArgument", { toolName: params.tool_name }), + undefined, + undefined, + undefined, + undefined, + { title: t("tools:errors.invalidInput") }, + ) pushToolResult( formatResponse.toolError( formatResponse.invalidMcpToolArgumentError(params.server_name, params.tool_name), + "use_mcp_tool", ), ) return { isValid: false } diff --git a/src/core/tools/writeToFileTool.ts b/src/core/tools/writeToFileTool.ts index e82eab92bc..152813ebbd 100644 --- a/src/core/tools/writeToFileTool.ts +++ b/src/core/tools/writeToFileTool.ts @@ -55,7 +55,7 @@ export async function writeToFileTool( if (!accessAllowed) { await cline.say("rooignore_error", relPath) - pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath))) + pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath), "write_to_file")) return } @@ -153,6 +153,7 @@ export async function writeToFileTool( pushToolResult( formatResponse.toolError( formatResponse.lineCountTruncationError(actualLineCount, isNewFile, diffStrategyEnabled), + "write_to_file", ), ) await cline.diffViewProvider.revertChanges() @@ -181,6 +182,7 @@ export async function writeToFileTool( `Content appears to be truncated (file has ${ newContent.split("\n").length } lines but was predicted to have ${predictedLineCount} lines), and found comments indicating omitted code (e.g., '// rest of code unchanged', '/* previous code */'). Please provide the complete file content without any omissions if possible, or otherwise use the 'apply_diff' tool to apply the diff to the original file.`, + "write_to_file", ), ) return @@ -254,6 +256,7 @@ export async function writeToFileTool( `Content appears to be truncated (file has ${ newContent.split("\n").length } lines but was predicted to have ${predictedLineCount} lines), and found comments indicating omitted code (e.g., '// rest of code unchanged', '/* previous code */'). Please provide the complete file content without any omissions if possible, or otherwise use the 'apply_diff' tool to apply the diff to the original file.`, + "write_to_file", ), ) return diff --git a/src/i18n/locales/en/tools.json b/src/i18n/locales/en/tools.json index 5b88affae6..a35d939671 100644 --- a/src/i18n/locales/en/tools.json +++ b/src/i18n/locales/en/tools.json @@ -14,5 +14,17 @@ "errors": { "policy_restriction": "Failed to create new task due to policy restrictions." } + }, + "errors": { + "fileNotFound": "File Not Found", + "parseError": "Parse Error", + "commandTimeout": "Command Timeout", + "permissionDenied": "Permission Denied", + "networkError": "Network Error", + "invalidInput": "Invalid Input", + "operationFailed": "Operation Failed", + "resourceNotFound": "Resource Not Found", + "configurationError": "Configuration Error", + "authenticationFailed": "Authentication Failed" } } diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 7b3107a2be..30533fc9d6 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -125,6 +125,8 @@ export const ChatRowContent = ({ const [editedContent, setEditedContent] = useState("") const [editMode, setEditMode] = useState(mode || "code") const [editImages, setEditImages] = useState([]) + const [isErrorExpanded, setIsErrorExpanded] = useState(false) // Default collapsed like diff_error + const [showErrorCopySuccess, setShowErrorCopySuccess] = useState(false) const { copyWithFeedback } = useCopyToClipboard() // Handle message events for image selection during edit mode @@ -216,7 +218,7 @@ export const ChatRowContent = ({ , - {t("chat:error")}, + {message.title || t("chat:error")}, ] case "mistake_limit_reached": return [ @@ -960,56 +962,32 @@ export const ChatRowContent = ({ case "diff_error": return (
-
+
setIsDiffErrorExpanded(!isDiffErrorExpanded)}> -
+ className={`${ + isDiffErrorExpanded ? "border-b border-vscode-editorGroup-border" : "" + } font-normal text-base text-vscode-editor-foreground flex items-center justify-between cursor-pointer focus:outline focus:outline-2 focus:outline-vscode-focusBorder`} + role="button" + tabIndex={0} + aria-expanded={isDiffErrorExpanded} + aria-label={t("chat:diffError.title")} + onClick={() => setIsDiffErrorExpanded(!isDiffErrorExpanded)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + setIsDiffErrorExpanded(!isDiffErrorExpanded) + } + }}> +
- {t("chat:diffError.title")} + className="codicon codicon-warning text-vscode-editorWarning-foreground opacity-80" + style={{ fontSize: 16, marginBottom: "-1.5px" }}> + {t("chat:diffError.title")}
-
+
{ e.stopPropagation() @@ -1034,12 +1012,7 @@ export const ChatRowContent = ({
{isDiffErrorExpanded && ( -
+
)} @@ -1244,15 +1217,58 @@ export const ChatRowContent = ({ ) case "error": return ( - <> - {title && ( -
- {icon} - {title} +
+
+
setIsErrorExpanded(!isErrorExpanded)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + setIsErrorExpanded(!isErrorExpanded) + } + }}> +
+ + {message.title || t("chat:error")} +
+
+ { + e.stopPropagation() + copyWithFeedback(message.text || "").then((success) => { + if (success) { + setShowErrorCopySuccess(true) + setTimeout(() => { + setShowErrorCopySuccess(false) + }, 1000) + } + }) + }}> + + + +
- )} -

{message.text}

- + {isErrorExpanded && ( +
+ +
+ )} +
+
) case "completion_result": return ( diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.spec.tsx new file mode 100644 index 0000000000..c5b46651d6 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatRow.spec.tsx @@ -0,0 +1,457 @@ +// npx vitest run src/components/chat/__tests__/ChatRow.spec.tsx + +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import { ChatRowContent } from "../ChatRow" +import type { ClineMessage } from "@roo-code/types" + +// Mock the clipboard utility +const mockCopyWithFeedback = vi.fn().mockResolvedValue(true) +vi.mock("@src/utils/clipboard", () => ({ + useCopyToClipboard: () => ({ + copyWithFeedback: mockCopyWithFeedback, + }), +})) + +// Mock the extension state context +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + mcpServers: [], + alwaysAllowMcp: false, + currentCheckpoint: null, + mode: "code", + }), +})) + +// Mock the translation hook +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "chat:error": "Error", + "chat:diffError.title": "Edit Unsuccessful", + } + return translations[key] || key + }, + }), + Trans: ({ children }: { children: React.ReactNode }) => <>{children}, + initReactI18next: { + type: "3rdParty", + init: () => {}, + }, +})) + +// Mock vscode API +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock CodeBlock component to avoid Tooltip issues +vi.mock("../../common/CodeBlock", () => ({ + default: ({ source }: { source: string }) =>
{source}
, +})) + +describe("ChatRow Error Display", () => { + const mockOnToggleExpand = vi.fn() + const mockOnSuggestionClick = vi.fn() + const mockOnBatchFileResponse = vi.fn() + const mockOnFollowUpUnmount = vi.fn() + + const baseProps = { + isExpanded: false, + isLast: false, + isStreaming: false, + onToggleExpand: mockOnToggleExpand, + onSuggestionClick: mockOnSuggestionClick, + onBatchFileResponse: mockOnBatchFileResponse, + onFollowUpUnmount: mockOnFollowUpUnmount, + isFollowUpAnswered: false, + editable: false, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("Error Message Display", () => { + it("should render error message with collapsible section", () => { + const errorMessage: ClineMessage = { + ts: Date.now(), + type: "say", + say: "error", + text: "This is an error message", + } + + render() + + // Check that warning icon is present (matching diff_error style) + const warningIcon = document.querySelector(".codicon-warning") + expect(warningIcon).toBeTruthy() + + // Check that error title is present + expect(screen.getByText("Error")).toBeTruthy() + + // Check that error text is NOT visible by default (collapsed) + expect(screen.queryByText("This is an error message")).toBeFalsy() + + // Check that chevron-down icon is present (collapsed state) + const chevronDown = document.querySelector(".codicon-chevron-down") + expect(chevronDown).toBeTruthy() + + // Check that copy button is present + const copyButton = document.querySelector(".codicon-copy") + expect(copyButton).toBeTruthy() + }) + + it("should toggle error message visibility when clicked", () => { + const errorMessage: ClineMessage = { + ts: Date.now(), + type: "say", + say: "error", + text: "This is a collapsible error", + } + + render() + + // Initially collapsed - chevron should be down + let chevron = document.querySelector(".codicon-chevron-down") + expect(chevron).toBeTruthy() + expect(screen.queryByText("This is a collapsible error")).toBeFalsy() + + // Click to expand + const header = screen.getByText("Error").closest("div")?.parentElement + if (header) { + fireEvent.click(header) + } + + // After expand - chevron should be up and text visible + chevron = document.querySelector(".codicon-chevron-up") + expect(chevron).toBeTruthy() + + // The text is now in a CodeBlock (pre element) due to matching diff_error + const codeBlock = document.querySelector("pre") + expect(codeBlock?.textContent).toBe("This is a collapsible error") + + // Click to collapse again + if (header) { + fireEvent.click(header) + } + + // Should be collapsed again + chevron = document.querySelector(".codicon-chevron-down") + expect(chevron).toBeTruthy() + expect(document.querySelector("pre")).toBeFalsy() + }) + + it("should handle copy button click for error messages", async () => { + const errorMessage: ClineMessage = { + ts: Date.now(), + type: "say", + say: "error", + text: "Error to copy", + } + + render() + + // Find and click copy button (VSCodeButton component) + const copyIcon = document.querySelector(".codicon-copy") + expect(copyIcon).toBeTruthy() + + // Click on the VSCodeButton which contains the copy icon + const vscodeButton = copyIcon?.closest("vscode-button") + expect(vscodeButton).toBeTruthy() + + if (vscodeButton) { + fireEvent.click(vscodeButton) + } + + // Verify copy function was called with correct text + await waitFor(() => { + expect(mockCopyWithFeedback).toHaveBeenCalledWith("Error to copy") + }) + }) + + it("should show check icon after successful copy", async () => { + const errorMessage: ClineMessage = { + ts: Date.now(), + type: "say", + say: "error", + text: "Error to copy with feedback", + } + + render() + + // Initially should show copy icon + const copyIcon = document.querySelector(".codicon-copy") + expect(copyIcon).toBeTruthy() + + // Click copy button (VSCodeButton component) + const vscodeButton = copyIcon?.closest("vscode-button") + if (vscodeButton) { + fireEvent.click(vscodeButton) + } + + // Should show check icon after successful copy + await waitFor(() => { + const checkIcon = document.querySelector(".codicon-check") + expect(checkIcon).toBeTruthy() + }) + }) + + it("should handle empty error text gracefully", () => { + const errorMessage: ClineMessage = { + ts: Date.now(), + type: "say", + say: "error", + text: "", + } + + render() + + // Should still render the collapsible structure + expect(screen.getByText("Error")).toBeTruthy() + const copyButton = document.querySelector(".codicon-copy") + expect(copyButton).toBeTruthy() + }) + + it("should handle null error text gracefully", () => { + const errorMessage: ClineMessage = { + ts: Date.now(), + type: "say", + say: "error", + text: null as any, + } + + render() + + // Should still render the collapsible structure + expect(screen.getByText("Error")).toBeTruthy() + const copyButton = document.querySelector(".codicon-copy") + expect(copyButton).toBeTruthy() + }) + + it("should use warning icon with warning color", () => { + const errorMessage: ClineMessage = { + ts: Date.now(), + type: "say", + say: "error", + text: "Styled error message", + } + + render() + + // Check that warning icon is present with warning color class + const warningIcon = document.querySelector(".codicon-warning") + expect(warningIcon).toBeTruthy() + // Check that the warning icon has the correct Tailwind class + expect(warningIcon?.classList.contains("text-vscode-editorWarning-foreground")).toBeTruthy() + }) + + it("should display custom title when provided", () => { + const errorMessage: ClineMessage = { + ts: Date.now(), + type: "say", + say: "error", + text: "This is a custom error", + title: "File Not Found", + } + + render() + + // Custom title should be visible + expect(screen.getByText("File Not Found")).toBeTruthy() + // Default "Error" title should not be visible + expect(screen.queryByText("Error")).toBeFalsy() + }) + + it("should fall back to default 'Error' title when custom title is not provided", () => { + const errorMessage: ClineMessage = { + ts: Date.now(), + type: "say", + say: "error", + text: "This is a default error", + // No title field provided + } + + render() + + // Default "Error" title should be visible + expect(screen.getByText("Error")).toBeTruthy() + }) + + it("should handle empty custom title by falling back to default", () => { + const errorMessage: ClineMessage = { + ts: Date.now(), + type: "say", + say: "error", + text: "Error with empty title", + title: "", // Empty title + } + + render() + + // Should fall back to default "Error" title + expect(screen.getByText("Error")).toBeTruthy() + }) + + it("should display custom title with special characters correctly", () => { + const errorMessage: ClineMessage = { + ts: Date.now(), + type: "say", + say: "error", + text: "Special character error", + title: "Error: File 'test.ts' not found!", + } + + render() + + // Custom title with special characters should be visible + expect(screen.getByText("Error: File 'test.ts' not found!")).toBeTruthy() + }) + }) + + describe("Diff Error Display", () => { + it("should render diff_error with collapsible section", () => { + const diffErrorMessage: ClineMessage = { + ts: Date.now(), + type: "say", + say: "diff_error", + text: "Diff application failed", + } + + render() + + // Check that warning icon is present + const warningIcon = document.querySelector(".codicon-warning") + expect(warningIcon).toBeTruthy() + + // Check that diff error title is present + expect(screen.getByText("Edit Unsuccessful")).toBeTruthy() + + // Check that copy button is present + const copyButton = document.querySelector(".codicon-copy") + expect(copyButton).toBeTruthy() + + // Should be collapsed by default for diff_error + const chevronDown = document.querySelector(".codicon-chevron-down") + expect(chevronDown).toBeTruthy() + }) + + it("should toggle diff_error visibility when clicked", () => { + const diffErrorMessage: ClineMessage = { + ts: Date.now(), + type: "say", + say: "diff_error", + text: "Diff content", + } + + render() + + // Initially collapsed + let chevron = document.querySelector(".codicon-chevron-down") + expect(chevron).toBeTruthy() + + // Click to expand + const header = screen.getByText("Edit Unsuccessful").closest("div")?.parentElement + if (header) { + fireEvent.click(header) + } + + // Should be expanded + chevron = document.querySelector(".codicon-chevron-up") + expect(chevron).toBeTruthy() + }) + }) + + describe("Consistency Between Error Types", () => { + it("should have similar structure for error and diff_error", () => { + const errorMessage: ClineMessage = { + ts: Date.now(), + type: "say", + say: "error", + text: "Regular error", + } + + const { container: errorContainer } = render() + + // Both should have collapsible structure + const errorChevron = errorContainer.querySelector(".codicon-chevron-up, .codicon-chevron-down") + expect(errorChevron).toBeTruthy() + + // Both should have copy button + const errorCopyButton = errorContainer.querySelector(".codicon-copy") + expect(errorCopyButton).toBeTruthy() + + // Clean up + errorContainer.remove() + + const diffErrorMessage: ClineMessage = { + ts: Date.now(), + type: "say", + say: "diff_error", + text: "Diff error", + } + + const { container: diffErrorContainer } = render( + , + ) + + const diffErrorChevron = diffErrorContainer.querySelector(".codicon-chevron-up, .codicon-chevron-down") + expect(diffErrorChevron).toBeTruthy() + + const diffErrorCopyButton = diffErrorContainer.querySelector(".codicon-copy") + expect(diffErrorCopyButton).toBeTruthy() + }) + + it("should handle multi-line error messages", () => { + const multiLineError: ClineMessage = { + ts: Date.now(), + type: "say", + say: "error", + text: "Line 1\nLine 2\nLine 3\nLine 4", + } + + render() + + // Should render as collapsible + const chevron = document.querySelector(".codicon-chevron-up, .codicon-chevron-down") + expect(chevron).toBeTruthy() + + // Should have copy button + const copyButton = document.querySelector(".codicon-copy") + expect(copyButton).toBeTruthy() + + // Click to expand + const header = screen.getByText("Error").closest("div")?.parentElement + if (header) { + fireEvent.click(header) + } + + // Text should be visible when expanded (in CodeBlock/pre element) + const codeBlock = document.querySelector("pre") + expect(codeBlock).toBeTruthy() + expect(codeBlock?.textContent).toBe("Line 1\nLine 2\nLine 3\nLine 4") + }) + + it("should handle very long single-line error messages", () => { + const longError: ClineMessage = { + ts: Date.now(), + type: "say", + say: "error", + text: "A".repeat(300), // 300 character error + } + + render() + + // Should render as collapsible + const chevron = document.querySelector(".codicon-chevron-up, .codicon-chevron-down") + expect(chevron).toBeTruthy() + + // Should have copy button + const copyButton = document.querySelector(".codicon-copy") + expect(copyButton).toBeTruthy() + }) + }) +})