From 7fb8c9b40197dcf01c5ded735d77ca80e1b948a9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 13:19:54 -0700 Subject: [PATCH] Improve React error boundary crash reporting (#3906) --- src/main/ipc/crash-reporting.test.ts | 85 +++ src/main/ipc/crash-reporting.ts | 168 +++++- src/preload/api-types.ts | 7 +- src/preload/index.ts | 11 +- src/renderer/src/App.tsx | 522 ++++++++++++------ .../crash-report/CrashReportDialog.tsx | 67 ++- .../editor/RichMarkdownErrorBoundary.tsx | 7 + .../RecoverableRenderErrorBoundary.tsx | 99 ++++ .../sidebar/DeleteWorktreeDialog.tsx | 46 +- .../react-error-boundary-reporting.test.ts | 128 +++++ .../src/lib/react-error-boundary-reporting.ts | 150 +++++ src/renderer/src/main.tsx | 10 +- src/renderer/src/web/main.tsx | 12 +- src/renderer/src/web/web-preload-api.ts | 1 + src/shared/crash-reporting.test.ts | 15 +- src/shared/crash-reporting.ts | 56 +- 16 files changed, 1183 insertions(+), 201 deletions(-) create mode 100644 src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx create mode 100644 src/renderer/src/lib/react-error-boundary-reporting.test.ts create mode 100644 src/renderer/src/lib/react-error-boundary-reporting.ts diff --git a/src/main/ipc/crash-reporting.test.ts b/src/main/ipc/crash-reporting.test.ts index 6ea6c300a..09282bf00 100644 --- a/src/main/ipc/crash-reporting.test.ts +++ b/src/main/ipc/crash-reporting.test.ts @@ -8,6 +8,7 @@ const { handlers, clipboardWriteTextMock, submitFeedbackMock } = vi.hoisted(() = })) vi.mock('electron', () => ({ + app: { getVersion: () => '1.0.0-test' }, clipboard: { writeText: clipboardWriteTextMock }, ipcMain: { removeHandler: vi.fn((channel: string) => handlers.delete(channel)), @@ -214,4 +215,88 @@ describe('registerCrashReportingHandlers', () => { }) expect(markSent).not.toHaveBeenCalled() }) + + it('records a deduped renderer error boundary report through the crash store', async () => { + const recorded = report('pending', 'react-render') + const recordMock = vi.fn(async () => recorded) + registerCrashReportingHandlers({ + getById: vi.fn(), + dismiss: vi.fn(), + markSent: vi.fn(), + markDismissedSent: vi.fn(), + listRecent: vi.fn(async () => []), + record: recordMock, + formatDiagnosticText: vi.fn() + } as never) + + const args = { + boundaryId: 'terminal.workbench', + surface: 'terminal-workbench', + errorName: 'TypeError', + errorMessage: 'Cannot read /Users/alice/project/token=abc123', + errorStack: 'TypeError: nope\n at /Users/alice/project/App.tsx:12:1', + componentStack: 'at Terminal\nat App', + activeView: 'terminal', + activeModal: 'none', + activeTabType: 'terminal', + activeRightSidebarTab: 'source-control', + hasActiveWorktree: true + } + + await expect(handlers.get('crashReports:recordRendererError')?.(null, args)).resolves.toEqual({ + ok: true, + report: recorded, + deduped: false + }) + await expect(handlers.get('crashReports:recordRendererError')?.(null, args)).resolves.toEqual({ + ok: true, + report: null, + deduped: true + }) + + expect(recordMock).toHaveBeenCalledTimes(1) + expect(recordMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'renderer', + processType: 'react-render', + reason: 'react-error-boundary', + exitCode: null, + appVersion: '1.0.0-test', + details: expect.objectContaining({ + boundary_id: 'terminal.workbench', + surface: 'terminal-workbench', + error_name: 'TypeError', + error_message: 'Cannot read /Users/alice/project/token=abc123', + active_view: 'terminal', + active_modal: 'none', + active_tab_type: 'terminal', + right_sidebar_tab: 'source-control', + has_active_worktree: true + }) + }) + ) + }) + + it('rejects invalid renderer error boundary surfaces', async () => { + const recordMock = vi.fn() + registerCrashReportingHandlers({ + getById: vi.fn(), + dismiss: vi.fn(), + markSent: vi.fn(), + markDismissedSent: vi.fn(), + listRecent: vi.fn(async () => []), + record: recordMock, + formatDiagnosticText: vi.fn() + } as never) + + await expect( + handlers.get('crashReports:recordRendererError')?.(null, { + boundaryId: 'terminal.workbench', + surface: 'unknown', + errorName: 'TypeError', + errorMessage: 'nope' + }) + ).resolves.toEqual({ ok: false, error: 'Invalid renderer error report.' }) + expect(recordMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts index 9de812035..f726b3ea4 100644 --- a/src/main/ipc/crash-reporting.ts +++ b/src/main/ipc/crash-reporting.ts @@ -1,14 +1,170 @@ -import { clipboard, ipcMain } from 'electron' +import os from 'node:os' +import { app, clipboard, ipcMain } from 'electron' import { formatCrashReportText, + type ReactErrorBoundaryReportArgs, + type ReactErrorBoundaryReportResult, type CrashReportSubmitArgs, type CrashReportSubmitResult } from '../../shared/crash-reporting' import { submitFeedback } from './feedback' import type { CrashReportStore } from '../crash-reporting/crash-report-store' +import { getCrashBreadcrumbSnapshot } from '../crash-reporting/crash-breadcrumb-store' const inFlightSubmissions = new Set() const submittedReportIds = new Set() +const recentRendererErrorReportKeys = new Map() + +const RENDERER_ERROR_DEDUPE_MS = 10 * 60 * 1000 +const MAX_RENDERER_ERROR_KEY_AGE_MS = RENDERER_ERROR_DEDUPE_MS * 2 + +const REACT_ERROR_BOUNDARY_SURFACES = new Set([ + 'app-root', + 'web-root', + 'workspace-shell', + 'sidebar', + 'terminal-workbench', + 'right-sidebar', + 'page', + 'modal', + 'overlay', + 'rich-markdown-editor' +]) + +function stringField(value: unknown, maxLength: number): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const trimmed = value.trim() + if (!trimmed) { + return undefined + } + return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed +} + +function nullableStringField(value: unknown, maxLength: number): string | null | undefined { + if (value === null) { + return null + } + return stringField(value, maxLength) +} + +function normalizeRendererErrorReportArgs(args: unknown): ReactErrorBoundaryReportArgs | null { + if (!args || typeof args !== 'object') { + return null + } + const record = args as Record + const boundaryId = stringField(record.boundaryId, 120) + const surface = stringField(record.surface, 80) + const errorName = stringField(record.errorName, 120) ?? 'Error' + const errorMessage = stringField(record.errorMessage, 1_000) ?? 'Unknown render error' + if ( + !boundaryId || + !surface || + !REACT_ERROR_BOUNDARY_SURFACES.has(surface as ReactErrorBoundaryReportArgs['surface']) + ) { + return null + } + + return { + boundaryId, + surface: surface as ReactErrorBoundaryReportArgs['surface'], + errorName, + errorMessage, + ...(stringField(record.errorStack, 8_000) + ? { errorStack: stringField(record.errorStack, 8_000) } + : {}), + ...(stringField(record.componentStack, 8_000) + ? { componentStack: stringField(record.componentStack, 8_000) } + : {}), + ...(stringField(record.activeView, 80) + ? { activeView: stringField(record.activeView, 80) } + : {}), + ...(nullableStringField(record.activeModal, 80) !== undefined + ? { activeModal: nullableStringField(record.activeModal, 80) ?? null } + : {}), + ...(stringField(record.activeTabType, 80) + ? { activeTabType: stringField(record.activeTabType, 80) } + : {}), + ...(stringField(record.activeRightSidebarTab, 80) + ? { activeRightSidebarTab: stringField(record.activeRightSidebarTab, 80) } + : {}), + ...(typeof record.hasActiveWorktree === 'boolean' + ? { hasActiveWorktree: record.hasActiveWorktree } + : {}) + } +} + +function pruneRendererErrorReportKeys(now: number): void { + for (const [key, seenAt] of recentRendererErrorReportKeys) { + if (now - seenAt > MAX_RENDERER_ERROR_KEY_AGE_MS) { + recentRendererErrorReportKeys.delete(key) + } + } +} + +function getRendererErrorReportKey(args: ReactErrorBoundaryReportArgs): string { + return JSON.stringify({ + boundaryId: args.boundaryId, + surface: args.surface, + errorName: args.errorName, + errorMessage: args.errorMessage, + componentStack: args.componentStack + }).slice(0, 12_000) +} + +async function recordRendererErrorReport( + store: CrashReportStore, + args: unknown +): Promise { + const normalized = normalizeRendererErrorReportArgs(args) + if (!normalized) { + return { ok: false, error: 'Invalid renderer error report.' } + } + + const now = Date.now() + pruneRendererErrorReportKeys(now) + const key = getRendererErrorReportKey(normalized) + if (now - (recentRendererErrorReportKeys.get(key) ?? 0) < RENDERER_ERROR_DEDUPE_MS) { + return { ok: true, report: null, deduped: true } + } + recentRendererErrorReportKeys.set(key, now) + + const report = await store.record({ + source: 'renderer', + processType: 'react-render', + reason: 'react-error-boundary', + exitCode: null, + appVersion: app.getVersion(), + platform: process.platform, + osRelease: os.release(), + arch: process.arch, + electronVersion: process.versions.electron ?? 'unknown', + chromeVersion: process.versions.chrome ?? 'unknown', + details: { + boundary_id: normalized.boundaryId, + surface: normalized.surface, + error_name: normalized.errorName, + error_message: normalized.errorMessage, + ...(normalized.errorStack ? { error_stack: normalized.errorStack } : {}), + ...(normalized.componentStack ? { component_stack: normalized.componentStack } : {}), + ...(normalized.activeView ? { active_view: normalized.activeView } : {}), + ...(normalized.activeModal !== undefined ? { active_modal: normalized.activeModal } : {}), + ...(normalized.activeTabType ? { active_tab_type: normalized.activeTabType } : {}), + ...(normalized.activeRightSidebarTab + ? { right_sidebar_tab: normalized.activeRightSidebarTab } + : {}), + ...(normalized.hasActiveWorktree !== undefined + ? { has_active_worktree: normalized.hasActiveWorktree } + : {}) + }, + // Why: React render failures are recoverable only because a boundary + // caught them; persist the same recent app breadcrumbs as native crashes. + breadcrumbs: getCrashBreadcrumbSnapshot() + }) + + return { ok: true, report, deduped: false } +} async function getLatestPendingReport( store: CrashReportStore @@ -67,6 +223,16 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { } ) + ipcMain.removeHandler('crashReports:recordRendererError') + ipcMain.handle('crashReports:recordRendererError', async (_event, args: unknown) => { + try { + return await recordRendererErrorReport(store, args) + } catch (error) { + console.error('[crash-reporting] Failed to record renderer error report:', error) + return { ok: false, error: 'Failed to record renderer error report.' } + } + }) + ipcMain.removeHandler('crashReports:submit') ipcMain.handle( 'crashReports:submit', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 909f367c2..eeefa13c6 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -193,7 +193,9 @@ import type { SkillDiscoveryResult } from '../shared/skills' import type { CrashReportRecord, CrashReportSubmitArgs, - CrashReportSubmitResult + CrashReportSubmitResult, + ReactErrorBoundaryReportArgs, + ReactErrorBoundaryReportResult } from '../shared/crash-reporting' export type { ShellOpenLocalPathResult } from '../shared/shell-open-types' @@ -873,6 +875,9 @@ export type PreloadApi = { getLatestPending: () => Promise getLatestReport: () => Promise dismiss: (args: { reportId: string }) => Promise + recordRendererError: ( + args: ReactErrorBoundaryReportArgs + ) => Promise submit: (args: CrashReportSubmitArgs) => Promise copyLatestDiagnostics: (args?: { reportId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index e261e7d13..e841dfec1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -157,7 +157,12 @@ import { import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-subscriptions' import type { RuntimeEnvironmentSubscriptionHandle } from './runtime-environment-subscriptions' import type { HostedReviewForBranchArgs } from '../shared/hosted-review' -import type { CrashReportSubmitArgs, CrashReportSubmitResult } from '../shared/crash-reporting' +import type { + CrashReportSubmitArgs, + CrashReportSubmitResult, + ReactErrorBoundaryReportArgs, + ReactErrorBoundaryReportResult +} from '../shared/crash-reporting' import type { PreloadApi } from './api-types' type NativeFileDropCallback = (data: NativeFileDropPayload) => void @@ -803,6 +808,10 @@ const api = { getLatestPending: () => ipcRenderer.invoke('crashReports:getLatestPending'), getLatestReport: () => ipcRenderer.invoke('crashReports:getLatestReport'), dismiss: (args: { reportId: string }) => ipcRenderer.invoke('crashReports:dismiss', args), + recordRendererError: ( + args: ReactErrorBoundaryReportArgs + ): Promise => + ipcRenderer.invoke('crashReports:recordRendererError', args), submit: (args: CrashReportSubmitArgs): Promise => ipcRenderer.invoke('crashReports:submit', args), copyLatestDiagnostics: (args?: { reportId?: string; notes?: string }) => diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a42a8db82..64020c86e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -65,6 +65,7 @@ import { import { DictationController } from './components/dictation/DictationController' import { WorkspacePortScanner } from './components/ports/WorkspacePortScanner' import { CrashReportDialog } from './components/crash-report/CrashReportDialog' +import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary' import { ConfirmationDialogProvider } from './components/confirmation-dialog' import RecentTabSwitcher from './components/tab-bar/RecentTabSwitcher' import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling' @@ -1591,63 +1592,70 @@ function App(): React.JSX.Element { {/* Why: leaf-mounted retention sync keeps agent-status retention subscriptions from re-rendering the App tree. */} -
- {/* Why: the non-workspace titlebar lives inside this left+center + +
+ {/* Why: the non-workspace titlebar lives inside this left+center wrapper so it does not span over the right-sidebar column — when the right sidebar is open, its own header anchors at the top alongside the titlebar instead of being pushed below it. */} -
- {/* Why: in workspace view (split groups always enabled), the +
+ {/* Why: in workspace view (split groups always enabled), the full-width titlebar is removed so tab groups + terminal extend to the top of the window. Left titlebar controls move to a header above the sidebar. Settings, landing, and the tasks page keep the titlebar. */} - {!workspaceActive ? ( -
-
- {titlebarLeftControls} -
- {activeView === 'activity' ? ( - - ) : ( + {!workspaceActive ? ( +
- )} - {showTitlebarExpandButton && ( - - - - - - Collapse pane - - - )} - {/* Why: when the right sidebar is open, its own header renders + className={`flex items-center${showSidebar && sidebarOpen ? ' overflow-hidden shrink-0' : ' shrink-0 mr-2'}`} + style={{ width: showSidebar && sidebarOpen ? sidebarWidth : undefined }} + > + {titlebarLeftControls} +
+ {activeView === 'activity' ? ( + + ) : ( +
+ )} + {showTitlebarExpandButton && ( + + + + + + Collapse pane + + + )} + {/* Why: when the right sidebar is open, its own header renders an identical close button — hide this copy so only one is visible at a time. */} - {!rightSidebarOpen && rightSidebarToggle} - {/* Why: reserve space so content is not obscured by the + {!rightSidebarOpen && rightSidebarToggle} + {/* Why: reserve space so content is not obscured by the fixed-position window-controls overlay on Windows. */} - {isWindows &&
} -
- ) : null} -
- {showSidebar ? ( - workspaceActive ? ( - /* Why: left column wraps the sidebar with a titlebar-height + {isWindows &&
} +
+ ) : null} +
+ {showSidebar ? ( + workspaceActive ? ( + /* Why: left column wraps the sidebar with a titlebar-height header above it. The header holds the same controls (traffic lights, sidebar toggle, "Orca" title, agent badge) that the full-width titlebar held while the center and right @@ -1655,148 +1663,282 @@ function App(): React.JSX.Element { When the sidebar is collapsed, take this header out of flex layout so the terminal/editor reclaim the left edge instead of leaving behind a content-width blank strip. */ -
- {titlebarLeftControls} -
-
- {/* Why: the workspace-view wrapper adds a fixed 36px header +
+ {titlebarLeftControls} +
+
+ {/* Why: the workspace-view wrapper adds a fixed 36px header above the sidebar. Without a flex-1/min-h-0 slot here, the sidebar falls back to its content height, so the worktree list loses its scroll viewport and the fixed bottom toolbar (including Add Project) gets pushed offscreen. */} + + + +
+
+ ) : ( + -
-
- ) : ( - - ) - ) : null} -
- {/* Why: right sidebar toggle floats at the top-right of the center + + ) + ) : null} +
+ {/* Why: right sidebar toggle floats at the top-right of the center column so it's always accessible whether the right sidebar is open or closed. Match the RightSidebar header's 36px height and top-0 anchor so the icon's vertical center is identical between open and closed states — otherwise toggling makes the icon jump a few pixels, which reads as layout jitter. */} - {workspaceActive && !rightSidebarOpen && ( -
- {rightSidebarToggle} + {workspaceActive && !rightSidebarOpen && ( +
+ {rightSidebarToggle} +
+ )} +
+
+ + + +
+ + + {activeView === 'settings' ? : null} + {activeView === 'skills' ? : null} + {activeView === 'tasks' ? : null} + {activeView === 'automations' ? : null} + {activeView === 'activity' ? : null} + {activeView === 'space' ? : null} + {activeView === 'mobile' ? : null} + {activeView === 'terminal' && !activeWorktreeId ? : null} + +
- )} -
-
- -
- - {activeView === 'settings' ? : null} - {activeView === 'skills' ? : null} - {activeView === 'tasks' ? : null} - {activeView === 'automations' ? : null} - {activeView === 'activity' ? : null} - {activeView === 'space' ? : null} - {activeView === 'mobile' ? : null} - {activeView === 'terminal' && !activeWorktreeId ? : null} - + {showFloatingTerminalButton ? ( + setFloatingTerminalOpenWithFocus((open) => !open)} + /> + ) : null}
- {showFloatingTerminalButton ? ( - setFloatingTerminalOpenWithFocus((open) => !open)} - /> - ) : null}
-
- {/* Why: keep RightSidebar mounted even when closed so that its + {/* Why: keep RightSidebar mounted even when closed so that its child components (FileExplorer, SourceControl, etc.) and their filesystem watchers + cached directory trees survive across open/close toggles. Unmount on the tasks view since that surface is intentionally distraction-free. */} - {showRightSidebarControls ? : null} -
+ {showRightSidebarControls ? ( + + + + ) : null} +
+ {floatingTerminalEnabled ? ( - + + + ) : null} - + + + {/* Why: root overlays can render Radix s; keep them inside the shared provider so lazy surfaces mount safely from any entry point. */} {mountedLazyModalIds.has('new-workspace-composer') ? ( - + + + + ) : null} + {mountedLazyModalIds.has('workspace-cleanup') ? ( + + + ) : null} - {mountedLazyModalIds.has('workspace-cleanup') ? : null} - {mountedLazyModalIds.has('quick-open') ? : null} - {mountedLazyModalIds.has('worktree-palette') ? : null} - {mountedLazyModalIds.has('feature-wall') ? : null} - {mountedLazyModalIds.has('feature-tips') ? : null} + {mountedLazyModalIds.has('quick-open') ? ( + + + + ) : null} + {mountedLazyModalIds.has('worktree-palette') ? ( + + + + ) : null} + {mountedLazyModalIds.has('feature-wall') ? ( + + + + ) : null} + {mountedLazyModalIds.has('feature-tips') ? ( + + + + ) : null} {/* Why: mount PetOverlay only after persisted UI hydration, with both independent pet toggles allowing it; otherwise a hidden pet flashes while the store still has default visibility. */} {renderPetOverlay ? ( - + + + ) : null} - - + + + + + + {/* Why: the existing-user opt-in banner mounts at App root so it renders once per renderer session, not per view. It gates internally on the cohort markers populated by the migration, @@ -1804,22 +1946,82 @@ function App(): React.JSX.Element { release and have not yet resolved consent. New users get no first-launch surface — see telemetry-plan.md §First-launch experience. */} - - - - - + + + + + + + + + + + + + + + {onboarding && shouldRenderOnboarding && !onboardingSettingsDetourActive ? ( - + + + ) : null} - - + + + + + + diff --git a/src/renderer/src/components/crash-report/CrashReportDialog.tsx b/src/renderer/src/components/crash-report/CrashReportDialog.tsx index 57fe7772a..fb3d60bb5 100644 --- a/src/renderer/src/components/crash-report/CrashReportDialog.tsx +++ b/src/renderer/src/components/crash-report/CrashReportDialog.tsx @@ -2,6 +2,10 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } f import { AlertTriangle, Clipboard, Send } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' +import { + REACT_ERROR_BOUNDARY_REPORT_AVAILABLE_EVENT, + takePendingReactErrorBoundaryReport +} from '@/lib/react-error-boundary-reporting' import { Dialog, DialogContent, @@ -11,15 +15,41 @@ import { DialogTitle } from '@/components/ui/dialog' import { useMountedRef } from '@/hooks/useMountedRef' -import { formatCrashReportText, type CrashReportRecord } from '../../../../shared/crash-reporting' +import { + formatCrashReportText, + isReactErrorBoundaryReport, + type CrashReportRecord +} from '../../../../shared/crash-reporting' import type { GitHubViewer } from '../../../../shared/types' function formatSummary(report: CrashReportRecord): string { + if (isReactErrorBoundaryReport(report)) { + const surface = typeof report.details.surface === 'string' ? report.details.surface : null + return surface ? `React render error in ${surface}` : 'React render error' + } return `${report.processType} ${report.reason}${ report.exitCode === null ? '' : ` (exit ${report.exitCode})` }` } +function getDialogTitle(report: CrashReportRecord | null): string { + return report && isReactErrorBoundaryReport(report) + ? 'Orca hit a recoverable UI error' + : 'Orca closed unexpectedly' +} + +function getDialogDescription(report: CrashReportRecord | null): string { + return report && isReactErrorBoundaryReport(report) + ? 'Send a privacy-safe diagnostic report to help us understand the failed UI surface.' + : 'Send a privacy-safe diagnostic report to help us understand what happened.' +} + +function getNotesPlaceholder(report: CrashReportRecord | null): string { + return report && isReactErrorBoundaryReport(report) + ? 'Optional: what were you doing before this UI error?' + : 'Optional: what were you doing before Orca closed?' +} + export function CrashReportDialog(): React.JSX.Element { const promptedThisLaunch = useRef(false) const mountedRef = useMountedRef() @@ -37,6 +67,11 @@ export function CrashReportDialog(): React.JSX.Element { [deferredNotes, report] ) + const openCrashReport = useCallback((nextReport: CrashReportRecord): void => { + setReport(nextReport) + setOpen(true) + }, []) + const loadCrashReport = useCallback( async (promptIfPresent: boolean): Promise => { setLoading(true) @@ -92,6 +127,28 @@ export function CrashReportDialog(): React.JSX.Element { }) }, [loadCrashReport, mountedRef]) + useEffect(() => { + const pendingReport = takePendingReactErrorBoundaryReport() + if (pendingReport) { + openCrashReport(pendingReport) + } + + const onReactErrorBoundaryReport = (): void => { + const nextReport = takePendingReactErrorBoundaryReport() + if (nextReport) { + openCrashReport(nextReport) + } + } + + window.addEventListener(REACT_ERROR_BOUNDARY_REPORT_AVAILABLE_EVENT, onReactErrorBoundaryReport) + return () => { + window.removeEventListener( + REACT_ERROR_BOUNDARY_REPORT_AVAILABLE_EVENT, + onReactErrorBoundaryReport + ) + } + }, [openCrashReport]) + useEffect(() => { if (!open) { setViewer(null) @@ -202,11 +259,9 @@ export function CrashReportDialog(): React.JSX.Element { - Orca closed unexpectedly + {getDialogTitle(report)} - - Send a privacy-safe diagnostic report to help us understand what happened. - + {getDialogDescription(report)} {report ? ( @@ -222,7 +277,7 @@ export function CrashReportDialog(): React.JSX.Element { value={notes} onChange={(event) => setNotes(event.target.value)} rows={4} - placeholder="Optional: what were you doing before Orca closed?" + placeholder={getNotesPlaceholder(report)} className="min-h-24 w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" />
diff --git a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx index cef290d9b..833b6f1bb 100644 --- a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx +++ b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { reportReactErrorBoundaryCrash } from '@/lib/react-error-boundary-reporting' type Props = { fileId: string @@ -36,6 +37,12 @@ export class RichMarkdownErrorBoundary extends React.Component { componentDidCatch(error: Error, info: React.ErrorInfo): void { console.error('[RichMarkdownEditor] render crash contained by boundary', error, info) + void reportReactErrorBoundaryCrash({ + boundaryId: 'editor.rich-markdown', + surface: 'rich-markdown-editor', + error, + errorInfo: info + }) } handleReset = (): void => { diff --git a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx new file mode 100644 index 000000000..58c5aafaa --- /dev/null +++ b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx @@ -0,0 +1,99 @@ +import React from 'react' +import { AlertTriangle, RotateCw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { reportReactErrorBoundaryCrash } from '@/lib/react-error-boundary-reporting' +import type { ReactErrorBoundaryReportArgs } from '../../../../shared/crash-reporting' + +type BoundaryFallbackArgs = { + error: Error | null + reset: () => void +} + +type Props = { + boundaryId: string + surface: ReactErrorBoundaryReportArgs['surface'] + children: React.ReactNode + className?: string + compact?: boolean + reportAsCrash?: boolean + resetKey?: string | number | boolean | null + title?: string + description?: string + fallback?: (args: BoundaryFallbackArgs) => React.ReactNode +} + +type State = { + error: Error | null + resetKey: Props['resetKey'] +} + +export class RecoverableRenderErrorBoundary extends React.Component { + state: State = { error: null, resetKey: this.props.resetKey } + + static getDerivedStateFromProps(props: Props, state: State): Partial | null { + if (props.resetKey !== state.resetKey) { + return { error: null, resetKey: props.resetKey } + } + return null + } + + static getDerivedStateFromError(error: Error): Partial { + return { error } + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + console.error(`[${this.props.boundaryId}] render crash contained by boundary`, error, errorInfo) + if (this.props.reportAsCrash === false) { + return + } + void reportReactErrorBoundaryCrash({ + boundaryId: this.props.boundaryId, + surface: this.props.surface, + error, + errorInfo + }) + } + + handleReset = (): void => { + this.setState({ error: null }) + } + + render(): React.ReactNode { + if (!this.state.error) { + return this.props.children + } + + if (this.props.fallback) { + return this.props.fallback({ error: this.state.error, reset: this.handleReset }) + } + + return ( +
+
+ +
+
+
+ {this.props.title ?? 'This part of Orca hit an error.'} +
+
+ {this.props.description ?? + 'The rest of the app is still running. Retry this surface or switch away and come back.'} +
+
+ +
+ ) + } +} diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx index 4b957d45e..22afc2bea 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx @@ -349,31 +349,29 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
)} - {!isMainWorktree && - allowSkipConfirm && - !canForceDelete && ( - // Why: only show "Don't ask again" for the primary confirmation. The - // force-delete variant is a recovery path that shouldn't double as a - // preference checkpoint; see handleDelete for the matching guard. - - )} + {dontAskAgain ? : null} + + Don't ask again + + )}