authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-09-02 07:12:36+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 01:12:36-04:00
log133db22fae34cfb2e62a806e99d22674b0376f35
tree7b813c3e9dc378e1385623a0baa0b38a4479f8c6
parentb520120cf169ce63a5606447a292451e97622c9a
signature Signed by PGP key B5690EEEBB952194

feat(web): copy the full error report from the error page (#9166)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

1 files changed, 48 insertions(+), 11 deletions(-)

apps/web/src/routes/__root.tsx+48-11
...@@ -8,9 +8,10 @@ import {...@@ -8,9 +8,10 @@ import {
8 useLocation,8 useLocation,
9 useNavigate,9 useNavigate,
10} from "@tanstack/react-router";10} from "@tanstack/react-router";
11import { useEffect, useEffectEvent, useRef, useState } from "react";11import { CheckIcon, CopyIcon } from "lucide-react";
12import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
1213
13import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding";14import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding";
14import { resolveServerBackedAppDisplayName } from "../branding.logic";15import { resolveServerBackedAppDisplayName } from "../branding.logic";
15import { AppSidebarLayout } from "../components/AppSidebarLayout";16import { AppSidebarLayout } from "../components/AppSidebarLayout";
16import { CommandPalette } from "../components/CommandPalette";17import { CommandPalette } from "../components/CommandPalette";
...@@ -22,6 +23,7 @@ import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAp...@@ -22,6 +23,7 @@ import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAp
22import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";23import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
23import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";24import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
24import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";25import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";
26import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
25import { useDefaultThemeAdoption } from "../hooks/useDefaultTheme";27import { useDefaultThemeAdoption } from "../hooks/useDefaultTheme";
26import { useEnvironmentThemeSync } from "../hooks/useEnvironmentTheme";28import { useEnvironmentThemeSync } from "../hooks/useEnvironmentTheme";
27import { Button } from "../components/ui/button";29import { Button } from "../components/ui/button";
...@@ -267,7 +269,9 @@ function HostedStaticEnvironmentBootstrap() {...@@ -267,7 +269,9 @@ function HostedStaticEnvironmentBootstrap() {
267269
268function RootRouteErrorView({ error, reset }: ErrorComponentProps) {270function RootRouteErrorView({ error, reset }: ErrorComponentProps) {
269 const message = errorMessage(error);271 const message = errorMessage(error);
270 const details = errorDetails(error);272 // Router pathname rather than window.location: desktop uses hash history, where the window path is always "/".
273 const pathname = useLocation({ select: (location) => location.pathname });
274 const report = useMemo(() => errorReport(error, pathname), [error, pathname]);
271275
272 return (276 return (
273 <div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-10 text-foreground sm:px-6">277 <div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-10 text-foreground sm:px-6">
...@@ -292,22 +296,32 @@ function RootRouteErrorView({ error, reset }: ErrorComponentProps) {...@@ -292,22 +296,32 @@ function RootRouteErrorView({ error, reset }: ErrorComponentProps) {
292 <Button size="sm" variant="outline" onClick={() => window.location.reload()}>296 <Button size="sm" variant="outline" onClick={() => window.location.reload()}>
293 Reload app297 Reload app
294 </Button>298 </Button>
299 <CopyErrorButton report={report} />
295 </div>300 </div>
296301
297 <details className="group mt-5 overflow-hidden rounded-lg border border-border/70 bg-background/55">302 <div className="mt-5 overflow-hidden rounded-lg border border-border/70 bg-background/55">
298 <summary className="cursor-pointer list-none px-3 py-2 text-xs font-medium text-muted-foreground">303 <p className="px-3 py-1.5 text-xs font-medium text-muted-foreground">Error report</p>
299 <span className="group-open:hidden">Show error details</span>304 <pre className="max-h-64 overflow-auto border-t border-border/70 bg-background/80 px-3 py-2 text-xs whitespace-pre-wrap text-foreground/85">
300 <span className="hidden group-open:inline">Hide error details</span>305 {report}
301 </summary>
302 <pre className="max-h-56 overflow-auto border-t border-border/70 bg-background/80 px-3 py-2 text-xs text-foreground/85">
303 {details}
304 </pre>306 </pre>
305 </details>307 </div>
306 </section>308 </section>
307 </div>309 </div>
308 );310 );
309}311}
310312
313/** Copies the full error report and swaps to a check mark for a moment as confirmation. */
314function CopyErrorButton({ report }: { report: string }) {
315 const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "error-report" });
316
317 return (
318 <Button size="sm" variant="outline" onClick={() => copyToClipboard(report)}>
319 {isCopied ? <CheckIcon className="text-success" /> : <CopyIcon />}
320 {isCopied ? "Copied" : "Copy error"}
321 </Button>
322 );
323}
324
311function errorMessage(error: unknown): string {325function errorMessage(error: unknown): string {
312 if (error instanceof Error && error.message.trim().length > 0) {326 if (error instanceof Error && error.message.trim().length > 0) {
313 return error.message;327 return error.message;
...@@ -336,6 +350,29 @@ function errorDetails(error: unknown): string {...@@ -336,6 +350,29 @@ function errorDetails(error: unknown): string {
336 }350 }
337}351}
338352
353const MAX_ERROR_CAUSE_DEPTH = 5;
354
355/**
356 * Full error text for bug reports: app build, page path, time, then the stack
357 * and any cause chain. Takes the pathname only so tokens in the query never
358 * land on the clipboard.
359 */
360function errorReport(error: unknown, pathname: string): string {
361 const lines = [
362 `${APP_DISPLAY_NAME} ${APP_VERSION}`,
363 `Path: ${pathname}`,
364 `Time: ${new Date().toISOString()}`,
365 "",
366 errorDetails(error),
367 ];
368 let cause = error instanceof Error ? error.cause : undefined;
369 for (let depth = 0; cause !== undefined && depth < MAX_ERROR_CAUSE_DEPTH; depth += 1) {
370 lines.push("", "Caused by:", errorDetails(cause));
371 cause = cause instanceof Error ? cause.cause : undefined;
372 }
373 return lines.join("\n");
374}
375
339function AuthenticatedTracingBootstrap() {376function AuthenticatedTracingBootstrap() {
340 useEffect(() => {377 useEffect(() => {
341 void configureClientTracing();378 void configureClientTracing();