mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-10 03:28:03 +00:00
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:
parent
e8deedd91b
commit
9d3c0268ae
21 changed files with 706 additions and 86 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
51
src/core/prompts/__tests__/responses-tool-error.spec.ts
Normal file
51
src/core/prompts/__tests__/responses-tool-error.spec.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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.`,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
457
webview-ui/src/components/chat/__tests__/ChatRow.spec.tsx
Normal file
457
webview-ui/src/components/chat/__tests__/ChatRow.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue