Q186: data that can no longer be updated says how old it is and why

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 07:55:58 -04:00
1 parent f5526b075c
commit 6f6ed48549
5 files changed
+71 -3

No files matched your search

+4 -1
View File
@@ -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<RouteId | null>(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() {
<ErrorBoundary key={pageKey} scope="page">{page}</ErrorBoundary>
</Shell>
</LeaveGuardContext.Provider>
{/* A failed manual retry is already recorded in syncFailure, which the banner shows. */}
{syncFailure && <StaleBanner failure={syncFailure} syncedAt={syncedAt} onRetry={() => void actions.reload().catch(() => undefined)} />}
{leaving && (
<div className="modal-backdrop" role="presentation">
<div className="modal" role="alertdialog" aria-label={t("Leave this page?")}>
+18
View File
@@ -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 (
<div role="alert" className="card" style={{ position: "fixed", top: 8, left: "50%", transform: "translateX(-50%)", zIndex: 900, display: "flex", gap: 10, alignItems: "center", padding: "8px 12px", borderColor: "var(--amber, var(--red))", maxWidth: "min(720px, calc(100vw - 32px))" }}>
<div style={{ fontSize: 13, minWidth: 0 }}>
<strong>{t("Not up to date.")}</strong>{" "}
{syncedAt ? t("Showing data from {time}.", { time: formatTime(syncedAt) }) : null}{" "}
<span style={{ color: "var(--text-2)", wordBreak: "break-word" }}>{t("Updating failed: {reason} (attempt {count}).", { reason: failure.message, count: failure.attempts })}</span>
</div>
<Button size="small" onClick={onRetry}>{t("Retry now")}</Button>
</div>
);
}
+10 -2
View File
@@ -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<AppSnapshot | null>(null);
const [syncFailure, setSyncFailure] = useState<SyncFailure | null>(null);
const [syncedAt, setSyncedAt] = useState<string | null>(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<T | null> => {
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<SnapshotActions>(() => ({ run, reload: load }), [run, load]);
return { snapshot, syncFailure, actions };
return { snapshot, syncFailure, syncedAt, actions };
}
+3
View File
@@ -696,6 +696,9 @@ export const zh: Record<string, string> = {
"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": "放弃",
+36
View File
@@ -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);
}
});