Improve React error boundary crash reporting (#3906)

This commit is contained in:
Neil 2026-05-30 13:19:54 -07:00 committed by GitHub
parent 0ac3fa28ed
commit 7fb8c9b401
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1183 additions and 201 deletions

View File

@ -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()
})
})

View File

@ -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<string>()
const submittedReportIds = new Set<string>()
const recentRendererErrorReportKeys = new Map<string, number>()
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<ReactErrorBoundaryReportArgs['surface']>([
'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<string, unknown>
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<ReactErrorBoundaryReportResult> {
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',

View File

@ -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<CrashReportRecord | null>
getLatestReport: () => Promise<CrashReportRecord | null>
dismiss: (args: { reportId: string }) => Promise<CrashReportRecord | null>
recordRendererError: (
args: ReactErrorBoundaryReportArgs
) => Promise<ReactErrorBoundaryReportResult>
submit: (args: CrashReportSubmitArgs) => Promise<CrashReportSubmitResult>
copyLatestDiagnostics: (args?: {
reportId?: string

View File

@ -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<ReactErrorBoundaryReportResult> =>
ipcRenderer.invoke('crashReports:recordRendererError', args),
submit: (args: CrashReportSubmitArgs): Promise<CrashReportSubmitResult> =>
ipcRenderer.invoke('crashReports:submit', args),
copyLatestDiagnostics: (args?: { reportId?: string; notes?: string }) =>

View File

@ -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. */}
<RetainedAgentsSyncGate />
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{/* Why: the non-workspace titlebar lives inside this left+center
<RecoverableRenderErrorBoundary
boundaryId="app.workspace-shell"
surface="workspace-shell"
resetKey={`${activeView}:${activeWorktreeId ?? 'none'}`}
title="The workspace shell hit an error."
description="The app is still running. Retry the shell or use the menu to report the crash details."
>
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{/* 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. */}
<div className="flex flex-col flex-1 min-w-0 min-h-0">
{/* Why: in workspace view (split groups always enabled), the
<div className="flex flex-col flex-1 min-w-0 min-h-0">
{/* 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 ? (
<div className="titlebar">
<div
className={`flex items-center${showSidebar && sidebarOpen ? ' overflow-hidden shrink-0' : ' shrink-0 mr-2'}`}
style={{ width: showSidebar && sidebarOpen ? sidebarWidth : undefined }}
>
{titlebarLeftControls}
</div>
{activeView === 'activity' ? (
<ActivityTitlebarControls />
) : (
{!workspaceActive ? (
<div className="titlebar">
<div
id="titlebar-tabs"
className={`flex flex-1 min-w-0 self-stretch${activeView !== 'terminal' || !activeWorktreeId ? ' invisible pointer-events-none' : ''}`}
/>
)}
{showTitlebarExpandButton && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="titlebar-icon-button"
onClick={handleToggleExpand}
aria-label="Collapse pane"
disabled={!activeTabCanExpand}
>
<Minimize2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Collapse pane
</TooltipContent>
</Tooltip>
)}
{/* 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}
</div>
{activeView === 'activity' ? (
<ActivityTitlebarControls />
) : (
<div
id="titlebar-tabs"
className={`flex flex-1 min-w-0 self-stretch${activeView !== 'terminal' || !activeWorktreeId ? ' invisible pointer-events-none' : ''}`}
/>
)}
{showTitlebarExpandButton && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="titlebar-icon-button"
onClick={handleToggleExpand}
aria-label="Collapse pane"
disabled={!activeTabCanExpand}
>
<Minimize2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Collapse pane
</TooltipContent>
</Tooltip>
)}
{/* 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 && <div className="window-controls-titlebar-spacer" />}
</div>
) : null}
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{showSidebar ? (
workspaceActive ? (
/* Why: left column wraps the sidebar with a titlebar-height
{isWindows && <div className="window-controls-titlebar-spacer" />}
</div>
) : null}
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{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. */
<div
className={`flex min-h-0 flex-col shrink-0${sidebarOpen ? '' : ' relative w-0 overflow-visible'}`}
>
<div
// Why: when the sidebar is collapsed, titlebar-left floats
// absolutely on top of the center column's own `border-l`
// (see TabGroupSplitLayout), occluding that seam. Add a
// `border-r` in the floating state so the vertical line
// between the traffic-light/nav cluster and the tab strip
// stays visible in both states. w-max keeps the floating
// header sized to its own controls instead of the w-0
// sidebar wrapper.
className={`titlebar-left${
sidebarOpen
? ''
: ' absolute top-0 left-0 z-10 w-max border-r border-border'
}`}
style={{
// Why: the Sidebar resize hook updates the sidebar DOM width
// directly during drag and only persists to Zustand on
// mouseup. In workspace view, size this header from the
// wrapper's live width so it tracks those in-flight resizes
// instead of leaving a stale-width gap until the drag ends.
width: sidebarOpen ? '100%' : undefined
}}
className={`flex min-h-0 flex-col shrink-0${sidebarOpen ? '' : ' relative w-0 overflow-visible'}`}
>
{titlebarLeftControls}
</div>
<div className="flex min-h-0 flex-1">
{/* Why: the workspace-view wrapper adds a fixed 36px header
<div
// Why: when the sidebar is collapsed, titlebar-left floats
// absolutely on top of the center column's own `border-l`
// (see TabGroupSplitLayout), occluding that seam. Add a
// `border-r` in the floating state so the vertical line
// between the traffic-light/nav cluster and the tab strip
// stays visible in both states. w-max keeps the floating
// header sized to its own controls instead of the w-0
// sidebar wrapper.
className={`titlebar-left${
sidebarOpen
? ''
: ' absolute top-0 left-0 z-10 w-max border-r border-border'
}`}
style={{
// Why: the Sidebar resize hook updates the sidebar DOM width
// directly during drag and only persists to Zustand on
// mouseup. In workspace view, size this header from the
// wrapper's live width so it tracks those in-flight resizes
// instead of leaving a stale-width gap until the drag ends.
width: sidebarOpen ? '100%' : undefined
}}
>
{titlebarLeftControls}
</div>
<div className="flex min-h-0 flex-1">
{/* 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. */}
<RecoverableRenderErrorBoundary
boundaryId="sidebar.worktrees"
surface="sidebar"
resetKey={`${activeView}:${activeWorktreeId ?? 'none'}`}
title="The workspace list hit an error."
description="The active workspace remains open. Retry the list or switch views."
>
<Sidebar
worktreeScrollOffsetRef={worktreeSidebarScrollOffsetRef}
worktreeScrollAnchorRef={worktreeSidebarScrollAnchorRef}
/>
</RecoverableRenderErrorBoundary>
</div>
</div>
) : (
<RecoverableRenderErrorBoundary
boundaryId="sidebar.worktrees"
surface="sidebar"
resetKey={`${activeView}:${activeWorktreeId ?? 'none'}`}
title="The workspace list hit an error."
description="The active page remains open. Retry the list or switch views."
>
<Sidebar
worktreeScrollOffsetRef={worktreeSidebarScrollOffsetRef}
worktreeScrollAnchorRef={worktreeSidebarScrollAnchorRef}
/>
</div>
</div>
) : (
<Sidebar
worktreeScrollOffsetRef={worktreeSidebarScrollOffsetRef}
worktreeScrollAnchorRef={worktreeSidebarScrollAnchorRef}
/>
)
) : null}
<div className="relative flex flex-1 min-w-0 min-h-0 overflow-hidden">
{/* Why: right sidebar toggle floats at the top-right of the center
</RecoverableRenderErrorBoundary>
)
) : null}
<div className="relative flex flex-1 min-w-0 min-h-0 overflow-hidden">
{/* 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 && (
<div
className="absolute top-0 z-10 flex items-center h-[36px]"
style={
{
// Why: right: var(--window-controls-width) is the single
// mechanism that keeps the toggle clear of the
// fixed-position window-controls overlay on Windows (138px)
// and sits at the right edge on non-Windows (0px). No
// internal spacer needed — adding one would push the button
// a further 138px to the left and cover the pane-actions
// Ellipsis button with an un-clickable div.
right: 'var(--window-controls-width)',
WebkitAppRegion: 'no-drag'
} as React.CSSProperties
}
>
{rightSidebarToggle}
{workspaceActive && !rightSidebarOpen && (
<div
className="absolute top-0 z-10 flex items-center h-[36px]"
style={
{
// Why: right: var(--window-controls-width) is the single
// mechanism that keeps the toggle clear of the
// fixed-position window-controls overlay on Windows (138px)
// and sits at the right edge on non-Windows (0px). No
// internal spacer needed — adding one would push the button
// a further 138px to the left and cover the pane-actions
// Ellipsis button with an un-clickable div.
right: 'var(--window-controls-width)',
WebkitAppRegion: 'no-drag'
} as React.CSSProperties
}
>
{rightSidebarToggle}
</div>
)}
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
<div
className={
activeView !== 'terminal' || !activeWorktreeId
? 'hidden flex-1 min-w-0 min-h-0'
: 'flex flex-1 min-w-0 min-h-0'
}
>
<RecoverableRenderErrorBoundary
boundaryId="terminal.workbench"
surface="terminal-workbench"
resetKey={activeWorktreeId ?? 'none'}
title="The workspace workbench hit an error."
description="Terminal, browser, or editor rendering failed in this workspace. Retry to remount it."
>
<Terminal />
</RecoverableRenderErrorBoundary>
</div>
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId={`page.${activeView}`}
surface="page"
resetKey={`${activeView}:${activeWorktreeId ?? 'none'}`}
title="This page hit an error."
description="Retry the page or navigate to another Orca surface."
>
{activeView === 'settings' ? <Settings /> : null}
{activeView === 'skills' ? <SkillsPage /> : null}
{activeView === 'tasks' ? <TaskPage /> : null}
{activeView === 'automations' ? <AutomationsPage /> : null}
{activeView === 'activity' ? <ActivityPrototypePage /> : null}
{activeView === 'space' ? <WorkspaceSpacePage /> : null}
{activeView === 'mobile' ? <MobilePage /> : null}
{activeView === 'terminal' && !activeWorktreeId ? <Landing /> : null}
</RecoverableRenderErrorBoundary>
</Suspense>
</div>
)}
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
<div
className={
activeView !== 'terminal' || !activeWorktreeId
? 'hidden flex-1 min-w-0 min-h-0'
: 'flex flex-1 min-w-0 min-h-0'
}
>
<Terminal />
</div>
<Suspense fallback={null}>
{activeView === 'settings' ? <Settings /> : null}
{activeView === 'skills' ? <SkillsPage /> : null}
{activeView === 'tasks' ? <TaskPage /> : null}
{activeView === 'automations' ? <AutomationsPage /> : null}
{activeView === 'activity' ? <ActivityPrototypePage /> : null}
{activeView === 'space' ? <WorkspaceSpacePage /> : null}
{activeView === 'mobile' ? <MobilePage /> : null}
{activeView === 'terminal' && !activeWorktreeId ? <Landing /> : null}
</Suspense>
{showFloatingTerminalButton ? (
<FloatingTerminalToggleButton
open={floatingTerminalOpen}
onToggle={() => setFloatingTerminalOpenWithFocus((open) => !open)}
/>
) : null}
</div>
{showFloatingTerminalButton ? (
<FloatingTerminalToggleButton
open={floatingTerminalOpen}
onToggle={() => setFloatingTerminalOpenWithFocus((open) => !open)}
/>
) : null}
</div>
</div>
</div>
{/* 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 ? <RightSidebar /> : null}
</div>
{showRightSidebarControls ? (
<RecoverableRenderErrorBoundary
boundaryId="right-sidebar"
surface="right-sidebar"
resetKey={`${activeWorktreeId ?? 'none'}:${rightSidebarTab}`}
title="The right sidebar hit an error."
description="Retry the sidebar or switch tabs to reload this surface."
>
<RightSidebar />
</RecoverableRenderErrorBoundary>
) : null}
</div>
</RecoverableRenderErrorBoundary>
{floatingTerminalEnabled ? (
<FloatingTerminalPanel
open={floatingTerminalOpen}
onOpenChange={setFloatingTerminalOpenWithFocus}
/>
<RecoverableRenderErrorBoundary
boundaryId="overlay.floating-workspace"
surface="overlay"
resetKey={floatingTerminalOpen}
compact
title="The floating workspace hit an error."
description="Retry the floating workspace or close and reopen it."
>
<FloatingTerminalPanel
open={floatingTerminalOpen}
onOpenChange={setFloatingTerminalOpenWithFocus}
/>
</RecoverableRenderErrorBoundary>
) : null}
<StatusBar floatingTerminalOpen={floatingTerminalOpen} />
<RecoverableRenderErrorBoundary
boundaryId="overlay.status-bar"
surface="overlay"
resetKey={activeView}
compact
title="The status bar hit an error."
description="Retry the status bar to remount its controls."
>
<StatusBar floatingTerminalOpen={floatingTerminalOpen} />
</RecoverableRenderErrorBoundary>
{/* Why: root overlays can render Radix <Tooltip>s; keep them inside
the shared provider so lazy surfaces mount safely from any entry point. */}
<Suspense fallback={null}>
{mountedLazyModalIds.has('new-workspace-composer') ? (
<NewWorkspaceComposerModal />
<RecoverableRenderErrorBoundary
boundaryId="modal.new-workspace-composer"
surface="modal"
resetKey={activeModal === 'new-workspace-composer'}
compact
>
<NewWorkspaceComposerModal />
</RecoverableRenderErrorBoundary>
) : null}
{mountedLazyModalIds.has('workspace-cleanup') ? (
<RecoverableRenderErrorBoundary
boundaryId="modal.workspace-cleanup"
surface="modal"
resetKey={activeModal === 'workspace-cleanup'}
compact
>
<WorkspaceCleanupDialog />
</RecoverableRenderErrorBoundary>
) : null}
{mountedLazyModalIds.has('workspace-cleanup') ? <WorkspaceCleanupDialog /> : null}
</Suspense>
<Suspense fallback={null}>
{mountedLazyModalIds.has('quick-open') ? <QuickOpen /> : null}
{mountedLazyModalIds.has('worktree-palette') ? <WorktreeJumpPalette /> : null}
{mountedLazyModalIds.has('feature-wall') ? <FeatureWallModal /> : null}
{mountedLazyModalIds.has('feature-tips') ? <FeatureTipsModal /> : null}
{mountedLazyModalIds.has('quick-open') ? (
<RecoverableRenderErrorBoundary
boundaryId="modal.quick-open"
surface="modal"
resetKey={activeModal === 'quick-open'}
compact
>
<QuickOpen />
</RecoverableRenderErrorBoundary>
) : null}
{mountedLazyModalIds.has('worktree-palette') ? (
<RecoverableRenderErrorBoundary
boundaryId="modal.worktree-palette"
surface="modal"
resetKey={activeModal === 'worktree-palette'}
compact
>
<WorktreeJumpPalette />
</RecoverableRenderErrorBoundary>
) : null}
{mountedLazyModalIds.has('feature-wall') ? (
<RecoverableRenderErrorBoundary
boundaryId="modal.feature-wall"
surface="modal"
resetKey={activeModal === 'feature-wall'}
compact
>
<FeatureWallModal />
</RecoverableRenderErrorBoundary>
) : null}
{mountedLazyModalIds.has('feature-tips') ? (
<RecoverableRenderErrorBoundary
boundaryId="modal.feature-tips"
surface="modal"
resetKey={activeModal === 'feature-tips'}
compact
>
<FeatureTipsModal />
</RecoverableRenderErrorBoundary>
) : null}
</Suspense>
{/* 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 ? (
<Suspense fallback={null}>
<PetOverlay />
<RecoverableRenderErrorBoundary
boundaryId="overlay.pet"
surface="overlay"
resetKey={petVisible}
compact
>
<PetOverlay />
</RecoverableRenderErrorBoundary>
</Suspense>
) : null}
<UpdateCard />
<StarNagCard />
<RecoverableRenderErrorBoundary
boundaryId="overlay.update-card"
surface="overlay"
resetKey={activeView}
compact
>
<UpdateCard />
</RecoverableRenderErrorBoundary>
<RecoverableRenderErrorBoundary
boundaryId="overlay.star-nag"
surface="overlay"
resetKey={activeView}
compact
>
<StarNagCard />
</RecoverableRenderErrorBoundary>
{/* 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. */}
<TelemetryFirstLaunchSurface />
<ZoomOverlay />
<SshPassphraseDialog />
<DeleteWorktreeDialog />
<CrashReportDialog />
<RecoverableRenderErrorBoundary
boundaryId="overlay.telemetry-first-launch"
surface="overlay"
resetKey={settings?.telemetry?.optedIn ?? 'unknown'}
compact
>
<TelemetryFirstLaunchSurface />
</RecoverableRenderErrorBoundary>
<RecoverableRenderErrorBoundary
boundaryId="overlay.zoom"
surface="overlay"
resetKey={activeView}
compact
>
<ZoomOverlay />
</RecoverableRenderErrorBoundary>
<RecoverableRenderErrorBoundary
boundaryId="modal.ssh-passphrase"
surface="modal"
resetKey={activeModal}
compact
>
<SshPassphraseDialog />
</RecoverableRenderErrorBoundary>
<RecoverableRenderErrorBoundary
boundaryId="modal.delete-worktree"
surface="modal"
resetKey={activeModal === 'delete-worktree'}
compact
>
<DeleteWorktreeDialog />
</RecoverableRenderErrorBoundary>
<RecoverableRenderErrorBoundary
boundaryId="modal.crash-report"
surface="modal"
reportAsCrash={false}
resetKey={activeModal}
compact
title="The crash report dialog hit an error."
description="Use the Help menu after retrying if you still need diagnostics."
>
<CrashReportDialog />
</RecoverableRenderErrorBoundary>
{onboarding && shouldRenderOnboarding && !onboardingSettingsDetourActive ? (
<Suspense fallback={null}>
<OnboardingFlow
onboarding={onboarding}
onOnboardingChange={setOnboarding}
onSettingsDetourStart={beginOnboardingSettingsDetour}
/>
<RecoverableRenderErrorBoundary
boundaryId="modal.onboarding"
surface="modal"
resetKey={onboardingSettingsDetourActive}
title="Onboarding hit an error."
description="Retry onboarding or close it and continue in the app."
>
<OnboardingFlow
onboarding={onboarding}
onOnboardingChange={setOnboarding}
onSettingsDetourStart={beginOnboardingSettingsDetour}
/>
</RecoverableRenderErrorBoundary>
</Suspense>
) : null}
<DictationController />
<RecentTabSwitcher />
<RecoverableRenderErrorBoundary
boundaryId="overlay.dictation"
surface="overlay"
resetKey={activeView}
compact
>
<DictationController />
</RecoverableRenderErrorBoundary>
<RecoverableRenderErrorBoundary
boundaryId="overlay.recent-tab-switcher"
surface="overlay"
resetKey={activeView}
compact
>
<RecentTabSwitcher />
</RecoverableRenderErrorBoundary>
</ConfirmationDialogProvider>
</TooltipProvider>
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />

View File

@ -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<void> => {
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 {
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
<AlertTriangle className="size-4 text-destructive" />
Orca closed unexpectedly
{getDialogTitle(report)}
</DialogTitle>
<DialogDescription className="text-xs">
Send a privacy-safe diagnostic report to help us understand what happened.
</DialogDescription>
<DialogDescription className="text-xs">{getDialogDescription(report)}</DialogDescription>
</DialogHeader>
{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"
/>
<div className="space-y-1.5">

View File

@ -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<Props, State> {
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 => {

View File

@ -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<Props, State> {
state: State = { error: null, resetKey: this.props.resetKey }
static getDerivedStateFromProps(props: Props, state: State): Partial<State> | null {
if (props.resetKey !== state.resetKey) {
return { error: null, resetKey: props.resetKey }
}
return null
}
static getDerivedStateFromError(error: Error): Partial<State> {
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 (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 px-6 text-center text-sm text-muted-foreground',
this.props.compact ? 'min-h-9 py-2' : 'h-full min-h-0 py-8',
this.props.className
)}
role="alert"
>
<div className="flex size-8 items-center justify-center rounded-full border border-destructive/25 bg-destructive/10 text-destructive">
<AlertTriangle className="size-4" />
</div>
<div className="space-y-1">
<div className="font-medium text-foreground">
{this.props.title ?? 'This part of Orca hit an error.'}
</div>
<div className="max-w-md text-xs">
{this.props.description ??
'The rest of the app is still running. Retry this surface or switch away and come back.'}
</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={this.handleReset}>
<RotateCw className="size-3.5" />
Retry
</Button>
</div>
)
}
}

View File

@ -349,31 +349,29 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
</div>
)}
{!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.
<button
type="button"
role="checkbox"
aria-checked={dontAskAgain}
onClick={() => setDontAskAgain((prev) => !prev)}
className="flex items-center gap-2 rounded-sm px-1 py-1 text-xs text-foreground/80 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
{!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.
<button
type="button"
role="checkbox"
aria-checked={dontAskAgain}
onClick={() => setDontAskAgain((prev) => !prev)}
className="flex items-center gap-2 rounded-sm px-1 py-1 text-xs text-foreground/80 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span
className={`flex size-4 items-center justify-center rounded-sm border transition-colors ${
dontAskAgain
? 'border-foreground bg-foreground text-background'
: 'border-muted-foreground bg-transparent'
}`}
>
<span
className={`flex size-4 items-center justify-center rounded-sm border transition-colors ${
dontAskAgain
? 'border-foreground bg-foreground text-background'
: 'border-muted-foreground bg-transparent'
}`}
>
{dontAskAgain ? <Check className="size-3" strokeWidth={3} /> : null}
</span>
Don&apos;t ask again
</button>
)}
{dontAskAgain ? <Check className="size-3" strokeWidth={3} /> : null}
</span>
Don&apos;t ask again
</button>
)}
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isDeleting}>

View File

@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
buildReactErrorBoundaryReportArgs,
clearReactErrorBoundaryReportingForTest,
reportReactErrorBoundaryCrash
} from './react-error-boundary-reporting'
import type { CrashReportRecord } from '../../../shared/crash-reporting'
const mocks = vi.hoisted(() => ({
recordRendererError: vi.fn(),
dispatchEvent: vi.fn(),
state: {
activeView: 'terminal',
activeModal: 'none',
activeTabType: 'editor',
rightSidebarTab: 'source-control',
activeWorktreeId: 'repo-1::/Users/alice/project'
}
}))
function makeReport(id: string): CrashReportRecord {
return {
id,
createdAt: '2026-05-30T20:00:00.000Z',
status: 'pending',
source: 'renderer',
processType: 'react-render',
reason: 'react-error-boundary',
exitCode: null,
appVersion: '1.0.0',
platform: 'darwin',
osRelease: '25.0.0',
arch: 'arm64',
electronVersion: '41.0.0',
chromeVersion: '141.0.0',
details: { surface: 'page' }
}
}
vi.mock('@/store', () => ({
useAppStore: {
getState: () => mocks.state
}
}))
beforeEach(() => {
clearReactErrorBoundaryReportingForTest()
mocks.recordRendererError.mockReset()
mocks.dispatchEvent.mockReset()
mocks.recordRendererError.mockResolvedValue({
ok: true,
report: makeReport('react-report-1'),
deduped: false
})
vi.stubGlobal('window', {
dispatchEvent: mocks.dispatchEvent,
api: {
crashReports: {
recordRendererError: mocks.recordRendererError
}
}
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('react error boundary reporting', () => {
it('builds a renderer error payload with low-cardinality app context', () => {
const args = buildReactErrorBoundaryReportArgs({
boundaryId: 'terminal.workbench',
surface: 'terminal-workbench',
error: new TypeError('Cannot render /Users/alice/project'),
errorInfo: { componentStack: 'at Terminal\nat App' },
context: {
activeView: 'terminal',
activeModal: 'none',
activeTabType: 'editor',
activeRightSidebarTab: 'source-control',
hasActiveWorktree: true
}
})
expect(args).toMatchObject({
boundaryId: 'terminal.workbench',
surface: 'terminal-workbench',
errorName: 'TypeError',
errorMessage: 'Cannot render /Users/alice/project',
componentStack: 'at Terminal\nat App',
activeView: 'terminal',
activeModal: 'none',
activeTabType: 'editor',
activeRightSidebarTab: 'source-control',
hasActiveWorktree: true
})
})
it('reports a caught render error once per boundary signature', async () => {
const error = new Error('render failed')
await reportReactErrorBoundaryCrash({
boundaryId: 'page.settings',
surface: 'page',
error,
errorInfo: { componentStack: 'at Settings\nat App' }
})
await reportReactErrorBoundaryCrash({
boundaryId: 'page.settings',
surface: 'page',
error,
errorInfo: { componentStack: 'at Settings\nat App' }
})
expect(mocks.recordRendererError).toHaveBeenCalledTimes(1)
expect(mocks.dispatchEvent).toHaveBeenCalledTimes(1)
expect(mocks.recordRendererError).toHaveBeenCalledWith(
expect.objectContaining({
boundaryId: 'page.settings',
surface: 'page',
activeView: 'terminal',
activeModal: 'none',
activeTabType: 'editor',
activeRightSidebarTab: 'source-control',
hasActiveWorktree: true
})
)
})
})

View File

@ -0,0 +1,150 @@
import type React from 'react'
import type {
CrashReportRecord,
ReactErrorBoundaryReportArgs
} from '../../../shared/crash-reporting'
type RendererErrorContext = Pick<
ReactErrorBoundaryReportArgs,
'activeView' | 'activeModal' | 'activeTabType' | 'activeRightSidebarTab' | 'hasActiveWorktree'
>
type BuildReportArgsInput = {
boundaryId: string
surface: ReactErrorBoundaryReportArgs['surface']
error: unknown
errorInfo?: React.ErrorInfo
context?: RendererErrorContext
}
const reportedRendererErrorKeys: string[] = []
const reportedRendererErrorKeySet = new Set<string>()
const MAX_REPORTED_RENDERER_ERROR_KEYS = 50
let pendingReactErrorBoundaryReport: CrashReportRecord | null = null
export const REACT_ERROR_BOUNDARY_REPORT_AVAILABLE_EVENT =
'orca:react-error-boundary-report-available'
function stringFromThrown(value: unknown): { name: string; message: string; stack?: string } {
if (value instanceof Error) {
return {
name: value.name || 'Error',
message: value.message || String(value),
...(value.stack ? { stack: value.stack } : {})
}
}
return {
name: 'NonErrorThrown',
message: String(value)
}
}
async function collectRendererErrorContext(): Promise<RendererErrorContext> {
try {
const { useAppStore } = await import('@/store')
const state = useAppStore.getState()
return {
activeView: state.activeView,
activeModal: state.activeModal,
activeTabType: state.activeTabType,
activeRightSidebarTab: state.rightSidebarTab,
hasActiveWorktree: state.activeWorktreeId !== null
}
} catch {
return {}
}
}
export function buildReactErrorBoundaryReportArgs({
boundaryId,
surface,
error,
errorInfo,
context
}: BuildReportArgsInput): ReactErrorBoundaryReportArgs {
const fields = stringFromThrown(error)
const componentStack = errorInfo?.componentStack?.trim()
return {
boundaryId,
surface,
errorName: fields.name,
errorMessage: fields.message,
...(fields.stack ? { errorStack: fields.stack } : {}),
...(componentStack ? { componentStack } : {}),
...(context?.activeView ? { activeView: context.activeView } : {}),
...(context?.activeModal !== undefined ? { activeModal: context.activeModal } : {}),
...(context?.activeTabType ? { activeTabType: context.activeTabType } : {}),
...(context?.activeRightSidebarTab
? { activeRightSidebarTab: context.activeRightSidebarTab }
: {}),
...(context?.hasActiveWorktree !== undefined
? { hasActiveWorktree: context.hasActiveWorktree }
: {})
}
}
function rememberRendererErrorKey(key: string): boolean {
if (reportedRendererErrorKeySet.has(key)) {
return false
}
reportedRendererErrorKeySet.add(key)
reportedRendererErrorKeys.push(key)
if (reportedRendererErrorKeys.length > MAX_REPORTED_RENDERER_ERROR_KEYS) {
const expiredKey = reportedRendererErrorKeys.shift()
if (expiredKey) {
reportedRendererErrorKeySet.delete(expiredKey)
}
}
return true
}
function getRendererErrorKey(args: ReactErrorBoundaryReportArgs): string {
return JSON.stringify({
boundaryId: args.boundaryId,
surface: args.surface,
errorName: args.errorName,
errorMessage: args.errorMessage,
componentStack: args.componentStack
})
}
export function takePendingReactErrorBoundaryReport(): CrashReportRecord | null {
const report = pendingReactErrorBoundaryReport
pendingReactErrorBoundaryReport = null
return report
}
function notifyReactErrorBoundaryReportAvailable(report: CrashReportRecord): void {
pendingReactErrorBoundaryReport = report
window.dispatchEvent(new CustomEvent(REACT_ERROR_BOUNDARY_REPORT_AVAILABLE_EVENT))
}
export async function reportReactErrorBoundaryCrash(
input: Omit<BuildReportArgsInput, 'context'>
): Promise<void> {
const context = await collectRendererErrorContext()
const args = buildReactErrorBoundaryReportArgs({ ...input, context })
if (!rememberRendererErrorKey(getRendererErrorKey(args))) {
return
}
try {
const result = await window.api?.crashReports?.recordRendererError?.(args)
if (result && !result.ok) {
console.warn('[react-error-boundary] Failed to record renderer crash:', result.error)
return
}
if (result?.ok && result.report && !result.deduped) {
notifyReactErrorBoundaryReportAvailable(result.report)
}
} catch (error) {
console.warn('[react-error-boundary] Crash reporting IPC failed:', error)
}
}
export function clearReactErrorBoundaryReportingForTest(): void {
reportedRendererErrorKeys.length = 0
reportedRendererErrorKeySet.clear()
pendingReactErrorBoundaryReport = null
}

View File

@ -3,6 +3,7 @@ import './assets/main.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary'
import { applyDocumentTheme } from './lib/document-theme'
import { shouldEnableReactGrab } from './lib/react-grab-dev-gate'
@ -21,6 +22,13 @@ applyDocumentTheme('system', { disableTransitions: false })
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<RecoverableRenderErrorBoundary
boundaryId="app.root"
surface="app-root"
title="Orca hit a renderer error."
description="The app shell could not finish rendering. Retry to remount it, or relaunch Orca if the error persists."
>
<App />
</RecoverableRenderErrorBoundary>
</StrictMode>
)

View File

@ -3,6 +3,7 @@ import '../assets/main.css'
import { lazy, Suspense, useMemo, useState } from 'react'
import ReactDOM from 'react-dom/client'
import WebConnect from './WebConnect'
import { RecoverableRenderErrorBoundary } from '../components/error-boundaries/RecoverableRenderErrorBoundary'
import {
clearPairingInputFromAddressBar,
parseWebPairingInput,
@ -48,4 +49,13 @@ function WebRoot(): React.JSX.Element {
)
}
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(<WebRoot />)
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<RecoverableRenderErrorBoundary
boundaryId="web.root"
surface="web-root"
title="Orca web hit a renderer error."
description="Retry the web client or reconnect to the paired runtime."
>
<WebRoot />
</RecoverableRenderErrorBoundary>
)

View File

@ -403,6 +403,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
getLatestPending: () => Promise.resolve(null),
getLatestReport: () => Promise.resolve(null),
dismiss: () => Promise.resolve(null),
recordRendererError: () => Promise.resolve({ ok: true, report: null, deduped: true }),
submit: () =>
Promise.resolve({
ok: false,

View File

@ -19,12 +19,21 @@ describe('crash-reporting shared helpers', () => {
})
it('keeps details on a strict primitive allowlist', () => {
const longStack = [
'Error: boom',
...Array.from(
{ length: 80 },
(_, index) => `at Component${index} (/Users/alice/project/src/file-${index}.tsx:1:1)`
)
].join('\n')
expect(
sanitizeCrashReportDetails({
name: 'GPU /home/alice/repo',
code: 9,
crashed: true,
missing: null,
error_stack: longStack,
nested: { nope: true },
infinite: Number.POSITIVE_INFINITY
})
@ -32,8 +41,12 @@ describe('crash-reporting shared helpers', () => {
name: 'GPU [redacted-path]',
code: 9,
crashed: true,
missing: null
missing: null,
error_stack: expect.stringContaining('[redacted-path]')
})
expect(
String(sanitizeCrashReportDetails({ error_stack: longStack }).error_stack).length
).toBeGreaterThan(240)
})
it('sanitizes breadcrumb data and caps to the latest thirty entries', () => {

View File

@ -42,6 +42,36 @@ export type CrashReportCreateInput = Omit<
breadcrumbs?: CrashReportBreadcrumbInput[]
}
export type ReactErrorBoundarySurface =
| 'app-root'
| 'web-root'
| 'workspace-shell'
| 'sidebar'
| 'terminal-workbench'
| 'right-sidebar'
| 'page'
| 'modal'
| 'overlay'
| 'rich-markdown-editor'
export type ReactErrorBoundaryReportArgs = {
boundaryId: string
surface: ReactErrorBoundarySurface
errorName: string
errorMessage: string
errorStack?: string
componentStack?: string
activeView?: string
activeModal?: string | null
activeTabType?: string | null
activeRightSidebarTab?: string | null
hasActiveWorktree?: boolean
}
export type ReactErrorBoundaryReportResult =
| { ok: true; report: CrashReportRecord | null; deduped: boolean }
| { ok: false; error: string }
export type CrashReportSubmitArgs = {
reportId?: string
notes?: string
@ -55,6 +85,7 @@ export type CrashReportSubmitResult =
| { ok: false; status: number | null; error: string; report?: CrashReportRecord }
const MAX_STRING_DETAIL_LENGTH = 240
const MAX_STACK_DETAIL_LENGTH = 4_000
const MAX_BREADCRUMB_NAME_LENGTH = 80
const MAX_BREADCRUMBS = 30
const MAX_FORMATTED_REPORT_LENGTH = 64_000
@ -87,7 +118,18 @@ export function isCrashReportReason(reason: string): boolean {
].includes(reason)
}
export function sanitizeCrashReportString(value: string): string {
export function isReactErrorBoundaryReport(report: CrashReportRecord): boolean {
return (
report.source === 'renderer' &&
report.processType === 'react-render' &&
report.reason === 'react-error-boundary'
)
}
export function sanitizeCrashReportString(
value: string,
maxLength = MAX_STRING_DETAIL_LENGTH
): string {
let sanitized = value
for (const pattern of PATH_PATTERNS) {
sanitized = sanitized.replace(pattern, '[redacted-path]')
@ -100,9 +142,13 @@ export function sanitizeCrashReportString(value: string): string {
return match.includes('@') ? '[redacted-credential]@' : '[redacted-secret]'
})
}
return sanitized.length > MAX_STRING_DETAIL_LENGTH
? `${sanitized.slice(0, MAX_STRING_DETAIL_LENGTH)}...`
: sanitized
return sanitized.length > maxLength ? `${sanitized.slice(0, maxLength)}...` : sanitized
}
function maxDetailStringLengthForKey(key: string): number {
return /(?:^|_)(?:stack|component_stack|error_stack)$/i.test(key)
? MAX_STACK_DETAIL_LENGTH
: MAX_STRING_DETAIL_LENGTH
}
export function sanitizeCrashReportDetails(
@ -111,7 +157,7 @@ export function sanitizeCrashReportDetails(
const sanitized: Record<string, CrashReportDetailValue> = {}
for (const [key, value] of Object.entries(details)) {
if (typeof value === 'string') {
sanitized[key] = sanitizeCrashReportString(value)
sanitized[key] = sanitizeCrashReportString(value, maxDetailStringLengthForKey(key))
} else if (typeof value === 'number' && Number.isFinite(value)) {
sanitized[key] = value
} else if (typeof value === 'boolean' || value === null) {