diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index a2808267..d6bb8b49 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -1,3 +1,4 @@ +import { StartupScreen } from "./StartupScreen"; import type { Focus } from "../pages/types"; import { Fragment, useEffect, useMemo, useState } from "react"; import { Shell, type RouteId } from "../shared/Shell"; @@ -17,7 +18,7 @@ import { SettingsPage } from "../pages/SettingsPage"; import { OfficePage } from "../pages/OfficePage"; export function App() { - const { snapshot, actions } = useSnapshot(); + const { snapshot, syncFailure, actions } = useSnapshot(); const [route, setRoute] = useState("home"); const [focus, setFocus] = useState(null); // Opening a page for an object remembers it; plain navigation forgets it. @@ -61,7 +62,8 @@ export function App() { }, [theme]); if (!snapshot) { - return
; + // A failed manual retry is already recorded in syncFailure, which the screen shows. + return void actions.reload().catch(() => undefined)} />; } // A company exists from Launch on, but its setup is unfinished until the connectors step is done; that is stored, so a restart resumes it. const unfinished = snapshot.organizations.find((organization) => organization.setupCompletedAt === null) ?? null; diff --git a/src/renderer/src/app/StartupScreen.tsx b/src/renderer/src/app/StartupScreen.tsx new file mode 100644 index 00000000..b6104620 --- /dev/null +++ b/src/renderer/src/app/StartupScreen.tsx @@ -0,0 +1,21 @@ +import { Button } from "../shared/ui"; +import { t } from "../i18n/i18n"; +import type { SyncFailure } from "./useSnapshot"; + +/** What the window shows before the first data arrives: starting, or why loading failed and that it keeps retrying. */ +export function StartupScreen({ failure, onRetry }: { failure: SyncFailure | null; onRetry: () => void }) { + return ( +
+ {failure ? ( +
+ {t("AgentCompany could not load its data.")} +
{failure.message}
+
{t("Retrying every few seconds (attempt {count}).", { count: failure.attempts })}
+
+
+ ) : ( +
{t("Starting AgentCompany…")}
+ )} +
+ ); +} diff --git a/src/renderer/src/app/useSnapshot.ts b/src/renderer/src/app/useSnapshot.ts index de20e5f3..06443036 100644 --- a/src/renderer/src/app/useSnapshot.ts +++ b/src/renderer/src/app/useSnapshot.ts @@ -7,16 +7,31 @@ export interface SnapshotActions { reload(): Promise; } +/** The latest failed load since the last success, and how many loads in a row have failed. */ +export interface SyncFailure { + message: string; + attempts: number; +} + export function useSnapshot(): { snapshot: AppSnapshot | null; + syncFailure: SyncFailure | null; actions: SnapshotActions; } { const [snapshot, setSnapshot] = useState(null); + const [syncFailure, setSyncFailure] = useState(null); const load = useCallback(async () => { - const result = await window.agentCompany.loadSnapshot(); - if (!result.ok) throw new Error(result.error.message); - setSnapshot(result.data); + try { + const result = await window.agentCompany.loadSnapshot(); + if (!result.ok) throw new Error(result.error.message); + setSnapshot(result.data); + setSyncFailure(null); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setSyncFailure((current) => ({ message, attempts: (current?.attempts ?? 0) + 1 })); + throw error; + } }, []); const run = useCallback(async ( @@ -72,5 +87,5 @@ export function useSnapshot(): { }, [load]); const actions = useMemo(() => ({ run, reload: load }), [run, load]); - return { snapshot, actions }; + return { snapshot, syncFailure, actions }; } diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index a8e5db2c..ab11df3e 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -694,6 +694,10 @@ export const zh: Record = { "Resets {date} (UTC)": "{date} 重置(UTC)", "Unsaved": "未保存", "Working language": "工作语言", + "AgentCompany could not load its data.": "AgentCompany 无法加载数据。", + "Retrying every few seconds (attempt {count}).": "每隔几秒重试一次(第 {count} 次)。", + "Retry now": "立即重试", + "Starting AgentCompany…": "正在启动 AgentCompany…", "Direct: only its members take part, and every message wakes the other members. As the board, you can read it even when you are not a member.": "私聊:只有成员参与,每条消息都会唤醒其他成员。作为董事会,即使你不是成员也能阅读。", "Company, division, department, project, pod and ad-hoc are labels for finding the conversation; they behave the same. An agent replies when @mentioned or already in the thread; a message that names no one goes to the most senior agent member.": "公司、事业部、部门、项目、小组和临时只是便于查找的标签,行为相同。Agent 在被 @提及或已在该话题中时回复;没有指名任何人的消息交给级别最高的 Agent 成员。", "Workspace: everyone in the company is a member, including agents hired later, and any agent can read it.": "工作区:公司里的每个人都是成员,包括之后招聘的 Agent,任何 Agent 都能读取。", diff --git a/tests/e2e/startup-failure.spec.ts b/tests/e2e/startup-failure.spec.ts new file mode 100644 index 00000000..31749621 --- /dev/null +++ b/tests/e2e/startup-failure.spec.ts @@ -0,0 +1,39 @@ +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"; + +test("a first load that fails says why, keeps retrying, and recovers", async () => { + const profileDir = join(tmpdir(), `agentcompany-new-e2e-startup-${randomUUID()}`); + mkdirSync(profileDir, { recursive: true }); + const launch = () => electron.launch({ + args: [resolve("out/main/index.js")], + env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, AGENTCOMPANY_NEW_AUTORUN: "0" }, + }); + const setDraft = (json: string | null) => { + const db = new DatabaseSync(join(profileDir, "agentcompany.db")); + db.prepare("update app_state set onboarding_draft_json = ? where id = 1").run(json); + db.close(); + }; + + let app = await launch(); + let page = await app.firstWindow(); + await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible(); + await app.close(); + + // A stored value the snapshot cannot read makes every load fail. + setDraft("{"); + app = await launch(); + page = await app.firstWindow(); + const failure = page.getByRole("alert"); + await expect(failure).toContainText("AgentCompany could not load its data."); + await expect(failure).toContainText("JSON"); + await expect(failure).toContainText(/attempt [2-9]/); + + setDraft(null); + await page.getByRole("button", { name: "Retry now" }).click(); + await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible(); + await app.close(); +});