mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-08 03:07:53 +00:00
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:
parent
fc70012f54
commit
82520b9bd9
3 changed files with 341 additions and 101 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
156
webview-ui/src/components/common/ErrorBanner.tsx
Normal file
156
webview-ui/src/components/common/ErrorBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
169
webview-ui/src/components/common/__tests__/ErrorBanner.spec.tsx
Normal file
169
webview-ui/src/components/common/__tests__/ErrorBanner.spec.tsx
Normal 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)" })
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue