diff --git a/packages/ui/src/components.ts b/packages/ui/src/components.ts index 27e448de..7fdcec25 100644 --- a/packages/ui/src/components.ts +++ b/packages/ui/src/components.ts @@ -1,4 +1,8 @@ export { Button, type ButtonProps } from "./components/button"; +export { + ErrorWrapper, + type ErrorWrapperProps, +} from "./components/ErrorWrapper"; export { Loading, LoadingBar, diff --git a/packages/ui/src/components/ErrorWrapper.stories.tsx b/packages/ui/src/components/ErrorWrapper.stories.tsx new file mode 100644 index 00000000..4c50e282 --- /dev/null +++ b/packages/ui/src/components/ErrorWrapper.stories.tsx @@ -0,0 +1,111 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { ErrorWrapper } from "./ErrorWrapper"; + +const STORY_ERROR = new Error("The dashboard request failed with HTTP 502", { + cause: "upstream service unavailable", +}); +STORY_ERROR.stack = [ + "Error: The dashboard request failed with HTTP 502", + " at loadDashboard (src/pages/Dashboard.tsx:84:15)", + " at Dashboard (src/pages/Dashboard.tsx:27:3)", +].join("\n"); + +function BrokenDashboard(): never { + throw STORY_ERROR; +} + +function mockClipboard(writeText: (text: string) => Promise): () => void { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + return () => { + if (descriptor) { + Object.defineProperty(navigator, "clipboard", descriptor); + } else { + Reflect.deleteProperty(navigator, "clipboard"); + } + }; +} + +const meta = { + title: "Components/ErrorWrapper", + component: ErrorWrapper, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + docs: { + description: { + component: + "Full-page React error boundary with normalized diagnostics and a support-ready copy action.", + }, + }, + }, + argTypes: { + children: { control: false }, + onError: { control: false }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole("alert")).toBeInTheDocument(); + await expect( + canvas.getByRole("heading", { name: "Something went wrong" }), + ).toBeInTheDocument(); + }, +}; + +export const CopySuccess: Story = { + ...Default, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const restoreClipboard = mockClipboard(() => Promise.resolve()); + try { + await userEvent.click( + canvas.getByRole("button", { name: "Copy error details" }), + ); + await expect( + canvas.getByRole("button", { name: "Copied" }), + ).toBeInTheDocument(); + await expect( + canvas.getByText("Error details copied to clipboard."), + ).toBeInTheDocument(); + } finally { + restoreClipboard(); + } + }, +}; + +export const ClipboardFailure: Story = { + ...Default, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const restoreClipboard = mockClipboard(() => + Promise.reject(new Error("Clipboard access denied")), + ); + try { + await userEvent.click( + canvas.getByRole("button", { name: "Copy error details" }), + ); + await expect( + canvas.getByText( + "Clipboard access failed. Expand the error details to copy individual values.", + ), + ).toBeInTheDocument(); + } finally { + restoreClipboard(); + } + }, +}; diff --git a/packages/ui/src/components/ErrorWrapper.test.tsx b/packages/ui/src/components/ErrorWrapper.test.tsx new file mode 100644 index 00000000..a2ea6958 --- /dev/null +++ b/packages/ui/src/components/ErrorWrapper.test.tsx @@ -0,0 +1,111 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ErrorWrapper } from "./ErrorWrapper"; + +function BrokenPage(): never { + throw new Error("Unable to load the account dashboard", { + cause: "upstream request returned HTTP 502", + }); +} + +const originalClipboardDescriptor = Object.getOwnPropertyDescriptor( + navigator, + "clipboard", +); +const originalPageUrl = window.location.href; + +describe("ErrorWrapper", () => { + afterEach(() => { + vi.restoreAllMocks(); + if (originalClipboardDescriptor) { + Object.defineProperty( + navigator, + "clipboard", + originalClipboardDescriptor, + ); + } else { + Reflect.deleteProperty(navigator, "clipboard"); + } + window.history.replaceState({}, "", originalPageUrl); + }); + + it("renders a full-page diagnostic fallback and copies a support-ready report", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + window.history.replaceState( + {}, + "", + "/accounts?access_token=secret#authorization-code", + ); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + render( + + + , + ); + + const fallback = screen.getByRole("alert"); + expect(fallback).toHaveClass("min-h-dvh"); + expect( + screen.getByRole("heading", { name: "Something went wrong" }), + ).toBeInTheDocument(); + expect(fallback).toHaveTextContent("Unable to load the account dashboard"); + + fireEvent.click(screen.getByRole("button", { name: "Copy error details" })); + + await waitFor(() => expect(writeText).toHaveBeenCalledOnce()); + const report = writeText.mock.calls[0]?.[0]; + expect(report).toEqual( + expect.stringContaining("Error: Unable to load the account dashboard"), + ); + expect(report).toEqual( + expect.stringContaining("Cause: upstream request returned HTTP 502"), + ); + expect(report).toEqual( + expect.stringContaining(`Page: ${window.location.origin}/accounts`), + ); + expect(report).not.toEqual(expect.stringContaining("access_token")); + expect(report).not.toEqual(expect.stringContaining("authorization-code")); + expect(report).toEqual(expect.stringContaining("React component stack:")); + expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(); + expect(liveRegion(fallback)).toHaveTextContent( + "Error details copied to clipboard.", + ); + }); + + it("announces a clipboard failure in the live region", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const writeText = vi.fn().mockRejectedValue(new Error("denied")); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + render( + + + , + ); + + const fallback = screen.getByRole("alert"); + expect(liveRegion(fallback)).toHaveTextContent(""); + + fireEvent.click(screen.getByRole("button", { name: "Copy error details" })); + + await waitFor(() => + expect(liveRegion(fallback)).toHaveTextContent( + "Clipboard access failed. Expand the error details to copy individual values.", + ), + ); + }); +}); + +function liveRegion(fallback: HTMLElement): HTMLElement { + const region = fallback.querySelector('[aria-live="polite"]'); + if (!region) throw new Error("ErrorWrapper is missing its live region"); + return region; +} diff --git a/packages/ui/src/components/ErrorWrapper.tsx b/packages/ui/src/components/ErrorWrapper.tsx new file mode 100644 index 00000000..5ef39fd3 --- /dev/null +++ b/packages/ui/src/components/ErrorWrapper.tsx @@ -0,0 +1,203 @@ +import { + Component, + useMemo, + useState, + type ErrorInfo, + type ReactNode, +} from "react"; +import { ErrorDetails } from "../data/diagnostics/ErrorDetails"; +import { + normalizeErrorDiagnostics, + type ErrorDiagnostics, +} from "../data/diagnostics/error-diagnostics"; +import { Icon } from "../data/Icon"; +import { UiCheck, UiCopy, UiWarningTriangle } from "../icons"; +import { cn } from "../lib/utils"; +import { Button } from "./button"; + +export type ErrorWrapperProps = { + children: ReactNode; + onError?: (error: Error, errorInfo: ErrorInfo) => void; +}; + +type ErrorWrapperState = { + error: Error | null; + componentStack: string | null; +}; + +export class ErrorWrapper extends Component< + ErrorWrapperProps, + ErrorWrapperState +> { + override state: ErrorWrapperState = { + error: null, + componentStack: null, + }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + override componentDidCatch(error: Error, errorInfo: ErrorInfo) { + this.setState({ componentStack: errorInfo.componentStack ?? null }); + this.props.onError?.(error, errorInfo); + } + + override render() { + if (this.state.error) { + return ( + + ); + } + return this.props.children; + } +} + +type CopyState = "idle" | "copied" | "failed"; + +function copyStatusMessage(copyState: CopyState): string { + if (copyState === "copied") return "Error details copied to clipboard."; + if (copyState === "failed") { + return "Clipboard access failed. Expand the error details to copy individual values."; + } + return ""; +} + +function ErrorFallback({ + error, + componentStack, +}: { + error: Error; + componentStack: string | null; +}) { + const [copyState, setCopyState] = useState("idle"); + const diagnostics = useMemo( + () => errorDiagnostics(error, componentStack), + [componentStack, error], + ); + + const copyDetails = async () => { + if (!navigator.clipboard?.writeText) { + setCopyState("failed"); + return; + } + try { + await navigator.clipboard.writeText(errorReport(error, componentStack)); + setCopyState("copied"); + } catch { + setCopyState("failed"); + } + }; + + return ( +
+
+
+ + + +
+

+ Unexpected application error +

+

+ Something went wrong +

+

+ {diagnostics.message} +

+
+
+ +
+ + + {copyStatusMessage(copyState)} + +
+ + +
+
+ ); +} + +function errorDiagnostics( + error: Error, + componentStack: string | null, +): ErrorDiagnostics { + const diagnostics = normalizeErrorDiagnostics( + error, + "An unexpected error was thrown without a message.", + ); + if (!diagnostics) { + throw new Error("ErrorWrapper could not normalize the captured error"); + } + + const cause = + error.cause === undefined + ? [] + : [["cause", String(error.cause)] as [string, string]]; + const reactStack = componentStack?.trim(); + return { + ...diagnostics, + context: [...diagnostics.context, ...cause], + ...(reactStack + ? { + stacktrace: [ + diagnostics.stacktrace, + "React component stack:", + reactStack, + ] + .filter(Boolean) + .join("\n\n"), + } + : {}), + }; +} + +function errorReport(error: Error, componentStack: string | null) { + const lines = [ + `Error: ${error.message || "An unexpected error was thrown without a message."}`, + ]; + if (error.name !== "Error") lines.push(`Type: ${error.name}`); + if (error.cause !== undefined) lines.push(`Cause: ${String(error.cause)}`); + if (typeof window !== "undefined") { + lines.push(`Page: ${window.location.origin}${window.location.pathname}`); + } + if (typeof navigator !== "undefined" && navigator.userAgent) { + lines.push(`User agent: ${navigator.userAgent}`); + } + lines.push(`Time: ${new Date().toISOString()}`); + if (error.stack) lines.push("", "Stack trace:", error.stack); + if (componentStack?.trim()) { + lines.push("", "React component stack:", componentStack.trim()); + } + return lines.join("\n"); +} diff --git a/packages/ui/src/data/ai/SessionViewer.rows.tsx b/packages/ui/src/data/ai/SessionViewer.rows.tsx index 0c0a3280..05c35d6b 100644 --- a/packages/ui/src/data/ai/SessionViewer.rows.tsx +++ b/packages/ui/src/data/ai/SessionViewer.rows.tsx @@ -5,7 +5,7 @@ import { UiBrain, UiChevronDown, UiSparkles, - UiUserCircle, + UiUser, UiWarningTriangle, } from "../../icons"; import { @@ -241,7 +241,7 @@ function UserRow({ DISC_TONE.slate, )} > - + @@ -268,7 +268,7 @@ function eventVisual(event: SessionEvent): EventVisual { } case "user": return { - icon: UiUserCircle, + icon: UiUser, tone: "slate", label: "User", summaryOnly: false,