From 530410dc5044028cef5fcd96cfbd3d26fdc62d96 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 6 Aug 2026 13:58:27 +0300 Subject: [PATCH 1/2] feat(ui): Add error boundary with copyable diagnostics Give consumers a shared full-page fallback for uncaught React errors, with normalized diagnostics and support-ready clipboard reporting. Include tests and Storybook coverage for successful and failed copy flows. Claude-Session-Id: 208885f0-1dd9-44f9-9b16-0d59284bd391 --- packages/ui/src/components.ts | 4 + .../src/components/ErrorWrapper.stories.tsx | 53 +++++ .../ui/src/components/ErrorWrapper.test.tsx | 88 ++++++++ packages/ui/src/components/ErrorWrapper.tsx | 202 ++++++++++++++++++ 4 files changed, 347 insertions(+) create mode 100644 packages/ui/src/components/ErrorWrapper.stories.tsx create mode 100644 packages/ui/src/components/ErrorWrapper.test.tsx create mode 100644 packages/ui/src/components/ErrorWrapper.tsx 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..d73aaa82 --- /dev/null +++ b/packages/ui/src/components/ErrorWrapper.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, 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; +} + +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(); + }, +}; diff --git a/packages/ui/src/components/ErrorWrapper.test.tsx b/packages/ui/src/components/ErrorWrapper.test.tsx new file mode 100644 index 00000000..983174cd --- /dev/null +++ b/packages/ui/src/components/ErrorWrapper.test.tsx @@ -0,0 +1,88 @@ +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", + }); +} + +describe("ErrorWrapper", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders a full-page diagnostic fallback and copies a support-ready report", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + 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: http://localhost")); + 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..e900d11f --- /dev/null +++ b/packages/ui/src/components/ErrorWrapper.tsx @@ -0,0 +1,202 @@ +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.href}`); + 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"); +} From fbeb570fdb34535bb33877cc4f0345c2bf566743 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 7 Aug 2026 09:14:25 +0300 Subject: [PATCH 2/2] fix(ui): Redact sensitive URL data from error reports Keep query parameters and URL fragments out of copied diagnostics to prevent accidental credential disclosure. Add coverage for clipboard outcomes and align session user visuals with the standard icon. --- .../src/components/ErrorWrapper.stories.tsx | 60 ++++++++++++++++++- .../ui/src/components/ErrorWrapper.test.tsx | 31 ++++++++-- packages/ui/src/components/ErrorWrapper.tsx | 5 +- .../ui/src/data/ai/SessionViewer.rows.tsx | 6 +- 4 files changed, 92 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/components/ErrorWrapper.stories.tsx b/packages/ui/src/components/ErrorWrapper.stories.tsx index d73aaa82..4c50e282 100644 --- a/packages/ui/src/components/ErrorWrapper.stories.tsx +++ b/packages/ui/src/components/ErrorWrapper.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; +import { expect, userEvent, within } from "storybook/test"; import { ErrorWrapper } from "./ErrorWrapper"; const STORY_ERROR = new Error("The dashboard request failed with HTTP 502", { @@ -15,6 +15,21 @@ 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, @@ -51,3 +66,46 @@ export const Default: Story = { ).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 index 983174cd..a2ea6958 100644 --- a/packages/ui/src/components/ErrorWrapper.test.tsx +++ b/packages/ui/src/components/ErrorWrapper.test.tsx @@ -8,13 +8,34 @@ function BrokenPage(): never { }); } +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 }, @@ -44,11 +65,13 @@ describe("ErrorWrapper", () => { expect(report).toEqual( expect.stringContaining("Cause: upstream request returned HTTP 502"), ); - expect(report).toEqual(expect.stringContaining("Page: http://localhost")); + 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(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(); expect(liveRegion(fallback)).toHaveTextContent( "Error details copied to clipboard.", ); diff --git a/packages/ui/src/components/ErrorWrapper.tsx b/packages/ui/src/components/ErrorWrapper.tsx index e900d11f..5ef39fd3 100644 --- a/packages/ui/src/components/ErrorWrapper.tsx +++ b/packages/ui/src/components/ErrorWrapper.tsx @@ -188,8 +188,9 @@ function errorReport(error: Error, componentStack: string | null) { ]; 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.href}`); + 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}`); } 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,