diff --git a/frontend/src/hooks/useGenerationStream.test.ts b/frontend/src/hooks/useGenerationStream.test.ts index 4825044..0d6f45c 100644 --- a/frontend/src/hooks/useGenerationStream.test.ts +++ b/frontend/src/hooks/useGenerationStream.test.ts @@ -521,7 +521,12 @@ describe("useGenerationStream", () => { expect(reconnectDelay(100, () => 1)).toBe(30_000); }); - it("pauses reconnects while offline and resumes when the browser comes online", async () => { + it("keeps retrying a dropped stream while the machine is offline", async () => { + // The backend is loopback-only -- normalizeApiBaseUrl refuses anything else + // in production -- so navigator.onLine says nothing about reaching it. + // Gating reconnects on it parked a dropped stream on "Waiting for + // network..." with no timer and no retry, and a laptop with its adapter + // off lost the rest of a generation that was still running locally. const hadOwnOnlineProperty = Object.prototype.hasOwnProperty.call(window.navigator, "onLine"); const onlineDescriptor = Object.getOwnPropertyDescriptor(window.navigator, "onLine"); Object.defineProperty(window.navigator, "onLine", { configurable: true, value: false }); @@ -535,12 +540,12 @@ describe("useGenerationStream", () => { act(() => { void result.current.consume({ jobId: "job-offline", threadId: "thread-offline", lastEventId: 0 }, vi.fn().mockResolvedValue(undefined), vi.fn()); }); - await waitFor(() => expect(useChatStore.getState().generation.statusText).toContain("paused while offline")); - expect(streamGeneration).toHaveBeenCalledTimes(1); - Object.defineProperty(window.navigator, "onLine", { configurable: true, value: true }); - act(() => window.dispatchEvent(new Event("online"))); + // No "online" event is ever dispatched: the machine stays offline + // throughout, and the retry has to happen anyway. await waitFor(() => expect(streamGeneration).toHaveBeenCalledTimes(2)); + expect(useChatStore.getState().generation.statusText).toContain("Retrying in"); + expect(useChatStore.getState().generation.statusText).not.toContain("offline"); act(() => result.current.stop()); } finally { if (onlineDescriptor) Object.defineProperty(window.navigator, "onLine", onlineDescriptor); diff --git a/frontend/src/hooks/useGenerationStream.ts b/frontend/src/hooks/useGenerationStream.ts index 8ced1a0..d652ee3 100644 --- a/frontend/src/hooks/useGenerationStream.ts +++ b/frontend/src/hooks/useGenerationStream.ts @@ -100,32 +100,20 @@ function delay(milliseconds: number, signal: AbortSignal): Promise { }); } +/** + * Wait before retrying a dropped stream. + * + * Deliberately does not consult `navigator.onLine`. That flag reports whether + * the machine has *network* connectivity, and the Cortex backend is not on the + * network: `normalizeApiBaseUrl` refuses anything but a same-origin path or a + * loopback host in production. Treating "no Wi-Fi" as "cannot reach the + * backend" parked a dropped stream on "Waiting for network..." with no timer + * and no retry, so a laptop with its adapter off -- a plane, a dead router -- + * lost the rest of a generation that was still running perfectly one process + * away. Offline is the normal case for a local-first app, not a fault. + */ function waitForReconnect(jobId: string, attempt: number, signal: AbortSignal): Promise { if (signal.aborted) return Promise.resolve(false); - if (!window.navigator.onLine) { - useChatStore.getState().setStatusText(jobId, "Connection paused while offline. Waiting for network..."); - return new Promise((resolve) => { - let settled = false; - const finish = (completed: boolean) => { - if (settled) return; - settled = true; - window.removeEventListener("online", onOnline); - signal.removeEventListener("abort", onAbort); - resolve(completed); - }; - const onOnline = () => { - useChatStore.getState().setStatusText(jobId, "Connection restored. Reconnecting..."); - finish(true); - }; - const onAbort = () => { - finish(false); - }; - window.addEventListener("online", onOnline); - signal.addEventListener("abort", onAbort, { once: true }); - if (signal.aborted) onAbort(); - else if (window.navigator.onLine) onOnline(); - }); - } const milliseconds = reconnectDelay(attempt); useChatStore.getState().setStatusText( jobId,