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:
1 parent
f5526b075c
commit
6f6ed48549
5 files changed
+71
-3
No files matched your search
@@ -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?")}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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": "放弃",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user