From 868a0f4d50027d25737c728159608f33b83b4265 Mon Sep 17 00:00:00 2001 From: Matthew Robert Wesney <157447210+dovvnloading@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:21:38 -0400 Subject: [PATCH] fix(frontend): keep a local generation streaming while the machine is offline Turning off Wi-Fi could cost the rest of a running generation. `waitForReconnect` treated `navigator.onLine === false` as "cannot reach the backend": it set "Connection paused while offline. Waiting for network..." and returned a promise that settled only on an `online` event. No timer, no retry. On a machine that stays offline -- a plane, a dead router, an adapter switched off -- the stream never reconnected, and the answer being generated one process away was lost to the UI. `navigator.onLine` reports whether the machine has network connectivity. The Cortex backend is not on the network: `normalizeApiBaseUrl` refuses anything but a same-origin path or a loopback host in production, and throws otherwise. So the flag says nothing about whether the backend is reachable, and offline is the ordinary operating condition for a local-first desktop app rather than a fault to wait out. The branch is gone. A dropped stream now always retries on the existing backoff, which is what a loopback connection needs, and the status text says what is actually happening. The test that covered the old behaviour asserted the pause and the resume, so it was pinning the defect; it now asserts the opposite -- with the machine offline for the whole test and no `online` event ever dispatched, the retry still has to happen. Co-Authored-By: Claude Opus 5 --- .../src/hooks/useGenerationStream.test.ts | 15 +++++--- frontend/src/hooks/useGenerationStream.ts | 36 +++++++------------ 2 files changed, 22 insertions(+), 29 deletions(-) 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,