Q179: a failed first load says why and keeps retrying visibly
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1cc67c244e
commit
7a4d986344
5 files changed
+87
-6
No files matched your search
@@ -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<RouteId>("home");
|
||||
const [focus, setFocus] = useState<Focus | null>(null);
|
||||
// Opening a page for an object remembers it; plain navigation forgets it.
|
||||
@@ -61,7 +62,8 @@ export function App() {
|
||||
}, [theme]);
|
||||
|
||||
if (!snapshot) {
|
||||
return <div className="app-shell" style={{ display: "grid", placeItems: "center" }} />;
|
||||
// A failed manual retry is already recorded in syncFailure, which the screen shows.
|
||||
return <StartupScreen failure={syncFailure} onRetry={() => 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;
|
||||
|
||||
@@ -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 (
|
||||
<div className="app-shell" style={{ display: "grid", placeItems: "center", padding: 24 }}>
|
||||
{failure ? (
|
||||
<div role="alert" style={{ maxWidth: 480, display: "grid", gap: 12, textAlign: "center" }}>
|
||||
<strong>{t("AgentCompany could not load its data.")}</strong>
|
||||
<div className="mono" style={{ fontSize: 12, color: "var(--text-2)", wordBreak: "break-word" }}>{failure.message}</div>
|
||||
<div style={{ fontSize: 13, color: "var(--text-3)" }}>{t("Retrying every few seconds (attempt {count}).", { count: failure.attempts })}</div>
|
||||
<div><Button onClick={onRetry}>{t("Retry now")}</Button></div>
|
||||
</div>
|
||||
) : (
|
||||
<div role="status" style={{ color: "var(--text-3)" }}>{t("Starting AgentCompany…")}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,16 +7,31 @@ export interface SnapshotActions {
|
||||
reload(): Promise<void>;
|
||||
}
|
||||
|
||||
/** 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<AppSnapshot | null>(null);
|
||||
const [syncFailure, setSyncFailure] = useState<SyncFailure | null>(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 <T extends AppSnapshot | null>(
|
||||
@@ -72,5 +87,5 @@ export function useSnapshot(): {
|
||||
}, [load]);
|
||||
|
||||
const actions = useMemo<SnapshotActions>(() => ({ run, reload: load }), [run, load]);
|
||||
return { snapshot, actions };
|
||||
return { snapshot, syncFailure, actions };
|
||||
}
|
||||
@@ -694,6 +694,10 @@ export const zh: Record<string, string> = {
|
||||
"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 都能读取。",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user