From 30398356449b5e555f6589950e47356af5698f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 05:56:11 -0400 Subject: [PATCH] Q181: a render error stays inside its page and says what failed Co-Authored-By: Claude Opus 5.5 --- src/renderer/src/app/App.tsx | 5 ++- src/renderer/src/i18n/zh.ts | 6 +++ src/renderer/src/main.tsx | 5 ++- src/renderer/src/shared/ErrorBoundary.tsx | 36 +++++++++++++++++ tests/e2e/render-recovery.spec.ts | 49 +++++++++++++++++++++++ 5 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/shared/ErrorBoundary.tsx create mode 100644 tests/e2e/render-recovery.spec.ts 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(); + } +});