From 6f6ed4854933d371ead1c21f96a6f5a10c6dd510 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 07:55:58 -0400 Subject: [PATCH] Q186: data that can no longer be updated says how old it is and why Co-Authored-By: Claude Opus 5.5 --- src/renderer/src/app/App.tsx | 5 +++- src/renderer/src/app/StaleBanner.tsx | 18 ++++++++++++++ src/renderer/src/app/useSnapshot.ts | 12 ++++++++-- src/renderer/src/i18n/zh.ts | 3 +++ tests/e2e/startup-failure.spec.ts | 36 ++++++++++++++++++++++++++++ 5 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/app/StaleBanner.tsx diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index 8158d1cc..9e7a02c8 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -1,3 +1,4 @@ +import { StaleBanner } from "./StaleBanner"; import { oneOf, readViewState, seedViewState, writeViewState } from "../shared/view-state"; import { LeaveGuardContext } from "./leave-guard"; import { t } from "../i18n/i18n"; @@ -27,7 +28,7 @@ import { OfficePage } from "../pages/OfficePage"; const isRoute = oneOf(ROUTE_IDS); export function App() { - const { snapshot, syncFailure, actions } = useSnapshot(); + const { snapshot, syncFailure, syncedAt, actions } = useSnapshot(); const [routeChoice, setRoute] = useState(null); // A focus belongs to the company it was opened in; another company's page never receives it. const [focusChoice, setFocus] = useState<{ orgId: string | null; focus: Focus } | null>(null); @@ -129,6 +130,8 @@ export function App() { {page} + {/* A failed manual retry is already recorded in syncFailure, which the banner shows. */} + {syncFailure && void actions.reload().catch(() => undefined)} />} {leaving && (
diff --git a/src/renderer/src/app/StaleBanner.tsx b/src/renderer/src/app/StaleBanner.tsx new file mode 100644 index 00000000..689d696b --- /dev/null +++ b/src/renderer/src/app/StaleBanner.tsx @@ -0,0 +1,18 @@ +import { Button } from "../shared/ui"; +import { t } from "../i18n/i18n"; +import { formatTime } from "../pages/types"; +import type { SyncFailure } from "./useSnapshot"; + +/** Says the data on screen is no longer current: from when it is, why updating failed, and that it keeps retrying. */ +export function StaleBanner({ failure, syncedAt, onRetry }: { failure: SyncFailure; syncedAt: string | null; onRetry: () => void }) { + return ( +
+
+ {t("Not up to date.")}{" "} + {syncedAt ? t("Showing data from {time}.", { time: formatTime(syncedAt) }) : null}{" "} + {t("Updating failed: {reason} (attempt {count}).", { reason: failure.message, count: failure.attempts })} +
+ +
+ ); +} diff --git a/src/renderer/src/app/useSnapshot.ts b/src/renderer/src/app/useSnapshot.ts index 06443036..632a3388 100644 --- a/src/renderer/src/app/useSnapshot.ts +++ b/src/renderer/src/app/useSnapshot.ts @@ -16,10 +16,13 @@ export interface SyncFailure { export function useSnapshot(): { snapshot: AppSnapshot | null; syncFailure: SyncFailure | null; + /** When the data on screen was last read in full (ISO), so stale data can say how old it is. */ + syncedAt: string | null; actions: SnapshotActions; } { const [snapshot, setSnapshot] = useState(null); const [syncFailure, setSyncFailure] = useState(null); + const [syncedAt, setSyncedAt] = useState(null); const load = useCallback(async () => { try { @@ -27,6 +30,7 @@ export function useSnapshot(): { if (!result.ok) throw new Error(result.error.message); setSnapshot(result.data); setSyncFailure(null); + setSyncedAt(new Date().toISOString()); } catch (error) { const message = error instanceof Error ? error.message : String(error); setSyncFailure((current) => ({ message, attempts: (current?.attempts ?? 0) + 1 })); @@ -39,7 +43,11 @@ export function useSnapshot(): { ): Promise => { const result = await operation(); if (!result.ok) throw new Error(result.error.message); - if (result.data) setSnapshot(result.data as AppSnapshot); + if (result.data) { + setSnapshot(result.data as AppSnapshot); + setSyncFailure(null); + setSyncedAt(new Date().toISOString()); + } return result.data; }, []); @@ -87,5 +95,5 @@ export function useSnapshot(): { }, [load]); const actions = useMemo(() => ({ run, reload: load }), [run, load]); - return { snapshot, syncFailure, actions }; + return { snapshot, syncFailure, syncedAt, actions }; } diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 1573bbc8..6665a2e3 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -696,6 +696,9 @@ export const zh: Record = { "Working language": "工作语言", "That did not work": "操作未成功", "Working…": "处理中…", + "Not up to date.": "数据不是最新的。", + "Showing data from {time}.": "当前显示 {time} 的数据。", + "Updating failed: {reason} (attempt {count}).": "更新失败:{reason}(第 {count} 次尝试)。", "Discard your changes?": "放弃你的修改?", "Keep editing": "继续编辑", "Discard": "放弃", diff --git a/tests/e2e/startup-failure.spec.ts b/tests/e2e/startup-failure.spec.ts index 31749621..65cc4c79 100644 --- a/tests/e2e/startup-failure.spec.ts +++ b/tests/e2e/startup-failure.spec.ts @@ -4,6 +4,8 @@ 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"; +import { closeApp, onboard } from "./onboard"; test("a first load that fails says why, keeps retrying, and recovers", async () => { const profileDir = join(tmpdir(), `agentcompany-new-e2e-startup-${randomUUID()}`); @@ -37,3 +39,37 @@ test("a first load that fails says why, keeps retrying, and recovers", async () await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible(); await app.close(); }); + +test("data that can no longer be updated says so, from when it is, and recovers", async () => { + const profileDir = join(tmpdir(), `agentcompany-new-e2e-stale-${randomUUID()}`); + mkdirSync(profileDir, { recursive: true }); + const builtinDir = seedBuiltinSkillsFixture(join(tmpdir(), `agentcompany-new-e2e-stale-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 }, + }); + 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(); + }; + const announceChange = () => app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0]!.webContents.send("agentcompany:renderer-event", { type: "snapshot-changed" }); + }); + try { + const page = await app.firstWindow(); + await onboard(page, "Stale Co"); + setDraft("{"); + await announceChange(); + const banner = page.getByRole("alert").filter({ hasText: "Not up to date." }); + await expect(banner).toContainText("Showing data from"); + await expect(banner).toContainText("JSON"); + // The rest of the app stays usable with the old data on screen. + await page.getByRole("button", { name: "Work" }).click(); + setDraft(null); + await banner.getByRole("button", { name: "Retry now" }).click(); + await expect(banner).toHaveCount(0); + } finally { + await closeApp(app); + } +});