| ... | ... | @@ -8,9 +8,10 @@ import { |
| 8 | 8 | useLocation, |
| 9 | 9 | useNavigate, |
| 10 | 10 | } from "@tanstack/react-router"; |
| 11 | | import { useEffect, useEffectEvent, useRef, useState } from "react"; |
| 11 | import { CheckIcon, CopyIcon } from "lucide-react"; |
| 12 | import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; |
| 12 | 13 | |
| 13 | | import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding"; |
| 14 | import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding"; |
| 14 | 15 | import { resolveServerBackedAppDisplayName } from "../branding.logic"; |
| 15 | 16 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; |
| 16 | 17 | import { CommandPalette } from "../components/CommandPalette"; |
| ... | ... | @@ -22,6 +23,7 @@ import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAp |
| 22 | 23 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; |
| 23 | 24 | import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; |
| 24 | 25 | import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; |
| 26 | import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; |
| 25 | 27 | import { useDefaultThemeAdoption } from "../hooks/useDefaultTheme"; |
| 26 | 28 | import { useEnvironmentThemeSync } from "../hooks/useEnvironmentTheme"; |
| 27 | 29 | import { Button } from "../components/ui/button"; |
| ... | ... | @@ -267,7 +269,9 @@ function HostedStaticEnvironmentBootstrap() { |
| 267 | 269 | |
| 268 | 270 | function RootRouteErrorView({ error, reset }: ErrorComponentProps) { |
| 269 | 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]); |
| 271 | 275 | |
| 272 | 276 | return ( |
| 273 | 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 | 296 | <Button size="sm" variant="outline" onClick={() => window.location.reload()}> |
| 293 | 297 | Reload app |
| 294 | 298 | </Button> |
| 299 | <CopyErrorButton report={report} /> |
| 295 | 300 | </div> |
| 296 | 301 | |
| 297 | | <details className="group 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"> |
| 299 | | <span className="group-open:hidden">Show error details</span> |
| 300 | | <span className="hidden group-open:inline">Hide error details</span> |
| 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} |
| 302 | <div className="mt-5 overflow-hidden rounded-lg border border-border/70 bg-background/55"> |
| 303 | <p className="px-3 py-1.5 text-xs font-medium text-muted-foreground">Error report</p> |
| 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"> |
| 305 | {report} |
| 304 | 306 | </pre> |
| 305 | | </details> |
| 307 | </div> |
| 306 | 308 | </section> |
| 307 | 309 | </div> |
| 308 | 310 | ); |
| 309 | 311 | } |
| 310 | 312 | |
| 313 | /** Copies the full error report and swaps to a check mark for a moment as confirmation. */ |
| 314 | function 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 | |
| 311 | 325 | function errorMessage(error: unknown): string { |
| 312 | 326 | if (error instanceof Error && error.message.trim().length > 0) { |
| 313 | 327 | return error.message; |
| ... | ... | @@ -336,6 +350,29 @@ function errorDetails(error: unknown): string { |
| 336 | 350 | } |
| 337 | 351 | } |
| 338 | 352 | |
| 353 | const 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 | */ |
| 360 | function 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 | |
| 339 | 376 | function AuthenticatedTracingBootstrap() { |
| 340 | 377 | useEffect(() => { |
| 341 | 378 | void configureClientTracing(); |