Skip to content

Commit 3121885

Browse files
author
Roo
committed
feat: implement react-error-boundary for component error isolation
- Add react-error-boundary dependency to webview-ui package - Create custom ErrorBoundary component with VSCode-themed fallback UI - Add internationalization support for error messages - Wrap all major view components (ChatView, SettingsView, HistoryView, etc.) with error boundaries - Implement error reporting utility for centralized error collection - Add comprehensive test coverage for ErrorBoundary component - Configure Vitest for React development mode to support testing Fixes #5731
1 parent 8a3dcfb commit 3121885

File tree

8 files changed

+414
-46
lines changed

8 files changed

+414
-46
lines changed

pnpm-lock.yaml

Lines changed: 20 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"pretty-bytes": "^7.0.0",
5555
"react": "^18.3.1",
5656
"react-dom": "^18.3.1",
57+
"react-error-boundary": "^6.0.0",
5758
"react-i18next": "^15.4.1",
5859
"react-markdown": "^9.0.3",
5960
"react-remark": "^2.1.0",

webview-ui/src/App.tsx

Lines changed: 66 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { AccountView } from "./components/account/AccountView"
2222
import { useAddNonInteractiveClickListener } from "./components/ui/hooks/useNonInteractiveClick"
2323
import { TooltipProvider } from "./components/ui/tooltip"
2424
import { STANDARD_TOOLTIP_DELAY } from "./components/ui/standard-tooltip"
25+
import { ErrorBoundary } from "./components/common/ErrorBoundary"
2526

2627
type Tab = "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account"
2728

@@ -169,60 +170,86 @@ const App = () => {
169170
// Do not conditionally load ChatView, it's expensive and there's state we
170171
// don't want to lose (user input, disableInput, askResponse promise, etc.)
171172
return showWelcome ? (
172-
<WelcomeView />
173+
<ErrorBoundary componentName="WelcomeView">
174+
<WelcomeView />
175+
</ErrorBoundary>
173176
) : (
174177
<>
175-
{tab === "modes" && <ModesView onDone={() => switchTab("chat")} />}
176-
{tab === "mcp" && <McpView onDone={() => switchTab("chat")} />}
177-
{tab === "history" && <HistoryView onDone={() => switchTab("chat")} />}
178+
{tab === "modes" && (
179+
<ErrorBoundary componentName="ModesView">
180+
<ModesView onDone={() => switchTab("chat")} />
181+
</ErrorBoundary>
182+
)}
183+
{tab === "mcp" && (
184+
<ErrorBoundary componentName="McpView">
185+
<McpView onDone={() => switchTab("chat")} />
186+
</ErrorBoundary>
187+
)}
188+
{tab === "history" && (
189+
<ErrorBoundary componentName="HistoryView">
190+
<HistoryView onDone={() => switchTab("chat")} />
191+
</ErrorBoundary>
192+
)}
178193
{tab === "settings" && (
179-
<SettingsView ref={settingsRef} onDone={() => setTab("chat")} targetSection={currentSection} />
194+
<ErrorBoundary componentName="SettingsView">
195+
<SettingsView ref={settingsRef} onDone={() => setTab("chat")} targetSection={currentSection} />
196+
</ErrorBoundary>
180197
)}
181198
{tab === "marketplace" && (
182-
<MarketplaceView
183-
stateManager={marketplaceStateManager}
184-
onDone={() => switchTab("chat")}
185-
targetTab={currentMarketplaceTab as "mcp" | "mode" | undefined}
186-
/>
199+
<ErrorBoundary componentName="MarketplaceView">
200+
<MarketplaceView
201+
stateManager={marketplaceStateManager}
202+
onDone={() => switchTab("chat")}
203+
targetTab={currentMarketplaceTab as "mcp" | "mode" | undefined}
204+
/>
205+
</ErrorBoundary>
187206
)}
188207
{tab === "account" && (
189-
<AccountView
190-
userInfo={cloudUserInfo}
191-
isAuthenticated={cloudIsAuthenticated}
192-
cloudApiUrl={cloudApiUrl}
193-
onDone={() => switchTab("chat")}
194-
/>
208+
<ErrorBoundary componentName="AccountView">
209+
<AccountView
210+
userInfo={cloudUserInfo}
211+
isAuthenticated={cloudIsAuthenticated}
212+
cloudApiUrl={cloudApiUrl}
213+
onDone={() => switchTab("chat")}
214+
/>
215+
</ErrorBoundary>
195216
)}
196-
<ChatView
197-
ref={chatViewRef}
198-
isHidden={tab !== "chat"}
199-
showAnnouncement={showAnnouncement}
200-
hideAnnouncement={() => setShowAnnouncement(false)}
201-
/>
202-
<HumanRelayDialog
203-
isOpen={humanRelayDialogState.isOpen}
204-
requestId={humanRelayDialogState.requestId}
205-
promptText={humanRelayDialogState.promptText}
206-
onClose={() => setHumanRelayDialogState((prev) => ({ ...prev, isOpen: false }))}
207-
onSubmit={(requestId, text) => vscode.postMessage({ type: "humanRelayResponse", requestId, text })}
208-
onCancel={(requestId) => vscode.postMessage({ type: "humanRelayCancel", requestId })}
209-
/>
217+
<ErrorBoundary componentName="ChatView">
218+
<ChatView
219+
ref={chatViewRef}
220+
isHidden={tab !== "chat"}
221+
showAnnouncement={showAnnouncement}
222+
hideAnnouncement={() => setShowAnnouncement(false)}
223+
/>
224+
</ErrorBoundary>
225+
<ErrorBoundary componentName="HumanRelayDialog">
226+
<HumanRelayDialog
227+
isOpen={humanRelayDialogState.isOpen}
228+
requestId={humanRelayDialogState.requestId}
229+
promptText={humanRelayDialogState.promptText}
230+
onClose={() => setHumanRelayDialogState((prev) => ({ ...prev, isOpen: false }))}
231+
onSubmit={(requestId, text) => vscode.postMessage({ type: "humanRelayResponse", requestId, text })}
232+
onCancel={(requestId) => vscode.postMessage({ type: "humanRelayCancel", requestId })}
233+
/>
234+
</ErrorBoundary>
210235
</>
211236
)
212237
}
213238

214239
const queryClient = new QueryClient()
215240

216241
const AppWithProviders = () => (
217-
<ExtensionStateContextProvider>
218-
<TranslationProvider>
219-
<QueryClientProvider client={queryClient}>
220-
<TooltipProvider delayDuration={STANDARD_TOOLTIP_DELAY}>
221-
<App />
222-
</TooltipProvider>
223-
</QueryClientProvider>
224-
</TranslationProvider>
225-
</ExtensionStateContextProvider>
242+
<ErrorBoundary componentName="App">
243+
<ExtensionStateContextProvider>
244+
<TranslationProvider>
245+
<QueryClientProvider client={queryClient}>
246+
<TooltipProvider delayDuration={STANDARD_TOOLTIP_DELAY}>
247+
<App />
248+
</TooltipProvider>
249+
</QueryClientProvider>
250+
</TranslationProvider>
251+
</ExtensionStateContextProvider>
252+
</ErrorBoundary>
226253
)
227254

228255
export default AppWithProviders
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import React from "react"
2+
import { ErrorBoundary as ReactErrorBoundary, FallbackProps } from "react-error-boundary"
3+
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
4+
import { useTranslation } from "react-i18next"
5+
import { errorReporter } from "../../utils/errorReporting"
6+
7+
interface ErrorFallbackProps extends FallbackProps {
8+
componentName?: string
9+
}
10+
11+
function ErrorFallback({ error, resetErrorBoundary, componentName }: ErrorFallbackProps) {
12+
const { t } = useTranslation("common")
13+
14+
return (
15+
<div className="flex flex-col items-center justify-center p-6 bg-vscode-editor-background border border-vscode-widget-border rounded-md m-4">
16+
<div className="flex items-center mb-4">
17+
<span className="codicon codicon-error text-vscode-errorForeground text-2xl mr-3" />
18+
<h2 className="text-lg font-semibold text-vscode-editor-foreground">
19+
{t("errorBoundary.title", "Something went wrong")}
20+
</h2>
21+
</div>
22+
23+
{componentName && (
24+
<p className="text-sm text-vscode-descriptionForeground mb-2">
25+
{t("errorBoundary.componentError", "Error in {{componentName}} component", { componentName })}
26+
</p>
27+
)}
28+
29+
<p className="text-sm text-vscode-descriptionForeground mb-4 text-center max-w-md">
30+
{t(
31+
"errorBoundary.description",
32+
"An error occurred in this part of the interface. You can try to recover by clicking the button below.",
33+
)}
34+
</p>
35+
36+
<details className="mb-4 w-full max-w-md">
37+
<summary className="cursor-pointer text-sm text-vscode-descriptionForeground hover:text-vscode-editor-foreground">
38+
{t("errorBoundary.showDetails", "Show error details")}
39+
</summary>
40+
<pre className="mt-2 p-3 bg-vscode-textCodeBlock-background border border-vscode-widget-border rounded text-xs text-vscode-editor-foreground overflow-auto max-h-32">
41+
{error.message}
42+
{error.stack && (
43+
<>
44+
{"\n\n"}
45+
{error.stack}
46+
</>
47+
)}
48+
</pre>
49+
</details>
50+
51+
<VSCodeButton appearance="primary" onClick={resetErrorBoundary}>
52+
{t("errorBoundary.retry", "Try again")}
53+
</VSCodeButton>
54+
</div>
55+
)
56+
}
57+
58+
interface ErrorBoundaryProps {
59+
children: React.ReactNode
60+
componentName?: string
61+
onError?: (error: Error, errorInfo: React.ErrorInfo) => void
62+
}
63+
64+
export function ErrorBoundary({ children, componentName, onError }: ErrorBoundaryProps) {
65+
const handleError = (error: Error, errorInfo: React.ErrorInfo) => {
66+
// Report error using our error reporting utility
67+
errorReporter.reportError(error, errorInfo, componentName)
68+
69+
// Call custom error handler if provided (for potential Sentry integration)
70+
onError?.(error, errorInfo)
71+
}
72+
73+
return (
74+
<ReactErrorBoundary
75+
FallbackComponent={(props) => <ErrorFallback {...props} componentName={componentName} />}
76+
onError={handleError}>
77+
{children}
78+
</ReactErrorBoundary>
79+
)
80+
}
81+
82+
export default ErrorBoundary

0 commit comments

Comments
 (0)