Improve React error boundary crash reporting (#3906)
This commit is contained in:
parent
0ac3fa28ed
commit
7fb8c9b401
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }) =>
|
||||
|
|
|
|||
|
|
@ -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' }} />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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't ask again
|
||||
</button>
|
||||
)}
|
||||
{dontAskAgain ? <Check className="size-3" strokeWidth={3} /> : null}
|
||||
</span>
|
||||
Don't ask again
|
||||
</button>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isDeleting}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue