diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 4fa921f443..a8ada3025b 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -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 || "code") const [editImages, setEditImages] = useState([]) - 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 ( -
-
-
setIsDiffErrorExpanded(!isDiffErrorExpanded)}> -
- - {t("chat:diffError.title")} -
-
- { - 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) - } - }) - }}> - - - -
-
- {isDiffErrorExpanded && ( -
- -
- )} -
-
+ ) case "subtask_result": return ( @@ -1132,15 +1049,13 @@ export const ChatRowContent = ({ ) case "error": return ( - <> - {title && ( -
- {icon} - {title} -
- )} -

{message.text}

- + ) case "completion_result": return ( diff --git a/webview-ui/src/components/common/ErrorBanner.tsx b/webview-ui/src/components/common/ErrorBanner.tsx new file mode 100644 index 0000000000..55120b4624 --- /dev/null +++ b/webview-ui/src/components/common/ErrorBanner.tsx @@ -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 = ({ + 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 ( +
+
+
+ + {title} +
+
+ {actions} + {details && ( + <> + + + +
+
+ {isExpanded && details && ( +
+ +
+ )} +
+ ) +} diff --git a/webview-ui/src/components/common/__tests__/ErrorBanner.spec.tsx b/webview-ui/src/components/common/__tests__/ErrorBanner.spec.tsx new file mode 100644 index 0000000000..89d1efe0f2 --- /dev/null +++ b/webview-ui/src/components/common/__tests__/ErrorBanner.spec.tsx @@ -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() + + expect(screen.getByText("Test Warning")).toBeInTheDocument() + const icon = document.querySelector(".codicon-warning") + expect(icon).toBeInTheDocument() + }) + + it("renders with error variant", () => { + render() + + expect(screen.getByText("Test Error")).toBeInTheDocument() + const icon = document.querySelector(".codicon-error") + expect(icon).toBeInTheDocument() + }) + + it("renders with info variant", () => { + render() + + expect(screen.getByText("Test Info")).toBeInTheDocument() + const icon = document.querySelector(".codicon-info") + expect(icon).toBeInTheDocument() + }) + + it("uses custom icon when provided", () => { + render() + + const icon = document.querySelector(".codicon-check") + expect(icon).toBeInTheDocument() + }) + + it("does not show expand/collapse controls when no details provided", () => { + render() + + 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() + + 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() + + // 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() + + // 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() + + 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() + + 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 = + render() + + expect(screen.getByTestId("custom-action")).toBeInTheDocument() + }) + + it("does not make banner clickable when no details", () => { + const { container } = render() + + const banner = container.querySelector('[style*="cursor"]') + expect(banner).toHaveStyle({ cursor: "default" }) + }) + + it("makes banner clickable when details are provided", () => { + const { container } = render() + + const banner = container.querySelector('[style*="cursor"]') + expect(banner).toHaveStyle({ cursor: "pointer" }) + }) + + it("applies correct colors based on variant", () => { + const { rerender } = render() + let icon = document.querySelector(".codicon-error") + expect(icon).toHaveStyle({ color: "var(--vscode-errorForeground)" }) + + rerender() + icon = document.querySelector(".codicon-warning") + expect(icon).toHaveStyle({ color: "var(--vscode-editorWarning-foreground)" }) + + rerender() + icon = document.querySelector(".codicon-info") + expect(icon).toHaveStyle({ color: "var(--vscode-charts-blue)" }) + }) +})