Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/web/app/play/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,15 @@
await new Promise((r) => setTimeout(r, 4000));
return attempt();
}
// HARD failure (HTTP error / network throw) gets one delayed retry too —
// live-caught 2026-07-20: an OpenRouter timeout chain 502'd the extract
// and the page silently had NO labels, NO codex, NO geo, with a console
// error as the only trace. Extraction transients are exactly as
// stochastic as the 0/0 flake above; the second pass usually lands.
if (first === null) {
await new Promise((r) => setTimeout(r, 8000));
return attempt();
}
return first;
}

Expand Down Expand Up @@ -679,6 +688,9 @@
const abortRef = useRef<AbortController | null>(null);
// The last generate body, any kind — powers the error banner's "Try again".
const lastGenerateRef = useRef<GenerateRequestBody | null>(null);
// True when the current error landed while the tab was HIDDEN — the
// freeze-suspension class; the visibilitychange effect auto-retries once.
const erroredWhileHiddenRef = useRef(false);
const streamRef = useRef<StreamClient | null>(null);
const [streamStatus, setStreamStatus] = useState<StreamStatus | "off">("off");
const [fallbackVideoUrl, setFallbackVideoUrl] = useState<string | null>(null);
Expand Down Expand Up @@ -1134,6 +1146,16 @@
}
setError((err as Error).message);
setPhase("error");
// Freeze-death marker (live-caught 2026-07-20): Chromium suspends a
// background tab's fetches (ERR_NETWORK_IO_SUSPENDED), so a 60-120s
// generation dies the moment the user tab-switches — and the backend
// cancels the work on disconnect. A failure that lands while HIDDEN
// is near-certainly the suspension, not the request: remember it and
// auto-retry once on return instead of greeting the user with an
// error banner for something they never watched fail.
erroredWhileHiddenRef.current =
typeof document !== "undefined" &&
document.visibilityState === "hidden";
setMorphFx(null);
setProgressiveDraft(false);
// Best-effort error sink so /status's "recent errors" panel can
Expand All @@ -1154,7 +1176,7 @@
}).catch(() => {});
}
},
[]

Check warning on line 1179 in apps/web/app/play/page.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useCallback has missing dependencies: 'bindTrace' and 'setMorphFx'. Either include them or remove the dependency array
);

// The error banner's "Try again": replay the exact failed request with the
Expand All @@ -1167,6 +1189,21 @@
void generate(rest);
}, [generate]);

// Resume-retry: when the tab comes back to the foreground and the current
// error was recorded while HIDDEN (the freeze-suspension class), fire the
// banner's own retry automatically — once. A real failure re-errors and
// the banner takes over as usual.
useEffect(() => {
const onVisible = () => {
if (document.visibilityState !== "visible") return;
if (!erroredWhileHiddenRef.current) return;
erroredWhileHiddenRef.current = false;
if (lastGenerateRef.current) retryLast();
};
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
}, [retryLast]);

// Build the expand body from the current page + session state and hand it to
// the bloom hook (which owns the SSE loop, tray state, persistence + abort).
const triggerExpand = useCallback(() => {
Expand Down Expand Up @@ -1822,7 +1859,7 @@
});
}
return items;
}, [contextMenu, phase, page, dispatchTapAt, runEdit, geoMap.entities.length, mutateWorldEntity]);

Check warning on line 1862 in apps/web/app/play/page.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useMemo has a missing dependency: 'worldState.overrideEnabled'. Either include it or remove the dependency array

const canGoBack = history.trailIdx > 0;
const canGoForward = history.trailIdx < history.trail.length - 1;
Expand Down Expand Up @@ -1860,7 +1897,7 @@
setHistory((prev) =>
prev.trailIdx <= 0 ? prev : navigateToTrailIdx(prev, prev.trailIdx - 1)
);
}, []);

Check warning on line 1900 in apps/web/app/play/page.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useCallback has a missing dependency: 'navigateToTrailIdx'. Either include it or remove the dependency array

const goForward = useCallback(() => {
setEditVerdictChip(null);
Expand All @@ -1869,7 +1906,7 @@
? prev
: navigateToTrailIdx(prev, prev.trailIdx + 1)
);
}, []);

Check warning on line 1909 in apps/web/app/play/page.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useCallback has a missing dependency: 'navigateToTrailIdx'. Either include it or remove the dependency array

const selectFromMap = useCallback((nodeId: string) => {
setEditVerdictChip(null);
Expand All @@ -1896,7 +1933,7 @@
return { ...prev, trail, trailIdx: trail.length - 1 };
});
setViewMode("page");
}, []);

Check warning on line 1936 in apps/web/app/play/page.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useCallback has a missing dependency: 'setMorphFx'. Either include it or remove the dependency array

// OUTWARD / zoom-out landed: mirror the persisted reparent into the live
// session — insert the container P, re-point the old root C under it (so the
Expand Down Expand Up @@ -2020,7 +2057,7 @@
return () => {
cancelled = true;
};
}, []);

Check warning on line 2060 in apps/web/app/play/page.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has a missing dependency: 'bindTrace'. Either include it or remove the dependency array

// Release the click re-entry guard whenever generation settles into a
// terminal state. `phase` is the canonical signal; abort/cancel paths
Expand Down Expand Up @@ -2125,7 +2162,7 @@
return () => {
ac.abort();
};
}, [

Check warning on line 2165 in apps/web/app/play/page.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has missing dependencies: 'prefetchCacheRef' and 'prefetchCandidateCountRef'. Either include them or remove the dependency array
phase,
page?.imageDataUrl,
page?.nodeId,
Expand Down Expand Up @@ -3137,7 +3174,7 @@
inflight.clear();
prefetchCurrentKeyRef.current = null;
};
}, [page, phase, generate, imageTier, loopWire, devModel, editMode, outputLocale, bucketKey, streamStatus, styleAnchor, promptForHint, worldEnabled, worldAutonomy, history, selectFromMap]);

Check warning on line 3177 in apps/web/app/play/page.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has missing dependencies: 'geoMap.bounds', 'geoMap.entities', 'prefetchCacheRef', 'prefetchCurrentKeyRef', 'prefetchInflightRef', 'prefetchPerPageCountRef', 'prefetchTimerRef', 'promptForClickDetail', 'sessionId', 'setMorphFx', 'worldDomLabels', and 'worldState.entities'. Either include them or remove the dependency array

// When the page changes, tear down any running stream.
useEffect(() => {
Expand Down
43 changes: 43 additions & 0 deletions apps/web/e2e/error-retry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,46 @@ test("forced failure shows the banner; retry sends a FRESH idempotency key", asy
expect(keys[1]).not.toBe(keys[0]);
expect(statuses[1]).toBe(200);
});

test("a failure that lands while HIDDEN auto-retries on return — no banner click needed", async ({
page,
}) => {
// The freeze-suspension class (live-caught): Chromium suspends background
// tabs' fetches, killing in-flight generations; the backend cancels on
// disconnect. The client remembers hidden-time errors and fires the
// banner's own retry automatically when the tab returns.
let posts = 0;
page.on("request", (req) => {
if (req.url().includes("/api/generate-page") && req.method() === "POST") {
posts += 1;
}
});

// The override must exist before the app's FIRST script: the mock error
// frame lands well under a second after hydration, so a post-goto evaluate
// loses the race and the error records as visible (caught live: the first
// version of this test did exactly that).
await page.addInitScript(() => {
let vis = "hidden";
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => vis,
});
(window as unknown as { __setVis: (v: string) => void }).__setVis = (v) => {
vis = v;
document.dispatchEvent(new Event("visibilitychange"));
};
});
await page.goto("/play?q=" + encodeURIComponent("MOCK_ERROR hidden town"));

await page
.getByRole("button", { name: /Try again/ })
.waitFor({ state: "visible", timeout: 60_000 });
expect(posts).toBe(1);

// The user comes back — the retry must fire on its own.
await page.evaluate(() =>
(window as unknown as { __setVis: (v: string) => void }).__setVis("visible"),
);
await expect.poll(() => posts, { timeout: 20_000 }).toBe(2);
});
Loading