diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx
index d6bb8b49..4b5f4af2 100644
--- a/src/renderer/src/app/App.tsx
+++ b/src/renderer/src/app/App.tsx
@@ -1,6 +1,7 @@
+import { ErrorBoundary } from "../shared/ErrorBoundary";
import { StartupScreen } from "./StartupScreen";
import type { Focus } from "../pages/types";
-import { Fragment, useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { Shell, type RouteId } from "../shared/Shell";
import { AvatarDirectoryContext, type AvatarLook } from "../shared/ui";
import { Onboarding } from "./Onboarding";
@@ -105,7 +106,7 @@ export function App() {
onDeleteOrg={(orgId) => void actions.run(() => window.agentCompany.deleteOrganization({ orgId }))}
onNewOrg={() => setCreatingOrg(true)}
>
- {page}
+ {page}
);
diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts
index 1c7c1977..7c2db68d 100644
--- a/src/renderer/src/i18n/zh.ts
+++ b/src/renderer/src/i18n/zh.ts
@@ -695,6 +695,12 @@ export const zh: Record = {
"Unsaved": "未保存",
"Working language": "工作语言",
"That did not work": "操作未成功",
+ "This page could not be shown.": "此页面无法显示。",
+ "AgentCompany hit an error it could not recover from here.": "AgentCompany 遇到了无法在此恢复的错误。",
+ "The rest of the app still works; go to another page or try this one again.": "应用的其他部分仍可使用;可以前往其他页面,或重试此页面。",
+ "Reloading the window starts it again; your data is kept.": "重新加载窗口即可重新开始;你的数据会保留。",
+ "Try again": "重试",
+ "Reload": "重新加载",
"AgentCompany could not load its data.": "AgentCompany 无法加载数据。",
"Retrying every few seconds (attempt {count}).": "每隔几秒重试一次(第 {count} 次)。",
"Retry now": "立即重试",
diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx
index 85204a64..f6e0a60e 100644
--- a/src/renderer/src/main.tsx
+++ b/src/renderer/src/main.tsx
@@ -2,6 +2,7 @@ import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./app/App";
import { FailureToasts } from "./shared/FailureToasts";
+import { ErrorBoundary } from "./shared/ErrorBoundary";
import "./styles/fonts.css";
import "./styles.css";
import "./styles/components.css";
@@ -9,7 +10,9 @@ import "./overlays.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
-
+
+
+ ,
);
diff --git a/src/renderer/src/shared/ErrorBoundary.tsx b/src/renderer/src/shared/ErrorBoundary.tsx
new file mode 100644
index 00000000..2964b69e
--- /dev/null
+++ b/src/renderer/src/shared/ErrorBoundary.tsx
@@ -0,0 +1,36 @@
+import { Component, type ErrorInfo, type ReactNode } from "react";
+import { Button } from "./ui";
+import { t } from "../i18n/i18n";
+
+/** Contains a render error to the part of the app that threw, says what failed, and offers a way back; the error is logged with its component stack. */
+export class ErrorBoundary extends Component<{ scope: "page" | "app"; children: ReactNode }, { error: Error | null }> {
+ state: { error: Error | null } = { error: null };
+
+ static getDerivedStateFromError(error: Error): { error: Error } {
+ return { error };
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo): void {
+ console.error(`[render] ${this.props.scope} failed: ${error.message}`, info.componentStack);
+ }
+
+ render(): ReactNode {
+ const { error } = this.state;
+ if (!error) return this.props.children;
+ const page = this.props.scope === "page";
+ return (
+
+ {page ? t("This page could not be shown.") : t("AgentCompany hit an error it could not recover from here.")}
+
{error.message}
+
+ {page ? t("The rest of the app still works; go to another page or try this one again.") : t("Reloading the window starts it again; your data is kept.")}
+
+
+ {page
+ ?
+ : }
+
+
+ );
+ }
+}
diff --git a/tests/e2e/render-recovery.spec.ts b/tests/e2e/render-recovery.spec.ts
new file mode 100644
index 00000000..2cd49c58
--- /dev/null
+++ b/tests/e2e/render-recovery.spec.ts
@@ -0,0 +1,49 @@
+import { _electron as electron, expect, test } from "@playwright/test";
+import { mkdirSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { randomUUID } from "node:crypto";
+import { DatabaseSync } from "node:sqlite";
+import { seedBuiltinSkillsFixture } from "./builtin-skills-fixture";
+
+test("a record that breaks one page leaves the rest of the app working", async () => {
+ const profileDir = join(tmpdir(), `agentcompany-new-e2e-render-${randomUUID()}`);
+ mkdirSync(profileDir, { recursive: true });
+ const builtinDir = seedBuiltinSkillsFixture(join(tmpdir(), `agentcompany-new-e2e-render-builtin-${randomUUID()}`));
+ const app = await electron.launch({
+ args: [resolve("out/main/index.js")],
+ env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, AGENTCOMPANY_NEW_AUTORUN: "0", AGENTCOMPANY_NEW_BUILTIN_SKILLS_DIR: builtinDir },
+ });
+ try {
+ const page = await app.firstWindow();
+ await page.getByRole("button", { name: "Get started" }).click();
+ await page.getByLabel("API key").fill("e2e-secret");
+ await page.getByLabel("Model").fill("gpt-5.4");
+ await page.getByRole("button", { name: "Continue" }).click();
+ await page.getByLabel("Company name").fill("Render Co");
+ await page.getByLabel("Your name").fill("Dana Lee");
+ await page.getByLabel("Description").fill("d");
+ await page.getByRole("button", { name: "Continue" }).click();
+ await page.getByRole("button", { name: "Continue" }).click();
+ await page.getByLabel("Goal").fill("Ship.");
+ await page.getByLabel("Acceptance criteria").fill("It ships.");
+ await page.getByRole("button", { name: "Launch" }).click();
+ await page.getByRole("button", { name: "Finish" }).click();
+
+ // A pending request with a risk level the Approvals page cannot draw.
+ const db = new DatabaseSync(join(profileDir, "agentcompany.db"));
+ const orgId = (db.prepare("select id from organizations").get() as { id: string }).id;
+ db.prepare("insert into approvals(id, org_id, kind, title, details, risk, requested_by_type, state, created_at) values ('app_bad', ?, 'task_risk', 'Broken request', 'd', 'unknown', 'system', 'pending', ?)")
+ .run(orgId, new Date().toISOString());
+ db.close();
+
+ await page.getByRole("button", { name: "Approvals" }).click();
+ const failure = page.getByRole("alert").filter({ hasText: "This page could not be shown." });
+ await expect(failure).toContainText("Approval risk tone is missing.");
+ await page.getByRole("button", { name: "Home" }).click();
+ await expect(failure).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Approvals" })).toBeVisible();
+ } finally {
+ await app.close();
+ }
+});