feat: unify error display with reusable ErrorBanner component

- Create ErrorBanner component for consistent error/warning display
- Replace generic error rendering with ErrorBanner in ChatRow
- Replace diff_error implementation with ErrorBanner
- Add comprehensive tests for ErrorBanner component
- Provides collapsible, less jarring error presentation with copy functionality

Fixes #7358
This commit is contained in:
Roo Code 2025-08-23 21:09:35 +00:00
parent fc70012f54
commit 82520b9bd9
3 changed files with 341 additions and 101 deletions

View file

@ -4,7 +4,7 @@ import { McpExecution } from "./McpExecution"
import { useSize } from "react-use"
import { useTranslation, Trans } from "react-i18next"
import deepEqual from "fast-deep-equal"
import { VSCodeBadge, VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { VSCodeBadge } from "@vscode/webview-ui-toolkit/react"
import type { ClineMessage } from "@roo-code/types"
import { Mode } from "@roo/modes"
@ -14,7 +14,6 @@ import { COMMAND_OUTPUT_STRING } from "@roo/combineCommandSequences"
import { safeJsonParse } from "@roo/safeJsonParse"
import { FollowUpData, SuggestionItem } from "@roo-code/types"
import { useCopyToClipboard } from "@src/utils/clipboard"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { findMatchingResourceOrTemplate } from "@src/utils/mcp"
import { vscode } from "@src/utils/vscode"
@ -28,8 +27,8 @@ import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock"
import UpdateTodoListToolBlock from "./UpdateTodoListToolBlock"
import CodeAccordian from "../common/CodeAccordian"
import CodeBlock from "../common/CodeBlock"
import MarkdownBlock from "../common/MarkdownBlock"
import { ErrorBanner } from "../common/ErrorBanner"
import { ReasoningBlock } from "./ReasoningBlock"
import Thumbnails from "../common/Thumbnails"
import McpResourceRow from "../mcp/McpResourceRow"
@ -116,13 +115,10 @@ export const ChatRowContent = ({
const { t } = useTranslation()
const { mcpServers, alwaysAllowMcp, currentCheckpoint, mode } = useExtensionState()
const [reasoningCollapsed, setReasoningCollapsed] = useState(true)
const [isDiffErrorExpanded, setIsDiffErrorExpanded] = useState(false)
const [showCopySuccess, setShowCopySuccess] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [editedContent, setEditedContent] = useState("")
const [editMode, setEditMode] = useState<Mode>(mode || "code")
const [editImages, setEditImages] = useState<string[]>([])
const { copyWithFeedback } = useCopyToClipboard()
// Handle message events for image selection during edit mode
useEffect(() => {
@ -854,92 +850,13 @@ export const ChatRowContent = ({
switch (message.say) {
case "diff_error":
return (
<div>
<div
style={{
marginTop: "0px",
overflow: "hidden",
marginBottom: "8px",
}}>
<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,
}}>
<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>
</div>
<div style={{ display: "flex", alignItems: "center" }}>
<VSCodeButton
appearance="icon"
style={{
padding: "3px",
height: "24px",
marginRight: "4px",
color: "var(--vscode-editor-foreground)",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "transparent",
}}
onClick={(e) => {
e.stopPropagation()
// Call copyWithFeedback and handle the Promise
copyWithFeedback(message.text || "").then((success) => {
if (success) {
// Show checkmark
setShowCopySuccess(true)
// Reset after a brief delay
setTimeout(() => {
setShowCopySuccess(false)
}, 1000)
}
})
}}>
<span
className={`codicon codicon-${showCopySuccess ? "check" : "copy"}`}></span>
</VSCodeButton>
<span
className={`codicon codicon-chevron-${isDiffErrorExpanded ? "up" : "down"}`}></span>
</div>
</div>
{isDiffErrorExpanded && (
<div
style={{
padding: "8px",
backgroundColor: "var(--vscode-editor-background)",
borderTop: "none",
}}>
<CodeBlock source={message.text || ""} language="xml" />
</div>
)}
</div>
</div>
<ErrorBanner
title={t("chat:diffError.title")}
variant="warning"
details={message.text || ""}
defaultExpanded={false}
detailsLanguage="xml"
/>
)
case "subtask_result":
return (
@ -1132,15 +1049,13 @@ export const ChatRowContent = ({
)
case "error":
return (
<>
{title && (
<div style={headerStyle}>
{icon}
{title}
</div>
)}
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>{message.text}</p>
</>
<ErrorBanner
title={t("chat:error")}
variant="error"
details={message.text || ""}
defaultExpanded={false}
detailsLanguage="text"
/>
)
case "completion_result":
return (

View file

@ -0,0 +1,156 @@
import React, { useState, useCallback } from "react"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useCopyToClipboard } from "@src/utils/clipboard"
import CodeBlock from "./CodeBlock"
export type ErrorBannerVariant = "warning" | "error" | "info"
export interface ErrorBannerProps {
/** The title text to display in the banner */
title: string
/** The variant/severity of the banner */
variant?: ErrorBannerVariant
/** The codicon name for the icon (e.g., "warning", "error", "info") */
icon?: string
/** The detailed content to show when expanded */
details?: string
/** Whether the banner should be expanded by default */
defaultExpanded?: boolean
/** Optional callback when copy button is clicked */
onCopy?: () => void
/** Optional additional actions to display on the right side */
actions?: React.ReactNode
/** The language for syntax highlighting in the details section */
detailsLanguage?: string
}
/**
* ErrorBanner component provides a consistent, collapsible banner for displaying
* errors, warnings, and informational messages. It follows the same visual pattern
* as the diff_error implementation with a subtle, less jarring appearance.
*/
export const ErrorBanner: React.FC<ErrorBannerProps> = ({
title,
variant = "warning",
icon,
details,
defaultExpanded = false,
onCopy,
actions,
detailsLanguage = "xml",
}) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
const [showCopySuccess, setShowCopySuccess] = useState(false)
const { copyWithFeedback } = useCopyToClipboard()
// Determine the icon to use based on variant if not explicitly provided
const iconName = icon || (variant === "error" ? "error" : variant === "info" ? "info" : "warning")
// Determine the color based on variant
const iconColor =
variant === "error"
? "var(--vscode-errorForeground)"
: variant === "info"
? "var(--vscode-charts-blue)"
: "var(--vscode-editorWarning-foreground)"
const handleToggleExpand = useCallback(() => {
setIsExpanded(!isExpanded)
}, [isExpanded])
const handleCopy = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation()
if (details) {
const success = await copyWithFeedback(details)
if (success) {
setShowCopySuccess(true)
setTimeout(() => {
setShowCopySuccess(false)
}, 1000)
}
}
onCopy?.()
},
[details, copyWithFeedback, onCopy],
)
return (
<div
style={{
marginTop: "0px",
overflow: "hidden",
marginBottom: "8px",
}}>
<div
style={{
borderBottom: isExpanded && details ? "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: details ? "pointer" : "default",
}}
onClick={details ? handleToggleExpand : undefined}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "10px",
flexGrow: 1,
}}>
<span
className={`codicon codicon-${iconName}`}
style={{
color: iconColor,
opacity: 0.8,
fontSize: 16,
marginBottom: "-1.5px",
}}
/>
<span style={{ fontWeight: "bold" }}>{title}</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
{actions}
{details && (
<>
<VSCodeButton
appearance="icon"
style={{
padding: "3px",
height: "24px",
color: "var(--vscode-editor-foreground)",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "transparent",
}}
onClick={handleCopy}
aria-label="Copy details">
<span className={`codicon codicon-${showCopySuccess ? "check" : "copy"}`} />
</VSCodeButton>
<span
className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}
aria-hidden="true"
/>
</>
)}
</div>
</div>
{isExpanded && details && (
<div
style={{
padding: "8px",
backgroundColor: "var(--vscode-editor-background)",
borderTop: "none",
}}>
<CodeBlock source={details} language={detailsLanguage} />
</div>
)}
</div>
)
}

View file

@ -0,0 +1,169 @@
import React from "react"
import { render, screen, fireEvent, waitFor } from "@/utils/test-utils"
import { describe, it, expect, vi, beforeEach } from "vitest"
import { ErrorBanner } from "../ErrorBanner"
// Mock the clipboard utility
vi.mock("@src/utils/clipboard", () => ({
useCopyToClipboard: () => ({
copyWithFeedback: vi.fn().mockResolvedValue(true),
}),
}))
describe("ErrorBanner", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("renders with title and default warning variant", () => {
render(<ErrorBanner title="Test Warning" />)
expect(screen.getByText("Test Warning")).toBeInTheDocument()
const icon = document.querySelector(".codicon-warning")
expect(icon).toBeInTheDocument()
})
it("renders with error variant", () => {
render(<ErrorBanner title="Test Error" variant="error" />)
expect(screen.getByText("Test Error")).toBeInTheDocument()
const icon = document.querySelector(".codicon-error")
expect(icon).toBeInTheDocument()
})
it("renders with info variant", () => {
render(<ErrorBanner title="Test Info" variant="info" />)
expect(screen.getByText("Test Info")).toBeInTheDocument()
const icon = document.querySelector(".codicon-info")
expect(icon).toBeInTheDocument()
})
it("uses custom icon when provided", () => {
render(<ErrorBanner title="Custom Icon" icon="check" />)
const icon = document.querySelector(".codicon-check")
expect(icon).toBeInTheDocument()
})
it("does not show expand/collapse controls when no details provided", () => {
render(<ErrorBanner title="No Details" />)
const chevron = document.querySelector(".codicon-chevron-down")
expect(chevron).not.toBeInTheDocument()
const copyButton = screen.queryByLabelText("Copy details")
expect(copyButton).not.toBeInTheDocument()
})
it("shows expand/collapse controls when details are provided", () => {
render(<ErrorBanner title="With Details" details="Error details here" />)
const chevron = document.querySelector(".codicon-chevron-down")
expect(chevron).toBeInTheDocument()
const copyButton = screen.getByLabelText("Copy details")
expect(copyButton).toBeInTheDocument()
})
it("expands and collapses when clicked", () => {
render(<ErrorBanner title="Expandable" details="Error details" />)
// Initially collapsed
expect(screen.queryByText("Error details")).not.toBeInTheDocument()
expect(document.querySelector(".codicon-chevron-down")).toBeInTheDocument()
// Click to expand
const banner = screen.getByText("Expandable").parentElement?.parentElement
fireEvent.click(banner!)
// Should be expanded
expect(screen.getByText("Error details")).toBeInTheDocument()
expect(document.querySelector(".codicon-chevron-up")).toBeInTheDocument()
// Click to collapse
fireEvent.click(banner!)
// Should be collapsed again
expect(screen.queryByText("Error details")).not.toBeInTheDocument()
expect(document.querySelector(".codicon-chevron-down")).toBeInTheDocument()
})
it("respects defaultExpanded prop", () => {
render(<ErrorBanner title="Default Expanded" details="Error details" defaultExpanded={true} />)
// Should be expanded by default
expect(screen.getByText("Error details")).toBeInTheDocument()
expect(document.querySelector(".codicon-chevron-up")).toBeInTheDocument()
})
it("copies details when copy button is clicked", async () => {
const onCopy = vi.fn()
render(<ErrorBanner title="Copy Test" details="Details to copy" onCopy={onCopy} />)
const copyButton = screen.getByLabelText("Copy details")
// Initially shows copy icon
expect(copyButton.querySelector(".codicon-copy")).toBeInTheDocument()
// Click copy button
fireEvent.click(copyButton)
// Should call onCopy callback
await waitFor(() => {
expect(onCopy).toHaveBeenCalled()
})
})
it("shows copy success feedback", async () => {
render(<ErrorBanner title="Copy Success" details="Details to copy" />)
const copyButton = screen.getByLabelText("Copy details")
// Initially shows copy icon
expect(copyButton.querySelector(".codicon-copy")).toBeInTheDocument()
// Click copy button
fireEvent.click(copyButton)
// Should show check icon briefly
await waitFor(() => {
expect(copyButton.querySelector(".codicon-check")).toBeInTheDocument()
})
})
it("renders custom actions", () => {
const customAction = <button data-testid="custom-action">Custom</button>
render(<ErrorBanner title="With Actions" actions={customAction} />)
expect(screen.getByTestId("custom-action")).toBeInTheDocument()
})
it("does not make banner clickable when no details", () => {
const { container } = render(<ErrorBanner title="Not Clickable" />)
const banner = container.querySelector('[style*="cursor"]')
expect(banner).toHaveStyle({ cursor: "default" })
})
it("makes banner clickable when details are provided", () => {
const { container } = render(<ErrorBanner title="Clickable" details="Some details" />)
const banner = container.querySelector('[style*="cursor"]')
expect(banner).toHaveStyle({ cursor: "pointer" })
})
it("applies correct colors based on variant", () => {
const { rerender } = render(<ErrorBanner title="Color Test" variant="error" />)
let icon = document.querySelector(".codicon-error")
expect(icon).toHaveStyle({ color: "var(--vscode-errorForeground)" })
rerender(<ErrorBanner title="Color Test" variant="warning" />)
icon = document.querySelector(".codicon-warning")
expect(icon).toHaveStyle({ color: "var(--vscode-editorWarning-foreground)" })
rerender(<ErrorBanner title="Color Test" variant="info" />)
icon = document.querySelector(".codicon-info")
expect(icon).toHaveStyle({ color: "var(--vscode-charts-blue)" })
})
})