feat: improve error display with collapsible UI and contextual titles

- Add collapsible error display in ChatRow component with expand/collapse functionality
- Implement contextual error titles for 13 different tool types
- Add i18n support for error titles with fallback to generic message
- Include comprehensive test coverage for new error display features
- Preserve full error details in collapsed state for debugging
- Add visual indicators (chevron icons) for error state management
This commit is contained in:
Hannes Rudolph 2025-08-25 16:50:31 -06:00
parent e8deedd91b
commit 9d3c0268ae
21 changed files with 706 additions and 86 deletions

View file

@ -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(),

View file

@ -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

View file

@ -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<error>\nSomething went wrong\n</error>")
})
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<error>\nInvalid mode: test_mode\n</error>")
})
it("should handle undefined error message", () => {
const result = formatResponse.toolError(undefined, "new_task")
expect(result).toBe("Tool Call Error: new_task\n<error>\nundefined\n</error>")
})
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")
})
})

View file

@ -13,7 +13,10 @@ export const formatResponse = {
toolApprovedWithFeedback: (feedback?: string) =>
`The user approved this operation and provided the following context:\n<feedback>\n${feedback}\n</feedback>`,
toolError: (error?: string) => `The tool execution failed with the following error:\n<error>\n${error}\n</error>`,
toolError: (error?: string, toolName?: string) => {
const title = toolName ? `Tool Call Error: ${toolName}` : "Tool Execution Error"
return `${title}\n<error>\n${error}\n</error>`
},
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.`,

View file

@ -1052,6 +1052,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
options: {
isNonInteractive?: boolean
metadata?: Record<string, unknown>
title?: string // Optional custom title for error messages
} = {},
contextCondense?: ContextCondense,
): Promise<undefined> {
@ -1086,6 +1087,7 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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({

View file

@ -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()
})

View file

@ -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<error_details>\nThe specified file could not be found. Please verify the file path and try again.\n</error_details>`
await cline.say("error", formattedError)
await cline.say("error", formattedError, undefined, undefined, undefined, undefined, {
title: t("tools:errors.fileNotFound"),
})
pushToolResult(formattedError)
return
}

View file

@ -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
}

View file

@ -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",
),
)

View file

@ -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 [

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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 }

View file

@ -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

View file

@ -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"
}
}

View file

@ -125,6 +125,8 @@ export const ChatRowContent = ({
const [editedContent, setEditedContent] = useState("")
const [editMode, setEditMode] = useState<Mode>(mode || "code")
const [editImages, setEditImages] = useState<string[]>([])
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 = ({
<span
className="codicon codicon-error"
style={{ color: errorColor, marginBottom: "-1.5px" }}></span>,
<span style={{ color: errorColor, fontWeight: "bold" }}>{t("chat:error")}</span>,
<span style={{ color: errorColor, fontWeight: "bold" }}>{message.title || t("chat:error")}</span>,
]
case "mistake_limit_reached":
return [
@ -960,56 +962,32 @@ export const ChatRowContent = ({
case "diff_error":
return (
<div>
<div
style={{
marginTop: "0px",
overflow: "hidden",
marginBottom: "8px",
}}>
<div className="mt-0 overflow-hidden mb-2">
<div
style={{
borderBottom: isDiffErrorExpanded
? "1px solid var(--vscode-editorGroup-border)"
: "none",
fontWeight: "normal",
fontSize: "var(--vscode-font-size)",
color: "var(--vscode-editor-foreground)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
cursor: "pointer",
}}
onClick={() => setIsDiffErrorExpanded(!isDiffErrorExpanded)}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "10px",
flexGrow: 1,
}}>
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)
}
}}>
<div className="flex items-center gap-2.5 flex-grow">
<span
className="codicon codicon-warning"
style={{
color: "var(--vscode-editorWarning-foreground)",
opacity: 0.8,
fontSize: 16,
marginBottom: "-1.5px",
}}></span>
<span style={{ fontWeight: "bold" }}>{t("chat:diffError.title")}</span>
className="codicon codicon-warning text-vscode-editorWarning-foreground opacity-80"
style={{ fontSize: 16, marginBottom: "-1.5px" }}></span>
<span className="font-bold">{t("chat:diffError.title")}</span>
</div>
<div style={{ display: "flex", alignItems: "center" }}>
<div className="flex items-center">
<VSCodeButton
appearance="icon"
style={{
padding: "3px",
height: "24px",
marginRight: "4px",
color: "var(--vscode-editor-foreground)",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "transparent",
}}
className="p-[3px] h-6 mr-1 text-vscode-editor-foreground flex items-center justify-center bg-transparent"
onClick={(e) => {
e.stopPropagation()
@ -1034,12 +1012,7 @@ export const ChatRowContent = ({
</div>
</div>
{isDiffErrorExpanded && (
<div
style={{
padding: "8px",
backgroundColor: "var(--vscode-editor-background)",
borderTop: "none",
}}>
<div className="p-2 bg-vscode-editor-background">
<CodeBlock source={message.text || ""} language="xml" />
</div>
)}
@ -1244,15 +1217,58 @@ export const ChatRowContent = ({
)
case "error":
return (
<>
{title && (
<div style={headerStyle}>
{icon}
{title}
<div>
<div className="mt-0 overflow-hidden mb-2">
<div
className={`${
isErrorExpanded ? "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={isErrorExpanded}
aria-label={message.title || t("chat:error")}
onClick={() => setIsErrorExpanded(!isErrorExpanded)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
setIsErrorExpanded(!isErrorExpanded)
}
}}>
<div className="flex items-center gap-2.5 flex-grow">
<span
className="codicon codicon-warning text-vscode-editorWarning-foreground opacity-80"
style={{ fontSize: 16, marginBottom: "-1.5px" }}></span>
<span className="font-bold">{message.title || t("chat:error")}</span>
</div>
<div className="flex items-center">
<VSCodeButton
appearance="icon"
className="p-[3px] h-6 mr-1 text-vscode-editor-foreground flex items-center justify-center bg-transparent"
onClick={(e) => {
e.stopPropagation()
copyWithFeedback(message.text || "").then((success) => {
if (success) {
setShowErrorCopySuccess(true)
setTimeout(() => {
setShowErrorCopySuccess(false)
}, 1000)
}
})
}}>
<span
className={`codicon codicon-${showErrorCopySuccess ? "check" : "copy"}`}></span>
</VSCodeButton>
<span
className={`codicon codicon-chevron-${isErrorExpanded ? "up" : "down"}`}></span>
</div>
</div>
)}
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>{message.text}</p>
</>
{isErrorExpanded && (
<div className="p-2 bg-vscode-editor-background">
<CodeBlock source={message.text || ""} language="xml" />
</div>
)}
</div>
</div>
)
case "completion_result":
return (

View file

@ -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<string, string> = {
"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 }) => <pre>{source}</pre>,
}))
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(<ChatRowContent {...baseProps} message={errorMessage} />)
// 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(<ChatRowContent {...baseProps} message={errorMessage} />)
// 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(<ChatRowContent {...baseProps} message={errorMessage} />)
// 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(<ChatRowContent {...baseProps} message={errorMessage} />)
// 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(<ChatRowContent {...baseProps} message={errorMessage} />)
// 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(<ChatRowContent {...baseProps} message={errorMessage} />)
// 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(<ChatRowContent {...baseProps} message={errorMessage} />)
// 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(<ChatRowContent {...baseProps} message={errorMessage} />)
// 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(<ChatRowContent {...baseProps} message={errorMessage} />)
// 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(<ChatRowContent {...baseProps} message={errorMessage} />)
// 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(<ChatRowContent {...baseProps} message={errorMessage} />)
// 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: "<error>Diff application failed</error>",
}
render(<ChatRowContent {...baseProps} message={diffErrorMessage} />)
// 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: "<error>Diff content</error>",
}
render(<ChatRowContent {...baseProps} message={diffErrorMessage} />)
// 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(<ChatRowContent {...baseProps} message={errorMessage} />)
// 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(
<ChatRowContent {...baseProps} message={diffErrorMessage} />,
)
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(<ChatRowContent {...baseProps} message={multiLineError} />)
// 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(<ChatRowContent {...baseProps} message={longError} />)
// 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()
})
})
})