fix(dashboard): harden visibility suspension, log caps, and mobile board UX

Suspend poll/SSE work when the tab is hidden, cap log buffers, restore board scroll more reliably, and improve list windowing/live tickers with related tests and a mobile-tab retention changeset.
This commit is contained in:
gsxdsm
2026-07-26 09:50:44 -07:00
parent 9bad0e1233
commit f157bf7460
59 changed files with 4431 additions and 487 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Dashboard survives mobile tab discards — no more white-splash reload with an empty board.
category: fix
dev: Visibility-gated every polling loop via `useVisibilityAwarePoll`; one shared `useLiveTimeTicker` replaces per-TaskCard 30s timers; sse-bus suspends channels after 60s hidden and drops `beforeunload`; service worker serves hashed `/assets/*` and fonts cache-first (`fusion-cache-v6`); SWR hydration TTLs raised (tasks/chat rooms 12h, default 6h) with an oversize-aware task snapshot writer; board scroll + view persist through an involuntary reload; log/stream buffers capped at 500; ListView and search-active board columns are now windowed; the terminal modal mounts only while open and disposes its WebGL addon. Dashboard vitest setup now clears `sessionStorage` per test so per-tab view state cannot leak between cases.

View File

@@ -1897,7 +1897,17 @@ function AppInner() {
</div>
{rightDock.dock}
</div>
{currentProject && (
{/*
FNXC:Terminal 2026-07-26-11:40:
Mount the terminal ONLY while it is open. It used to be mounted for the whole session (visibility driven purely by `isOpen`), so a closed terminal still ran `useTerminalSessions` + `useTerminal`: a live PTY WebSocket, its heartbeat interval, and — because xterm is torn down on close, leaving no `onData` subscriber — an UNBOUNDED client-side buffer of every byte the shell emitted while the user was elsewhere. Background timers/sockets are a primary tab-discard signal on iOS Safari and Chrome Android, and the growing buffer is the memory pressure that triggers the discard; together they are why returning to the dashboard after a few minutes costs a full white-splash reload.
This does NOT regress the "persist across tab switches" requirement: `terminalOpen` survives view switches, so switching views keeps the modal mounted and its buffer intact. Only an explicit close unmounts. Terminal tabs are server-side PTY sessions restored on reopen, with scrollback replayed by the server on reconnect.
FNXC:Terminal 2026-07-26-14:10 (CORRECTION — the paragraph above originally ended "close already disposed xterm and its scrollback, so nothing is lost that closing did not already discard"; that was FALSE and must not be reasserted):
Closing DID dispose xterm, but the WebSocket stayed open with zero `onData` subscribers, so `useTerminal`'s `initialBufferRef.current.data` accumulated EVERY byte emitted while closed and `onData()` replayed the whole array verbatim when xterm re-initialized on reopen. Reopen was therefore lossless for arbitrarily long closed-terminal output. It no longer is: unmounting closes the socket, and reopen now starts from the server's replay — `MAX_SCROLLBACK_SIZE = 50000` CHARACTERS in `packages/dashboard/src/terminal-service.ts` (~600-800 typical lines), not lines and not unbounded.
Keeping the component mounted is nonetheless the WRONG repair, because the property it preserved was itself the defect: that buffer has no cap and is never drained while closed, so a long-running command (a watch build, `tail -f`) left in a closed terminal grows the heap without bound for as long as the app is open — strictly worse than losing scrollback, and precisely the memory pressure that gets the tab discarded. There is no in-component way to keep both properties: the buffer lives inside `useTerminal`, which cannot outlive the mount.
The real ceiling is the server ring, and the correct place to recover the lost history is to raise `MAX_SCROLLBACK_SIZE` (the repo's own CLI-agent session ring is 512 KiB by comparison) or to bound-and-persist the client buffer outside the component. Both are outside this change's file scope; this comment records the deliberate, known trade so it is not rediscovered as a mystery.
*/}
{currentProject && modalManager.terminalOpen && (
<TerminalModal
isOpen={modalManager.terminalOpen}
onClose={closeTerminalWithNav}

View File

@@ -1,9 +1,138 @@
import { existsSync, readFileSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { runInNewContext } from "node:vm";
import { inflateSync } from "node:zlib";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { loadAllAppCss } from "../test/cssFixture";
/*
FNXC:PWAOffline 2026-07-26-10:44:
Restore latency after a mobile discard is a behavior, not a source-string shape, so it needs an executable seam. Evaluating sw.js in a fresh vm context with fake `caches`/`fetch` exercises the real fetch handler without a browser, a build step, or any timers — the cheapest harness that can prove "cache hit means zero network calls".
*/
type FakeResponse = { ok: boolean; body: string; clone: () => FakeResponse };
function makeResponse(body: string, ok = true): FakeResponse {
const response: FakeResponse = { ok, body, clone: () => response };
return response;
}
type FakeRequest = {
url: string;
method: string;
mode?: string;
destination?: string;
headers: { get: (name: string) => string | null };
};
function makeRequest(url: string, init: { mode?: string; destination?: string } = {}): FakeRequest {
return {
url,
method: "GET",
mode: init.mode ?? "no-cors",
destination: init.destination ?? "",
headers: { get: () => null },
};
}
/*
FNXC:PWAOffline 2026-07-26-14:05:
`store` is a Map, whose iteration order is insertion order — the same ordering guarantee the Cache API
gives `cache.keys()` and which the SW's eviction relies on. Passing an existing store into a second
loadServiceWorker() call models a service worker that was terminated and restarted between builds,
which is the realistic shape of "successive rebuilds against one persistent origin cache".
*/
function loadServiceWorker(existingStore?: Map<string, FakeResponse>) {
const source = readFileSync(resolve(__dirname, "../public/sw.js"), "utf8");
const store = existingStore ?? new Map<string, FakeResponse>();
const fetchMock = vi.fn(async (request: FakeRequest) => makeResponse(`network:${request.url}`));
const cache = {
match: async (request: FakeRequest) => store.get(request.url),
put: async (request: FakeRequest, response: FakeResponse) => {
store.set(request.url, response);
},
addAll: async () => undefined,
keys: async () => [...store.keys()].map((url) => ({ url })),
delete: async (request: { url: string }) => store.delete(request.url),
};
const caches = {
open: async () => cache,
match: async (request: FakeRequest) => store.get(request.url),
keys: async () => [],
delete: async () => true,
};
const listeners = new Map<string, (event: unknown) => void>();
const sandbox = {
self: {
addEventListener: (type: string, handler: (event: unknown) => void) => {
listeners.set(type, handler);
},
skipWaiting: async () => undefined,
clients: { claim: async () => undefined },
},
caches,
fetch: fetchMock,
console,
URL,
};
runInNewContext(source, sandbox);
async function handleFetch(request: FakeRequest): Promise<FakeResponse | undefined> {
const fetchListener = listeners.get("fetch");
expect(fetchListener).toBeTypeOf("function");
let responded: Promise<FakeResponse> | undefined;
fetchListener!({
request,
respondWith: (value: Promise<FakeResponse>) => {
responded = value;
},
waitUntil: () => undefined,
});
return responded ? await responded : undefined;
}
async function runActivate(): Promise<void> {
const activateListener = listeners.get("activate");
expect(activateListener).toBeTypeOf("function");
let pending: Promise<unknown> | undefined;
activateListener!({
waitUntil: (value: Promise<unknown>) => {
pending = value;
},
});
if (pending) await pending;
}
return { handleFetch, runActivate, fetchMock, store, cache };
}
/*
FNXC:PWAOffline 2026-07-26-14:05:
The SW schedules cache pruning fire-and-forget so it can never delay a fetch response. The prune chain
contains only already-resolved promises against the fake cache, so a single macrotask turn drains it —
no fake timers, no polling, no arbitrary sleep.
*/
async function flushPendingPrune(): Promise<void> {
await new Promise((done) => setTimeout(done, 0));
}
function buildAssetUrl(build: number, index: number): string {
// Mimics Vite's `[name]-[hash].js`; the hash segment must satisfy HASHED_ASSET_PATTERN.
return `https://fusion.test/assets/chunk-B${String(build).padStart(3, "0")}Z${String(index).padStart(4, "0")}.js`;
}
function countCachedAssets(store: Map<string, FakeResponse>): number {
return [...store.keys()].filter((url) => url.includes("/assets/")).length;
}
const HASHED_ASSET_URL = "https://fusion.test/assets/index-CydU98D-.js";
type DecodedPng = {
width: number;
height: number;
@@ -194,7 +323,7 @@ describe("PWA configuration", () => {
expect(swSource).toContain('addEventListener("install"');
expect(swSource).toContain('addEventListener("fetch"');
expect(swSource).toContain('addEventListener("activate"');
expect(swSource).toContain('const CACHE_NAME = "fusion-cache-v5";');
expect(swSource).toContain('const CACHE_NAME = "fusion-cache-v6";');
});
it("service worker bypasses SSE requests instead of trying to cache them", () => {
@@ -234,6 +363,186 @@ describe("PWA configuration", () => {
expect(swSource).toContain("await self.clients.claim()");
});
describe("service worker restore strategy", () => {
it("serves a cached content-hashed asset without any network call", async () => {
const { handleFetch, fetchMock, store } = loadServiceWorker();
store.set(HASHED_ASSET_URL, makeResponse("cached-entry-chunk"));
const response = await handleFetch(makeRequest(HASHED_ASSET_URL, { destination: "script" }));
expect(response?.body).toBe("cached-entry-chunk");
expect(fetchMock).not.toHaveBeenCalled();
});
it("falls through to the network for a hashed asset that is not cached, and populates the cache", async () => {
const { handleFetch, fetchMock, store } = loadServiceWorker();
const first = await handleFetch(makeRequest(HASHED_ASSET_URL, { destination: "script" }));
expect(first?.body).toBe(`network:${HASHED_ASSET_URL}`);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(store.get(HASHED_ASSET_URL)).toBeDefined();
const second = await handleFetch(makeRequest(HASHED_ASSET_URL, { destination: "script" }));
expect(second?.body).toBe(`network:${HASHED_ASSET_URL}`);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("never pins a failed hashed-asset response into the immutable cache", async () => {
const { handleFetch, fetchMock, store } = loadServiceWorker();
fetchMock.mockImplementationOnce(async () => makeResponse("not-found", false));
await handleFetch(makeRequest(HASHED_ASSET_URL, { destination: "script" }));
expect(store.has(HASHED_ASSET_URL)).toBe(false);
});
it("keeps navigation network-first even when a cached shell exists", async () => {
const { handleFetch, fetchMock, store } = loadServiceWorker();
const shellUrl = "https://fusion.test/";
store.set(shellUrl, makeResponse("cached-shell"));
const response = await handleFetch(
makeRequest(shellUrl, { mode: "navigate", destination: "document" }),
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(response?.body).toBe(`network:${shellUrl}`);
});
it("keeps a non-hashed /assets/ URL on the network-first path", async () => {
const { handleFetch, fetchMock, store } = loadServiceWorker();
const unhashedUrl = "https://fusion.test/assets/vendor-runtime.js";
store.set(unhashedUrl, makeResponse("cached-unhashed"));
const response = await handleFetch(makeRequest(unhashedUrl, { destination: "script" }));
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(response?.body).toBe(`network:${unhashedUrl}`);
});
it("serves preloaded fonts cache-first regardless of hashing", async () => {
const { handleFetch, fetchMock, store } = loadServiceWorker();
const fontUrl = "https://fusion.test/fonts/SymbolsNerdFontMono-Regular.ttf";
store.set(fontUrl, makeResponse("cached-font"));
const response = await handleFetch(makeRequest(fontUrl, { destination: "font" }));
expect(response?.body).toBe("cached-font");
expect(fetchMock).not.toHaveBeenCalled();
});
/*
FNXC:PWAOffline 2026-07-26-14:05:
Cache-first hashed assets made cache size load-bearing while nothing evicted within a generation,
so a self-hosted Fusion rebuilt daily accumulated every dead build's ~130 chunks forever. On iOS
that risks the all-or-nothing per-origin quota eviction, which would take localStorage (SWR board
snapshot, kb-dashboard-* prefs) with it. These tests pin the two halves of the bound: the cache
stays capped across successive builds, and assets the running shell uses are never the ones evicted.
*/
it("bounds the asset cache across successive rebuilds instead of growing forever", async () => {
const buildSize = 130;
const sharedStore = new Map<string, FakeResponse>();
sharedStore.set("https://fusion.test/", makeResponse("cached-shell"));
sharedStore.set("https://fusion.test/api/tasks", makeResponse("cached-tasks"));
const counts: number[] = [];
// Each build gets a fresh SW instance against the same persistent cache: a service worker is
// terminated when idle, so successive rebuilds do not share one session's exemption set.
for (let build = 1; build <= 3; build += 1) {
const { handleFetch } = loadServiceWorker(sharedStore);
for (let index = 0; index < buildSize; index += 1) {
await handleFetch(makeRequest(buildAssetUrl(build, index), { destination: "script" }));
}
await flushPendingPrune();
counts.push(countCachedAssets(sharedStore));
}
// Unbounded growth would be 130 / 260 / 390.
expect(counts[0]).toBe(buildSize);
expect(counts[1]).toBeLessThanOrEqual(200);
expect(counts[2]).toBeLessThanOrEqual(200);
// The newest build must be fully resident — eviction removes dead builds, not the live one.
for (let index = 0; index < buildSize; index += 1) {
expect(sharedStore.has(buildAssetUrl(3, index))).toBe(true);
}
// The oldest build is what got reclaimed.
const survivingBuildOne = Array.from({ length: buildSize }, (_, index) =>
sharedStore.has(buildAssetUrl(1, index)),
).filter(Boolean).length;
expect(survivingBuildOne).toBeLessThan(buildSize);
// Eviction is scoped to hashed /assets/ entries; the shell and API fallbacks are untouched.
expect(sharedStore.has("https://fusion.test/")).toBe(true);
expect(sharedStore.has("https://fusion.test/api/tasks")).toBe(true);
});
it("never evicts assets the current shell is using, even when they are the oldest entries", async () => {
const { handleFetch, store } = loadServiceWorker();
const shellAssets = Array.from({ length: 40 }, (_, index) => buildAssetUrl(9, index));
// The running build's chunks are cached FIRST, so plain insertion-order eviction would take
// them before anything else. The session-referenced exemption must override that ordering.
for (const assetUrl of shellAssets) {
await handleFetch(makeRequest(assetUrl, { destination: "script" }));
}
await flushPendingPrune();
// A previous build's leftovers land in the cache *after* them (newer by insertion order).
for (let index = 0; index < 200; index += 1) {
store.set(buildAssetUrl(8, index), makeResponse("dead-build-chunk"));
}
// One more live request drives the cache over the cap and triggers a prune.
await handleFetch(makeRequest(buildAssetUrl(9, 40), { destination: "script" }));
await flushPendingPrune();
for (const assetUrl of shellAssets) {
expect(store.has(assetUrl)).toBe(true);
}
expect(store.has(buildAssetUrl(9, 40))).toBe(true);
expect(countCachedAssets(store)).toBeLessThanOrEqual(200);
});
it("prunes an over-cap cache on activate, not only on the fetch cold path", async () => {
const { runActivate, store } = loadServiceWorker();
for (let index = 0; index < 250; index += 1) {
store.set(buildAssetUrl(7, index), makeResponse("dead-build-chunk"));
}
await runActivate();
expect(countCachedAssets(store)).toBe(200);
});
it("keeps serving assets when cache pruning throws", async () => {
const { handleFetch, store, cache, fetchMock } = loadServiceWorker();
cache.keys = async () => {
throw new Error("quota inspection failed");
};
const response = await handleFetch(makeRequest(HASHED_ASSET_URL, { destination: "script" }));
await flushPendingPrune();
expect(response?.body).toBe(`network:${HASHED_ASSET_URL}`);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(store.has(HASHED_ASSET_URL)).toBe(true);
});
it("keeps /api/ responses network-first so cached data cannot go stale", async () => {
const { handleFetch, fetchMock, store } = loadServiceWorker();
const apiUrl = "https://fusion.test/api/tasks";
store.set(apiUrl, makeResponse("cached-tasks"));
const response = await handleFetch(makeRequest(apiUrl));
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(response?.body).toBe(`network:${apiUrl}`);
});
});
describe("logo assets", () => {
it("logo.svg uses ring + swoosh geometry matching Header.tsx brand mark", () => {
const logoSvg = readFileSync(resolve(__dirname, "../public/logo.svg"), "utf8");
@@ -316,7 +625,7 @@ describe("PWA configuration", () => {
expect(indexHtml).toContain('<link rel="icon" type="image/svg+xml" href="/logo.svg" />');
expect(indexHtml).toContain('<link rel="apple-touch-icon" href="/icons/icon-192.png" />');
expect(swSource).toContain('const CACHE_NAME = "fusion-cache-v5";');
expect(swSource).toContain('const CACHE_NAME = "fusion-cache-v6";');
});
});
});

View File

@@ -0,0 +1,175 @@
/*
FNXC:DashboardSSE 2026-07-26-10:55:
Regression coverage for the hidden-tab SSE suspend. The requirement it guards: a backgrounded mobile
tab must schedule NO SSE work (no open EventSource, no keepalive interval, no heartbeat reconnect)
once it has been hidden past the grace delay, because that background work is a primary OS/browser
page-discard signal and the discard is what the operator sees as a white-splash reload. It also pins
the bfcache requirement that no `beforeunload` listener is registered.
Fake timers only — the grace delay is 60s of wall clock.
*/
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { MockEventSource } from "../../vitest.setup";
import {
subscribeSse,
__resetSseBus,
__sseBusChannelState,
SSE_HIDDEN_SUSPEND_DELAY_MS,
} from "../sse-bus";
const URL_A = "/api/events?projectId=suspend-test";
function setVisibility(state: "visible" | "hidden"): void {
Object.defineProperty(document, "visibilityState", { value: state, configurable: true });
document.dispatchEvent(new Event("visibilitychange"));
}
/**
* Advance `ms` while feeding the server heartbeats a real /api/events stream would send, so these
* assertions isolate the suspend behavior instead of tripping the unrelated 45s heartbeat timeout.
*/
function advanceWithHeartbeats(ms: number, es: MockEventSource): void {
const STEP_MS = 20_000;
let remaining = ms;
while (remaining > STEP_MS) {
vi.advanceTimersByTime(STEP_MS);
es._emit("heartbeat");
remaining -= STEP_MS;
}
vi.advanceTimersByTime(remaining);
}
beforeEach(() => {
window.sessionStorage.clear();
vi.useFakeTimers();
});
afterEach(() => {
__resetSseBus();
setVisibility("visible");
vi.clearAllTimers();
vi.useRealTimers();
});
describe("sse-bus hidden-tab suspend", () => {
it("closes channels after the grace period while the tab stays hidden", () => {
subscribeSse(URL_A, { events: { "task:updated": () => {} } });
const es = MockEventSource.instances[0]!;
expect(__sseBusChannelState(URL_A)).toMatchObject({ suspended: false, hasEventSource: true });
setVisibility("hidden");
// Still connected during the grace window.
advanceWithHeartbeats(SSE_HIDDEN_SUSPEND_DELAY_MS - 1, es);
expect(__sseBusChannelState(URL_A)).toMatchObject({ suspended: false, hasEventSource: true });
expect(es.close).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(es.close).toHaveBeenCalled();
expect(__sseBusChannelState(URL_A)).toMatchObject({
suspended: true,
closed: false,
hasEventSource: false,
});
});
it("stops the keepalive interval while suspended so nothing fires when hidden", () => {
subscribeSse(URL_A, {});
const es = MockEventSource.instances[0]!;
expect(__sseBusChannelState(URL_A)?.hasKeepaliveTimer).toBe(true);
setVisibility("hidden");
advanceWithHeartbeats(SSE_HIDDEN_SUSPEND_DELAY_MS, es);
expect(__sseBusChannelState(URL_A)?.hasKeepaliveTimer).toBe(false);
// A long hidden stretch must not resurrect the socket via heartbeat/reconnect timers.
const instanceCount = MockEventSource.instances.length;
vi.advanceTimersByTime(10 * SSE_HIDDEN_SUSPEND_DELAY_MS);
expect(MockEventSource.instances).toHaveLength(instanceCount);
expect(__sseBusChannelState(URL_A)?.hasEventSource).toBe(false);
});
it("does not close when the tab becomes visible before the grace period expires", () => {
subscribeSse(URL_A, {});
const es = MockEventSource.instances[0]!;
setVisibility("hidden");
advanceWithHeartbeats(SSE_HIDDEN_SUSPEND_DELAY_MS / 2, es);
setVisibility("visible");
advanceWithHeartbeats(SSE_HIDDEN_SUSPEND_DELAY_MS * 2, es);
expect(es.close).not.toHaveBeenCalled();
expect(MockEventSource.instances).toHaveLength(1);
expect(__sseBusChannelState(URL_A)).toMatchObject({ suspended: false, hasEventSource: true });
});
it("reopens suspended channels on visible and signals subscribers to resync", () => {
const onReconnect = vi.fn();
subscribeSse(URL_A, { events: { "task:updated": () => {} }, onReconnect });
// The real EventSource fires `open` on connect; the mock does not, and `hasOpenedOnce` is what
// turns the post-resume open into an onReconnect resync signal.
MockEventSource.instances[0]!._emit("open");
expect(onReconnect).not.toHaveBeenCalled();
setVisibility("hidden");
advanceWithHeartbeats(SSE_HIDDEN_SUSPEND_DELAY_MS, MockEventSource.instances[0]!);
expect(__sseBusChannelState(URL_A)?.suspended).toBe(true);
setVisibility("visible");
expect(MockEventSource.instances).toHaveLength(2);
expect(__sseBusChannelState(URL_A)).toMatchObject({
suspended: false,
hasEventSource: true,
hasKeepaliveTimer: true,
});
// Events missed while suspended are recovered by the reconnect resync signal.
const reopened = MockEventSource.instances[1]!;
reopened._emit("open");
expect(onReconnect).toHaveBeenCalled();
});
it("delivers events again on the reopened stream", () => {
const received: unknown[] = [];
subscribeSse(URL_A, { events: { "task:updated": (e) => received.push(JSON.parse(e.data)) } });
setVisibility("hidden");
advanceWithHeartbeats(SSE_HIDDEN_SUSPEND_DELAY_MS, MockEventSource.instances[0]!);
setVisibility("visible");
const reopened = MockEventSource.instances[1]!;
reopened._emit("task:updated", { id: "t-9" });
expect(received).toEqual([{ id: "t-9" }]);
});
it("releases the suspend on a bfcache pageshow that arrives without a visibilitychange", () => {
subscribeSse(URL_A, {});
setVisibility("hidden");
advanceWithHeartbeats(SSE_HIDDEN_SUSPEND_DELAY_MS, MockEventSource.instances[0]!);
expect(__sseBusChannelState(URL_A)?.suspended).toBe(true);
// Restored from bfcache: the page is visible again but no visibilitychange was delivered.
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
expect(__sseBusChannelState(URL_A)).toMatchObject({ suspended: false, hasEventSource: true });
});
it("registers no beforeunload listener on either EventSource path (bfcache eligibility)", async () => {
const spy = vi.spyOn(window, "addEventListener");
try {
vi.resetModules();
await import("../sse-bus");
await import("../api/event-source");
const types = spy.mock.calls.map(([type]) => type);
expect(types).not.toContain("beforeunload");
expect(types).toContain("pagehide");
} finally {
spy.mockRestore();
vi.resetModules();
}
});
});

View File

@@ -6,12 +6,30 @@ import { appendTokenQuery } from "../auth";
export type StreamConnectionState = "connected" | "reconnecting";
// Track every live createResilientEventSource instance so we can close their
// underlying EventSource sockets on page unload. Without this, Chrome holds
// the HTTP/1.1 sockets open in its keep-alive pool across refreshes, exhausts
// its 6-per-origin limit after ~3 refreshes, and every new fetch stalls —
// leaving the dashboard frozen on "Initializing...". sse-bus.ts has its own
// handler; this one covers the parallel EventSource path in api.ts.
/*
* Track every live createResilientEventSource instance so we can close their
* underlying EventSource sockets on page unload. Without this, Chrome holds
* the HTTP/1.1 sockets open in its keep-alive pool across refreshes, exhausts
* its 6-per-origin limit after ~3 refreshes, and every new fetch stalls —
* leaving the dashboard frozen on "Initializing...". sse-bus.ts has its own
* handler; this one covers the parallel EventSource path in api.ts.
*
* FNXC:DashboardSSE 2026-07-26-10:44:
* `beforeunload` was REMOVED here for the same reason as in sse-bus.ts and must not be re-added:
* a registered beforeunload handler makes the page ineligible for Safari's page cache / bfcache,
* which is exactly the cheap restore path the mobile dashboard needs after the OS backgrounds it.
* `pagehide` fires in every case beforeunload does, plus on bfcache freeze, so nothing is lost.
*
* FNXC:DashboardSSE 2026-07-26-10:47:
* These streams are DELIBERATELY NOT hidden-suspended, unlike the sse-bus channels. They carry
* in-flight AI generation (chat, planning, ai-text) and long-running scheduling runs; closing the
* EventSource mid-generation drops the server's only consumer and the user loses the response they
* are waiting on. Resumption is best-effort (`lastEventId` replay depends on the route keeping a
* ring buffer), so suspending here would trade a possible tab discard for a certain data loss.
* They are also short-lived and idle-free between messages, so they are a far weaker discard signal
* than the always-on board channels. If suspend is ever wanted here, gate it on a stream that has
* signalled completion.
*/
const activeResilientEventSources = new Set<{ close: () => void }>();
if (typeof window !== "undefined") {
const closeAll = () => {
@@ -20,7 +38,6 @@ if (typeof window !== "undefined") {
}
};
window.addEventListener("pagehide", closeAll);
window.addEventListener("beforeunload", closeAll);
}
export interface ResilientEventSourceOptions {

View File

@@ -24,6 +24,7 @@ import { getAgentHealthStatus } from "../utils/agentHealth";
import type { AgentHealthStatus } from "../utils/agentHealth";
import { SkillMultiselect } from "./SkillMultiselect";
import { subscribeSse } from "../sse-bus";
import { MAX_LOG_ENTRIES, capLogEntries } from "../hooks/useAgentLogs";
import { DEFAULT_HEARTBEAT_INTERVAL_MS, formatHeartbeatInterval, resolveHeartbeatIntervalMs } from "../utils/heartbeatIntervals";
import { formatAgentSkillBadgeLabel } from "../utils/agentSkills";
import { CustomModelDropdown } from "./CustomModelDropdown";
@@ -141,6 +142,99 @@ const RUN_STATUS_ICONS: Record<string, { icon: typeof CheckCircle; color: string
const DEFAULT_HEARTBEAT_INTERVAL_LABEL = formatHeartbeatInterval(DEFAULT_HEARTBEAT_INTERVAL_MS);
const CONFIG_AUTOSAVE_DEBOUNCE_MS = 700;
/*
FNXC:AgentLogHistory 2026-07-26-13:05:
CORRECTION to FNXC:MobileTabRetention 2026-07-26-10:34/10:35/10:38/10:40, which claimed that passing a
fetched run log through `capLogEntries` was the way to keep a backgrounded mobile tab from being
discarded. That reasoning was wrong and must not be reintroduced: `fetchAgentRunLogs` returns a run's
ENTIRE log array unpaginated and accepts no offset, and this view has no loadMore/offset path, so
capping the FETCHED array destroyed data the client already held — for a 1500-entry run the operator
permanently lost entries 0..999, including the run's opening prompt and first tool calls, with no UI
path back to them.
The memory goal is served by not RENDERING 1500 rows, not by destroying them. So: the fetched array is
kept whole in state, and the RENDER is windowed to the newest LOG_WINDOW_INITIAL entries with a
"Load older" affordance that walks back to entry 0. This reuses the board's manual paging pattern
(Column.tsx VISIBLE_TASKS_INCREMENT / ListView.tsx LIST_SECTION_VISIBLE_*) rather than adding a
virtualization dependency — see AGENTS.md "Reuse Components ... (No Drift)".
Log tails read bottom-up, so the window is anchored to the END of the array (newest visible by
default) and grows backwards, the mirror image of the board's top-anchored window.
*/
const LOG_WINDOW_INITIAL = MAX_LOG_ENTRIES;
const LOG_WINDOW_INCREMENT = MAX_LOG_ENTRIES;
/**
* FNXC:AgentLogHistory 2026-07-26-13:08:
* Live SSE append with a SOFT ceiling, identical in intent to `useAgentLogs`'s tail: the buffer is
* held at `max(MAX_LOG_ENTRIES, prev.length)` so an hour-long stream cannot grow without bound, while
* a deliberately larger buffer (a 1500-entry fetched run) is NOT collapsed back to the cap on the
* first streamed line. Unlike the previous `capLogEntries([...prev, entry])` this never shrinks an
* array the user can still page through.
*/
function appendLiveLogEntry<T>(previous: T[], entry: T): T[] {
const limit = Math.max(MAX_LOG_ENTRIES, previous.length);
if (previous.length + 1 <= limit) return [...previous, entry];
return [...previous.slice(previous.length + 1 - limit), entry];
}
/**
* FNXC:AgentLogHistory 2026-07-26-13:10:
* Renders a bounded window over a complete log array plus the shared "Load older" button. Both agent
* log surfaces (Logs tab, expanded run in the Runs tab) use this one component so the two cannot
* drift — the reported defect only named the run stream, but the same discard existed on both.
* `resetKey` (task id / run id) collapses the window back to one screenful when the underlying
* stream is replaced; appends to the same stream must NOT reset it, or paging back would be undone
* by the next streamed line.
*/
function WindowedAgentLogViewer({
entries,
resetKey,
testId,
}: {
entries: AgentLogEntry[];
resetKey: string;
testId: string;
}) {
const { t } = useTranslation("app");
const [visibleCount, setVisibleCount] = useState(LOG_WINDOW_INITIAL);
useEffect(() => {
setVisibleCount(LOG_WINDOW_INITIAL);
}, [resetKey]);
const hiddenCount = Math.max(0, entries.length - visibleCount);
const visibleEntries = useMemo(
() => (entries.length > visibleCount ? entries.slice(entries.length - visibleCount) : entries),
[entries, visibleCount],
);
const handleLoadOlder = useCallback(() => {
setVisibleCount((current) => current + LOG_WINDOW_INCREMENT);
}, []);
return (
<>
{hiddenCount > 0 && (
<div className="log-window-loader">
<button
type="button"
className="btn btn-secondary btn-sm"
data-testid={`${testId}-load-older`}
onClick={handleLoadOlder}
>
{t("agents.loadOlderLogs", "Load {{count}} older ({{remaining}} remaining)", {
count: Math.min(LOG_WINDOW_INCREMENT, hiddenCount),
remaining: hiddenCount,
})}
</button>
</div>
)}
<AgentLogViewer entries={visibleEntries} loading={false} />
</>
);
}
function pickDefaultAgentMemoryPath(files: MemoryFileInfo[], currentPath: string): string {
if (files.some((file) => file.path === currentPath)) {
return currentPath;
@@ -223,7 +317,14 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
}
}, [agentId, projectId]);
const loadLogs = useCallback(async () => {
/*
FNXC:AgentLogSuspendRecovery 2026-07-26-13:22:
`force` bypasses the `loadedLatestRunLogsRef` "already loaded this run" short-circuit. Tab switches
keep that memo (it exists to avoid refetching a run the view already holds), but an SSE reconnect
after a suspend gap MUST refetch even for the same run id — the memo would otherwise make the heal
a no-op and the missed lines would never arrive.
*/
const loadLogs = useCallback(async (options?: { force?: boolean }) => {
// Capture context version at callback creation - stale responses will be rejected
const contextVersionAtCapture = contextVersionRef.current;
const currentAgentId = agentId;
@@ -255,11 +356,14 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
setLogs([]);
return;
}
if (loadedLatestRunLogsRef.current === latest.id) {
if (!options?.force && loadedLatestRunLogsRef.current === latest.id) {
return;
}
const entries = await fetchAgentRunLogs(currentAgentId, latest.id, currentProjectId);
if (isStale()) return;
// FNXC:AgentLogHistory 2026-07-26-13:12: the fetched run is stored WHOLE — the render is windowed
// by WindowedAgentLogViewer instead. Capping here destroyed the run's opening entries outright
// (see the correction note on LOG_WINDOW_INITIAL).
setLogs(entries);
loadedLatestRunLogsRef.current = latest.id;
} catch (err) {
@@ -268,6 +372,17 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
}
}, [agent?.taskId, agentId, projectId]);
/*
FNXC:AgentLogSuspendRecovery 2026-07-26-13:20:
SSE channels are now suspended after ~60s hidden (mobile tab-retention work), so every reopen is a
potential gap: lines emitted while suspended were never delivered and a tail that only appends can
never learn about them. Each log subscription therefore refetches authoritative state in
`onReconnect`. Held in a ref so the refetch does not become an effect dependency — that would tear
down and re-open the very subscription it is meant to heal on every render.
*/
const loadLogsRef = useRef(loadLogs);
loadLogsRef.current = loadLogs;
const loadMailbox = useCallback(async () => {
setIsLoadingMailbox(true);
setMailboxError(null);
@@ -401,7 +516,14 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
if (contextVersionRef.current !== contextVersionAtStart) return;
try {
const entry: AgentLogEntry = JSON.parse(e.data);
setLogs(prev => [...prev, entry]);
/*
FNXC:AgentLogHistory 2026-07-26-13:24:
Latest-run log tail. Soft-bounded (see appendLiveLogEntry): still bounded so a long
stream cannot grow the resident set until a backgrounded mobile tab is discarded, but
no longer collapses a larger fetched run back to the cap and destroys its opening
entries — replacing the previous `capLogEntries([...prev, entry])`.
*/
setLogs(prev => appendLiveLogEntry(prev, entry));
} catch {
// ignore malformed events
}
@@ -412,6 +534,13 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
setIsStreaming(true);
}
},
onReconnect: () => {
// FNXC:AgentLogSuspendRecovery 2026-07-26-13:26: heal the suspend gap by refetching the
// run's authoritative log array rather than resuming a tail that silently skipped lines.
if (contextVersionRef.current !== contextVersionAtStart) return;
setIsStreaming(true);
void loadLogsRef.current({ force: true });
},
onError: () => {
if (contextVersionRef.current === contextVersionAtStart) {
setIsStreaming(false);
@@ -506,7 +635,9 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
if (contextVersionRef.current !== contextVersionAtStart) return;
try {
const entry: AgentLogEntry = JSON.parse(e.data);
setLogs(prev => [...prev, entry]);
// FNXC:AgentLogHistory 2026-07-26-13:28: Current-task log tail — same soft-bounded ring
// as the latest-run tail above (see appendLiveLogEntry).
setLogs(prev => appendLiveLogEntry(prev, entry));
} catch {
// Ignore parse errors
}
@@ -517,6 +648,13 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
setIsStreaming(true);
}
},
onReconnect: () => {
// FNXC:AgentLogSuspendRecovery 2026-07-26-13:29: a reopen after the hidden-tab suspend
// window means lines were missed; refetch the task's authoritative log page.
if (contextVersionRef.current !== contextVersionAtStart) return;
setIsStreaming(true);
void loadLogsRef.current({ force: true });
},
onError: () => {
if (contextVersionRef.current === contextVersionAtStart) {
setIsStreaming(false);
@@ -955,11 +1093,18 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
)}
{activeTab === "logs" && (
/*
FNXC:AgentLogHistory 2026-07-26-13:34: `windowResetKey` collapses the render window back
to one screenful only when the underlying log stream is REPLACED (different task, or a
different latest run), never on an append — otherwise a streamed line would undo the
operator's paging back through history.
*/
<LogsTab
logs={logs}
isStreaming={isStreaming}
hasTask={!!agent.taskId || logs.length > 0 || latestRun !== null}
fallbackLabel={!agent.taskId && latestRun ? t("agents.latestRunLabel", "Latest run · {{id}}", { id: latestRun.id.slice(0, 8) }) : null}
windowResetKey={agent.taskId ?? latestRun?.id ?? "none"}
/>
)}
@@ -1416,11 +1561,13 @@ function LogsTab({
isStreaming,
hasTask,
fallbackLabel,
windowResetKey,
}: {
logs: AgentLogEntry[];
isStreaming: boolean;
hasTask: boolean;
fallbackLabel?: string | null;
windowResetKey: string;
}) {
const { t } = useTranslation("app");
@@ -1442,6 +1589,15 @@ function LogsTab({
<div className="logs-tab">
<div className="logs-header">
<span className="logs-count">{t("agents.logEntries", "{{count}} entries", { count: logs.length })}</span>
{/*
FNXC:AgentLogHistory 2026-07-26-13:32:
REMOVED the "Showing the most recent 500 entries" banner. It was false as written: it named a
cap that DESTROYED the older entries, so the operator was told about data that no longer
existed anywhere in the client and offered no way to get it back. Entries beyond the render
window are now still held, and the "Load older (N remaining)" button inside
WindowedAgentLogViewer is the single, actionable truncation signal — it states the remaining
count and reaches entry 0. Do not reintroduce a second, static banner beside it.
*/}
{fallbackLabel && (
<span className="text-muted logs-fallback-label">{fallbackLabel}</span>
)}
@@ -1461,7 +1617,7 @@ function LogsTab({
</p>
</div>
) : (
<AgentLogViewer entries={logs} loading={false} />
<WindowedAgentLogViewer entries={logs} resetKey={windowResetKey} testId="agent-logs" />
)}
</div>
);
@@ -1767,6 +1923,27 @@ function RunsTab({
const hasAutoExpandedInitialRunRef = useRef(false);
const didMountRunNowRefreshRef = useRef(false);
/*
FNXC:AgentLogSuspendRecovery 2026-07-26-13:38:
Authoritative refetch for the expanded run's logs, used by the run-log SSE `onReconnect` so a
suspend gap self-heals. Held in a ref (not a dependency) so re-creating it cannot tear down and
re-open the subscription it heals. Reads `selectedRunId` from a ref for the same reason.
*/
const selectedRunIdRef = useRef<string | null>(null);
selectedRunIdRef.current = selectedRunId;
const refreshRunLogsRef = useRef<() => Promise<void>>(async () => {});
refreshRunLogsRef.current = async () => {
const runId = selectedRunIdRef.current;
if (!runId) return;
try {
const entries = await fetchAgentRunLogs(agentId, runId, projectId);
if (selectedRunIdRef.current !== runId) return;
setRunLogs(entries);
} catch {
// Leave the existing buffer in place; the next reconnect or run click retries.
}
};
// Load runs on mount
const loadRuns = useCallback(async () => {
try {
@@ -1856,12 +2033,19 @@ function RunsTab({
"agent:log": (e) => {
try {
const entry: AgentLogEntry = JSON.parse(e.data);
setRunLogs(prev => [...prev, entry]);
// FNXC:AgentLogHistory 2026-07-26-13:36: Expanded-run log tail — soft-bounded ring, same
// as the other two tails in this file (see appendLiveLogEntry).
setRunLogs(prev => appendLiveLogEntry(prev, entry));
} catch {
// ignore malformed events
}
},
},
onReconnect: () => {
// FNXC:AgentLogSuspendRecovery 2026-07-26-13:37: the expanded run's tail loses lines across
// the hidden-tab suspend window too; refetch the run's full log array on reopen.
void refreshRunLogsRef.current();
},
},
);
}, [selectedRunId, selectedRunStatus, agentId, projectId]);
@@ -1884,6 +2068,8 @@ function RunsTab({
fetchAgentRunLogs(agentId, runId, projectId),
fetchAgentRunDetail(agentId, runId, projectId),
]);
// FNXC:AgentLogHistory 2026-07-26-13:40: stored WHOLE — `fetchAgentRunLogs` is unpaginated, so
// capping here was an unrecoverable data loss. The render is windowed instead.
setRunLogs(logs);
setDetailRun(detail);
} catch (err) {
@@ -2170,7 +2356,18 @@ function RunsTab({
) : runLogs.length === 0 ? (
<div className="text-muted run-output-empty">{t("agents.noLogsForRun", "No logs available for this run")}</div>
) : (
<AgentLogViewer entries={runLogs} loading={false} />
/*
FNXC:AgentLogHistory 2026-07-26-13:42:
REMOVED the "Showing the most recent 500 entries" note here for the same reason as the
Logs tab: it advertised a cap that had already discarded the run's opening entries with
no way back. The window's "Load older (N remaining)" button replaces it and reaches
entry 0 of the run.
*/
<WindowedAgentLogViewer
entries={runLogs}
resetKey={selectedRunId ?? "none"}
testId="agent-run-logs"
/>
)}
</div>
</div>

View File

@@ -337,8 +337,19 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
).length,
[tasks, columnFlags, globalPaused, taskStuckTimeoutMs, lastFetchTimeMs],
);
// When search is active, skip pagination so all matching tasks are visible
const shouldPaginate = !isArchived && !isSearchActive && !showWorktreeGroups && tasks.length > PAGINATED_COLUMN_THRESHOLD;
/*
FNXC:BoardColumnWindowing 2026-07-26-11:48:
Search used to disable pagination entirely (`!isSearchActive`) so every match rendered at once. That
escape hatch is unbounded — a broad query over a large project mounts an unlimited number of
~4000-line TaskCards, and a resident set that large is a primary reason mobile browsers reclaim the
backgrounded tab (the operator sees a white-splash reload on return). It is also unnecessary: the
`tasks` handed to this column are ALREADY search-filtered upstream, so paginating them still shows
matches — just an increment at a time behind the same "Load more" button. The remaining bypasses are
bounded: the archived column is server-paginated (100 per page) and worktree grouping renders only
the WIP/processing lane. The window resets whenever the search term toggles so a new result set
starts from one screenful again.
*/
const shouldPaginate = !isArchived && !showWorktreeGroups && tasks.length > PAGINATED_COLUMN_THRESHOLD;
useEffect(() => {
setVisibleTaskCount((current) => {
@@ -350,6 +361,11 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
});
}, [showWorktreeGroups, isArchived, tasks.length]);
// Entering/leaving search replaces the result set; collapse back to one window.
useEffect(() => {
setVisibleTaskCount(VISIBLE_TASKS_INITIAL);
}, [isSearchActive]);
const handleDragOver = useCallback((e: React.DragEvent) => {
// Don't allow dropping into archived column via drag-drop
if (isArchived) return;

View File

@@ -990,6 +990,24 @@ rows while preserving their existing badge geometry.
border-bottom: 1px solid var(--border);
}
/*
FNXC:ListViewWindowing 2026-07-26-11:40:
Each list section renders only a window of its tasks so a large project cannot inflate the DOM to the
point where mobile browsers reclaim the backgrounded tab. This is the container for the shared
"Load more" affordance that reveals the next increment; it only positions the button — the button
itself reuses the board's `.btn.btn-secondary.btn-sm` primitive so List and Board read as one system.
*/
.list-section-load-more {
display: flex;
justify-content: center;
padding: var(--space-md);
border-bottom: 1px solid var(--border);
}
.list-section-load-more-row:hover {
background: transparent; /* not a task row — suppress the row hover affordance */
}
/* === List View Single-Pane Responsive ===
FNXC:ListView 2026-07-10-00:00 (FN-7809):
Tablet-width List view shares the mobile single-pane scaffolding so the full-width toolbar and QuickEntryBox are not clipped by the desktop split sidebar. Mobile behavior stays unchanged; desktop split rules remain active above the tablet tier.

View File

@@ -84,6 +84,29 @@ First-run list view users should see only the Title column by default for a clea
const DEFAULT_LIST_COLUMNS = ["title"] as const;
type ListColumn = typeof ALL_LIST_COLUMNS[number];
/*
FNXC:ListViewWindowing 2026-07-26-11:20:
Mobile browsers (iOS Safari tabs, iOS installed PWAs, Chrome Android) reclaim a backgrounded tab whose
resident set is large, which the operator sees as a white-splash "reload" on return. ListView used to
render EVERY grouped task row/card at once, so a project with thousands of tasks produced a DOM large
enough to be a primary contributor to that reclaim. No virtualization library exists in this repo and
none may be added, so List reuses the board's manual paging affordance (Column.tsx
VISIBLE_TASKS_INITIAL / VISIBLE_TASKS_INCREMENT) with the same "Load more" button styling and copy.
Invariants this window must not break:
- Filtering (search/column/stale/hide-done/workflow) runs over the FULL task set in `groupedTasks`;
the window is applied AFTER, per section, so a match beyond the window is still reachable via
"Load more" instead of being filtered out of existence.
- Grouping is preserved: the window is per column section, never across the flattened list, so every
section keeps its own header, count (which reports the FULL group size), and collapse state.
- Selection is id-based (`kb-dashboard-selected-tasks` / `kb-dashboard-list-selected-task` in
projectStorage), so a selected task outside the window stays selected. The window is additionally
widened to cover the persisted single selection so the highlighted row remains visible after a
remount rather than silently vanishing from the rendered list.
*/
const LIST_SECTION_VISIBLE_INITIAL = 50;
const LIST_SECTION_VISIBLE_INCREMENT = 25;
function getNodeStatusLabel(status: NodeInfo["status"], t: TFunction<"app">): string {
if (status === "online") return t("listView.nodeStatusOnline", "Online");
if (status === "connecting") return t("listView.nodeStatusConnecting", "Connecting");
@@ -932,6 +955,60 @@ export function ListView({
return Object.values(groupedTasks).reduce((sum, group) => sum + group.length, 0);
}, [groupedTasks]);
/*
FNXC:ListViewWindowing 2026-07-26-11:24:
Per-section reveal counters, keyed by column id. Absent entries mean "still at the initial window".
Every change to what the FULL set contains or how it is ordered (search text, column filter,
hide-done, stale filters, sort, workflow selection, project) resets the counters so a fresh result
set starts from one screen of rows again — otherwise a previously-expanded section would keep an
arbitrarily large DOM alive across filter changes, which is exactly the resident-set growth that
gets the backgrounded tab reclaimed.
*/
const [sectionVisibleCounts, setSectionVisibleCounts] = useState<Record<string, number>>({});
useEffect(() => {
setSectionVisibleCounts({});
}, [
projectId,
searchQuery,
selectedColumn,
hideDoneTasks,
staleOnlyFilter,
stalePausedReviewOnlyFilter,
sortField,
sortDirection,
selectedWorkflowId,
]);
/**
* FNXC:ListViewWindowing 2026-07-26-11:28:
* Slice each already-filtered, already-sorted section down to its visible window. `hiddenCount`
* drives the shared "Load more" affordance; a section at or under its window renders unchanged with
* no button shell. The window is stretched to include the persisted single-selection index so the
* selected row is never hidden by paging.
*/
const listSectionWindows = useMemo(() => {
const windows: Record<string, { tasks: Task[]; hiddenCount: number }> = {};
for (const [columnId, group] of Object.entries(groupedTasks)) {
const stored = sectionVisibleCounts[columnId] ?? LIST_SECTION_VISIBLE_INITIAL;
const selectedIndex = selectedTaskId ? group.findIndex((task) => task.id === selectedTaskId) : -1;
const effective = Math.max(stored, selectedIndex >= 0 ? selectedIndex + 1 : 0);
if (group.length <= effective) {
windows[columnId] = { tasks: group, hiddenCount: 0 };
continue;
}
windows[columnId] = { tasks: group.slice(0, effective), hiddenCount: group.length - effective };
}
return windows;
}, [groupedTasks, sectionVisibleCounts, selectedTaskId]);
const handleLoadMoreSection = useCallback((columnId: ColumnId, currentVisibleCount: number) => {
setSectionVisibleCounts((previous) => ({
...previous,
[columnId]: currentVisibleCount + LIST_SECTION_VISIBLE_INCREMENT,
}));
}, []);
// Selection logic that depends on groupedTasks (must be after groupedTasks definition)
// Toggle all visible tasks
const toggleSelectAll = useCallback(() => {
@@ -2641,6 +2718,11 @@ export function ListView({
const isEmpty = columnTasks.length === 0;
if (searchQuery && isEmpty) return null;
// FNXC:ListViewWindowing 2026-07-26-11:32: header count stays the FULL group size; only the rendered slice is windowed.
const sectionWindow = listSectionWindows[column] ?? { tasks: columnTasks, hiddenCount: 0 };
const windowedTasks = sectionWindow.tasks;
const hiddenTaskCount = sectionWindow.hiddenCount;
const isCollapsed = collapsedSections.has(column);
return (
@@ -2672,7 +2754,7 @@ export function ListView({
{isEmpty ? (
<div className="list-empty-cell list-card-empty">{t("listView.noTasks", "No tasks")}</div>
) : (
columnTasks.map((task) => {
windowedTasks.map((task) => {
const isDoneColumn = isCompleteColumn(task.column);
const visualStatus = isDoneColumn ? "done" : task.status;
const isFailed = !isDoneColumn && task.status === "failed" && !hasPendingAutomaticRecovery(task, lastFetchTimeMs);
@@ -2812,6 +2894,20 @@ export function ListView({
);
})
)}
{hiddenTaskCount > 0 && (
<div className="list-section-load-more">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => handleLoadMoreSection(column, windowedTasks.length)}
>
{t("column.loadMore", "Load {{count}} more ({{remaining}} remaining)", {
count: Math.min(LIST_SECTION_VISIBLE_INCREMENT, hiddenTaskCount),
remaining: hiddenTaskCount,
})}
</button>
</div>
)}
</>
)}
</Fragment>
@@ -2878,6 +2974,11 @@ export function ListView({
// When text filtering, hide empty sections entirely
if (searchQuery && isEmpty) return null;
// FNXC:ListViewWindowing 2026-07-26-11:34: header count stays the FULL group size; only the rendered slice is windowed.
const sectionWindow = listSectionWindows[column] ?? { tasks: columnTasks, hiddenCount: 0 };
const windowedTasks = sectionWindow.tasks;
const hiddenTaskCount = sectionWindow.hiddenCount;
const isCollapsed = collapsedSections.has(column);
return (
@@ -2909,7 +3010,7 @@ export function ListView({
</td>
</tr>
) : (
columnTasks.map((task) => {
windowedTasks.map((task) => {
const isDoneColumn = isCompleteColumn(task.column);
const visualStatus = isDoneColumn ? "done" : task.status;
const isFailed = !isDoneColumn && task.status === "failed" && !hasPendingAutomaticRecovery(task, lastFetchTimeMs);
@@ -3084,6 +3185,22 @@ export function ListView({
);
})
)}
{hiddenTaskCount > 0 && (
<tr className="list-section-load-more-row">
<td colSpan={visibleColumns.size + (bulkEditEnabled ? 1 : 0)} className="list-section-load-more">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => handleLoadMoreSection(column, windowedTasks.length)}
>
{t("column.loadMore", "Load {{count}} more ({{remaining}} remaining)", {
count: Math.min(LIST_SECTION_VISIBLE_INCREMENT, hiddenTaskCount),
remaining: hiddenTaskCount,
})}
</button>
</td>
</tr>
)}
</>
)}
</Fragment>

View File

@@ -37,6 +37,20 @@ import {
/** ACK cadence — ACK roughly every 32KB of consumed output. */
const ACK_THRESHOLD_BYTES = 32 * 1024;
/*
FNXC:Terminal 2026-07-26-11:30:
Mobile browsers (iOS Safari tab, iOS installed PWA, Chrome Android) DISCARD a backgrounded tab under memory pressure, costing the user a full white-splash reload on return. xterm retains its whole scrollback ring in JS memory, so this ring is one of the larger allocations this view holds. That made 2000 lines look like a free win.
FNXC:Terminal 2026-07-26-14:05 (CORRECTION — do not restore the 2000-line value on the old reasoning):
The 2000-line cut above was justified with "the server-side replay on re-attach remains the authority for older history". THAT WAS FALSE, in two ways, and the wrong reasoning must not be reintroduced:
1. The server ring is NOT unbounded and is NOT larger than the client ring. It is `DEFAULT_SCROLLBACK_BYTES = 512 * 1024` BYTES in `packages/engine/src/cli-agent/session-manager.ts` — roughly 6000-7000 typical 80-column lines, i.e. LESS than the 10000-line client ring it was supposed to back-stop.
2. More importantly, server replay only happens AT ATTACH TIME (`cli-session-ws.ts` sends one `scrollback` frame on connect). While a session stays attached, the client ring is the ONLY history the user can scroll back through — nothing re-fetches evicted lines. So every line evicted past the cap is permanently unreachable, not merely "not cached locally".
Concretely: an agent session emitting ~4000 lines of build output loses the first compile error at 2000. Restored to the pre-cut 10000, which sits at/above what the server could replay anyway, so the ring is genuinely the user-reachable history and not a redundant copy of it.
Keep in step with TerminalModal's TERMINAL_SCROLLBACK_LINES (duplicated rather than shared so neither terminal surface pulls the other's heavy module into its lazy chunk). Note the two surfaces have DIFFERENT server rings — TerminalModal's is far smaller (50000 characters) — so the values are kept in step for maintenance, not because the backing store is the same.
The WebGL-context disposal below is the part of the memory work that was sound; it stays.
*/
const TERMINAL_SCROLLBACK_LINES = 10000;
const RESIZE_DEBOUNCE_MS = 100;
/**
@@ -194,6 +208,11 @@ export function SessionTerminal({
const containerRef = useRef<HTMLDivElement | null>(null);
const xtermRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<ITerminalAddon | null>(null);
/*
FNXC:Terminal 2026-07-26-11:32:
The WebGL renderer owns a real GL context plus glyph atlas textures. GL contexts are a scarce process-wide resource that GC does not release promptly, and unreleased ones are a known source of iOS memory pressure — which is what makes the OS discard the backgrounded tab. Hold the addon so teardown disposes it EXPLICITLY before term.dispose(), instead of relying on xterm's AddonManager or the onContextLoss handler to get there.
*/
const webglAddonRef = useRef<ITerminalAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [postureTooltipOpen, setPostureTooltipOpen] = useState(false);
@@ -457,7 +476,7 @@ export function SessionTerminal({
cursorBlink: terminalPreferences.cursorBlink && ticketCanAcceptInput,
cursorStyle: terminalPreferences.cursorStyle,
disableStdin: !ticketCanAcceptInput,
scrollback: 10000,
scrollback: TERMINAL_SCROLLBACK_LINES,
// Defensive: do NOT register an OSC 52 (clipboard-write) handler. The
// server-side neutralizer (U10) strips it; we add no client handling.
fontFamily: resolvedFontFamily,
@@ -503,8 +522,10 @@ export function SessionTerminal({
} catch {
/* fall back to DOM renderer */
}
if (webglAddonRef.current === webgl) webglAddonRef.current = null;
});
term.loadAddon(webgl);
webglAddonRef.current = webgl;
}
} catch {
/* WebGL unavailable — DOM renderer is the default fallback */
@@ -701,6 +722,18 @@ export function SessionTerminal({
}
wsRef.current = null;
}
/*
FNXC:Terminal 2026-07-26-11:35:
Dispose the WebGL addon explicitly BEFORE the terminal, so the GL context is released deterministically on teardown rather than left to xterm's AddonManager and GC. See webglAddonRef.
*/
if (webglAddonRef.current) {
try {
webglAddonRef.current.dispose();
} catch {
/* already disposed (e.g. by onContextLoss) */
}
webglAddonRef.current = null;
}
const term = xtermRef.current;
if (term) {
try {

View File

@@ -32,6 +32,7 @@ import { plannerOverseerBadgeTooltip, plannerOverseerStateLabel } from "./planne
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
import { useTaskDiffStats } from "../hooks/useTaskDiffStats";
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
import { useLiveTimeTicker } from "../hooks/useLiveTimeTicker";
import { isTaskStuck } from "../utils/taskStuck";
import { hasPendingAutomaticRecovery, isTaskManuallyRetryable } from "../utils/taskRecovery";
import { getRevertOfId, isTaskReverted } from "../utils/taskRevert";
@@ -328,7 +329,8 @@ const TIME_INDICATOR_COLUMNS = new Set<ColumnId>([
"in-review",
"done",
]);
const LIVE_TIME_INDICATOR_POLL_MS = 30_000;
// FNXC:BoardPerformance 2026-07-26-09:48: LIVE_TIME_INDICATOR_POLL_MS now lives with the shared
// ticker (`hooks/useLiveTimeTicker`) so the cadence and the single timer that honors it cannot drift.
/*
FNXC:TaskCardStatus 2026-07-31-00:00:
@@ -990,7 +992,6 @@ function TaskCardComponent({
const [isPrCreateOpen, setIsPrCreateOpen] = useState(false);
const [isAddressingPrFeedback, setIsAddressingPrFeedback] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [timeIndicatorNowMs, setTimeIndicatorNowMs] = useState(() => Date.now());
const [lifecycleNowMs, setLifecycleNowMs] = useState(() => Date.now());
/*
@@ -1557,38 +1558,47 @@ function TaskCardComponent({
const showProgressSection =
unifiedProgress.total > 0 && (task.status === "executing" || task.column === "in-progress");
useEffect(() => {
/*
FNXC:BoardPerformance 2026-07-26-09:46:
This card used to own a `window.setInterval` for its live elapsed-time indicator, so a 60-card board
ran 60 independent 30s timers that kept waking the tab even while backgrounded. Mobile browsers
(iOS Safari, iOS PWA, Chrome Android) discard a backgrounded page that never goes idle, which is
what produced the white-splash reload operators saw on returning to the dashboard. The card now
DERIVES whether it needs a live indicator and subscribes to the single shared ticker in
`useLiveTimeTicker` (one interval process-wide, suspended while hidden, immediate tick on return).
Cards that are ineligible must NOT subscribe: eligibility is exactly the set of early-returns the
old effect used, so cadence, formatting, and which cards animate are unchanged.
*/
const wantsLiveTimeIndicator = useMemo(() => {
if (task.column !== "in-progress" && task.column !== "in-review") {
return;
return false;
}
const merging = task.status != null && ACTIVE_MERGE_STATUSES.has(task.status);
const nowMs = Date.now();
if (task.column === "in-progress") {
const endToEndMs = getTaskEndToEndDurationMs(task, Date.now());
const elapsedMs = getInProgressElapsedMs(task, Date.now());
const instrumentedMs = getInstrumentedDurationMs(task, Date.now());
const endToEndMs = getTaskEndToEndDurationMs(task, nowMs);
const elapsedMs = getInProgressElapsedMs(task, nowMs);
const instrumentedMs = getInstrumentedDurationMs(task, nowMs);
if (endToEndMs == null && elapsedMs == null && instrumentedMs == null) {
return;
return false;
}
}
if (!merging && task.column === "in-review") {
const endToEndMs = getTaskEndToEndDurationMs(task, Date.now());
const instrumentedMs = getInstrumentedDurationMs(task, Date.now());
const endToEndMs = getTaskEndToEndDurationMs(task, nowMs);
const instrumentedMs = getInstrumentedDurationMs(task, nowMs);
if (endToEndMs == null && instrumentedMs == null) {
return;
return false;
}
}
setTimeIndicatorNowMs(Date.now());
const interval = window.setInterval(() => {
setTimeIndicatorNowMs(Date.now());
}, LIVE_TIME_INDICATOR_POLL_MS);
return () => window.clearInterval(interval);
return true;
}, [task.column, task.status, task.columnMovedAt, task.updatedAt, task.workflowStepResults, task.timedExecutionMs, task.firstExecutionAt, task.cumulativeActiveMs, task.executionStartedAt, task.executionCompletedAt]);
const timeIndicatorNowMs = useLiveTimeTicker(wantsLiveTimeIndicator);
const timeIndicator = useMemo(() => {
if (!TIME_INDICATOR_COLUMNS.has(task.column)) {
return null;

View File

@@ -70,6 +70,19 @@ const XTERM_INIT_TIMEOUT_MS = 10000;
const XTERM_IMPORT_RETRY_DELAYS_MS = [500, 1500, 3000] as const;
/*
FNXC:Terminal 2026-07-26-11:05:
Mobile browsers (iOS Safari tab, iOS installed PWA, Chrome Android) DISCARD a backgrounded tab when its resident set is large, and the user then pays a full white-splash reload on return. xterm's scrollback ring is retained verbatim in JS memory (line buffers, not just rendered rows), so this ring x a wide viewport is one of the larger single allocations the dashboard holds. That made 2000 lines look like a free win.
FNXC:Terminal 2026-07-26-14:05 (CORRECTION — do not restore the 2000-line value on the old reasoning):
The 2000-line cut above was justified with "the PTY's own server-side scrollback is replayed on reconnect anyway, so the reachable history is unchanged". THAT WAS FALSE. The server ring is `MAX_SCROLLBACK_SIZE = 50000` in `packages/dashboard/src/terminal-service.ts`, and the unit is CHARACTERS, not lines: the buffer is a plain string that is `slice(-50000)`d on every append, and `server.ts` replays exactly that truncated string on reconnect. 50000 characters is only ~600-800 typical terminal lines — the server holds STRICTLY LESS history than even the 2000-line client ring, so it can never back-stop it.
Consequence of the false claim: a build emitting ~4000 lines used to let the user scroll back to the first compile error; at 2000 lines that error was evicted from the client ring and unreachable from the server too. Restored to the pre-cut 5000.
This ring is therefore the AUTHORITATIVE user-reachable history for this surface, not a local cache of something the server also has. Any future reduction has to be argued against 50000 characters of server replay, not against an imagined larger server buffer.
Keep this value in step with SessionTerminal's TERMINAL_SCROLLBACK_LINES (duplicated rather than shared so neither terminal surface pulls the other's heavy module into its lazy chunk). Note the two surfaces have DIFFERENT server rings — the CLI-agent one is 512 KiB — so they are kept in step for maintenance, not because the backing store is the same.
The WebGL-context disposal in disposeXtermInstance is the part of the memory work that was sound; it stays.
*/
const TERMINAL_SCROLLBACK_LINES = 5000;
export type TerminalDisplayMode = "docked" | "floating" | "below";
export const TERMINAL_DISPLAY_MODE_STORAGE_PREFIX = "fusion:terminal-display-mode-";
@@ -605,6 +618,11 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
const overlayMouseDownRef = useRef(false);
const xtermRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<ITerminalAddon | null>(null);
/*
FNXC:Terminal 2026-07-26-11:10:
The WebGL renderer holds a real GL context plus its glyph atlas textures. A GL context that is dropped without an explicit dispose() is a well-known source of memory pressure on iOS (contexts are a scarce, process-wide resource and are not released promptly by GC), and memory pressure is what makes the OS discard the backgrounded tab. Hold the addon so every teardown path disposes it EXPLICITLY before terminal.dispose(), instead of relying on xterm's AddonManager or the onContextLoss handler to get there.
*/
const webglAddonRef = useRef<ITerminalAddon | null>(null);
const hasInitialCommandRun = useRef<string | false>(false);
const pendingInitialCommandRef = useRef<{ command: string; commandKey: string; sessionId: string } | null>(null);
const creatingInitialCommandTabRef = useRef(false);
@@ -645,6 +663,40 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
terminalPreferencesRef.current = terminalPreferences;
resolvedFontFamilyRef.current = resolvedFontFamily;
/**
* Release the live xterm instance and everything whose lifetime is tied to it.
*
* FNXC:Terminal 2026-07-26-11:15:
* Four call sites (session/project switch, modal close, session-invalid swap, manual reinit) plus the new unmount teardown all have to release the SAME set of resources: the WebGL addon's GL context, the terminal (scrollback ring + DOM/canvas layers), the fit addon, and the window resize listener bound to that instance. They had drifted into four hand-copied blocks, none of which disposed the WebGL addon. Any one of them missing a resource leaves a GL context or a multi-megabyte scrollback buffer resident, which is exactly the memory pressure that makes mobile browsers discard the backgrounded tab. Single helper so a new teardown path cannot forget one.
* Refs only — callers still own their own React state resets, which differ per path.
*/
const disposeXtermInstance = useCallback(() => {
// WebGL first: dispose the renderer while its terminal is still alive so the
// addon can detach cleanly, then drop the GL context reference.
if (webglAddonRef.current) {
try {
webglAddonRef.current.dispose();
} catch {
/* already disposed (e.g. by onContextLoss) */
}
webglAddonRef.current = null;
}
if (xtermRef.current) {
try {
xtermRef.current.dispose();
} catch {
/* already disposed */
}
xtermRef.current = null;
}
fitAddonRef.current = null;
xtermInitializedRef.current = false;
if (windowResizeListenerRef.current) {
window.removeEventListener("resize", windowResizeListenerRef.current);
windowResizeListenerRef.current = null;
}
}, []);
useEffect(() => {
setDisplayModeState(readTerminalDisplayMode(projectId));
setDockedHeight(readTerminalDockedHeight(projectId));
@@ -1551,14 +1603,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
// Clean up existing xterm if switching sessions/projects or if DOM was cleared
if (xtermRef.current && (xtermInitializedRef.current !== currentSessionId || projectChanged)) {
xtermRef.current.dispose();
xtermRef.current = null;
fitAddonRef.current = null;
xtermInitializedRef.current = false;
if (windowResizeListenerRef.current) {
window.removeEventListener("resize", windowResizeListenerRef.current);
windowResizeListenerRef.current = null;
}
disposeXtermInstance();
setXtermReady(false);
setXtermInitError(null);
}
@@ -1616,7 +1661,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
white: "#d4d4d4",
},
allowProposedApi: true,
scrollback: 5000,
scrollback: TERMINAL_SCROLLBACK_LINES,
});
// Load addons
@@ -1637,8 +1682,10 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
const webglAddon = new WebglAddon();
webglAddon.onContextLoss(() => {
webglAddon.dispose();
if (webglAddonRef.current === webglAddon) webglAddonRef.current = null;
});
terminal.loadAddon(webglAddon);
webglAddonRef.current = webglAddon;
} catch {
// WebGL not available, fallback to canvas
}
@@ -1857,10 +1904,12 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
clearTimeout(watchdogTimer);
}
// Don't dispose xterm here - it should persist across tab switches
// Only dispose when the modal is fully closed
/*
FNXC:Terminal 2026-07-26-11:20:
Deliberately NOT disposing here. This effect re-runs on every terminal-tab / session change, and the instance must survive a tab switch (the body above disposes+recreates only when the session actually changed). Release is owned by the close effect, the session-invalid swap, manual reinit, and the unmount teardown below — never by this cleanup.
*/
};
}, [fitAndResizeForSession, isOpen, isReady, activeTab?.sessionId, projectId, remeasureAfterTerminalFontLoad]);
}, [disposeXtermInstance, fitAndResizeForSession, isOpen, isReady, activeTab?.sessionId, projectId, remeasureAfterTerminalFontLoad]);
// (Input forwarding + window resize listener are wired inside initTerminal
// so they share the xterm instance's lifetime — see comment there.)
@@ -1868,6 +1917,12 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
// FNXC:Terminal 2026-06-22-09:00: Run any active drag teardown when the component unmounts mid-drag so document pointer listeners + the pending docked-resize rAF never outlive the modal.
useEffect(() => () => dragTeardownRef.current?.(), []);
/*
FNXC:Terminal 2026-07-26-11:25:
Unmount teardown. The close-cleanup effect below is keyed on `isOpen` and has no cleanup function, so an unmount (project switch, or App unmounting the modal now that it is mounted only while open) left the xterm, its scrollback ring, and the WebGL context reachable-but-orphaned until GC happened to run. Mobile browsers discard a backgrounded tab on memory pressure, and a GL context is not released promptly by GC — so "the collector will get to it" is not good enough here. Release synchronously on unmount.
*/
useEffect(() => () => disposeXtermInstance(), [disposeXtermInstance]);
// Cleanup xterm when modal closes
useEffect(() => {
if (isOpen) return;
@@ -1876,16 +1931,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
dragTeardownRef.current?.();
// Modal is closed - cleanup xterm
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
}
fitAddonRef.current = null;
xtermInitializedRef.current = false;
if (windowResizeListenerRef.current) {
window.removeEventListener("resize", windowResizeListenerRef.current);
windowResizeListenerRef.current = null;
}
disposeXtermInstance();
setXtermReady(false);
setXtermInitError(null);
hasInitialCommandRun.current = false;
@@ -1896,7 +1942,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
setShowShortcuts(false);
setShowPreferences(false);
setStickyModifier(null);
}, [isOpen]);
}, [disposeXtermInstance, isOpen]);
// Subscribe to terminal data.
// Depends on `xtermReady` so subscriptions are established after the
@@ -2262,16 +2308,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
hasInitialCommandRun.current = false;
// Dispose current xterm so the init effect re-runs with the new session
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
}
fitAddonRef.current = null;
xtermInitializedRef.current = false;
if (windowResizeListenerRef.current) {
window.removeEventListener("resize", windowResizeListenerRef.current);
windowResizeListenerRef.current = null;
}
disposeXtermInstance();
setXtermReady(false);
setXtermInitError(null);
@@ -2280,7 +2317,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
});
});
return unsub;
}, [onSessionInvalid, replaceActiveTabSession]);
}, [disposeXtermInstance, onSessionInvalid, replaceActiveTabSession]);
// Overlay dismiss — track mousedown source so a click that starts on the
// modal but releases on the overlay (e.g. when dragging the resize grip
@@ -2327,20 +2364,11 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
// Used when xterm initialization fails/stalls but the backend session is fine.
const handleReinitialize = useCallback(() => {
// Dispose any partially-initialized xterm
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
}
fitAddonRef.current = null;
xtermInitializedRef.current = false;
if (windowResizeListenerRef.current) {
window.removeEventListener("resize", windowResizeListenerRef.current);
windowResizeListenerRef.current = null;
}
disposeXtermInstance();
// Clear error state and reset readiness so the init effect re-runs
setXtermInitError(null);
setXtermReady(false);
}, []);
}, [disposeXtermInstance]);
const handleRefreshPage = useCallback(() => {
window.location.reload();

View File

@@ -2618,6 +2618,15 @@ describe("App view switching", () => {
});
first.unmount();
/*
* FNXC:ViewState 2026-07-26-12:56:
* Each render here stands for a separate BOOT, not a remount of the same tab. useViewState now
* keeps a per-tab sessionStorage copy of the live view so an involuntary mobile tab discard
* restores where the operator actually was; jsdom shares one session store across the whole test,
* so a boot must start from a cleared one or the previous render's view wins over the localStorage
* value this step is asserting on.
*/
sessionStorage.clear();
localStorage.setItem(taskViewStorageKey(), "board");
const second = render(<App />);
await waitFor(() => {
@@ -2625,6 +2634,8 @@ describe("App view switching", () => {
});
second.unmount();
// Third boot — same fresh-tab reset as above.
sessionStorage.clear();
localStorage.setItem(taskViewStorageKey(), "plugin:fusion-plugin-dependency-graph:graph");
(fetchPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{

View File

@@ -732,23 +732,28 @@ describe("Column pagination", () => {
});
});
it("disables pagination when isSearchActive is true, showing all tasks", () => {
/*
FNXC:BoardColumnWindowing 2026-07-26-12:30:
These two cases previously pinned the OLD contract (search disables pagination, render every match).
That escape hatch was unbounded and is deliberately gone: `tasks` arrives already search-filtered, so
paginating search results still shows matches while keeping the mounted TaskCard count bounded — the
resident set is what makes mobile browsers discard the backgrounded tab.
*/
it("paginates even when isSearchActive is true", () => {
const tasks = Array.from({ length: 110 }, (_, index) => makeTask(`KB-${String(index + 1).padStart(3, "0")}`));
render(<Column {...defaultProps} column="todo" tasks={tasks} isSearchActive={true} />);
// All 110 tasks should be visible — no pagination applied during active search
expect(screen.getAllByTestId(/task-/)).toHaveLength(110);
expect(screen.queryByRole("button", { name: /Load 25 more/i })).toBeNull();
expect(screen.getAllByTestId(/task-/)).toHaveLength(50);
expect(screen.getByRole("button", { name: /Load 25 more/i })).toBeTruthy();
});
it("restores pagination when isSearchActive changes back to false", () => {
it("collapses the window back to one screenful when isSearchActive changes back to false", () => {
const tasks = Array.from({ length: 110 }, (_, index) => makeTask(`KB-${String(index + 1).padStart(3, "0")}`));
const { rerender } = render(<Column {...defaultProps} column="todo" tasks={tasks} isSearchActive={true} />);
// All tasks visible during search
expect(screen.getAllByTestId(/task-/)).toHaveLength(110);
expect(screen.getAllByTestId(/task-/)).toHaveLength(50);
// Search cleared — pagination resumes
// Search cleared — the result set changed, so the window resets to the initial page.
rerender(<Column {...defaultProps} column="todo" tasks={tasks} isSearchActive={false} />);
expect(screen.getAllByTestId(/task-/)).toHaveLength(50);

View File

@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { ListView } from "../ListView";
import type { MergeResult, Task } from "@fusion/core";
import { scopedKey } from "../../utils/projectStorage";
/*
FNXC:ListViewWindowing 2026-07-26-11:52:
Regression coverage for the List-view render window. Mobile browsers reclaim a backgrounded tab whose
resident set is large, which operators experience as a white-splash reload on return; rendering every
task row of a large project at once was a direct contributor. These tests pin the invariants the
window must not break — the filter runs over the FULL set with the window applied after, grouping and
section counts stay whole, and id-based selection survives a task falling outside the window.
*/
vi.mock("../../api", () => ({
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
fetchSettings: vi.fn().mockResolvedValue({}),
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
fetchTaskDetail: vi.fn(),
batchUpdateTaskModels: vi.fn(),
fetchNodes: vi.fn(() => new Promise(() => {})),
fetchBoardWorkflows: vi.fn(() => new Promise(() => {})),
rebuildTaskSpec: vi.fn().mockResolvedValue({}),
refreshPrStatus: vi.fn().mockResolvedValue({}),
updateTask: vi.fn(),
api: vi.fn().mockResolvedValue({ sessions: [] }),
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: () => () => {},
}));
vi.mock("../QuickEntryBox", () => ({
QuickEntryBox: () => <div data-testid="quick-entry-box" />,
}));
vi.mock("../TaskDetailModal", () => ({
TaskDetailContent: ({ task }: { task: { id: string } }) => (
<div data-testid="task-detail-content">{task.id}</div>
),
}));
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: vi.fn(), confirmWithChoice: vi.fn() }),
}));
const PROJECT_ID = "proj-windowing";
const TOTAL_TASKS = 200;
const INITIAL_WINDOW = 50;
const INCREMENT = 25;
function makeTask(index: number): Task {
const id = `FN-${String(index).padStart(3, "0")}`;
return {
id,
// Only one task carries the needle so search can be proven to reach past the window.
title: index === 190 ? "Needle far outside the window" : `Task ${id}`,
description: `Description for ${id}`,
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
status: "pending",
paused: false,
log: [],
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
} as Task;
}
// Sorted ascending by numeric id in the Todo column, so FN-190 is deterministically at index 189 —
// far outside the initial 50-row window.
const TASKS: Task[] = Array.from({ length: TOTAL_TASKS }, (_, i) => makeTask(i + 1));
const FAR_TASK_ID = "FN-190";
function renderList(props: Partial<React.ComponentProps<typeof ListView>> = {}) {
return render(
<ListView
tasks={TASKS}
onMoveTask={vi.fn(async () => TASKS[0])}
onDeleteTask={vi.fn(async () => TASKS[0])}
onMergeTask={vi.fn(async () => ({ merged: false }) as unknown as MergeResult)}
onOpenDetail={vi.fn()}
addToast={vi.fn()}
projectId={PROJECT_ID}
searchQuery=""
{...props}
/>,
);
}
function renderedTaskIds(): string[] {
return Array.from(document.querySelectorAll<HTMLElement>("[data-id]"))
.map((el) => el.dataset.id ?? "")
.filter((id) => id.startsWith("FN-"));
}
beforeEach(() => {
localStorage.clear();
});
describe("ListView render windowing", () => {
it("renders only the initial window of a large section, not every task", () => {
renderList();
expect(renderedTaskIds()).toHaveLength(INITIAL_WINDOW);
// The section header still reports the FULL group size — grouping is preserved.
expect(screen.getByText(String(TOTAL_TASKS))).toBeTruthy();
expect(screen.getByRole("button", { name: /Load 25 more/i })).toBeTruthy();
});
it("reveals the next increment when Load more is clicked", () => {
renderList();
act(() => {
fireEvent.click(screen.getByRole("button", { name: /Load 25 more/i }));
});
expect(renderedTaskIds()).toHaveLength(INITIAL_WINDOW + INCREMENT);
act(() => {
fireEvent.click(screen.getByRole("button", { name: /Load 25 more/i }));
});
expect(renderedTaskIds()).toHaveLength(INITIAL_WINDOW + INCREMENT * 2);
});
it("filters against the full set, so a match beyond the window is still found", () => {
renderList({ searchQuery: "Needle" });
const ids = renderedTaskIds();
expect(ids).toEqual([FAR_TASK_ID]);
// A single match needs no paging affordance.
expect(screen.queryByRole("button", { name: /Load \d+ more/i })).toBeNull();
});
it("keeps a selected task outside the window selected and visible", () => {
localStorage.setItem(scopedKey("kb-dashboard-list-selected-task", PROJECT_ID), FAR_TASK_ID);
localStorage.setItem(
scopedKey("kb-dashboard-selected-tasks", PROJECT_ID),
JSON.stringify([FAR_TASK_ID]),
);
renderList();
// Selection state is id-based and untouched by the window.
expect(
JSON.parse(localStorage.getItem(scopedKey("kb-dashboard-selected-tasks", PROJECT_ID)) ?? "[]"),
).toContain(FAR_TASK_ID);
expect(localStorage.getItem(scopedKey("kb-dashboard-list-selected-task", PROJECT_ID))).toBe(FAR_TASK_ID);
// ...and the window is widened so the persisted single selection is still rendered.
expect(renderedTaskIds()).toContain(FAR_TASK_ID);
});
});

View File

@@ -0,0 +1,139 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, act, cleanup } from "@testing-library/react";
import {
LIVE_TIME_INDICATOR_POLL_MS,
isLiveTimeTickerRunning,
liveTimeTickerSubscriberCount,
useLiveTimeTicker,
} from "../../hooks/useLiveTimeTicker";
/*
FNXC:BoardPerformance 2026-07-26-09:55:
Regression coverage for the mobile tab-discard fix: every rendered TaskCard used to own a 30s
`setInterval`, so a full board kept a backgrounded tab busy and iOS Safari / iOS PWA / Chrome Android
discarded the page (white-splash reload on return). The invariant asserted here is the shared
ticker's contract, not one card's behavior:
1. N subscribers => exactly ONE interval.
2. No ticks while `document.visibilityState === "hidden"`.
3. An immediate tick on hidden -> visible, so indicators are never stale on return.
4. Zero subscribers => no interval at all.
The subscriber is a minimal component calling `useLiveTimeTicker`, exercising the same seam TaskCard
uses; rendering real TaskCards would only add cost, not signal, since the timer no longer lives there.
*/
function setVisibility(state: "visible" | "hidden") {
Object.defineProperty(document, "visibilityState", { configurable: true, value: state });
document.dispatchEvent(new Event("visibilitychange"));
}
/** Stand-in for a TaskCard that qualifies for a live elapsed-time indicator. */
function TickerCard({ enabled = true }: { enabled?: boolean }) {
const nowMs = useLiveTimeTicker(enabled);
return <div data-testid="card">{nowMs}</div>;
}
let setIntervalSpy: ReturnType<typeof vi.spyOn>;
let clearIntervalSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
vi.useFakeTimers();
setIntervalSpy = vi.spyOn(window, "setInterval");
clearIntervalSpy = vi.spyOn(window, "clearInterval");
});
afterEach(() => {
cleanup();
setIntervalSpy.mockRestore();
clearIntervalSpy.mockRestore();
vi.useRealTimers();
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
});
describe("shared live-time ticker", () => {
it("creates ONE interval for N mounted cards, not N", () => {
const view = render(
<>
<TickerCard />
<TickerCard />
<TickerCard />
<TickerCard />
<TickerCard />
</>,
);
expect(liveTimeTickerSubscriberCount()).toBe(5);
expect(setIntervalSpy).toHaveBeenCalledTimes(1);
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), LIVE_TIME_INDICATOR_POLL_MS);
expect(isLiveTimeTickerRunning()).toBe(true);
view.unmount();
});
it("does not subscribe cards that opt out of the live indicator", () => {
const view = render(
<>
<TickerCard enabled={false} />
<TickerCard enabled={false} />
</>,
);
expect(liveTimeTickerSubscriberCount()).toBe(0);
expect(isLiveTimeTickerRunning()).toBe(false);
expect(setIntervalSpy).not.toHaveBeenCalled();
view.unmount();
});
it("stops ticking while the tab is hidden and ticks immediately on becoming visible", () => {
const view = render(<TickerCard />);
const card = view.getByTestId("card");
const initial = card.textContent;
// Visible: the ticker advances on cadence.
act(() => {
vi.advanceTimersByTime(LIVE_TIME_INDICATOR_POLL_MS);
});
const afterVisibleTick = card.textContent;
expect(afterVisibleTick).not.toBe(initial);
// Hidden: the interval is torn down, and no amount of elapsed time ticks.
act(() => {
setVisibility("hidden");
});
expect(isLiveTimeTickerRunning()).toBe(false);
expect(clearIntervalSpy).toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(LIVE_TIME_INDICATOR_POLL_MS * 10);
});
expect(card.textContent).toBe(afterVisibleTick);
// Visible again: tick IMMEDIATELY, without waiting a full period.
act(() => {
setVisibility("visible");
});
expect(card.textContent).not.toBe(afterVisibleTick);
expect(isLiveTimeTickerRunning()).toBe(true);
view.unmount();
});
it("clears the interval when the last subscriber unmounts", () => {
const view = render(
<>
<TickerCard />
<TickerCard />
</>,
);
expect(isLiveTimeTickerRunning()).toBe(true);
view.rerender(<TickerCard />);
expect(liveTimeTickerSubscriberCount()).toBe(1);
expect(isLiveTimeTickerRunning()).toBe(true);
view.unmount();
expect(liveTimeTickerSubscriberCount()).toBe(0);
expect(isLiveTimeTickerRunning()).toBe(false);
expect(clearIntervalSpy).toHaveBeenCalled();
});
});

View File

@@ -28,6 +28,7 @@ import { ReliabilityView } from "../ReliabilityView";
import { NodesView } from "../NodesView";
import type { ToastType } from "../../hooks/useToast";
import type { TaskView } from "../../hooks/useViewState";
import { useVisibilityAwarePoll } from "../../hooks/visibilitySuspension";
import { SdlcFunnel } from "./SdlcFunnel";
import { inferProviderIconKey } from "../../utils/providerIconKey";
import { Bar, type BarDatum } from "./charts/Bar";
@@ -174,16 +175,29 @@ function OverviewTab({
const [codebaseMetrics, setCodebaseMetrics] = useState<CodebaseMetrics | null>(null);
const [verificationRequests, setVerificationRequests] = useState<TaskVerificationRequest[]>([]);
useEffect(() => {
let cancelled = false;
const refresh = () => void api<{ requests: TaskVerificationRequest[] }>(withProjectId("/command-center/verification-requests", projectId))
.then((response) => { if (!cancelled) setVerificationRequests(response.requests); })
.catch(() => { if (!cancelled) setVerificationRequests([]); });
refresh();
const timer = window.setInterval(refresh, OVERVIEW_TOKEN_REFRESH_MS);
return () => { cancelled = true; window.clearInterval(timer); };
const verificationRequestsVersionRef = useRef(0);
const refreshVerificationRequests = useCallback(() => {
const versionAtStart = verificationRequestsVersionRef.current;
const isStale = () => verificationRequestsVersionRef.current !== versionAtStart;
void api<{ requests: TaskVerificationRequest[] }>(withProjectId("/command-center/verification-requests", projectId))
.then((response) => { if (!isStale()) setVerificationRequests(response.requests); })
.catch(() => { if (!isStale()) setVerificationRequests([]); });
}, [projectId]);
useEffect(() => {
refreshVerificationRequests();
return () => { verificationRequestsVersionRef.current += 1; };
}, [refreshVerificationRequests]);
/*
FNXC:MobileTabRetention 2026-07-26-11:32:
Verification-request polling is suspended while the document is hidden. The Overview surface kept this
request in flight every refresh cycle in the background, and continuous background network work is a
primary reason iOS Safari/PWA and Chrome Android discard the tab, producing the white-splash reload
operators saw on return. One refresh fires on the hidden -> visible edge.
*/
useVisibilityAwarePoll(refreshVerificationRequests, OVERVIEW_TOKEN_REFRESH_MS);
useEffect(() => {
let cancelled = false;
setCodebaseMetrics(null);

View File

@@ -129,7 +129,16 @@ export function useLiveSnapshot(projectId?: string): LiveSnapshotState {
// Re-evaluate polling against the freshest snapshot after every fetch.
// "In-flight" = any active session or run. Idle → no interval exists.
const snap = snapshotRef.current;
const inFlight = !!snap && (snap.activeSessions > 0 || snap.activeRuns > 0);
/*
FNXC:MobileTabRetention 2026-07-26-11:40:
The live-snapshot poll is self-managed (it re-arms itself from each response), so the visibility gate
lives here rather than in `useVisibilityAwarePoll`: a hidden document must never re-arm the timer.
A backgrounded page that keeps fetching is a primary iOS/Chrome Android discard signal, and the
discard is the white-splash reload operators saw on return. The visibilitychange handler below calls
`load()` on the hidden -> visible edge, which refreshes once and re-arms polling if work is live.
*/
const documentVisible = typeof document === "undefined" || document.visibilityState !== "hidden";
const inFlight = documentVisible && !!snap && (snap.activeSessions > 0 || snap.activeRuns > 0);
if (inFlight) {
// Start the poll interval iff one is not already running.
if (pollTimerRef.current === null) {
@@ -167,7 +176,21 @@ export function useLiveSnapshot(projectId?: string): LiveSnapshotState {
onReconnect: () => void load(),
});
const handleVisibilityChange = () => {
if (document.visibilityState === "hidden") {
stopPolling();
return;
}
void load();
};
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", handleVisibilityChange);
}
return () => {
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", handleVisibilityChange);
}
unsubscribe();
stopPolling();
};

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { ActivityAnalytics } from "@fusion/core";
import type { DateRange } from "../DateRangePicker";
@@ -8,6 +8,7 @@ import { LineChart as RechartsLineChart, PieChart } from "../charts/recharts";
import { AreaShell } from "./AreaShell";
import { useAnalyticsArea } from "./useAnalyticsArea";
import { formatCount, isInvalidRange } from "./areaShared";
import { useVisibilityAwarePoll } from "../../../hooks/visibilitySuspension";
const ACTIVITY_LIVE_REFRESH_MS = 15_000;
@@ -46,15 +47,14 @@ export function ActivityArea({ range, projectId }: { range: DateRange; projectId
const invalidRange = isInvalidRange(range);
const isInitialLoading = isLoading && data === null;
useEffect(() => {
if (invalidRange) {
return undefined;
}
const interval = window.setInterval(() => {
reload();
}, ACTIVITY_LIVE_REFRESH_MS);
return () => window.clearInterval(interval);
}, [invalidRange, reload]);
/*
FNXC:MobileTabRetention 2026-07-26-11:22:
The Activity live refresh is suspended while the document is hidden. Charts nobody is looking at must not
keep the page busy — sustained background fetching is a primary iOS/Chrome Android discard signal, and the
discard is what forced the white-splash reload on return. One reload fires on the hidden -> visible edge so
the charts are current the moment they are seen.
*/
useVisibilityAwarePoll(reload, ACTIVITY_LIVE_REFRESH_MS, { enabled: !invalidRange });
const agentRuns = data?.agentRuns ?? { total: 0, active: 0, completed: 0, failed: 0 };
const agentRunPieData = useMemo(

View File

@@ -43,6 +43,7 @@ import { ReportActionMenu } from "../../ReportActionMenu";
import { ReportModal } from "../../ReportModal";
import { resolveReportContextRefs } from "../../../utils/reportContextRefs";
import { copyTextToClipboard } from "../../../utils/copyToClipboard";
import { capLogEntries } from "../../../hooks/useAgentLogs";
import "./SystemControlsArea.css";
/*
@@ -70,7 +71,14 @@ FNXC:SystemPanelFnBinary 2026-07-15-09:54:
the shared job log viewer so operators see live output without hunting.
*/
const LOG_VIEW_CAP = 500;
/*
FNXC:MobileTabRetention 2026-07-26-10:48:
Bounded ring for every streamed tail in this panel (server logs AND rebuild job output). A full
workspace rebuild emits many thousands of lines; retaining all of them grows the page's resident set
until a backgrounded mobile tab is discarded by the OS and reloads with a white splash on return.
The joined-string render below is O(kept lines) per frame, so the cap bounds render cost too.
*/
export const LOG_VIEW_CAP = 500;
const RESTART_POLL_MS = 1500;
const BACK_ONLINE_RELOAD_DELAY_MS = 3000;
// Bound the post-restart wait so a server that never comes back (crashed
@@ -110,6 +118,15 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
const [job, setJob] = useState<SystemRebuildJobSnapshot | null>(null);
const [jobLines, setJobLines] = useState<SystemRebuildJobLine[]>([]);
/*
FNXC:MobileTabRetention 2026-07-26-10:52:
Stream dedupe is keyed on the line's monotonic `i` and must stay O(1) per incoming line. The old
Array.some() scan was O(n^2) over a rebuild's thousands of lines, burning CPU in the background —
itself a discard signal on iOS/Chrome Android — on top of the memory growth. The Set is a ref, not
state, so it survives the cap trimming older lines out of the rendered buffer and a trimmed line
can never be re-appended by a stream replay.
*/
const seenJobLineIndexesRef = useRef<Set<number>>(new Set());
const jobOutputRef = useRef<HTMLPreElement | null>(null);
const jobFollowingRef = useRef(true);
const jobSectionRef = useRef<HTMLDivElement | null>(null);
@@ -153,6 +170,7 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
// Adopting a different (resumed) job — clear stale lines so the new
// job's stream doesn't render mixed with the previous job's output.
setJobLines([]);
seenJobLineIndexesRef.current = new Set();
jobFollowingRef.current = true;
return next.activeRebuild;
});
@@ -177,7 +195,13 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
const { job: current } = await fetchCurrentSystemRebuild();
if (!cancelled && current) {
setJob(current);
setJobLines(current.lines ?? []);
// FNXC:MobileTabRetention 2026-07-26-10:55: The buffered hydration payload is the whole
// job so far — keep only the newest LOG_VIEW_CAP lines and seed the dedupe index from them.
const hydrated = current.lines ?? [];
// Seed from ALL hydrated indexes (not just the kept tail) so a stream replay cannot
// re-append a line the cap already trimmed out of the rendered buffer.
seenJobLineIndexesRef.current = new Set(hydrated.map((line) => line.i));
setJobLines(capLogEntries(hydrated, LOG_VIEW_CAP));
}
} catch {
// Best-effort hydration; the live stream still fills a running job.
@@ -196,10 +220,10 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
line: (event) => {
try {
const line = JSON.parse((event as MessageEvent).data) as SystemRebuildJobLine;
setJobLines((current) => {
if (current.some((existing) => existing.i === line.i)) return current;
return [...current, line];
});
// O(1) dedupe on the line's monotonic index, then a bounded append.
if (seenJobLineIndexesRef.current.has(line.i)) return;
seenJobLineIndexesRef.current.add(line.i);
setJobLines((current) => capLogEntries([...current, line], LOG_VIEW_CAP));
} catch {
// Ignore malformed stream payloads.
}
@@ -287,10 +311,8 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
log: (event) => {
try {
const entry = JSON.parse((event as MessageEvent).data) as SystemLogEntryDto;
setLogEntries((current) => {
const next = [...current, entry];
return next.length > LOG_VIEW_CAP ? next.slice(-LOG_VIEW_CAP) : next;
});
// Shared bounded-tail helper (see hooks/useAgentLogs.ts) — one cap implementation.
setLogEntries((current) => capLogEntries([...current, entry], LOG_VIEW_CAP));
} catch {
// Ignore malformed stream payloads.
}
@@ -835,6 +857,22 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
{jobStatusLabel}
</span>
</div>
{/*
FNXC:MobileTabRetention 2026-07-26-11:02:
The rendered buffer is capped at LOG_VIEW_CAP, so a long rebuild's earliest output is
dropped. `i` is the job's monotonic line index: a first kept line with i > 0 proves lines
were trimmed, and the operator must be told rather than read a clipped tail as the whole
build log.
*/}
{(jobLines[0]?.i ?? 0) > 0 ? (
<p className="cc-system-note" data-testid="cc-system-rebuild-output-truncated">
{t(
"systemControls.jobOutputTruncated",
"Showing the most recent {{count}} lines — earlier output trimmed",
{ count: LOG_VIEW_CAP },
)}
</p>
) : null}
<pre ref={jobOutputRef} className="cc-syscontrols-output" aria-live="polite" onScroll={updateJobFollowState}>
{jobLines.map((line) => `${line.stream === "stderr" ? "! " : ""}${line.text}`).join("\n")}
</pre>

View File

@@ -11,6 +11,7 @@ import {
type SystemStatsResponse,
} from "../../../api";
import { useNodes } from "../../../hooks/useNodes";
import { useVisibilityAwarePoll } from "../../../hooks/visibilitySuspension";
import { Bar, type BarDatum } from "../charts/Bar";
import { RadialGauge } from "../charts/RadialGauge";
import { Sparkline } from "../charts/Sparkline";
@@ -166,14 +167,20 @@ export function SystemStatsArea({ projectId }: { projectId?: string }) {
useEffect(() => {
void loadStats();
const timer = window.setInterval(() => {
void loadStats();
}, SYSTEM_STATS_POLL_MS);
return () => {
window.clearInterval(timer);
};
}, [loadStats]);
/*
FNXC:MobileTabRetention 2026-07-26-11:26:
System telemetry sampling is suspended while the document is hidden. This poll both fetches and appends a
rolling sample every cycle, so in the background it burned network AND grew memory — the two conditions
that make iOS Safari/PWA and Chrome Android discard the tab and reload it white-splash on return. Sampling
resumes with one immediate reading when the tab becomes visible; the gap in the rolling series is expected.
*/
const pollSystemStats = useCallback(() => {
void loadStats();
}, [loadStats]);
useVisibilityAwarePoll(pollSystemStats, SYSTEM_STATS_POLL_MS);
useEffect(() => {
setSelectedNodeId((current) => (current && nodes.some((node) => node.id === current) ? current : null));
}, [nodes]);

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { api, withProjectId } from "../../../api/legacy";
import type { DateRange } from "../DateRangePicker";
import { isInvalidRange, rangeQuery } from "./areaShared";
import { useVisibilityAwarePoll } from "../../../hooks/visibilitySuspension";
export interface AnalyticsAreaState<T> {
data: T | null;
@@ -84,15 +85,14 @@ export function useAnalyticsArea<T>(
void load();
}, [load]);
useEffect(() => {
if (invalid || options.pollMs === undefined) {
return undefined;
}
const interval = window.setInterval(() => {
void load();
}, options.pollMs);
return () => window.clearInterval(interval);
}, [invalid, load, options.pollMs]);
/*
FNXC:MobileTabRetention 2026-07-26-11:18:
Every Command Center analytics area shares this poll, so leaving it running in the background meant a
backgrounded mobile tab issued several analytics fetches per minute — a primary reason iOS Safari/PWA and
Chrome Android discard the page and force the white-splash reload seen on return. The interval is torn down
while the document is hidden and fires one refresh on the hidden -> visible edge.
*/
useVisibilityAwarePoll(load, options.pollMs ?? 0, { enabled: !invalid && options.pollMs !== undefined });
const reload = useCallback(() => {
void load();

View File

@@ -0,0 +1,313 @@
/*
FNXC:BoardNavigation 2026-07-26-11:05:
Regression coverage for the mobile tab-discard reload: iOS Safari (tab + installed PWA) and Chrome
Android throw away a backgrounded dashboard and reload it when the operator returns, which used to
drop them at the top of the board because the scroll snapshot lived only in a useRef.
The invariant under test is the whole restore path, not just the storage round trip: the snapshot is
written at hide time, replayed only once the reloaded board actually has columns, and never wins over
the in-memory board -> task-detail -> back restore.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import {
captureBoardScrollSnapshot,
persistBoardScrollSnapshot,
readPersistedBoardScrollSnapshot,
} from "../../utils/boardScrollSnapshot";
import type { ProjectInfo } from "../../api";
import { useBoardScrollRestore } from "../useBoardScrollRestore";
import { useViewState } from "../useViewState";
function mountBoard(options: { withColumns: boolean }): void {
document.body.innerHTML = options.withColumns
? `
<div class="project-content">
<main id="board">
<section class="column" data-column="todo"><div class="column-body"></div></section>
<section class="column" data-column="in-progress"><div class="column-body"></div></section>
</main>
</div>
`
: `
<div class="project-content">
<main id="board"></main>
</div>
`;
}
function board(): HTMLElement {
return document.getElementById("board") as HTMLElement;
}
function columnBody(columnId: string): HTMLElement {
return document.querySelector(`[data-column="${columnId}"] .column-body`) as HTMLElement;
}
describe("board scroll restore across a reload", () => {
beforeEach(() => {
vi.useFakeTimers();
window.sessionStorage.clear();
document.body.innerHTML = "";
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
window.sessionStorage.clear();
document.body.innerHTML = "";
});
it("persists the board snapshot on hide and replays it after a simulated reload", () => {
mountBoard({ withColumns: true });
board().scrollLeft = 240;
columnBody("todo").scrollTop = 380;
const first = renderHook(() => useBoardScrollRestore("board"));
// The tab is backgrounded/discarded: pagehide is the last callback we are guaranteed to run.
act(() => {
window.dispatchEvent(new Event("pagehide"));
});
expect(readPersistedBoardScrollSnapshot()).toMatchObject({
boardLeft: 240,
columnTops: { todo: 380, "in-progress": 0 },
});
// Simulated reload: hook state and DOM scroll offsets are gone, sessionStorage is not.
first.unmount();
mountBoard({ withColumns: true });
expect(board().scrollLeft).toBe(0);
renderHook(() => useBoardScrollRestore("board"));
act(() => {
vi.advanceTimersByTime(200);
});
expect(board().scrollLeft).toBe(240);
expect(columnBody("todo").scrollTop).toBe(380);
});
it("does not restore against an empty board, and waits for the board to render", () => {
persistBoardScrollSnapshot({
boardLeft: 240,
boardTop: 0,
columnTops: { todo: 380 },
projectContentLeft: 0,
projectContentTop: 0,
documentLeft: 0,
documentTop: 0,
});
// A freshly reloaded board renders before its first fetch resolves: no columns yet.
mountBoard({ withColumns: false });
renderHook(() => useBoardScrollRestore("board"));
act(() => {
vi.advanceTimersByTime(500);
});
expect(board().scrollLeft).toBe(0);
// Board content arrives; the bounded replay picks it up on a later tick.
mountBoard({ withColumns: true });
act(() => {
vi.advanceTimersByTime(200);
});
expect(board().scrollLeft).toBe(240);
expect(columnBody("todo").scrollTop).toBe(380);
});
it("gives up after the bounded replay budget instead of polling forever", () => {
persistBoardScrollSnapshot({
boardLeft: 240,
boardTop: 0,
columnTops: { todo: 380 },
projectContentLeft: 0,
projectContentTop: 0,
documentLeft: 0,
documentTop: 0,
});
mountBoard({ withColumns: false });
renderHook(() => useBoardScrollRestore("board"));
act(() => {
vi.advanceTimersByTime(10_000);
});
expect(vi.getTimerCount()).toBe(0);
mountBoard({ withColumns: true });
act(() => {
vi.advanceTimersByTime(1000);
});
expect(board().scrollLeft).toBe(0);
});
it("ignores a stale persisted snapshot whose columns no longer exist", () => {
persistBoardScrollSnapshot({
boardLeft: 240,
boardTop: 0,
columnTops: { "column-from-another-project": 380 },
projectContentLeft: 0,
projectContentTop: 0,
documentLeft: 0,
documentTop: 0,
});
mountBoard({ withColumns: true });
renderHook(() => useBoardScrollRestore("board"));
act(() => {
vi.advanceTimersByTime(10_000);
});
expect(board().scrollLeft).toBe(0);
});
it("lets the in-memory back-navigation restore win over the persisted snapshot", () => {
// A stale persisted position from before the operator scrolled again.
persistBoardScrollSnapshot({
boardLeft: 999,
boardTop: 0,
columnTops: { todo: 999 },
projectContentLeft: 0,
projectContentTop: 0,
documentLeft: 0,
documentTop: 0,
});
mountBoard({ withColumns: true });
board().scrollLeft = 120;
columnBody("todo").scrollTop = 40;
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => {
cb(0);
return 0;
});
const { result, rerender } = renderHook(
({ taskView }: { taskView: "board" | "task-detail" }) => useBoardScrollRestore(taskView),
{ initialProps: { taskView: "board" } as { taskView: "board" | "task-detail" } },
);
// Opening task detail captures the live position; back-to-board restores it.
act(() => {
result.current.capture();
rerender({ taskView: "task-detail" });
});
board().scrollLeft = 0;
columnBody("todo").scrollTop = 0;
act(() => {
result.current.requestRestore();
rerender({ taskView: "board" });
vi.advanceTimersByTime(500);
});
expect(board().scrollLeft).toBe(120);
expect(columnBody("todo").scrollTop).toBe(40);
// capture() also refreshes the persisted copy so a discard right now restores the same place.
expect(readPersistedBoardScrollSnapshot()).toMatchObject({ boardLeft: 120 });
});
it("captures nothing to persist when the board is not mounted", () => {
document.body.innerHTML = "";
expect(captureBoardScrollSnapshot()).toBeNull();
renderHook(() => useBoardScrollRestore("board"));
act(() => {
window.dispatchEvent(new Event("pagehide"));
});
expect(readPersistedBoardScrollSnapshot()).toBeNull();
});
});
/*
FNXC:ViewState 2026-07-26-11:30:
Companion coverage for the same discard-restore story on the VIEW axis: a tab that comes back from an
OS discard must keep the view the operator was on (Command Center / Settings included), while a
genuinely fresh boot keeps the FN-7649 bounce to Board.
*/
describe("task view restore across a reload", () => {
const PROJECT: ProjectInfo = {
id: "proj_reload",
name: "Demo",
path: "/demo",
status: "active",
isolationMode: "in-process",
createdAt: "",
updatedAt: "",
};
function options(): Parameters<typeof useViewState>[0] {
return {
projectsLoading: false,
projectsError: null,
currentProjectLoading: false,
currentProject: PROJECT,
projectsLength: 1,
setupWizardOpen: false,
openSetupWizard: vi.fn(),
themeMode: "dark",
setThemeMode: vi.fn(),
};
}
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
});
afterEach(() => {
localStorage.clear();
sessionStorage.clear();
});
it("keeps command-center across a same-tab reload but bounces a fresh boot to board", async () => {
const first = renderHook(() => useViewState(options()));
await waitFor(() => {
expect(first.result.current.taskView).toBe("board");
});
act(() => {
first.result.current.handleChangeTaskView("command-center");
});
await waitFor(() => {
expect(sessionStorage.getItem("kb:proj_reload:kb-dashboard-task-view-session")).toBe("command-center");
});
// Same tab, reloaded/restored: sessionStorage survives.
first.unmount();
const restored = renderHook(() => useViewState(options()));
await waitFor(() => {
expect(restored.result.current.taskView).toBe("command-center");
});
restored.unmount();
// Fresh tab: sessionStorage is not inherited, so the landing guard still applies.
sessionStorage.clear();
const freshBoot = renderHook(() => useViewState(options()));
await waitFor(() => {
expect(freshBoot.result.current.taskView).toBe("board");
});
});
it("does not restore task-detail, whose task snapshot is in-memory only", async () => {
const first = renderHook(() => useViewState(options()));
await waitFor(() => {
expect(first.result.current.taskView).toBe("board");
});
act(() => {
first.result.current.handleChangeTaskView("task-detail");
});
await waitFor(() => {
expect(sessionStorage.getItem("kb:proj_reload:kb-dashboard-task-view-session")).toBe("task-detail");
});
first.unmount();
const restored = renderHook(() => useViewState(options()));
await waitFor(() => {
expect(restored.result.current.taskView).toBe("board");
});
});
});

View File

@@ -0,0 +1,171 @@
/*
FNXC:MobileTabRetention 2026-07-26-11:40:
Regression coverage for the bounded-log-buffer invariant. Mobile browsers discard a backgrounded tab
whose resident set keeps growing, which the operator experiences as a white-splash reload on return;
every live log tail must therefore be a bounded ring that retains the NEWEST entries.
Surface enumeration — the invariant is asserted for every append path changed for this fix, not just
one repro:
- `capLogEntries` itself (the single shared helper the streaming surfaces call),
- `useAgentLogs` live SSE tail (TaskDetailModal per-task log),
- the AgentDetailView / SystemControlsArea tails, which delegate to `capLogEntries` and are covered
through it plus the SystemControlsArea cap constant,
- `appendChatMessageChronologically`, which is deliberately NOT capped (user-visible transcript)
and is asserted to keep every message while still producing chronological order.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { MAX_LOG_ENTRIES, capLogEntries, useAgentLogs } from "../useAgentLogs";
import { appendChatMessageChronologically, type ChatMessageInfo } from "../useChat";
import { LOG_VIEW_CAP } from "../../components/command-center/areas/SystemControlsArea";
import { fetchAgentLogsWithMeta } from "../../api";
import { MockEventSource } from "../../../vitest.setup";
vi.mock("../../api", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
fetchAgentLogsWithMeta: vi.fn().mockResolvedValue({ entries: [], total: 0, hasMore: false }),
}));
const mockFetchAgentLogsWithMeta = vi.mocked(fetchAgentLogsWithMeta);
function getConnection(taskId: string): MockEventSource | undefined {
const url = `/api/tasks/${taskId}/logs/stream`;
const matching = MockEventSource.instances.filter((e) => e.url === url);
return matching[matching.length - 1];
}
function makeEntry(index: number) {
return {
timestamp: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(),
taskId: "FN-001",
text: `entry-${index}`,
type: "text" as const,
};
}
beforeEach(() => {
MockEventSource.instances = [];
mockFetchAgentLogsWithMeta.mockReset().mockResolvedValue({ entries: [], total: 0, hasMore: false });
vi.useRealTimers();
});
afterEach(() => {
for (const instance of MockEventSource.instances) {
instance.close();
}
MockEventSource.instances = [];
vi.useRealTimers();
});
describe("capLogEntries", () => {
it("keeps the newest entries once the cap is exceeded", () => {
const entries = Array.from({ length: MAX_LOG_ENTRIES + 25 }, (_, i) => i);
const capped = capLogEntries(entries);
expect(capped).toHaveLength(MAX_LOG_ENTRIES);
expect(capped[0]).toBe(25);
expect(capped.at(-1)).toBe(MAX_LOG_ENTRIES + 24);
});
it("returns the same array reference when under the cap", () => {
const entries = [1, 2, 3];
expect(capLogEntries(entries)).toBe(entries);
});
it("honors an explicit cap (the Command Center system panel shares this helper)", () => {
expect(LOG_VIEW_CAP).toBe(MAX_LOG_ENTRIES);
const lines = Array.from({ length: LOG_VIEW_CAP + 10 }, (_, i) => i);
const capped = capLogEntries(lines, LOG_VIEW_CAP);
expect(capped).toHaveLength(LOG_VIEW_CAP);
expect(capped.at(-1)).toBe(LOG_VIEW_CAP + 9);
});
});
describe("useAgentLogs live tail", () => {
it("bounds the streamed tail and retains the newest entries", async () => {
const { result } = renderHook(() => useAgentLogs("FN-001", true));
await waitFor(() => {
expect(getConnection("FN-001")).toBeDefined();
});
const es = getConnection("FN-001")!;
const overflow = MAX_LOG_ENTRIES + 40;
act(() => {
for (let index = 0; index < overflow; index++) {
es._emit("agent:log", makeEntry(index));
}
});
await waitFor(() => {
expect(result.current.entries).toHaveLength(MAX_LOG_ENTRIES);
});
// Newest-wins: the oldest 40 frames were dropped, the tail is intact.
expect(result.current.entries[0].text).toBe("entry-40");
expect(result.current.entries.at(-1)?.text).toBe(`entry-${overflow - 1}`);
// The reader must not mistake a trimmed tail for the whole log: older entries
// remain fetchable, so the "load older" affordance stays available.
expect(result.current.hasMore).toBe(true);
});
it("holds a user-paged buffer at its size instead of collapsing it to the cap", async () => {
const paged = Array.from({ length: MAX_LOG_ENTRIES + 100 }, (_, i) => makeEntry(i));
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: paged, total: paged.length, hasMore: false });
const { result } = renderHook(() => useAgentLogs("FN-001", true));
await waitFor(() => {
expect(result.current.entries).toHaveLength(paged.length);
});
const es = getConnection("FN-001")!;
act(() => {
es._emit("agent:log", makeEntry(9999));
});
await waitFor(() => {
expect(result.current.entries.at(-1)?.text).toBe("entry-9999");
});
// Bounded: the buffer stays at the size the user paged to (one oldest entry drops per
// new line) rather than growing forever OR snapping back down to MAX_LOG_ENTRIES.
expect(result.current.entries).toHaveLength(paged.length);
expect(result.current.entries[0].text).toBe("entry-1");
});
});
describe("appendChatMessageChronologically", () => {
const message = (id: string, createdAt: string): ChatMessageInfo => ({
id,
sessionId: "session-1",
role: "user",
content: id,
createdAt,
});
it("never drops user-visible transcript messages", () => {
let transcript: ChatMessageInfo[] = [];
for (let index = 0; index < MAX_LOG_ENTRIES + 50; index++) {
transcript = appendChatMessageChronologically(
transcript,
message(`m-${index}`, new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString()),
);
}
expect(transcript).toHaveLength(MAX_LOG_ENTRIES + 50);
expect(transcript[0].id).toBe("m-0");
expect(transcript.at(-1)?.id).toBe(`m-${MAX_LOG_ENTRIES + 49}`);
});
it("restores chronological order when a message arrives out of order", () => {
const first = message("a", "2026-01-01T00:00:00.000Z");
const third = message("c", "2026-01-01T00:00:02.000Z");
const second = message("b", "2026-01-01T00:00:01.000Z");
const result = appendChatMessageChronologically([first, third], second);
expect(result.map((m) => m.id)).toEqual(["a", "b", "c"]);
});
});

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useAgentLogs } from "../useAgentLogs";
import { useAgentLogs, MAX_LOG_ENTRIES } from "../useAgentLogs";
import { fetchAgentLogsWithMeta } from "../../api";
// Mock the api module
@@ -245,7 +245,15 @@ describe("useAgentLogs", () => {
expect(result.current.entries.at(-1)?.text).toBe(`entry-${oversizedCount - 1}`);
});
it("keeps oversized live SSE history without truncation", async () => {
/*
FNXC:MobileTabRetention 2026-07-26-12:20:
This case previously asserted the live SSE tail was retained WITHOUT truncation. That contract is
deliberately reversed: an unbounded tail grows the resident set for the whole session, which is what
makes a mobile browser discard the backgrounded tab (white-splash reload on return). The tail is now
a bounded ring at MAX_LOG_ENTRIES, and `hasMore` is forced true once it trims so the reader always
keeps a "load older" affordance rather than seeing a silently-clipped tail.
*/
it("bounds the live SSE tail at MAX_LOG_ENTRIES and signals truncation via hasMore", async () => {
const streamedCount = 520;
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: streamedCount, hasMore: false });
@@ -268,11 +276,12 @@ describe("useAgentLogs", () => {
});
await waitFor(() => {
expect(result.current.entries).toHaveLength(streamedCount);
expect(result.current.entries).toHaveLength(MAX_LOG_ENTRIES);
});
expect(result.current.entries[0].text).toBe("live-0");
expect(result.current.entries[0].text).toBe(`live-${streamedCount - MAX_LOG_ENTRIES}`);
expect(result.current.entries.at(-1)?.text).toBe(`live-${streamedCount - 1}`);
expect(result.current.hasMore).toBe(true);
});
it("does not fetch when taskId is null", () => {
@@ -402,7 +411,13 @@ describe("useAgentLogs", () => {
]);
});
it("keeps full history across initial load, loadMore, and live streaming", async () => {
/*
FNXC:MobileTabRetention 2026-07-26-12:24:
A user-paged buffer (550 via loadMore) is HELD at its expanded size rather than collapsed back to
MAX_LOG_ENTRIES — capping a prepend of older pages would discard exactly what the user just asked
for. Streaming past that ceiling drops one oldest entry per new line so the buffer stops growing.
*/
it("holds a user-paged buffer at its size while streaming, dropping the oldest entry", async () => {
const initialLogs = Array.from({ length: 300 }, (_, index) => ({
timestamp: `2026-01-02T00:${String(index).padStart(2, "0")}:00Z`,
taskId: "FN-001",
@@ -445,11 +460,11 @@ describe("useAgentLogs", () => {
});
await waitFor(() => {
expect(result.current.entries).toHaveLength(551);
expect(result.current.entries.at(-1)?.text).toBe("live-after-large-history");
});
expect(result.current.entries[0].text).toBe("older-0");
expect(result.current.entries.at(-1)?.text).toBe("live-after-large-history");
expect(result.current.entries).toHaveLength(550);
expect(result.current.entries[0].text).toBe("older-1");
});
it("loadMore does not trigger when already loading more", async () => {

View File

@@ -5,6 +5,10 @@ import type { TaskView } from "../useViewState";
vi.mock("../../utils/boardScrollSnapshot", () => ({
captureBoardScrollSnapshot: vi.fn(),
restoreBoardScrollSnapshot: vi.fn(() => true),
// The hook also mirrors the snapshot into sessionStorage so it survives a mobile tab discard;
// this factory replaces the whole module, so those exports must exist here too.
persistBoardScrollSnapshot: vi.fn(),
readPersistedBoardScrollSnapshot: vi.fn(() => null),
}));
import { captureBoardScrollSnapshot, restoreBoardScrollSnapshot } from "../../utils/boardScrollSnapshot";

View File

@@ -296,7 +296,11 @@ describe("useChat", () => {
localStorage.setItem(
chatSessionsCacheKey(projectId),
JSON.stringify({
savedAt: Date.now() - 120_000,
// FNXC:MobileTabDiscard 2026-07-26-12:10: this case needs an envelope that is genuinely PAST
// the hydration TTL so nothing hydrates and the in-memory session list stays empty. Derive the
// age from SWR_TASKS_MAX_AGE_MS instead of a literal so raising the TTL (12h, to survive a
// mobile tab discard) cannot silently turn this into the "cached sessions hydrate" case.
savedAt: Date.now() - (swrCacheModule.SWR_TASKS_MAX_AGE_MS + 120_000),
data: [makeSession({ id: "session-stale", agentId: "agent-001" })],
}),
);

View File

@@ -0,0 +1,199 @@
/*
FNXC:MobileTabDiscard 2026-07-26-14:26:
Regression coverage for the freshness half of the mobile tab-discard restore.
Raising `SWR_TASKS_MAX_AGE_MS` from 60s to hours made a hydrated snapshot able to be OLDER than every
downstream freshness threshold for the first time. `lastFetchTimeMs` started as a bare
`useRef(undefined)` that only a successful fetch assigned, so every consumer fell back to `Date.now()`
and measured an hours-old `updatedAt` against NOW: on an iOS-PWA restore all in-progress cards
rendered 'stuck', their agent pulse was suppressed (`isTaskAgentActive({isStuck:true})`), and
Column/ExecutorStatusBar reported the same false counts until the mount revalidation resolved.
The invariant: `lastFetchTimeMs` describes the AGE OF THE ROWS CURRENTLY IN `tasks`, on the FIRST
render, not just after a fetch. Asserted against real localStorage and the real swrCache module —
a mocked cache is what let the missing `savedAt` plumbing hide.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import type { Task } from "@fusion/core";
import { useTasks } from "../useTasks";
import * as api from "../../api";
import { SWR_CACHE_KEYS } from "../../utils/swrCache";
import { isTaskStuck, countStuckTasks } from "../../utils/taskStuck";
import { isTaskAgentActive } from "../../utils/taskActivity";
vi.mock("../../api", async (importOriginal) => {
const { createDashboardApiMock } = await import("../../test/mockApi");
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
fetchTasks: vi.fn().mockResolvedValue([]),
});
});
class MockEventSource {
static instances: MockEventSource[] = [];
url: string;
readyState = 1;
close = vi.fn(() => {
this.readyState = 2;
});
constructor(url: string) {
this.url = url;
MockEventSource.instances.push(this);
}
addEventListener(): void {}
removeEventListener(): void {}
}
const originalEventSource = globalThis.EventSource;
const mockFetchTasks = vi.mocked(api.fetchTasks);
const PROJECT_ID = "proj-freshness";
const CACHE_KEY = `${SWR_CACHE_KEYS.TASKS_PREFIX}${PROJECT_ID}`;
const TWO_HOURS_MS = 2 * 60 * 60 * 1000;
/** Project default from `packages/core/src/settings-schema.ts`. */
const TASK_STUCK_TIMEOUT_MS = 600_000;
function createInProgressTask(id: string, updatedAtMs: number): Task {
return {
id,
title: `Card ${id}`,
description: "",
column: "in-progress",
status: "executing",
dependencies: [],
steps: [],
log: [],
createdAt: new Date(updatedAtMs - 60_000).toISOString(),
updatedAt: new Date(updatedAtMs).toISOString(),
} as Task;
}
/** Seed the project snapshot with an explicit write time, mimicking a tab discarded `ageMs` ago. */
function seedSnapshot(tasks: Task[], ageMs: number): number {
const savedAt = Date.now() - ageMs;
localStorage.setItem(CACHE_KEY, JSON.stringify({ savedAt, data: tasks }));
return savedAt;
}
beforeEach(() => {
MockEventSource.instances = [];
(globalThis as unknown as { EventSource: unknown }).EventSource = MockEventSource;
localStorage.clear();
mockFetchTasks.mockReset().mockResolvedValue([]);
});
afterEach(() => {
(globalThis as unknown as { EventSource: unknown }).EventSource = originalEventSource;
localStorage.clear();
vi.useRealTimers();
});
describe("useTasks hydration freshness (dataAsOfMs)", () => {
it("reports the envelope savedAt, not now, on the first render after a 2-hour discard", () => {
const savedAt = seedSnapshot([createInProgressTask("FN-1", Date.now() - TWO_HOURS_MS)], TWO_HOURS_MS);
// Never resolves: everything asserted here is the pre-revalidation restore frame.
mockFetchTasks.mockReturnValue(new Promise<Task[]>(() => {}));
const { result } = renderHook(() => useTasks({ projectId: PROJECT_ID }));
expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-1"]);
expect(result.current.lastFetchTimeMs).toBe(savedAt);
});
it("does not mark a whole board stuck when the snapshot itself is hours old", () => {
const savedAt = Date.now() - TWO_HOURS_MS;
// Each card was updated a minute before the snapshot was written: fresh RELATIVE TO the snapshot,
// hours old relative to now. This is the operator's 6 in-progress cards after an iOS PWA discard.
const tasks = Array.from({ length: 6 }, (_, index) =>
createInProgressTask(`FN-${index}`, savedAt - 60_000),
);
seedSnapshot(tasks, TWO_HOURS_MS);
mockFetchTasks.mockReturnValue(new Promise<Task[]>(() => {}));
const { result } = renderHook(() => useTasks({ projectId: PROJECT_ID }));
const dataAsOfMs = result.current.lastFetchTimeMs;
expect(dataAsOfMs).toBe(savedAt);
// The reported surface: TaskCard's `isStuck` / status badge.
for (const task of result.current.tasks) {
expect(isTaskStuck(task, TASK_STUCK_TIMEOUT_MS, dataAsOfMs)).toBe(false);
}
// Column.activeTaskCount and ExecutorStatusBar/useExecutorStats counters read the same clock.
expect(countStuckTasks(result.current.tasks, TASK_STUCK_TIMEOUT_MS, dataAsOfMs)).toBe(0);
// TaskCard's agent pulse is suppressed by `isStuck`; with an honest clock it stays lit.
for (const task of result.current.tasks) {
const isStuck = isTaskStuck(task, TASK_STUCK_TIMEOUT_MS, dataAsOfMs);
expect(isTaskAgentActive(task, { isStuck })).toBe(true);
}
// Guard the exact regression: the old `undefined` clock (=> Date.now()) called all six stuck.
expect(countStuckTasks(result.current.tasks, TASK_STUCK_TIMEOUT_MS, undefined)).toBe(6);
});
it("still reports a genuinely stuck card as stuck against the snapshot's own clock", () => {
const savedAt = Date.now() - TWO_HOURS_MS;
seedSnapshot(
[
createInProgressTask("FN-FRESH", savedAt - 60_000),
// Already idle for 20 minutes when the snapshot was taken.
createInProgressTask("FN-STUCK", savedAt - 20 * 60_000),
],
TWO_HOURS_MS,
);
mockFetchTasks.mockReturnValue(new Promise<Task[]>(() => {}));
const { result } = renderHook(() => useTasks({ projectId: PROJECT_ID }));
const dataAsOfMs = result.current.lastFetchTimeMs;
const stuckIds = result.current.tasks
.filter((task) => isTaskStuck(task, TASK_STUCK_TIMEOUT_MS, dataAsOfMs))
.map((task) => task.id);
expect(stuckIds).toEqual(["FN-STUCK"]);
});
it("advances the clock to now once the mount revalidation lands real data", async () => {
const savedAt = seedSnapshot([createInProgressTask("FN-OLD", Date.now() - TWO_HOURS_MS)], TWO_HOURS_MS);
mockFetchTasks.mockResolvedValue([createInProgressTask("FN-NEW", Date.now())]);
const { result } = renderHook(() => useTasks({ projectId: PROJECT_ID }));
expect(result.current.lastFetchTimeMs).toBe(savedAt);
await waitFor(() => {
expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-NEW"]);
});
expect(result.current.lastFetchTimeMs).toBeGreaterThan(savedAt);
});
it("leaves the clock undefined when there is no snapshot to describe", async () => {
mockFetchTasks.mockReturnValue(new Promise<Task[]>(() => {}));
const { result } = renderHook(() => useTasks({ projectId: PROJECT_ID }));
expect(result.current.tasks).toEqual([]);
expect(result.current.lastFetchTimeMs).toBeUndefined();
});
it("re-anchors the clock to the new project's snapshot on a project switch", async () => {
const otherProjectId = "proj-freshness-other";
const otherKey = `${SWR_CACHE_KEYS.TASKS_PREFIX}${otherProjectId}`;
const firstSavedAt = seedSnapshot([createInProgressTask("FN-A", Date.now() - TWO_HOURS_MS)], TWO_HOURS_MS);
const otherSavedAt = Date.now() - 30 * 60_000;
localStorage.setItem(
otherKey,
JSON.stringify({ savedAt: otherSavedAt, data: [createInProgressTask("FN-B", otherSavedAt - 60_000)] }),
);
mockFetchTasks.mockReturnValue(new Promise<Task[]>(() => {}));
const { result, rerender } = renderHook(
({ projectId }: { projectId: string }) => useTasks({ projectId }),
{ initialProps: { projectId: PROJECT_ID } },
);
expect(result.current.lastFetchTimeMs).toBe(firstSavedAt);
rerender({ projectId: otherProjectId });
await waitFor(() => {
expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-B"]);
});
expect(result.current.lastFetchTimeMs).toBe(otherSavedAt);
});
});

View File

@@ -0,0 +1,212 @@
/*
FNXC:MobileTabDiscard 2026-07-26-11:05:
Regression coverage for the mobile tab-discard restore. iOS Safari, iOS PWAs, and Chrome Android
discard a backgrounded dashboard tab after a few minutes; on return the bundle re-executes and the
board must repaint from its localStorage snapshot instead of starting from []. The invariant under
test is the whole stale-while-revalidate contract, not just the TTL number:
1. a snapshot older than the old 60s bound (minutes / hours) still hydrates on mount,
2. hydration always issues exactly one immediate revalidation and reports `isStale` while it runs
(App renders <TopProgressBar visible={isRevalidating}> off that flag), and
3. a failed revalidation clears the entry so the next mount cannot re-hydrate unverifiable data.
These are asserted against real localStorage + the real swrCache module, because a mocked cache is
exactly what let the expired-snapshot bug hide.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import type { Task } from "@fusion/core";
import { useTasks } from "../useTasks";
import * as api from "../../api";
import { SWR_CACHE_KEYS, SWR_TASKS_MAX_AGE_MS } from "../../utils/swrCache";
vi.mock("../../api", async (importOriginal) => {
const { createDashboardApiMock } = await import("../../test/mockApi");
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
fetchTasks: vi.fn().mockResolvedValue([]),
});
});
class MockEventSource {
static instances: MockEventSource[] = [];
url: string;
readyState = 1;
close = vi.fn(() => {
this.readyState = 2;
});
constructor(url: string) {
this.url = url;
MockEventSource.instances.push(this);
}
addEventListener(): void {}
removeEventListener(): void {}
}
const originalEventSource = globalThis.EventSource;
const mockFetchTasks = vi.mocked(api.fetchTasks);
const PROJECT_ID = "proj-discard";
const CACHE_KEY = `${SWR_CACHE_KEYS.TASKS_PREFIX}${PROJECT_ID}`;
const FIVE_MINUTES_MS = 5 * 60 * 1000;
function createTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-CACHED",
title: "Cached card",
description: "",
column: "todo",
dependencies: [],
steps: [],
log: [],
createdAt: "2026-07-26T09:00:00.000Z",
updatedAt: "2026-07-26T09:00:00.000Z",
...overrides,
} as Task;
}
/** Seed the project snapshot with an explicit age, mimicking a tab discarded `ageMs` ago. */
function seedSnapshot(tasks: Task[], ageMs: number): void {
localStorage.setItem(
CACHE_KEY,
JSON.stringify({ savedAt: Date.now() - ageMs, data: tasks }),
);
}
beforeEach(() => {
MockEventSource.instances = [];
(globalThis as unknown as { EventSource: unknown }).EventSource = MockEventSource;
localStorage.clear();
mockFetchTasks.mockReset().mockResolvedValue([]);
});
afterEach(() => {
(globalThis as unknown as { EventSource: unknown }).EventSource = originalEventSource;
localStorage.clear();
vi.useRealTimers();
});
describe("useTasks stale snapshot hydration (mobile tab discard)", () => {
it("hydrates a snapshot several minutes old on mount", async () => {
seedSnapshot([createTask({ id: "FN-STALE" })], FIVE_MINUTES_MS);
// Never resolve: proves the board painted from cache, not from the fetch.
mockFetchTasks.mockReturnValue(new Promise<Task[]>(() => {}));
const { result } = renderHook(() => useTasks({ projectId: PROJECT_ID }));
expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-STALE"]);
expect(result.current.isStale).toBe(true);
});
it("hydrates a snapshot just under the hydration TTL and drops one past it", () => {
seedSnapshot([createTask({ id: "FN-OLD" })], SWR_TASKS_MAX_AGE_MS - 60_000);
mockFetchTasks.mockReturnValue(new Promise<Task[]>(() => {}));
const { result, unmount } = renderHook(() => useTasks({ projectId: PROJECT_ID }));
expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-OLD"]);
unmount();
seedSnapshot([createTask({ id: "FN-ANCIENT" })], SWR_TASKS_MAX_AGE_MS + 60_000);
const expired = renderHook(() => useTasks({ projectId: PROJECT_ID }));
expect(expired.result.current.tasks).toEqual([]);
});
it("issues exactly one immediate revalidation after hydrating, then clears the stale flag", async () => {
seedSnapshot([createTask({ id: "FN-STALE" })], FIVE_MINUTES_MS);
mockFetchTasks.mockResolvedValue([createTask({ id: "FN-FRESH" })]);
const { result } = renderHook(() => useTasks({ projectId: PROJECT_ID }));
expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-STALE"]);
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-FRESH"]);
});
expect(result.current.isStale).toBe(false);
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
});
it("clears the entry when the revalidation fails so the next mount does not re-hydrate it", async () => {
seedSnapshot([createTask({ id: "FN-STALE" })], FIVE_MINUTES_MS);
mockFetchTasks.mockRejectedValue(new Error("offline"));
const first = renderHook(() => useTasks({ projectId: PROJECT_ID }));
expect(first.result.current.tasks.map((task) => task.id)).toEqual(["FN-STALE"]);
await waitFor(() => {
expect(first.result.current.lastRefreshErrorAt).not.toBeNull();
});
expect(localStorage.getItem(CACHE_KEY)).toBeNull();
first.unmount();
const second = renderHook(() => useTasks({ projectId: PROJECT_ID }));
expect(second.result.current.tasks).toEqual([]);
});
it("still persists a snapshot when the full board exceeds the write budget", async () => {
// ~2.5KB of log/description bulk per row across 400 rows blows past the 500KB envelope cap.
const heavyTasks = Array.from({ length: 400 }, (_, index) =>
createTask({
id: `FN-${index.toString().padStart(3, "0")}`,
description: "x".repeat(1_200),
log: Array.from({ length: 12 }, () => ({ timestamp: "2026-07-26T09:00:00.000Z", action: "y".repeat(100) })),
} satisfies Partial<Task>),
);
mockFetchTasks.mockResolvedValue(heavyTasks);
const { result } = renderHook(() => useTasks({ projectId: PROJECT_ID }));
await waitFor(() => {
expect(result.current.tasks).toHaveLength(400);
});
await waitFor(() => {
expect(localStorage.getItem(CACHE_KEY)).not.toBeNull();
});
const stored = JSON.parse(localStorage.getItem(CACHE_KEY) ?? "null") as { data: Task[] };
expect(stored.data.length).toBeGreaterThan(0);
expect(stored.data[0]).not.toHaveProperty("log");
});
it("re-hydrates the persisted snapshot on a simulated discard-and-restore", async () => {
mockFetchTasks.mockResolvedValue([createTask({ id: "FN-PERSISTED" })]);
const live = renderHook(() => useTasks({ projectId: PROJECT_ID }));
await waitFor(() => {
expect(live.result.current.tasks.map((task) => task.id)).toEqual(["FN-PERSISTED"]);
});
live.unmount();
// Discard: the page is evicted and re-executed minutes later with only localStorage surviving.
const raw = JSON.parse(localStorage.getItem(CACHE_KEY) ?? "null") as { savedAt: number; data: Task[] };
localStorage.setItem(CACHE_KEY, JSON.stringify({ ...raw, savedAt: raw.savedAt - FIVE_MINUTES_MS }));
mockFetchTasks.mockReturnValue(new Promise<Task[]>(() => {}));
const restored = renderHook(() => useTasks({ projectId: PROJECT_ID }));
expect(restored.result.current.tasks.map((task) => task.id)).toEqual(["FN-PERSISTED"]);
});
});
describe("useTasks in-app view re-entry freshness", () => {
it("still catches up after a minute away even though the hydration TTL is hours", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockFetchTasks.mockResolvedValue([createTask({ id: "FN-A" })]);
const { result, rerender } = renderHook(
({ sseEnabled }: { sseEnabled: boolean }) => useTasks({ projectId: PROJECT_ID, sseEnabled }),
{ initialProps: { sseEnabled: false } },
);
await waitFor(() => {
expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-A"]);
});
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
vi.setSystemTime(Date.now() + 61_000);
mockFetchTasks.mockResolvedValue([createTask({ id: "FN-B" })]);
await act(async () => {
rerender({ sseEnabled: true });
});
await waitFor(() => {
expect(mockFetchTasks).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-B"]);
});
});
});

View File

@@ -34,6 +34,9 @@ describe("useViewState", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
// The hook also mirrors the live view into sessionStorage for same-tab reload/discard restore;
// clear it too or one test's view leaks into the next test's landing resolution.
sessionStorage.clear();
vi.spyOn(pluginViewRegistry, "isPluginViewRegistered").mockImplementation(() => false);
});
@@ -576,6 +579,8 @@ describe("useViewState", () => {
for (const view of legacyViews) {
localStorage.clear();
// Each loop iteration is a separate fresh boot, not a same-tab reload.
sessionStorage.clear();
localStorage.setItem(`kb:proj_123:kb-dashboard-task-view`, view);
const { result } = renderHook(() =>

View File

@@ -0,0 +1,370 @@
/*
FNXC:MobileTabRetention 2026-07-26-11:52:
Invariant regression test for the mobile tab-discard fix. Mobile browsers (iOS Safari tabs, iOS installed
PWAs, Chrome Android) reclaim a backgrounded page that keeps doing work, which showed up as a full
white-splash reload whenever the operator returned to the dashboard after a few minutes away. The invariant
being locked here is deliberately asserted across a REPRESENTATIVE SAMPLE of polling surfaces, not a single
repro (AGENTS.md "Fix the Invariant, Not the Repro"):
1. while `document.visibilityState === "hidden"`, an elapsed poll interval performs NO fetch, and
2. on the hidden -> visible transition, EXACTLY ONE refresh happens and polling resumes.
Every dashboard poll routes through the one shared `useVisibilityAwarePoll` helper, so the helper itself is
covered directly and the hook-level cases prove the helper is actually wired into each surface.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
const fetchDashboardHealth = vi.fn();
const refreshDashboardHealth = vi.fn();
const fetchActivityLog = vi.fn();
const fetchActivityFeed = vi.fn();
const fetchProjectHealth = vi.fn();
const apiFn = vi.fn();
vi.mock("../../api", () => ({
fetchDashboardHealth: (...a: unknown[]) => fetchDashboardHealth(...a),
refreshDashboardHealth: (...a: unknown[]) => refreshDashboardHealth(...a),
fetchActivityLog: (...a: unknown[]) => fetchActivityLog(...a),
fetchActivityFeed: (...a: unknown[]) => fetchActivityFeed(...a),
fetchProjectHealth: (...a: unknown[]) => fetchProjectHealth(...a),
api: (...a: unknown[]) => apiFn(...a),
}));
import { __resetVisibleEdgeStaggerRegistryForTests, useVisibilityAwarePoll } from "../visibilitySuspension";
import { useActivityLog } from "../useActivityLog";
import { useDashboardHealth } from "../useDashboardHealth";
import { useProjectHealth } from "../useProjectHealth";
import { useStashOrphanCount } from "../useStashOrphanCount";
/** Drive the jsdom visibility state and dispatch the event the gate listens for. */
function setVisibility(state: "visible" | "hidden") {
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => state,
});
document.dispatchEvent(new Event("visibilitychange"));
}
describe("polling loops are suspended while the document is hidden", () => {
beforeEach(() => {
vi.useFakeTimers();
__resetVisibleEdgeStaggerRegistryForTests();
setVisibility("visible");
for (const fn of [fetchDashboardHealth, fetchActivityLog, fetchActivityFeed, fetchProjectHealth, apiFn]) {
fn.mockReset();
}
fetchDashboardHealth.mockResolvedValue({ status: "ok" });
fetchActivityLog.mockResolvedValue([]);
fetchActivityFeed.mockResolvedValue([]);
fetchProjectHealth.mockResolvedValue({ ok: true });
apiFn.mockResolvedValue({ count: 0 });
});
afterEach(() => {
vi.useRealTimers();
setVisibility("visible");
});
it("useVisibilityAwarePoll: no ticks while hidden, exactly one refresh on becoming visible", async () => {
const tick = vi.fn();
renderHook(() => useVisibilityAwarePoll(tick, 5_000));
await act(async () => {
await vi.advanceTimersByTimeAsync(15_000);
});
expect(tick).toHaveBeenCalledTimes(3);
tick.mockClear();
await act(async () => {
setVisibility("hidden");
await vi.advanceTimersByTimeAsync(60_000);
});
expect(tick).not.toHaveBeenCalled();
await act(async () => {
setVisibility("visible");
});
// Exactly one immediate refresh on the hidden -> visible edge, before the interval re-arms.
expect(tick).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(5_000);
});
expect(tick).toHaveBeenCalledTimes(2);
});
it("useVisibilityAwarePoll: mounting while hidden arms nothing until the tab is shown", async () => {
setVisibility("hidden");
const tick = vi.fn();
renderHook(() => useVisibilityAwarePoll(tick, 5_000));
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
});
expect(tick).not.toHaveBeenCalled();
await act(async () => {
setVisibility("visible");
});
expect(tick).toHaveBeenCalledTimes(1);
});
/*
Each case below is a distinct surface that previously polled unconditionally: a 5s feed poll, a 10s
multi-project health poll, a 15s backend-health poll, and a 30s badge poll. They share one assertion shape
so a surface that drifts off the shared helper fails here.
*/
// `useProjectHealth` keys its fetch callback off the identity of the id array, so the sample must pass a
// stable reference exactly as real callers do.
const PROJECT_IDS = ["p1"];
const surfaces: Array<{
name: string;
intervalMs: number;
render: () => { unmount: () => void };
spy: () => ReturnType<typeof vi.fn>;
}> = [
{
name: "useActivityLog (5s feed poll)",
intervalMs: 5_000,
render: () => renderHook(() => useActivityLog({ projectId: "p1" })),
spy: () => fetchActivityLog,
},
{
name: "useProjectHealth (10s health poll)",
intervalMs: 10_000,
render: () => renderHook(() => useProjectHealth(PROJECT_IDS)),
spy: () => fetchProjectHealth,
},
{
name: "useDashboardHealth (15s backend-health poll)",
intervalMs: 15_000,
render: () => renderHook(() => useDashboardHealth()),
spy: () => fetchDashboardHealth,
},
{
name: "useStashOrphanCount (30s badge poll)",
intervalMs: 30_000,
render: () => renderHook(() => useStashOrphanCount("p1")),
spy: () => apiFn,
},
];
for (const surface of surfaces) {
it(`${surface.name} performs no fetch while hidden and refreshes once on return`, async () => {
let view: { unmount: () => void } | null = null;
await act(async () => {
view = surface.render();
await vi.advanceTimersByTimeAsync(0);
});
const spy = surface.spy();
spy.mockClear();
await act(async () => {
setVisibility("hidden");
// Well past several poll periods for every surface under test.
await vi.advanceTimersByTimeAsync(surface.intervalMs * 6);
});
expect(spy).not.toHaveBeenCalled();
await act(async () => {
setVisibility("visible");
await vi.advanceTimersByTimeAsync(0);
});
expect(spy).toHaveBeenCalledTimes(1);
// Polling resumes at the normal cadence once visible again.
await act(async () => {
await vi.advanceTimersByTimeAsync(surface.intervalMs);
});
expect(spy).toHaveBeenCalledTimes(2);
await act(async () => {
view?.unmount();
});
});
}
});
/*
FNXC:MobileTabRetention 2026-07-26-14:20:
Companion invariant to the suspension tests above, locking the fix for a regression THOSE tests permitted.
Suspending every poller on `hidden` and refreshing every poller on `visible` traded a background drain for a
foreground stampede: one visibilitychange fired ~35 synchronous refreshes (≈25 in-viewport runtime-fallback
badges plus the singleton pollers) against a 6-connection-per-origin browser cap, on a waking mobile radio,
while the SSE bus was reopening its EventSource on the same edge.
Asserted here:
1. N mounted background consumers do NOT all refresh in the same tick on the visible edge,
2. a `priority: "critical"` consumer still refreshes synchronously on that edge (staleness is not the fix),
3. every consumer does eventually refresh, exactly once, within the bounded stagger window, and
4. the re-armed intervals inherit the stagger offset, so the herd does not simply re-form one interval later.
Stagger geometry is deterministic (subscription order, no randomness) so these are exact-count assertions.
*/
describe("the hidden -> visible edge is staggered, not a synchronized stampede", () => {
const STEP_MS = 150;
const WINDOW_MS = 3_000;
const INTERVAL_MS = 60_000;
beforeEach(() => {
vi.useFakeTimers();
__resetVisibleEdgeStaggerRegistryForTests();
setVisibility("visible");
});
afterEach(() => {
vi.useRealTimers();
setVisibility("visible");
});
/** Mount `ticks.length` pollers inside one hook so registry order is the array order. */
function renderPollers(
ticks: Array<ReturnType<typeof vi.fn>>,
priorityOf: (index: number) => "critical" | "background" = () => "background",
) {
return renderHook(() => {
// Hook order is stable because the array length is fixed for the lifetime of each test.
ticks.forEach((tick, index) => {
useVisibilityAwarePoll(tick, INTERVAL_MS, { priority: priorityOf(index) });
});
});
}
function calledCount(ticks: Array<ReturnType<typeof vi.fn>>): number {
return ticks.filter((tick) => tick.mock.calls.length > 0).length;
}
it("N background consumers do not all fire in the same tick, and all refresh within the window", async () => {
const N = 8;
const ticks = Array.from({ length: N }, () => vi.fn());
const view = renderPollers(ticks);
await act(async () => {
setVisibility("hidden");
await vi.advanceTimersByTimeAsync(INTERVAL_MS * 3);
});
expect(calledCount(ticks)).toBe(0);
// The visible edge itself: only the slot-0 consumer refreshes synchronously.
await act(async () => {
setVisibility("visible");
});
expect(calledCount(ticks)).toBe(1);
expect(ticks[0]).toHaveBeenCalledTimes(1);
// Each subsequent slot lands one step later, so no single tick carries the whole burst.
for (let index = 1; index < N; index += 1) {
await act(async () => {
await vi.advanceTimersByTimeAsync(STEP_MS);
});
expect(calledCount(ticks)).toBe(index + 1);
expect(ticks[index]).toHaveBeenCalledTimes(1);
}
// Nothing is dropped or duplicated: every consumer refreshed exactly once inside the bounded window.
await act(async () => {
await vi.advanceTimersByTimeAsync(WINDOW_MS);
});
for (const tick of ticks) {
expect(tick).toHaveBeenCalledTimes(1);
}
await act(async () => {
view.unmount();
});
});
it("a critical consumer still refreshes immediately on the visible edge", async () => {
const ticks = Array.from({ length: 4 }, () => vi.fn());
// The critical one is mounted LAST, so a slot-order-only implementation would have delayed it.
const criticalIndex = 3;
const view = renderPollers(ticks, (index) => (index === criticalIndex ? "critical" : "background"));
await act(async () => {
setVisibility("hidden");
await vi.advanceTimersByTimeAsync(INTERVAL_MS * 3);
});
expect(calledCount(ticks)).toBe(0);
await act(async () => {
setVisibility("visible");
});
// Synchronously on the edge: the critical consumer plus the slot-0 background consumer.
expect(ticks[criticalIndex]).toHaveBeenCalledTimes(1);
expect(ticks[0]).toHaveBeenCalledTimes(1);
expect(ticks[1]).not.toHaveBeenCalled();
expect(ticks[2]).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(WINDOW_MS);
});
for (const tick of ticks) {
expect(tick).toHaveBeenCalledTimes(1);
}
await act(async () => {
view.unmount();
});
});
it("re-armed intervals inherit the stagger offset instead of re-synchronizing", async () => {
const ticks = Array.from({ length: 2 }, () => vi.fn());
const view = renderPollers(ticks);
await act(async () => {
setVisibility("hidden");
await vi.advanceTimersByTimeAsync(INTERVAL_MS * 3);
});
await act(async () => {
setVisibility("visible");
await vi.advanceTimersByTimeAsync(STEP_MS);
});
expect(ticks[0]).toHaveBeenCalledTimes(1);
expect(ticks[1]).toHaveBeenCalledTimes(1);
// Slot 0 armed at edge+0, slot 1 at edge+STEP: their next interval ticks must not coincide.
await act(async () => {
await vi.advanceTimersByTimeAsync(INTERVAL_MS - STEP_MS);
});
expect(ticks[0]).toHaveBeenCalledTimes(2);
expect(ticks[1]).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(STEP_MS);
});
expect(ticks[1]).toHaveBeenCalledTimes(2);
await act(async () => {
view.unmount();
});
});
it("backgrounding again mid-stagger cancels the pending refresh — a hidden page does no work", async () => {
const ticks = Array.from({ length: 3 }, () => vi.fn());
const view = renderPollers(ticks);
await act(async () => {
setVisibility("hidden");
await vi.advanceTimersByTimeAsync(INTERVAL_MS);
});
await act(async () => {
setVisibility("visible");
await vi.advanceTimersByTimeAsync(STEP_MS / 2);
setVisibility("hidden");
await vi.advanceTimersByTimeAsync(WINDOW_MS + INTERVAL_MS);
});
// Only the synchronous slot-0 refresh got through; the queued ones were cancelled by re-hiding.
expect(ticks[0]).toHaveBeenCalledTimes(1);
expect(ticks[1]).not.toHaveBeenCalled();
expect(ticks[2]).not.toHaveBeenCalled();
await act(async () => {
view.unmount();
});
});
});

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type { ActivityFeedEntry } from "../api";
import { fetchActivityFeed, fetchActivityLog } from "../api";
import { useVisibilityAwarePoll } from "./visibilitySuspension";
export interface UseActivityLogResult {
/** Activity log entries */
@@ -20,6 +21,8 @@ export interface UseActivityLogResult {
}
const POLL_INTERVAL_MS = 5000; // 5 seconds
/** Upper bound on retained activity entries; matches the 500-line cap in useMultiAgentLogs. */
const MAX_RETAINED_ENTRIES = 500;
export interface UseActivityLogOptions {
/** Filter by project ID (used with unified central feed) */
@@ -59,7 +62,6 @@ export function useActivityLog(options: UseActivityLogOptions = {}): UseActivity
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const lastTimestampRef = useRef<string | undefined>(undefined);
/**
@@ -134,7 +136,18 @@ export function useActivityLog(options: UseActivityLogOptions = {}): UseActivity
}));
}
setEntries((prev) => [...prev, ...data]);
/*
FNXC:MobileTabRetention 2026-07-26-10:22:
loadMore appended pages unbounded, so a long session grew the entry array without limit and inflated
the page's resident set — large-memory pages are the second discard trigger on mobile. Cap retained
entries at MAX_RETAINED_ENTRIES (same bound as useMultiAgentLogs). The head of the array is the
current refresh page the operator is looking at, and loadMore only appends continuation pages, so the
cap truncates the tail rather than evicting what is on screen.
*/
setEntries((prev) => {
const merged = [...prev, ...data];
return merged.length > MAX_RETAINED_ENTRIES ? merged.slice(0, MAX_RETAINED_ENTRIES) : merged;
});
setHasMore(data.length === limit);
if (data.length > 0) {
@@ -158,21 +171,14 @@ export function useActivityLog(options: UseActivityLogOptions = {}): UseActivity
refresh();
}, [refresh]);
// Auto-refresh polling
useEffect(() => {
if (!autoRefresh) return;
intervalRef.current = setInterval(() => {
refresh();
}, POLL_INTERVAL_MS);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [autoRefresh, refresh]);
/*
FNXC:MobileTabRetention 2026-07-26-10:18:
Auto-refresh polling must stop while the tab is backgrounded. A page that keeps fetching in the
background is a primary discard signal on iOS Safari/PWA and Chrome Android, which is what made the
dashboard white-splash reload on return. `useVisibilityAwarePoll` suspends the interval while hidden
and issues exactly one refresh when the tab becomes visible again.
*/
useVisibilityAwarePoll(refresh, POLL_INTERVAL_MS, { enabled: autoRefresh });
return {
entries,

View File

@@ -6,6 +6,31 @@ import { recordResumeEvent } from "../utils/resumeInstrumentation";
const INITIAL_LOAD_LIMIT = 100;
/*
FNXC:MobileTabRetention 2026-07-26-10:20:
Mobile browsers (iOS Safari tabs, iOS PWAs, Chrome Android) discard a backgrounded page whose
resident set is large, which the operator sees as a full white-splash reload on return. Every live
log tail must therefore be a bounded ring, never an array that grows for the lifetime of the session:
an agent streaming for an hour otherwise pins tens of MB of log entries per open surface.
500 matches the caps already enforced by useMultiAgentLogs and useDevServerLogs — one number so the
per-surface memory ceiling stays predictable.
*/
export const MAX_LOG_ENTRIES = 500;
/**
* Keep only the newest `cap` items of a streaming buffer.
*
* Whole-list cap: it bounds how many entries are retained, never the content of an individual
* entry. Newest-wins — a log tail is read from the bottom, so dropping the oldest entries is the
* only truncation that preserves what the reader is actually looking at.
*
* Generic so the agent-detail and command-center streams can share this one implementation instead
* of each re-deriving the same `slice(-N)` (see AGENTS.md "Reuse Components ... (No Drift)").
*/
export function capLogEntries<T>(entries: T[], cap: number = MAX_LOG_ENTRIES): T[] {
return entries.length > cap ? entries.slice(-cap) : entries;
}
function getActiveContextKey(taskId: string | null, enabled: boolean, projectId?: string): string | null {
if (!taskId || !enabled) return null;
return `${projectId ?? ""}\u0000${taskId}`;
@@ -46,6 +71,8 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
// Refs for state that needs to survive re-renders
const unsubscribeRef = useRef<(() => void) | null>(null);
const cancelledRef = useRef(false);
// Sticky "the live tail dropped older entries" flag; see the SSE handler below.
const trimmedLiveTailRef = useRef(false);
// Track the project context version to detect stale SSE events after project switches.
// Incremented whenever projectId changes, invalidating any in-flight SSE handlers.
@@ -82,6 +109,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
cancelledRef.current = true;
// Clear entries immediately on context change to prevent stale data visibility
trimmedLiveTailRef.current = false;
setEntries([]);
setLoading(false);
setHasMore(false);
@@ -187,7 +215,25 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
}
try {
const entry: AgentLogEntry = JSON.parse(e.data);
setEntries((prev) => [...prev, entry]);
/*
FNXC:MobileTabRetention 2026-07-26-10:24:
The live tail is bounded so a long-running agent cannot grow this buffer without
limit (see MAX_LOG_ENTRIES). The ceiling is `max(MAX_LOG_ENTRIES, prev.length)`:
streaming holds the buffer at whatever size it has (dropping one oldest entry per
new line) instead of collapsing a transcript the user deliberately expanded with
loadMore() straight back down to the cap.
A trim sets `trimmedLiveTailRef`, which forces `hasMore` on: older entries still
exist server-side and the viewer's "load older" affordance is the truncation signal,
so the reader is never shown a silently-clipped tail that looks complete. The flag is
a ref because the trim decision is only known inside the state updater, and it is
idempotent so React's double-invoked updaters cannot corrupt it.
*/
setEntries((prev) => {
const limit = Math.max(MAX_LOG_ENTRIES, prev.length);
if (prev.length + 1 <= limit) return [...prev, entry];
trimmedLiveTailRef.current = true;
return [...prev.slice(prev.length + 1 - limit), entry];
});
setTotal((prev) => (prev !== null ? prev + 1 : null));
} catch {
// skip malformed events
@@ -244,8 +290,21 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
}
}, [taskId, projectId, entries.length, loadingMore]);
const clear = useCallback(() => setEntries([]), []);
const clear = useCallback(() => {
trimmedLiveTailRef.current = false;
setEntries([]);
}, []);
const initialContextLoading = Boolean(activeContextKey && loadedContextKey !== activeContextKey);
return { entries, loading: loading || initialContextLoading, clear, loadMore, hasMore, total, loadingMore };
return {
entries,
loading: loading || initialContextLoading,
clear,
loadMore,
// A trimmed live tail always leaves older entries behind on the server, so the
// "load older" affordance must stay reachable even when the last fetch said otherwise.
hasMore: hasMore || trimmedLiveTailRef.current,
total,
loadingMore,
};
}

View File

@@ -11,6 +11,7 @@ The mailbox approval banner represents only real ApprovalRequest rows delivered
import { useCallback, useEffect, useRef, useState } from "react";
import type { Task } from "@fusion/core";
import { fetchApprovals } from "../api";
import { subscribeSse } from "../sse-bus";
import {
type ApprovalBannerCandidate,
@@ -69,7 +70,50 @@ export function useApprovalBanner({
setCandidate(next);
};
return subscribeSse(`/api/events${query}`, {
let disposed = false;
/*
FNXC:ApprovalBanner 2026-07-26-14:20:
Missed-event recovery. The banner used to exist ONLY as a function of the `approval:requested`
event, with no refetch of any kind. Every SSE gap — an error reconnect, and since the mobile
hidden-tab suspend a routine backgrounded tab — therefore dropped approvals permanently: the
operator was never shown the request and the requesting agent blocked indefinitely on a decision
that could not be made. On every reopen, re-read the authoritative pending list and converge:
surface the newest undismissed pending request, and clear a banner whose request is no longer
pending (decided elsewhere while we were disconnected).
*/
const resyncPendingApprovals = () => {
void fetchApprovals({ status: "pending", limit: 50 }, currentProjectId)
.then((list) => {
if (disposed) return;
const pending = [...list.requests].sort((a, b) =>
(b.updatedAt ?? b.createdAt ?? "").localeCompare(a.updatedAt ?? a.createdAt ?? ""),
);
const newest = pending.find((request) => {
const dedupeKey = `approval:${request.id}`;
const dismissedAt = approvalDismissalsRef.current.get(dedupeKey);
return dismissedAt === undefined || parseDateMs(request.updatedAt ?? request.createdAt) > dismissedAt;
});
if (!newest) {
// Server has nothing pending for us: any banner still on screen was decided while the
// stream was down.
setCandidate((current) => (current === null ? current : null));
return;
}
const dedupeKey = `approval:${newest.id}`;
seenApprovalKeysRef.current.add(dedupeKey);
triggerApprovalBanner({
dedupeKey,
updatedAtMs: parseDateMs(newest.updatedAt ?? newest.createdAt),
});
})
.catch(() => {
// A failed resync must not clear a banner we already have; the next reopen retries.
});
};
const unsubscribe = subscribeSse(`/api/events${query}`, {
onReconnect: resyncPendingApprovals,
events: {
"approval:requested": (event: MessageEvent) => {
try {
@@ -109,6 +153,11 @@ export function useApprovalBanner({
},
},
});
return () => {
disposed = true;
unsubscribe();
};
}, [currentProjectId, gitHubStarPromptShown, onStarPrompt]);
const dismissApproval = useCallback((dismissed: ApprovalBannerCandidate) => {

View File

@@ -4,11 +4,20 @@ Preserves horizontal board scroll and per-column vertical scroll across a board
FNXC:BoardNavigation 2026-06-29-20:45:
Mobile Back-to-board must restore the clicked-card board position after the full-panel detail unmounts. Retry the restore for a bounded sequence of animation frames because mobile board layout stabilization and workflow-board hydration can temporarily leave #board unavailable or reset its offsets after the first post-return frame.
FNXC:BoardNavigation 2026-07-26-10:20:
Mobile browsers discard a backgrounded dashboard tab (iOS Safari tab, iOS installed PWA, Chrome Android alike) and reload it from scratch when the user returns — the "white splash reload". That is an involuntary, OS-driven event, so the app must be able to put the user back where they were rather than at the top of the board.
Two additions carry the snapshot across that reload:
1. Persist on hide. `pagehide` / `visibilitychange:hidden` is the LAST moment we are guaranteed to run before a discard, and the common case is a user who was sitting on the board and never opened task detail — so the existing capture-on-open-detail path alone would have nothing to restore. Snapshot-on-hide is cheap (a handful of scrollTop reads plus one sessionStorage write) and does no background work while hidden, so it does not itself make the tab a discard candidate.
2. Replay on mount. A reloaded board has no rows until its first fetch resolves, so the restore is retried on a bounded timer (not a busy rAF chain — a discarded-tab restore can take far longer than a remount, and the timer stays cheap) until the board actually has columns, then stops. Any real user scroll/keyboard input aborts the replay immediately; never fight the user for control of the scroll position.
The mount replay defers to the in-memory back-navigation path: it only seeds the ref when nothing is captured and no restore is pending, and it re-checks that pending flag on every attempt, so the two paths can never race for the same board.
*/
import { useCallback, useEffect, useRef } from "react";
import {
captureBoardScrollSnapshot,
persistBoardScrollSnapshot,
readPersistedBoardScrollSnapshot,
restoreBoardScrollSnapshot,
type BoardScrollSnapshot,
} from "../utils/boardScrollSnapshot";
@@ -16,6 +25,13 @@ import type { TaskView } from "./useViewState";
const MAX_RESTORE_ATTEMPTS = 6;
/*
Bounded reload-replay budget: ~4s of 100ms polls. Long enough to outlast a cold board fetch on a
mobile connection, short enough that a board which never renders stops costing anything.
*/
const RELOAD_RESTORE_INTERVAL_MS = 100;
const RELOAD_RESTORE_MAX_ATTEMPTS = 40;
export interface UseBoardScrollRestoreResult {
capture: () => void;
requestRestore: () => void;
@@ -24,15 +40,98 @@ export interface UseBoardScrollRestoreResult {
export function useBoardScrollRestore(taskView: TaskView): UseBoardScrollRestoreResult {
const boardScrollSnapshotRef = useRef<BoardScrollSnapshot | null>(null);
const pendingBoardScrollRestoreRef = useRef(false);
const taskViewRef = useRef(taskView);
useEffect(() => {
taskViewRef.current = taskView;
}, [taskView]);
const capture = useCallback(() => {
boardScrollSnapshotRef.current = captureBoardScrollSnapshot();
const snapshot = captureBoardScrollSnapshot();
boardScrollSnapshotRef.current = snapshot;
// Mirror to sessionStorage so a discard/reload between now and the return still restores.
persistBoardScrollSnapshot(snapshot);
}, []);
const requestRestore = useCallback(() => {
pendingBoardScrollRestoreRef.current = true;
}, []);
// Snapshot-on-hide: the last guaranteed callback before an OS tab discard.
useEffect(() => {
if (typeof window === "undefined" || typeof document === "undefined") return;
const persistNow = () => {
if (taskViewRef.current !== "board") return;
const snapshot = captureBoardScrollSnapshot();
if (!snapshot) return;
boardScrollSnapshotRef.current = snapshot;
persistBoardScrollSnapshot(snapshot);
};
const handleVisibilityChange = () => {
if (document.visibilityState === "hidden") persistNow();
};
window.addEventListener("pagehide", persistNow);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("pagehide", persistNow);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
// Reload/discard replay: restore the persisted snapshot once the board actually has content.
useEffect(() => {
if (typeof window === "undefined") return;
// An in-flight in-memory restore owns the board; do not seed a competing one.
if (boardScrollSnapshotRef.current || pendingBoardScrollRestoreRef.current) return;
const persisted = readPersistedBoardScrollSnapshot();
if (!persisted) return;
boardScrollSnapshotRef.current = persisted;
let attempts = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
const cancel = () => {
cancelled = true;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
window.removeEventListener("wheel", cancel);
window.removeEventListener("touchstart", cancel);
window.removeEventListener("keydown", cancel);
};
const attempt = () => {
timer = null;
if (cancelled) return;
attempts += 1;
// Only replay while the board is the visible surface, and never on top of a pending
// back-navigation restore — that path has a fresher snapshot.
if (taskViewRef.current === "board" && !pendingBoardScrollRestoreRef.current) {
if (restoreBoardScrollSnapshot(boardScrollSnapshotRef.current)) {
cancel();
return;
}
}
if (attempts >= RELOAD_RESTORE_MAX_ATTEMPTS) {
cancel();
return;
}
timer = setTimeout(attempt, RELOAD_RESTORE_INTERVAL_MS);
};
window.addEventListener("wheel", cancel, { passive: true });
window.addEventListener("touchstart", cancel, { passive: true });
window.addEventListener("keydown", cancel);
timer = setTimeout(attempt, 0);
return cancel;
}, []);
useEffect(() => {
if (taskView !== "board" || !pendingBoardScrollRestoreRef.current) return;
const scheduleFrame = typeof window.requestAnimationFrame === "function"

View File

@@ -358,13 +358,38 @@ into a partial cache after a later assistant turn. Every client-side transcript
ascending createdAt order, with id as a deterministic tie-breaker, so optimistic replacement,
mid-stream reloads, and SSE echoes cannot move user bubbles past later turns.
*/
function compareChatMessagesChronologically(a: ChatMessageInfo, b: ChatMessageInfo): number {
const createdAtDifference = Date.parse(a.createdAt) - Date.parse(b.createdAt);
return Number.isFinite(createdAtDifference) && createdAtDifference !== 0
? createdAtDifference
: a.id.localeCompare(b.id);
}
function sortChatMessagesChronologically(messages: ChatMessageInfo[]): ChatMessageInfo[] {
return [...messages].sort((a, b) => {
const createdAtDifference = Date.parse(a.createdAt) - Date.parse(b.createdAt);
return Number.isFinite(createdAtDifference) && createdAtDifference !== 0
? createdAtDifference
: a.id.localeCompare(b.id);
});
return [...messages].sort(compareChatMessagesChronologically);
}
/*
FNXC:MobileTabRetention 2026-07-26-11:15:
Chat history is user-visible content the reader can still scroll to, so it is NOT capped — silently
dropping a conversation the user is reading would be a real regression, unlike the disposable log
tails bounded elsewhere for the same mobile-tab-discard problem.
What is fixed instead is the per-append cost: appending a message re-sorted the ENTIRE transcript
(O(n log n) plus a second array copy) on every optimistic send and every SSE frame, which is
sustained background CPU — itself a discard signal on iOS Safari / Chrome Android — for a stream
that is already chronological. The transcript is kept sorted by every mutation path, so an append
whose message already sorts at or after the tail needs no sort at all; only genuinely out-of-order
arrivals pay for the full sort and keep FN's ChatMessageOrder invariant above intact.
*/
export function appendChatMessageChronologically(
previous: ChatMessageInfo[],
message: ChatMessageInfo,
): ChatMessageInfo[] {
const last = previous[previous.length - 1];
if (!last || compareChatMessagesChronologically(last, message) <= 0) {
return [...previous, message];
}
return sortChatMessagesChronologically([...previous, message]);
}
function reconcileOptimisticSentMessage(previous: ChatMessageInfo[], persisted: ChatMessageInfo): ChatMessageInfo[] {
@@ -375,7 +400,7 @@ function reconcileOptimisticSentMessage(previous: ChatMessageInfo[], persisted:
&& candidate.sessionId === persisted.sessionId
&& candidate.content.trim() === persisted.content.trim(),
);
if (optimisticIndex < 0) return sortChatMessagesChronologically([...previous, persisted]);
if (optimisticIndex < 0) return appendChatMessageChronologically(previous, persisted);
const next = [...previous];
next[optimisticIndex] = persisted;
return sortChatMessagesChronologically(next);
@@ -1450,7 +1475,7 @@ export function useChat(
content,
createdAt: new Date().toISOString(),
};
setMessages((prev) => sortChatMessagesChronologically([...prev, userMessage]));
setMessages((prev) => appendChatMessageChronologically(prev, userMessage));
// Clear streaming state
setStreamingText("");
@@ -1498,7 +1523,7 @@ export function useChat(
streamingMessageIdsRef.current.add(assistantMessage.id);
// Preserve user message and add assistant message
setMessages((prev) => sortChatMessagesChronologically([...prev, assistantMessage]));
setMessages((prev) => appendChatMessageChronologically(prev, assistantMessage));
setStreamingText("");
setStreamingThinking("");
@@ -1956,7 +1981,7 @@ export function useChat(
) {
setMessages((prev) => {
if (prev.some((m) => m.id === message.id)) return prev;
return sortChatMessagesChronologically([...prev, message]);
return appendChatMessageChronologically(prev, message);
});
setStreamingText("");
setStreamingThinking("");
@@ -1981,7 +2006,7 @@ export function useChat(
return reconcileOptimisticSentMessage(prev, message);
}
return sortChatMessagesChronologically([...prev, message]);
return appendChatMessageChronologically(prev, message);
});
}
};

View File

@@ -3,9 +3,12 @@ FNXC:DashboardHealth 2026-06-24-00:00:
Dashboard backend health (engine availability, task-id integrity, db-corruption status), fetched on mount and refreshable on demand. Extracted from AppInner; exposes setHealth so the TaskIdIntegrityBanner can patch the cached health from its own remediation callback.
*/
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
import type { DashboardHealthResponse } from "../api";
import { fetchDashboardHealth, refreshDashboardHealth } from "../api";
import { useVisibilityAwarePoll } from "./visibilitySuspension";
const HEALTH_POLL_INTERVAL_MS = 15_000;
export interface UseDashboardHealthResult {
health: DashboardHealthResponse | null;
@@ -33,40 +36,50 @@ export function useDashboardHealth(): UseDashboardHealthResult {
}
}, []);
const cancelledRef = useRef(false);
const settledOnceRef = useRef(false);
useEffect(() => {
let cancelled = false;
let settledOnce = false;
/*
* FNXC:DashboardHealth 2026-07-03-08:40:
* Poll health periodically instead of fetching once on mount. Engine availability is transient:
* right after a project is created the engine is still starting, so the first fetch reports
* engine.available=false and the "AI engine is not running" banner shows. Without polling, health
* was never refreshed, so the banner stayed up permanently even after the engine came online.
* Re-fetching every 15s lets the banner clear on its own. Preserve the previous value on transient
* poll errors (only clear to null if we never had a value) so banners don't flicker.
*/
const load = () => {
fetchDashboardHealth()
.then((next) => {
if (cancelled) return;
settledOnce = true;
setHealth(next);
})
.catch(() => {
if (cancelled) return;
setHealth((prev) => (settledOnce ? prev : null));
});
};
load();
const interval = setInterval(load, 15_000);
cancelledRef.current = false;
return () => {
cancelled = true;
clearInterval(interval);
cancelledRef.current = true;
};
}, []);
/*
* FNXC:DashboardHealth 2026-07-03-08:40:
* Poll health periodically instead of fetching once on mount. Engine availability is transient:
* right after a project is created the engine is still starting, so the first fetch reports
* engine.available=false and the "AI engine is not running" banner shows. Without polling, health
* was never refreshed, so the banner stayed up permanently even after the engine came online.
* Re-fetching every 15s lets the banner clear on its own. Preserve the previous value on transient
* poll errors (only clear to null if we never had a value) so banners don't flicker.
*/
const load = useCallback(() => {
fetchDashboardHealth()
.then((next) => {
if (cancelledRef.current) return;
settledOnceRef.current = true;
setHealth(next);
})
.catch(() => {
if (cancelledRef.current) return;
setHealth((prev) => (settledOnceRef.current ? prev : null));
});
}, []);
useEffect(() => {
load();
}, [load]);
/*
FNXC:MobileTabRetention 2026-07-26-10:46:
The 15s health poll is suspended while the document is hidden. A backgrounded page that keeps hitting the
network is discarded by iOS Safari/PWA and Chrome Android, which is the cold white-splash reload operators
saw on return; the banner's self-clearing behavior above is preserved because the hidden -> visible edge
re-fetches once before the interval re-arms.
*/
useVisibilityAwarePoll(load, HEALTH_POLL_INTERVAL_MS);
return { health, setHealth, refreshing, refreshError, refresh };
}

View File

@@ -22,6 +22,7 @@ import {
type DevServerState,
} from "../api";
import { subscribeSse } from "../sse-bus";
import { useVisibilityAwarePoll } from "./visibilitySuspension";
const MAX_LOG_LINES = 500;
const POLL_INTERVAL_MS = 3000;
@@ -414,20 +415,15 @@ export function useDevServer(projectId?: string): UseDevServerReturn {
};
}, [projectId, refresh, subscriptionSessionId]);
// Polling while server is running
useEffect(() => {
if (session?.status !== "running" && session?.status !== "starting") {
return;
}
const interval = setInterval(() => {
void refresh();
}, POLL_INTERVAL_MS);
return () => {
clearInterval(interval);
};
}, [refresh, session?.status]);
/*
FNXC:MobileTabRetention 2026-07-26-10:26:
Polling while the dev server runs is additionally gated on document visibility: a backgrounded mobile tab
that keeps issuing 3s status fetches is discarded by the OS, producing the white-splash reload on return.
Dev-server status is display-only, so suspending it while hidden loses nothing; the visible transition
refreshes once so the panel is current the moment the operator looks at it.
*/
const devServerPollEnabled = session?.status === "running" || session?.status === "starting";
useVisibilityAwarePoll(refresh, POLL_INTERVAL_MS, { enabled: devServerPollEnabled });
const startServer = useCallback(async (command: string, cwd?: string) => {
contextVersionRef.current += 1;

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchEngineStatus, startEngine, type EngineStatusResponse } from "../api";
import { useVisibilityAwarePoll } from "./visibilitySuspension";
const POLL_INTERVAL_MS = 10000;
@@ -87,17 +88,13 @@ export function useEngineStatus(projectId?: string): UseEngineStatusResult {
void refetch();
}, [refetch]);
useEffect(() => {
if (!projectId || status?.connected) return;
const interval = window.setInterval(() => {
void refetch();
}, POLL_INTERVAL_MS);
return () => {
window.clearInterval(interval);
};
}, [projectId, refetch, status?.connected]);
/*
FNXC:MobileTabRetention 2026-07-26-10:34:
The "engine not yet connected" retry poll is suspended while the tab is hidden. Nothing observes engine
connectivity while the page is backgrounded, and a page that keeps fetching is exactly what iOS/Chrome
Android discard; the hidden -> visible edge re-checks once so a reconnected engine is reflected on return.
*/
useVisibilityAwarePoll(refetch, POLL_INTERVAL_MS, { enabled: Boolean(projectId) && !status?.connected });
return useMemo(() => ({
status,

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { EvalCategoryScore, EvalEvidenceReference, EvalFollowUpSuggestion } from "@fusion/core";
import { getEval, listEvalRuns, listEvals } from "../api";
import { readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache";
import { useVisibilityAwarePoll } from "./visibilitySuspension";
const FILTER_DEBOUNCE_MS = 300;
const POLL_INTERVAL_MS = 15_000;
@@ -196,15 +197,19 @@ export function useEvals(options?: { projectId?: string }) {
void loadDetail(selectedEvalId);
}, [loadDetail, selectedEvalId]);
useEffect(() => {
const intervalId = window.setInterval(() => {
void refresh();
if (selectedEvalId) {
void loadDetail(selectedEvalId);
}
}, POLL_INTERVAL_MS);
return () => window.clearInterval(intervalId);
/*
FNXC:MobileTabRetention 2026-07-26-10:42:
The evals list/detail poll is suspended while the document is hidden. Even a view-scoped 15s loop keeps a
backgrounded mobile tab "busy" and eligible for OS discard, which surfaces as a white-splash reload when
the operator switches back; the hidden -> visible edge polls once so results are fresh on return.
*/
const pollEvals = useCallback(() => {
void refresh();
if (selectedEvalId) {
void loadDetail(selectedEvalId);
}
}, [loadDetail, refresh, selectedEvalId]);
useVisibilityAwarePoll(pollEvals, POLL_INTERVAL_MS);
const setSelectedEvalId = useCallback((value: string | null) => {
selectedEvalIdRef.current = value;

View File

@@ -4,7 +4,7 @@ import { enrichRunningAgentTaskShapeFromFlags, isRunningAgentTask, isWaitingAgen
import { fetchExecutorStats } from "../api";
import type { ExecutorStats, ExecutorState } from "../api";
import { isTaskStuck } from "../utils/taskStuck";
import { isLikelyTabSuspensionError, isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
import { isLikelyTabSuspensionError, isVisibilityResumeError, useTabVisibilitySuspension, useVisibilityAwarePoll } from "./visibilitySuspension";
const POLL_INTERVAL_MS = 5000; // 5 seconds - different from useProjectHealth's 10s
/*
@@ -141,7 +141,6 @@ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTim
} | null>(null);
const [loading, setLoading] = useState(true);
const [errorState, setErrorState] = useState<{ projectId?: string; message: string } | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const abortRef = useRef<AbortController | null>(null);
const hasFetchedStatsRef = useRef(false);
const consecutiveFailuresRef = useRef(0);
@@ -222,25 +221,13 @@ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTim
};
}, [refresh]);
// Polling - refresh every 5 seconds
useEffect(() => {
// Clear any existing interval
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
// Start new polling interval
intervalRef.current = setInterval(() => {
refresh();
}, POLL_INTERVAL_MS);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [refresh]);
/*
FNXC:MobileTabRetention 2026-07-26-10:12:
This 5s poll was one of the loudest background loops. Mobile browsers discard a backgrounded page that
keeps issuing network requests, so the interval is suspended while the document is hidden and resumed
with a single immediate refresh on return. See `useVisibilityAwarePoll`.
*/
useVisibilityAwarePoll(refresh, POLL_INTERVAL_MS);
const currentProjectApiDataState = apiDataState && apiDataState.projectId === projectId ? apiDataState : null;
const apiData = currentProjectApiDataState?.data ?? DEFAULT_API_DATA;

View File

@@ -0,0 +1,133 @@
import { useEffect, useState } from "react";
/*
FNXC:BoardPerformance 2026-07-26-09:40:
Mobile browsers (iOS Safari tabs, iOS installed PWAs, Chrome Android) discard a backgrounded page
when it keeps doing work or holds a large resident set; the user then gets a full white-splash reload
on return. A page that never goes idle in the background is one of the strongest discard signals the
OS reads.
Before this module every rendered TaskCard owned its own `window.setInterval` for the live
elapsed-time indicator, so a 60-card board woke the tab 60 times every 30s — including while hidden.
This module collapses that into ONE process-wide ticker with three requirements:
1. Exactly one interval regardless of subscriber count; zero subscribers means NO timer at all.
2. The timer only runs while `document.visibilityState === "visible"`. Going hidden stops it, so a
backgrounded tab is genuinely idle.
3. Becoming visible ticks IMMEDIATELY, so elapsed-time indicators are never stale on return —
losing the background ticks must not cost correctness, only background work.
The ticker is a module singleton rather than a React context on purpose: TaskCard is rendered from
Board/Column, DockTaskList, WorktreeGroup, useRightDockController, and dashboard/MainContent. A
singleton covers every one of those surfaces without a provider mount, so no future TaskCard host can
silently fall back to a per-card timer.
*/
/** Cadence of the live elapsed-time indicators on task cards. */
export const LIVE_TIME_INDICATOR_POLL_MS = 30_000;
type TickListener = () => void;
const listeners = new Set<TickListener>();
let intervalId: number | null = null;
let visibilityListenerBound = false;
function isDocumentVisible(): boolean {
// Non-DOM environments (SSR, plain-node unit tests) are treated as visible so the
// ticker still behaves normally rather than silently never firing.
return typeof document === "undefined" || document.visibilityState === "visible";
}
function notifyListeners(): void {
for (const listener of [...listeners]) {
listener();
}
}
function startInterval(): void {
if (intervalId !== null || typeof window === "undefined") {
return;
}
if (listeners.size === 0 || !isDocumentVisible()) {
return;
}
intervalId = window.setInterval(notifyListeners, LIVE_TIME_INDICATOR_POLL_MS);
}
function stopInterval(): void {
if (intervalId === null || typeof window === "undefined") {
return;
}
window.clearInterval(intervalId);
intervalId = null;
}
function handleVisibilityChange(): void {
if (!isDocumentVisible()) {
stopInterval();
return;
}
// Requirement 3: catch up on return before re-arming, so the first frame after
// the tab is foregrounded already shows a fresh elapsed time.
notifyListeners();
startInterval();
}
/**
* Subscribes to the shared live-time ticker. Returns an unsubscribe function.
* The underlying interval and `visibilitychange` listener exist only while at
* least one subscriber is registered.
*/
export function subscribeLiveTimeTicker(listener: TickListener): () => void {
listeners.add(listener);
if (listeners.size === 1 && typeof document !== "undefined" && !visibilityListenerBound) {
document.addEventListener("visibilitychange", handleVisibilityChange);
visibilityListenerBound = true;
}
startInterval();
return () => {
listeners.delete(listener);
if (listeners.size > 0) {
return;
}
stopInterval();
if (visibilityListenerBound && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", handleVisibilityChange);
visibilityListenerBound = false;
}
};
}
/** Test-only introspection: whether the shared interval is currently armed. */
export function isLiveTimeTickerRunning(): boolean {
return intervalId !== null;
}
/** Test-only introspection: current subscriber count. */
export function liveTimeTickerSubscriberCount(): number {
return listeners.size;
}
/**
* Returns a "now" timestamp that advances once per {@link LIVE_TIME_INDICATOR_POLL_MS}
* while the tab is visible.
*
* When `enabled` is false the caller does NOT subscribe (no timer work is attributed to it) and the
* returned value stays frozen at mount time — matching the pre-shared-ticker behavior of cards whose
* column/status made them ineligible for a live indicator.
*/
export function useLiveTimeTicker(enabled: boolean): number {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
if (!enabled) {
return;
}
// Freshen on (re-)subscribe: the shared ticker's cadence is global, so a card
// mounting mid-interval must not wait up to a full period for its first value.
setNowMs(Date.now());
return subscribeLiveTimeTicker(() => setNowMs(Date.now()));
}, [enabled]);
return nowMs;
}

View File

@@ -168,12 +168,22 @@ export function useMergeAdvanceNotice({ projectId, apiBase = "/api" }: { project
void fetchEvents();
void fetchPushStatus();
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
/*
FNXC:MergeAdvanceNotice 2026-07-26-14:26:
Missed-event recovery. The advance notice and Smart Pull affordance were refreshed ONLY from
inside the `task:merged` handler, so a merge landing while the SSE stream was down (error
reconnect, or the mobile hidden-tab suspend) left the operator's checkout silently behind with no
banner and no pull affordance. The refetch pair is the same one the event handler runs, so a
reopen converges on the authoritative merge-advance events and push status.
*/
const resync = () => {
void fetchEvents();
void fetchPushStatus();
};
const unsubscribe = subscribeSse(`${apiBase}/events${query}`, {
onReconnect: resync,
events: {
"task:merged": () => {
void fetchEvents();
void fetchPushStatus();
},
"task:merged": resync,
},
});
return () => unsubscribe();

View File

@@ -9,6 +9,7 @@ import {
type NodeSettingsSyncResult,
type NodeAuthSyncResult,
} from "../api-node";
import { useVisibilityAwarePoll } from "./visibilitySuspension";
// ── Sync State Utilities ───────────────────────────────────────────────────────
@@ -156,8 +157,8 @@ export function useNodeSettingsSync(): UseNodeSettingsSyncResult {
const initialLoadCompleteRef = useRef(false);
// Abort controller for cancelling in-flight requests
const abortRef = useRef<AbortController | null>(null);
// Polling interval ref
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// Whether the sync-status poll should run at all (turned off once no nodes are tracked).
const [pollingEnabled, setPollingEnabled] = useState(true);
/**
* Fetch sync status for a single node and update state.
@@ -237,39 +238,31 @@ export function useNodeSettingsSync(): UseNodeSettingsSyncResult {
}, [fetchNodeStatus, t]);
/**
* Start polling sync status for all tracked nodes.
*/
const startPolling = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
intervalRef.current = setInterval(() => {
void refresh();
}, POLL_INTERVAL_MS);
}, [refresh]);
/**
* Stop polling.
* Stop polling (used when the last tracked node is removed).
*/
const stopPolling = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setPollingEnabled(false);
}, []);
// Initial fetch and polling setup
// Initial fetch
useEffect(() => {
void refresh();
startPolling();
return () => {
stopPolling();
if (abortRef.current) {
abortRef.current.abort();
}
};
}, [refresh, startPolling, stopPolling]);
}, [refresh]);
/*
FNXC:MobileTabRetention 2026-07-26-11:06:
Node sync-status polling is suspended while the document is hidden, in addition to the existing
"no tracked nodes" gate. Background network work keeps the page from ever going idle, which is a primary
reason iOS Safari/PWA and Chrome Android discard the dashboard tab and force a cold reload on return.
Sync status is re-fetched once on the hidden -> visible edge.
*/
useVisibilityAwarePoll(refresh, POLL_INTERVAL_MS, { enabled: pollingEnabled });
/**
* Start tracking a node for sync status polling.

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { ProjectHealth } from "../api";
import { fetchProjectHealth } from "../api";
import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
import { isVisibilityResumeError, useTabVisibilitySuspension, useVisibilityAwarePoll } from "./visibilitySuspension";
export interface UseMultiProjectHealthResult {
/** Map of project ID to health data */
@@ -35,7 +35,6 @@ export function useProjectHealth(projectIds: string[]): UseMultiProjectHealthRes
const [healthMap, setHealthMap] = useState<Record<string, ProjectHealth | null>>({});
const [loading, setLoading] = useState(true); // Start true for initial load
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const abortRef = useRef<AbortController | null>(null);
const healthMapRef = useRef(healthMap);
const visibilitySuspension = useTabVisibilitySuspension();
@@ -153,27 +152,13 @@ export function useProjectHealth(projectIds: string[]): UseMultiProjectHealthRes
};
}, [refresh]);
// Polling - refresh every 10 seconds
useEffect(() => {
if (projectIds.length === 0) return;
// Clear any existing interval
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
// Start new polling interval
intervalRef.current = setInterval(() => {
void refresh();
}, POLL_INTERVAL_MS);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [refresh, projectIds.length]);
/*
FNXC:MobileTabRetention 2026-07-26-10:30:
Per-project health polling is suspended while the document is hidden. Background network work keeps the
page from ever going idle, which is what makes mobile browsers reclaim the tab and force a cold reload on
return; one refresh fires on the hidden -> visible edge so health badges are not stale when seen.
*/
useVisibilityAwarePoll(refresh, POLL_INTERVAL_MS, { enabled: projectIds.length > 0 });
return {
healthMap,

View File

@@ -19,6 +19,7 @@ import { subscribeSse } from "../sse-bus";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
import type { ResearchAvailability, ResearchRunDetail, ResearchRunListItem } from "../research-types";
import { readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, SWR_LONG_MAX_AGE_MS, writeCache } from "../utils/swrCache";
import { useVisibilityAwarePoll } from "./visibilitySuspension";
const SEARCH_DEBOUNCE_MS = 300;
const POLL_INTERVAL_MS = 4000;
@@ -241,15 +242,27 @@ export function useResearch(options?: { projectId?: string }) {
sseChannel,
});
const pollTimer = window.setInterval(refreshIfActive, POLL_INTERVAL_MS);
return () => {
active = false;
unsubscribe();
window.clearInterval(pollTimer);
};
}, [projectId, refreshRuns, selectedRunId, loadRun]);
/*
FNXC:MobileTabRetention 2026-07-26-11:12:
The research SSE-backstop poll moved out of the subscription effect so it can be visibility-gated: while
the document is hidden the interval is torn down entirely, because a backgrounded page that keeps fetching
is a primary iOS/Chrome Android discard signal and the discard is what produced the white-splash reload on
return. SSE still delivers live updates while visible, and the hidden -> visible edge polls once.
*/
const pollResearch = useCallback(() => {
void refreshRuns();
if (selectedRunId) {
void loadRun(selectedRunId);
}
}, [refreshRuns, selectedRunId, loadRun]);
useVisibilityAwarePoll(pollResearch, POLL_INTERVAL_MS);
const setSelectedRunIdAndCache = useCallback((value: string | null) => {
setSelectedRunId(value);
writeCache(selectedIdCacheKey, value, { maxBytes: 500_000 });

View File

@@ -14,8 +14,9 @@
* This hook only polls while `enabled` is true (callers should pass
* `isInViewport` so off-screen cards do not generate background traffic).
*/
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchTaskRuntimeFallback, type TaskRuntimeFallbackResponse } from "../api/legacy";
import { useVisibilityAwarePoll } from "./visibilitySuspension";
const POLL_INTERVAL_MS = 30_000;
@@ -111,6 +112,38 @@ export function useRuntimeFallbackStatus(
projectId?: string,
): RuntimeFallbackStatus {
const [status, setStatus] = useState<RuntimeFallbackStatus>(IDLE_STATUS);
const contextVersionRef = useRef(0);
const poll = useCallback(async () => {
if (!enabled || !taskId) return;
const versionAtStart = contextVersionRef.current;
let data: TaskRuntimeFallbackResponse;
try {
data = await fetchTaskRuntimeFallback(taskId, projectId);
} catch {
// Network hiccups shouldn't flip a shown badge back off; just skip this cycle.
return;
}
if (contextVersionRef.current !== versionAtStart) return;
if (!data.showFallbackBadge || !data.runtimeHint) {
setStatus(IDLE_STATUS);
return;
}
// Dedupe against the shared module-level store (not a per-instance ref)
// so a fallback event toasts exactly once across every simultaneously
// mounted badge instance for this task, not once per instance.
const isNewlyObserved = data.eventId !== null && claimToastOnce(taskId, data.eventId);
setStatus({
showBadge: true,
runtimeHint: data.runtimeHint,
reason: data.reason,
message: formatRuntimeFallbackMessage(data.runtimeHint),
shouldToastNow: isNewlyObserved,
});
}, [taskId, enabled, projectId]);
useEffect(() => {
if (!enabled || !taskId) {
@@ -118,47 +151,31 @@ export function useRuntimeFallbackStatus(
return;
}
let cancelled = false;
const poll = async () => {
let data: TaskRuntimeFallbackResponse;
try {
data = await fetchTaskRuntimeFallback(taskId, projectId);
} catch {
// Network hiccups shouldn't flip a shown badge back off; just skip this cycle.
return;
}
if (cancelled) return;
if (!data.showFallbackBadge || !data.runtimeHint) {
setStatus(IDLE_STATUS);
return;
}
// Dedupe against the shared module-level store (not a per-instance ref)
// so a fallback event toasts exactly once across every simultaneously
// mounted badge instance for this task, not once per instance.
const isNewlyObserved = data.eventId !== null && taskId !== undefined && claimToastOnce(taskId, data.eventId);
setStatus({
showBadge: true,
runtimeHint: data.runtimeHint,
reason: data.reason,
message: formatRuntimeFallbackMessage(data.runtimeHint),
shouldToastNow: isNewlyObserved,
});
};
void poll();
const interval = setInterval(() => {
void poll();
}, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(interval);
contextVersionRef.current += 1;
};
}, [taskId, enabled, projectId]);
}, [taskId, enabled, poll]);
/*
FNXC:MobileTabRetention 2026-07-26-10:55:
Runtime-fallback badge polling is suspended while the document is hidden, on top of the existing viewport
`enabled` gate. Board cards run one of these each, so a backgrounded tab was issuing a burst of fetches
every 30s — the exact "page never goes idle" signal that makes iOS/Chrome Android discard the tab and
force a white-splash reload. Badges re-poll once on the hidden -> visible edge.
FNXC:MobileTabRetention 2026-07-26-14:20:
`priority: "background"` is stated EXPLICITLY here even though it is the helper default, because this is the
call site that makes the visible-edge stampede matter: one instance per in-viewport card means ~25 identical
-shaped requests fire off a single visibilitychange, against a 6-connection-per-origin browser cap and while
the SSE bus is trying to reopen. A runtime-fallback badge is ancillary decoration — it changes at most once
per agent session — so spreading it across the stagger window costs the operator nothing. Do not "promote"
this to `priority: "critical"`; that is what reintroduces the herd.
*/
useVisibilityAwarePoll(poll, POLL_INTERVAL_MS, {
enabled: enabled && Boolean(taskId),
priority: "background",
});
return status;
}

View File

@@ -3,8 +3,9 @@ FNXC:StashRecovery 2026-06-24-00:00:
App-level count of orphaned stash-recovery entries, polled every 30s and surfaced as a header/mobile-nav badge. Extracted verbatim from AppInner so the root component no longer owns the polling loop.
*/
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../api";
import { useVisibilityAwarePoll } from "./visibilitySuspension";
export interface UseStashOrphanCountResult {
stashOrphanCount: number;
@@ -15,23 +16,32 @@ const POLL_INTERVAL_MS = 30000;
export function useStashOrphanCount(currentProjectId: string | undefined): UseStashOrphanCountResult {
const [stashOrphanCount, setStashOrphanCount] = useState(0);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const data = await api<{ count: number }>("/stash-recovery/orphans");
if (!cancelled) setStashOrphanCount(data.count ?? 0);
} catch {
if (!cancelled) setStashOrphanCount(0);
}
};
void load();
const timer = window.setInterval(() => void load(), POLL_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(timer);
};
const contextVersionRef = useRef(0);
const load = useCallback(async () => {
const versionAtStart = contextVersionRef.current;
try {
const data = await api<{ count: number }>("/stash-recovery/orphans");
if (contextVersionRef.current === versionAtStart) setStashOrphanCount(data.count ?? 0);
} catch {
if (contextVersionRef.current === versionAtStart) setStashOrphanCount(0);
}
}, [currentProjectId]);
useEffect(() => {
void load();
return () => {
contextVersionRef.current += 1;
};
}, [load]);
/*
FNXC:MobileTabRetention 2026-07-26-10:50:
The 30s stash-orphan badge poll is suspended while the document is hidden. A badge count nobody can see is
never worth keeping a backgrounded mobile tab awake — background network work is a primary OS discard
signal and produced the white-splash reload on return. The badge re-counts once when the tab is shown again.
*/
useVisibilityAwarePoll(load, POLL_INTERVAL_MS);
return { stashOrphanCount };
}

View File

@@ -3,12 +3,54 @@ import type { Task, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueA
import { normalizeColumnId } from "@fusion/core";
import * as api from "../api";
import { subscribeSse } from "../sse-bus";
import { clearCache, readCache, SWR_CACHE_KEYS, SWR_TASKS_MAX_AGE_MS, writeCache } from "../utils/swrCache";
import { clearCache, readCache, readCacheEntry, SWR_CACHE_KEYS, SWR_TASKS_MAX_AGE_MS, writeCache } from "../utils/swrCache";
import { pushTrace } from "../utils/dashboardTraceBuffer";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
const loggedTaskCacheHitProjects = new Set<string>();
const TASK_VIEW_REENTRY_FRESHNESS_MS = SWR_TASKS_MAX_AGE_MS;
/*
FNXC:MobileTabDiscard 2026-07-26-10:34:
In-app task-view re-entry freshness is deliberately NOT the hydration TTL. `SWR_TASKS_MAX_AGE_MS` was
raised to hours so a discarded mobile tab can repaint its last board instantly; this bound answers a
different question — "is the LIVE in-memory snapshot recent enough to skip the catch-up fetch when the
user returns to Board/List within the same page session?" — and must stay short, because task SSE is
disabled off task-list views and missed events need server confirmation.
*/
const TASK_VIEW_REENTRY_FRESHNESS_MS = 60_000;
/*
FNXC:MobileTabDiscard 2026-07-26-10:40:
Snapshot-write budget. `writeCache` drops any payload over `maxBytes`, and it dropped SILENTLY: a board
with many long-lived tasks (each carrying an unbounded `log` array) serialized past 500KB, so nothing
was ever cached and the mobile-discard restore had no snapshot to hydrate from at all — the cache
appeared to "work" while being a no-op exactly on the boards that need it most. The snapshot is a render
seed, not a data mirror, so `log` is stripped (the board never renders it; task detail fetches its own),
and on a still-over-budget payload the row count is shrunk until the write lands.
*/
const TASK_CACHE_MAX_BYTES = 500_000;
const TASK_CACHE_ROW_LIMITS = [500, 250, 100, 50] as const;
function toCachedTaskRow(task: Task): Task {
const log = (task as Task & { log?: unknown }).log;
if (!Array.isArray(log) || log.length === 0) {
return task;
}
const { log: _droppedLog, ...rest } = task as Task & { log?: unknown };
return rest as Task;
}
/** Persist the board snapshot, shrinking row count until it fits the quota budget. Returns whether anything was written. */
function writeTaskCacheSnapshot(cacheKey: string, tasks: Task[]): boolean {
for (const limit of TASK_CACHE_ROW_LIMITS) {
const payload = tasks.length > limit ? tasks.slice(0, limit).map(toCachedTaskRow) : tasks.map(toCachedTaskRow);
// `!== false` so a test double that returns undefined is treated as a successful write
// rather than driving the shrink loop down to its smallest tier.
if (writeCache(cacheKey, payload, { maxBytes: TASK_CACHE_MAX_BYTES }) !== false) {
return true;
}
}
return false;
}
/*
FNXC:WorkflowColumns 2026-07-19-2b:05 (U12 / R2 / R11):
@@ -165,14 +207,35 @@ export function useTasks(options?: UseTasksOptions) {
const projectId = options?.projectId;
const searchQuery = options?.searchQuery;
const sseEnabled = options?.sseEnabled ?? true;
/*
FNXC:MobileTabDiscard 2026-07-26-10:48:
First paint after a mobile tab discard must show the last known board, not an empty one. This
initializer is the only thing standing between the restore and a blank board, so it hydrates from a
snapshot that may be hours old (`SWR_TASKS_MAX_AGE_MS`). That is safe only because hydration is
always paired with revalidation: `isStale` starts true (App renders <TopProgressBar visible> off it)
and the mount effect below unconditionally issues one `refreshTasks({ clearOnError: true })`, whose
failure branch CLEARS this cache entry so a wrong snapshot cannot survive into the next restore.
*/
/*
FNXC:MobileTabDiscard 2026-07-26-14:12:
Captured by the `tasks` initializer below and consumed by the `lastFetchTimeMs` ref initializer on
the SAME first render, so the hydrated board is described by the snapshot's real write time from its
very first paint. A `useRef` initial value is only honored on first render, which is exactly when the
`useState` initializer runs — the two stay in lockstep without an extra localStorage read.
*/
let hydratedSnapshotSavedAtMs: number | undefined;
const [tasks, setTasks] = useState<Task[]>(() => {
if (!projectId) {
return [];
}
const cachedTasks = readCache<Task[]>(`${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`, { maxAgeMs: SWR_TASKS_MAX_AGE_MS });
if (Array.isArray(cachedTasks) && cachedTasks.length > 0 && !loggedTaskCacheHitProjects.has(projectId)) {
loggedTaskCacheHitProjects.add(projectId);
console.info("[swr-cache] hit tasks=", cachedTasks.length, "projectId=", projectId);
const cached = readCacheEntry<Task[]>(`${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`, { maxAgeMs: SWR_TASKS_MAX_AGE_MS });
const cachedTasks = cached?.data ?? null;
if (Array.isArray(cachedTasks)) {
hydratedSnapshotSavedAtMs = cached?.savedAt;
if (cachedTasks.length > 0 && !loggedTaskCacheHitProjects.has(projectId)) {
loggedTaskCacheHitProjects.add(projectId);
console.info("[swr-cache] hit tasks=", cachedTasks.length, "projectId=", projectId);
}
}
return Array.isArray(cachedTasks) ? filterActiveTasks(cachedTasks.map(normalizeTask)) : [];
});
@@ -200,9 +263,22 @@ export function useTasks(options?: UseTasksOptions) {
const searchQueryRef = useRef(searchQuery);
const refreshTasksRef = useRef<typeof refreshTasks>(null!);
const prevSseEnabledRef = useRef(sseEnabled);
// Tracks when task data was last confirmed fresh by the server.
// Used to prevent false positives in stuck detection when tab has been in background.
const lastFetchTimeMs = useRef<number | undefined>(undefined);
/*
FNXC:MobileTabDiscard 2026-07-26-14:12:
"Data as of" clock for everything derived from `tasks` (isTaskStuck / countStuckTasks, TaskCard's
isStuck + isAgentActive, Column's activeTaskCount, ExecutorStatusBar's stuck counters, and the
taskRecovery affordances). It describes the AGE OF THE ROWS CURRENTLY IN `tasks`, not the age of
this hook instance.
It is seeded from the hydrated snapshot's envelope `savedAt` rather than left `undefined`. When it
is `undefined` every consumer falls back to `Date.now()`; combined with the raised
`SWR_TASKS_MAX_AGE_MS`, an iOS-PWA restore that hydrated a 2-hour-old board measured hours-old
`updatedAt` values against NOW and rendered every in-progress card 'stuck' (and forced
isAgentActive false, killing the live pulse) until the mount revalidation resolved — seconds on a
waking mobile radio, precisely the restore this cache exists to improve. Seeding makes the first
paint honest; `refreshTasks` overwrites it with `Date.now()` the moment real server data lands.
*/
const lastFetchTimeMs = useRef<number | undefined>(hydratedSnapshotSavedAtMs);
const lastConfirmedProjectIdRef = useRef<string | undefined>(undefined);
const lastConfirmedSearchQueryRef = useRef<string | undefined>(undefined);
const lastConfirmedIncludeArchivedRef = useRef(false);
@@ -297,8 +373,7 @@ export function useTasks(options?: UseTasksOptions) {
setTasks(normalizedFetchedTasks);
}
if (requestProjectId) {
const cachedPayload = fetchedTasks.length > 500 ? fetchedTasks.slice(0, 500) : fetchedTasks;
writeCache(`${SWR_CACHE_KEYS.TASKS_PREFIX}${requestProjectId}`, cachedPayload, { maxBytes: 500_000 });
writeTaskCacheSnapshot(`${SWR_CACHE_KEYS.TASKS_PREFIX}${requestProjectId}`, fetchedTasks);
}
setIsStale(false);
setLastRefreshErrorAt(null);
@@ -313,6 +388,12 @@ export function useTasks(options?: UseTasksOptions) {
return;
}
setLastRefreshErrorAt(Date.now());
/*
FNXC:MobileTabDiscard 2026-07-26-10:52:
Load-bearing for the long hydration TTL: a snapshot is only allowed to outlive a tab discard
because a failed revalidation deletes it here. Without this, an unverifiable board could be
re-hydrated on every subsequent restore for the whole TTL window. Do not weaken.
*/
if (requestProjectId) {
clearCache(`${SWR_CACHE_KEYS.TASKS_PREFIX}${requestProjectId}`);
}
@@ -458,18 +539,34 @@ export function useTasks(options?: UseTasksOptions) {
return;
}
const cachedTasks = readCache<Task[]>(`${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`, { maxAgeMs: SWR_TASKS_MAX_AGE_MS });
const cached = readCacheEntry<Task[]>(`${SWR_CACHE_KEYS.TASKS_PREFIX}${projectId}`, { maxAgeMs: SWR_TASKS_MAX_AGE_MS });
const cachedTasks = cached?.data ?? null;
if (Array.isArray(cachedTasks)) {
if (cachedTasks.length > 0 && !loggedTaskCacheHitProjects.has(projectId)) {
loggedTaskCacheHitProjects.add(projectId);
console.info("[swr-cache] hit tasks=", cachedTasks.length, "projectId=", projectId);
}
setTasks(filterActiveTasks(cachedTasks.map(normalizeTask)));
/*
FNXC:MobileTabDiscard 2026-07-26-14:18:
A project switch replaces `tasks` with the new project's snapshot, so the freshness clock must
be replaced too — the previous project's fetch time no longer describes these rows. Only set it
when a snapshot was actually hydrated: on a cache miss the previous project's rows stay on
screen (stale-while-revalidate), and their real fetch time remains the honest answer. This runs
before the mount/refresh effect below, so the fetch that resolves next still wins.
*/
lastFetchTimeMs.current = cached?.savedAt;
}
setIsStale(true);
}, [projectId]);
// Fetch initial tasks and recover when the tab becomes visible again.
/*
FNXC:MobileTabDiscard 2026-07-26-10:55:
The mandatory half of stale-while-revalidate. Runs once per mount (and per projectId change) with no
freshness shortcut, so a board hydrated from an hours-old snapshot is always corrected by exactly one
immediate fetch, and `isStale` marks the window so the revalidating indicator is visible meanwhile.
*/
useEffect(() => {
setIsStale(true);
void refreshTasks({ clearOnError: true });

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { getErrorMessage } from "@fusion/core";
import { fetchUsageData, type ProviderUsage } from "../api";
import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
import { isVisibilityResumeError, useTabVisibilitySuspension, useVisibilityAwarePoll } from "./visibilitySuspension";
interface UsageDataState {
providers: ProviderUsage[];
@@ -39,7 +39,6 @@ export function useUsageData(options: UseUsageDataOptions = {}) {
hasFetched: false,
});
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const stateRef = useRef(state);
const visibilitySuspension = useTabVisibilitySuspension();
@@ -99,21 +98,17 @@ export function useUsageData(options: UseUsageDataOptions = {}) {
fetchData();
}, [fetchData]);
// Auto-refresh
useEffect(() => {
if (!autoRefresh) return;
pollRef.current = setInterval(() => {
fetchData(false);
}, pollInterval);
return () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
};
}, [autoRefresh, pollInterval, fetchData]);
/*
FNXC:MobileTabRetention 2026-07-26-11:00:
Usage auto-refresh is suspended while the document is hidden. Provider usage is a display-only number, and
a backgrounded page that keeps polling it is treated by iOS Safari/PWA and Chrome Android as a live page
worth reclaiming — the discard is what produced the full white-splash reload on return. The hidden ->
visible edge refreshes once so the returning operator sees current usage.
*/
const pollUsage = useCallback(() => {
void fetchData(false);
}, [fetchData]);
useVisibilityAwarePoll(pollUsage, pollInterval, { enabled: autoRefresh });
// Cleanup on unmount
useEffect(() => {
@@ -121,9 +116,6 @@ export function useUsageData(options: UseUsageDataOptions = {}) {
if (abortRef.current) {
abortRef.current.abort();
}
if (pollRef.current) {
clearInterval(pollRef.current);
}
};
}, []);

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ThemeMode } from "@fusion/core";
import type { ProjectInfo } from "../api";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import { getScopedItem, scopedKey, setScopedItem } from "../utils/projectStorage";
import { getPluginViewId, isPluginViewId, isPluginViewRegistered } from "../plugins/pluginViewRegistry";
import { recordActivity } from "../utils/report-capture";
@@ -91,6 +91,54 @@ function resolveLandingTaskView(value: TaskView): TaskView {
return value === "command-center" || value === "settings" ? "board" : value;
}
/*
FNXC:ViewState 2026-07-26-10:35:
Mobile browsers DISCARD a backgrounded dashboard tab and reload it when the user returns. That reload is indistinguishable from a fresh boot to `localStorage`, so the landing-view guard above bounced an operator who was reading Command Center or Settings back to the Board every time they took a phone call — losing their place through no action of their own.
The two cases ARE distinguishable by storage lifetime: `sessionStorage` is per-tab and survives reload AND discard-restore, but is never inherited by a newly opened tab. So a session-scoped copy of the live view means "this tab was already running and came back", while its absence means "genuinely fresh boot".
Deliberately conservative: the session copy bypasses `resolveLandingTaskView` ONLY on the first hydration of a tab that already had a view. A new tab, a cleared session, and every explicit project switch (`hasHydratedScopedTaskViewRef` already true) all keep the FN-7649 bounce to Board. The stored value is a view name only — never a URL, task id, or content.
*/
const SESSION_TASK_VIEW_KEY = "kb-dashboard-task-view-session";
/*
FNXC:ViewState 2026-07-26-10:44:
`task-detail` is the one view a same-tab restore must NOT reproduce: the detail's task snapshot is in-memory only, so a restored `task-detail` renders MainContent's empty-detail Board fallback — a Board wearing the wrong view name, which also suppresses the board scroll replay. Resolve it to `board` instead. A `?task=` deep link still re-opens the real detail on reload via useDeepLink.
*/
function resolveSessionTaskView(value: TaskView): TaskView {
return value === "task-detail" ? "board" : normalizeTaskView(value);
}
function getSessionStorage(): Storage | null {
if (typeof window === "undefined") return null;
try {
const storage = window.sessionStorage;
if (!storage || typeof storage.getItem !== "function" || typeof storage.setItem !== "function") return null;
return storage;
} catch {
// Safari private mode / storage disabled: fall back to the fresh-boot landing behavior.
return null;
}
}
function getScopedSessionTaskView(projectId?: string): string | null {
const storage = getSessionStorage();
if (!storage) return null;
try {
return storage.getItem(scopedKey(SESSION_TASK_VIEW_KEY, projectId));
} catch {
return null;
}
}
function setScopedSessionTaskView(value: TaskView, projectId?: string): void {
const storage = getSessionStorage();
if (!storage) return;
try {
storage.setItem(scopedKey(SESSION_TASK_VIEW_KEY, projectId), value);
} catch {
// Quota failures must never break navigation.
}
}
function migrateLegacyRoadmapsView(value: string): TaskView {
if (value !== "roadmaps") {
return "board";
@@ -153,6 +201,10 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
});
const [taskView, setTaskView] = useState<TaskView>(() => {
// Same-tab restore (reload / OS tab discard) keeps the view the operator was actually on.
const sessionView = getScopedSessionTaskView();
if (isTaskView(sessionView)) return resolveSessionTaskView(sessionView);
const saved = getScopedItem("kb-dashboard-task-view");
const legacyReliabilityView = migrateLegacyReliabilityView(saved);
if (legacyReliabilityView) return legacyReliabilityView;
@@ -169,6 +221,22 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
}, [viewMode]);
useEffect(() => {
/*
First hydration of a tab that was already running (reload / discard-restore): the per-tab
session copy is the operator's real last view, so honor it verbatim. Every later run of this
effect is an explicit project switch and keeps the FN-7649 landing bounce.
*/
if (!hasHydratedScopedTaskViewRef.current) {
const sessionView = getScopedSessionTaskView(currentProject?.id);
if (isTaskView(sessionView)) {
setTaskView(resolveSessionTaskView(sessionView));
if (currentProject?.id) {
hasHydratedScopedTaskViewRef.current = true;
}
return;
}
}
const saved = getScopedItem("kb-dashboard-task-view", currentProject?.id);
const legacyReliabilityView = migrateLegacyReliabilityView(saved);
const retiredStashRecoveryView = migrateRetiredStashRecoveryView(saved);
@@ -196,6 +264,12 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
useEffect(() => {
setScopedItem("kb-dashboard-task-view", taskView, currentProject?.id);
/*
Per-tab copy: what THIS tab is showing right now, for a reload/discard-restore of this tab.
Written with the same project scoping as the localStorage copy and never mirrored unscoped —
an unscoped mirror would let the previous project's view leak into the next project's landing.
*/
setScopedSessionTaskView(taskView, currentProject?.id);
}, [currentProject?.id, taskView]);
useEffect(() => {

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { getErrorMessage } from "@fusion/core";
import { fetchWorkspaces, type WorkspaceTaskInfo } from "../api";
import { useVisibilityAwarePoll } from "./visibilitySuspension";
export interface WorkspaceInfo {
id: string;
@@ -43,42 +44,49 @@ export function useWorkspaces(projectId?: string): UseWorkspacesReturn {
const [workspaces, setWorkspaces] = useState<WorkspaceInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Monotonic context version: bumped whenever the mounted projectId scope changes or the hook unmounts, so
// a response from a superseded scope can never overwrite the current one (replaces the previous
// effect-local `cancelled` flag, which no longer exists now that the poll lives outside the effect).
const contextVersionRef = useRef(0);
useEffect(() => {
let cancelled = false;
const loadWorkspaces = useCallback(async () => {
const versionAtStart = contextVersionRef.current;
const isStale = () => contextVersionRef.current !== versionAtStart;
try {
const response = await fetchWorkspaces(projectId);
if (isStale()) {
return;
}
async function loadWorkspaces() {
try {
const response = await fetchWorkspaces(projectId);
if (cancelled) {
return;
}
setProjectName(getProjectName(response.project));
setWorkspaces(response.tasks.map(mapTaskWorkspace));
setError(null);
} catch (err) {
if (!cancelled) {
setError(getErrorMessage(err) || "Failed to load workspaces");
}
} finally {
if (!cancelled) {
setLoading(false);
}
setProjectName(getProjectName(response.project));
setWorkspaces(response.tasks.map(mapTaskWorkspace));
setError(null);
} catch (err) {
if (!isStale()) {
setError(getErrorMessage(err) || "Failed to load workspaces");
}
} finally {
if (!isStale()) {
setLoading(false);
}
}
void loadWorkspaces();
const intervalId = window.setInterval(() => {
void loadWorkspaces();
}, POLL_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [projectId]);
useEffect(() => {
void loadWorkspaces();
return () => {
contextVersionRef.current += 1;
};
}, [loadWorkspaces]);
/*
FNXC:MobileTabRetention 2026-07-26-10:38:
Workspace listing polling is suspended while the document is hidden. Continuous background fetches are a
primary reason mobile browsers discard the dashboard tab (the returning user then sees a white-splash cold
reload); the hidden -> visible edge reloads once so the workspace list is current when looked at.
*/
useVisibilityAwarePoll(loadWorkspaces, POLL_INTERVAL_MS);
return {
projectName,
workspaces,

View File

@@ -1,5 +1,172 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
/**
* FNXC:MobileTabRetention 2026-07-26-10:05:
* Mobile browsers (iOS Safari tabs, iOS installed PWAs, Chrome Android) discard a backgrounded page
* when it keeps doing work — a page whose timers keep issuing network requests never registers as idle
* and is a prime reclaim candidate, which is why returning to Fusion after a few minutes produced a
* full white-splash reload. `useVisibilityAwarePoll` is the ONE shared gate every dashboard polling loop
* must use: the interval is torn down entirely while `document.visibilityState === "hidden"` (not merely
* skipped, so the page holds no armed timer at all), and on the hidden -> visible transition it fires
* exactly one immediate refresh before re-arming, so the operator still sees fresh data on return.
*
* Do not add a second visibility pattern — AGENTS.md requires reusing this helper. If a hook already owns
* its own `visibilitychange` refresh listener, pass `refreshOnVisible: false` so the refresh is not doubled.
*/
/*
FNXC:MobileTabRetention 2026-07-26-14:20:
Visible-edge stampede control. The first cut of this helper fired `refreshOnVisible` SYNCHRONOUSLY in every
subscriber on the single `visibilitychange` event, and re-armed every interval at the same instant. On a board
with ~25 in-viewport cards that is ~35 simultaneous requests (one runtime-fallback fetch per card plus the
singleton pollers) against a browser cap of 6 concurrent HTTP/1.1 connections per origin, queued behind a
waking mobile radio — and `sse-bus.reopenVisibleChannels()` is competing for a connection on that same edge
(`app/api/event-source.ts` documents per-origin exhaustion as a real failure mode here). Before the mobile
work each poller owned an independently drifted interval, so no synchronized burst existed; the fix
reintroduced a thundering herd on exactly the restore path it was optimizing.
Resolution: a DETERMINISTIC stagger derived from subscription order (no randomness, so it is testable).
- Subscribers registered as `priority: "background"` (the default) take a slot from a module-level
insertion-ordered registry; slot N resumes at `(N % SLOTS) * STEP_MS`, so at most ceil(total/SLOTS)
subscribers hit the network in any one step and slot 0 stays synchronous (a lone poller is unchanged).
- `priority: "critical"` opts out of the registry entirely and resumes synchronously, for data the operator
is directly looking at. It is opt-in rather than default so the safe behavior is the default.
- The INTERVAL is armed inside the staggered resume, not on the edge, so the offsets persist and the pollers
stay drifted apart instead of re-synchronizing and re-bursting one interval later.
- The pending stagger timer is cleared by `stop()`, so a tab that is backgrounded again mid-window issues no
request — the "hidden page does no work" invariant is preserved, not weakened.
The `dedupe()` helper in `app/api/dedupe.ts` deliberately does NOT cover this: it collapses concurrent calls
sharing one cache key, whereas the 25 badge requests carry 25 distinct task ids, so there is nothing to
collapse. Modulo wrap keeps the spread bounded by VISIBLE_EDGE_STAGGER_WINDOW_MS regardless of subscriber
count; user-visible staleness on return is therefore capped at ~3s for ancillary data only.
Board task data is NOT affected: `useTasks` owns its own `visibilitychange` refresh listener and never routes
through this helper, so the operator's primary data is still refreshed immediately on return.
*/
const VISIBLE_EDGE_STAGGER_STEP_MS = 150;
const VISIBLE_EDGE_STAGGER_WINDOW_MS = 3_000;
const VISIBLE_EDGE_STAGGER_SLOTS = Math.max(
1,
Math.round(VISIBLE_EDGE_STAGGER_WINDOW_MS / VISIBLE_EDGE_STAGGER_STEP_MS),
);
/** Insertion-ordered registry of background subscribers; membership position is the stagger slot. */
const staggeredVisibleEdgeSubscribers = new Set<object>();
/** Slot index is recomputed at each visible edge so unmounted subscribers never leave holes in the schedule. */
function visibleEdgeDelayMs(token: object): number {
let index = 0;
for (const registered of staggeredVisibleEdgeSubscribers) {
if (registered === token) {
break;
}
index += 1;
}
return (index % VISIBLE_EDGE_STAGGER_SLOTS) * VISIBLE_EDGE_STAGGER_STEP_MS;
}
/**
* Test-only escape hatch: clears the module-level stagger registry so one test's mounted subscribers cannot
* shift another test's slot assignments. No-op outside the test build.
*/
export function __resetVisibleEdgeStaggerRegistryForTests(): void {
if (import.meta.env.MODE !== "test") return;
staggeredVisibleEdgeSubscribers.clear();
}
export type VisibilityPollPriority = "critical" | "background";
export function useVisibilityAwarePoll(
callback: () => void,
intervalMs: number,
options: { enabled?: boolean; refreshOnVisible?: boolean; priority?: VisibilityPollPriority } = {},
): void {
const { enabled = true, refreshOnVisible = true, priority = "background" } = options;
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
if (!enabled) {
return;
}
const tick = () => callbackRef.current();
// Non-DOM environments (SSR/unit harnesses without `document`) keep the plain interval.
if (typeof document === "undefined") {
const id = setInterval(tick, intervalMs);
return () => clearInterval(id);
}
// Stagger identity for this subscriber. Critical subscribers stay out of the registry so they never
// take a slot and always resume synchronously.
const staggerToken = {};
if (priority !== "critical") {
staggeredVisibleEdgeSubscribers.add(staggerToken);
}
let timer: ReturnType<typeof setInterval> | null = null;
let staggerTimer: ReturnType<typeof setTimeout> | null = null;
const start = () => {
if (timer === null) {
timer = setInterval(tick, intervalMs);
}
};
const stop = () => {
if (staggerTimer !== null) {
clearTimeout(staggerTimer);
staggerTimer = null;
}
if (timer !== null) {
clearInterval(timer);
timer = null;
}
};
// Refresh once and re-arm the interval FROM the staggered instant, so subscribers stay drifted apart
// instead of re-synchronizing one interval after the edge.
const resume = () => {
staggerTimer = null;
if (refreshOnVisible) {
tick();
}
start();
};
const handleVisibilityChange = () => {
if (document.visibilityState === "hidden") {
stop();
return;
}
// Both timers null is the hidden -> visible edge (or a mount that happened while hidden). The
// `staggerTimer` half of the guard also makes a duplicate visibilitychange during the stagger window
// a no-op rather than a second queued refresh.
if (timer === null && staggerTimer === null) {
const delayMs = priority === "critical" ? 0 : visibleEdgeDelayMs(staggerToken);
if (delayMs === 0) {
resume();
} else {
staggerTimer = setTimeout(resume, delayMs);
}
}
};
if (document.visibilityState !== "hidden") {
start();
}
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
stop();
staggeredVisibleEdgeSubscribers.delete(staggerToken);
};
}, [enabled, intervalMs, refreshOnVisible, priority]);
}
let lastHiddenAt: number | null = null;
let lastVisibleAt: number | null = null;

View File

@@ -1,4 +1,172 @@
const CACHE_NAME = "fusion-cache-v5";
const CACHE_NAME = "fusion-cache-v6";
/*
FNXC:PWAOffline 2026-07-26-10:12:
Mobile browsers (iOS Safari tabs, iOS installed PWAs, Chrome Android) discard backgrounded pages under memory pressure. When the user returns, the tab is re-navigated from scratch, so restore cost IS the perceived bug: a network-first bundle refetch pulls ~2.8MB raw / 750KB gzipped of entry chunk (14MB across ~130 chunks) over a just-waking radio before anything paints, producing the white splash.
Vite emits content-hashed asset filenames (`[name]-[hash].[ext]`), so a hashed URL's bytes can never change — a new build emits a NEW url. Those are therefore safe to serve cache-first: a cache hit is authoritative, and a post-deploy hash simply misses and is fetched. HASHED_ASSET_PATTERN is the conservative gate for that guarantee; anything under /assets/ that does not prove it carries a hash stays network-first.
Conservatism rules for the pattern: the trailing dash-delimited segment of the basename must be >=8 chars of Vite's base64url hash alphabet (dash permitted only as the final character, which keeps the segment genuinely trailing instead of letting the match span earlier dashes) AND must contain at least one uppercase letter or digit. Human-authored kebab-case basenames (`vendor-runtime.js`, `foo-bar-baz.css`) fail the mixed-alphabet check and stay network-first. A real hash fails the pattern only when it embeds a dash before its last character (~10% of hashes) or is all-lowercase-alpha ((28/64)^8, ~0.1%); both are harmless downgrades to the previous network-first behavior, never a stale-serve.
*/
const HASHED_ASSET_PATTERN = /-(?=[A-Za-z0-9_-]*[A-Z0-9])[A-Za-z0-9_]{7,}[A-Za-z0-9_-]?\.[A-Za-z0-9]+$/;
/**
* FNXC:PWAOffline 2026-07-26-10:18:
* Cache-first eligibility. Two admissible classes:
* 1. Content-hashed build assets under /assets/ — immutable by construction (see HASHED_ASSET_PATTERN).
* 2. Font requests (`request.destination === "font"`, e.g. the preloaded /fonts/SymbolsNerdFontMono-Regular.ttf) — not hash-named, but immutable in practice; a replaced font is picked up on the next CACHE_NAME bump. Blocking first paint on a font refetch over a waking radio is not worth that staleness window.
*
* Deliberately NOT admissible: the navigation shell, /api/*, and non-hashed scripts/styles.
*
* @param {URL} url
* @param {Request} request
* @returns {boolean}
*/
function isImmutableAssetRequest(url, request) {
if (request.destination === "font") {
return true;
}
return url.pathname.startsWith("/assets/") && HASHED_ASSET_PATTERN.test(url.pathname);
}
/*
FNXC:PWAOffline 2026-07-26-14:05:
Cache-first hashed assets made cache SIZE load-bearing, and nothing evicted within a generation — `activate` only drops caches whose key !== CACHE_NAME, and CACHE_NAME is a hand-bumped literal. Fusion is self-hosted and rebuilt constantly (Command Center "Rebuild + restart" reloads the page onto a new build), each build emitting ~130 fresh hashed chunks (~14MB). Every prior build's chunks stayed resident forever, so a week of daily rebuilds parks ~100MB of dead chunks on the origin. On iOS the origin quota is enforced per-origin and eviction is ALL-OR-NOTHING for the bucket: blowing it wipes localStorage too, taking out the SWR board snapshot and the `kb-dashboard-*` preferences that the rest of the restore work depends on. So the unbounded cache does not merely waste disk — it can destroy the very state that makes restore cheap.
Chosen bound: an insertion-ordered entry cap over hashed /assets/ entries only.
- Why not "keep only what the current index.html references": index.html names only the entry chunk; the other ~129 are lazy imports discovered inside JS. Reconstructing that graph in the SW means parsing bundles — fragile, and a mis-parse strands the user on a failed dynamic import.
- Why not a CACHE_NAME-per-build bump: correct in principle, but CACHE_NAME is a literal in a static, untemplated file. Doing it properly needs build-time templating of sw.js (a Vite plugin emitting the build hash) — real build wiring, not a one-line change, and out of scope here. Recorded as the eventual better answer rather than faked with a hardcoded hash.
- Why insertion order is the right recency proxy: hashed URLs are immutable, so an entry is only ever inserted once, at the moment its build first ran. Cache API `keys()` is specified to return entries in insertion order, so oldest-first == oldest-build-first. Evicting from the front removes dead builds before live ones, with no metadata bookkeeping to persist or corrupt.
Safety against evicting an asset the RUNNING build still needs: every hashed URL this service-worker session has served (hit or miss) is recorded in `sessionReferencedAssets` and is exempt from eviction while it remains in that set (itself bounded — see MAX_SESSION_REFERENCED_ASSETS). A chunk the current page has already loaded is thereby pinned. A not-yet-lazy-loaded chunk of the current build is protected by the cap being sized for multiple builds, and by the fact that it is at the END of insertion order. `versionCheck.ts`'s handleChunkLoadError/isStaleChunkError remains a backstop, deliberately not the primary design.
*/
const MAX_IMMUTABLE_CACHE_ENTRIES = Infinity;
/*
FNXC:PWAOffline 2026-07-26-14:05:
The eviction-exemption set must itself be bounded, or it re-opens the hole it exists to make safe: a
service worker that survives many rebuilds (they are cheap to keep alive under active use) would
accumulate every build's served chunks as permanently-exempt and the cap could never bite. Capping it
drop-oldest makes the resident set provably bounded at MAX_IMMUTABLE_CACHE_ENTRIES +
MAX_SESSION_REFERENCED_ASSETS in the worst case rather than unbounded. Dropping the oldest exemption
is safe for the same reason eviction is: a chunk evicted but still needed is re-fetched from the
network, and the only unrecoverable case (build gone from the server) is the stale-chunk case
versionCheck.ts handles and would occur with no cache at all.
*/
const MAX_SESSION_REFERENCED_ASSETS = 200;
/** @type {Set<string>} URLs of hashed assets served during this SW session; exempt from eviction. Set iteration is insertion-ordered, so the first entry is the oldest. */
const sessionReferencedAssets = new Set();
/**
* @param {string} requestUrl
* @returns {void}
*/
function rememberSessionReferencedAsset(requestUrl) {
sessionReferencedAssets.delete(requestUrl);
sessionReferencedAssets.add(requestUrl);
while (sessionReferencedAssets.size > MAX_SESSION_REFERENCED_ASSETS) {
const oldest = sessionReferencedAssets.values().next();
if (oldest.done) {
break;
}
sessionReferencedAssets.delete(oldest.value);
}
}
/** Prune runs are single-flight; overlapping cold-path misses must not scan/delete concurrently. */
let immutablePruneInFlight = false;
/**
* FNXC:PWAOffline 2026-07-26-14:05:
* URL-only classifier for prunable entries. Deliberately does NOT reuse isImmutableAssetRequest():
* that one consults `request.destination`, which is not guaranteed to survive a round trip through
* the Cache API on a `keys()` result. Fonts therefore fall outside the prunable set — there are a
* handful of them and they are not the growth term.
*
* @param {string} requestUrl
* @returns {boolean}
*/
function isPrunableAssetUrl(requestUrl) {
try {
const pathname = new URL(requestUrl).pathname;
return pathname.startsWith("/assets/") && HASHED_ASSET_PATTERN.test(pathname);
} catch {
return false;
}
}
/**
* FNXC:PWAOffline 2026-07-26-14:05:
* Evict oldest-inserted hashed assets until the hashed-entry count is back under the cap, skipping
* anything this session referenced. If every over-cap entry is session-referenced the sweep simply
* does less work than requested — never evicting a live chunk is more important than hitting the cap
* exactly. Fully defensive: it is invoked fire-and-forget so a throw, a rejected delete, or a slow
* keys() scan can never delay or fail the fetch response it was triggered from.
*
* @param {Cache} cache
* @returns {Promise<void>}
*/
async function pruneImmutableAssetCache(cache) {
if (immutablePruneInFlight) {
return;
}
immutablePruneInFlight = true;
try {
const keys = await cache.keys();
const evictable = [];
let hashedTotal = 0;
for (const cachedRequest of keys) {
if (!cachedRequest || !isPrunableAssetUrl(cachedRequest.url)) {
continue;
}
hashedTotal += 1;
if (!sessionReferencedAssets.has(cachedRequest.url)) {
evictable.push(cachedRequest);
}
}
let overflow = hashedTotal - MAX_IMMUTABLE_CACHE_ENTRIES;
if (overflow <= 0) {
return;
}
for (const cachedRequest of evictable) {
if (overflow <= 0) {
break;
}
try {
await cache.delete(cachedRequest);
overflow -= 1;
} catch (deleteError) {
console.warn("[sw] immutable asset eviction failed", deleteError);
}
}
} catch (error) {
console.warn("[sw] immutable asset prune failed", error);
} finally {
immutablePruneInFlight = false;
}
}
/**
* FNXC:PWAOffline 2026-07-26-14:05:
* Fire-and-forget prune trigger. Runs on the cold path only (after a cache MISS populated a new
* entry, i.e. exactly when a new build is arriving) and on activate. Not awaited: awaiting would put
* an O(cache) keys() scan in front of each of a new build's ~130 first-load chunk responses.
*
* @param {Cache} cache
* @returns {void}
*/
function scheduleImmutableAssetPrune(cache) {
try {
void pruneImmutableAssetCache(cache);
} catch (error) {
console.warn("[sw] immutable asset prune scheduling failed", error);
}
}
const APP_SHELL_URLS = [
"/",
"/index.html",
@@ -35,6 +203,14 @@ self.addEventListener("activate", (event) => {
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key)),
);
// FNXC:PWAOffline 2026-07-26-14:05: cross-generation eviction above only fires on a
// CACHE_NAME bump; this bounds the CURRENT generation on every SW activation too.
try {
const cache = await caches.open(CACHE_NAME);
await pruneImmutableAssetCache(cache);
} catch (pruneError) {
console.warn("[sw] activate prune failed", pruneError);
}
await self.clients.claim();
} catch (error) {
console.warn("[sw] activate cleanup failed", error);
@@ -64,8 +240,10 @@ self.addEventListener("fetch", (event) => {
const isBuiltAssetRequest =
url.pathname.startsWith("/assets/") ||
request.destination === "script" ||
request.destination === "style" ||
request.destination === "font";
request.destination === "style";
// NOTE: `request.destination === "font"` used to be listed here. Fonts are
// now claimed by isImmutableAssetRequest() above, so repeating them would be
// an unreachable branch.
// EventSource requests stay open indefinitely. Waiting on cache.put() for an
// infinite response body prevents the browser from ever receiving the stream
@@ -75,9 +253,10 @@ self.addEventListener("fetch", (event) => {
return;
}
// Always revalidate the HTML shell so navigation picks up the latest hashed
// asset names instead of getting stuck on a cached index.html that points at
// a stale bundle.
/*
FNXC:PWAOffline 2026-07-26-10:24:
The navigation shell MUST stay network-first. index.html is the only unhashed document in the graph, so it is the single source of truth for which hashed asset URLs are current. Keeping it fresh is precisely what makes cache-first hashed assets safe: after a deploy the fresh shell names new hashes, those miss the cache, and are fetched. Serving the shell from cache would pin the tab to a previous build's hashes indefinitely. Cache remains an offline fallback only.
*/
if (isNavigationRequest) {
event.respondWith((async () => {
try {
@@ -126,10 +305,52 @@ self.addEventListener("fetch", (event) => {
return;
}
// Built assets are content-hashed, but an already-controlled browser can
// keep old entries in this named cache across local rebuilds. Prefer the
// server response so tabs cannot stay on stale JS/CSS and render a blank
// shell after an update. The cache remains an offline fallback.
/*
FNXC:PWAOffline 2026-07-26-10:31:
Immutable assets are served CACHE-FIRST: a hit returns without touching the network, so a discarded-and-restored mobile tab repaints from local storage instead of re-downloading the bundle over a waking radio. On a miss we fetch, populate, and return.
This replaces the previous network-first-for-everything rule, which existed out of a stale-JS fear. That fear does not apply here: the URL is content-hashed, so its bytes are immutable and a hit can never be "stale" — a rebuild produces a different URL, which misses. The fear DOES apply to the navigation shell, which is why that branch above is left network-first.
Only `response.ok` is cached. A hashed URL that 404s (deploy mid-flight, partially uploaded build) must never be pinned into an immutable cache entry, because nothing would ever evict it before the next CACHE_NAME bump.
*/
if (isImmutableAssetRequest(url, request)) {
event.respondWith((async () => {
// FNXC:PWAOffline 2026-07-26-14:05: recorded BEFORE the hit/miss branch so an asset the
// running build is using is eviction-exempt whether it came from cache or network.
rememberSessionReferencedAsset(request.url);
try {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
const networkResponse = await fetch(request);
if (networkResponse && networkResponse.ok) {
try {
await cache.put(request, networkResponse.clone());
scheduleImmutableAssetPrune(cache);
} catch (cacheError) {
console.warn("[sw] immutable asset cache put failed", cacheError);
}
}
return networkResponse;
} catch (error) {
console.warn("[sw] immutable asset cache flow failed", error);
const fallback = await caches.match(request);
if (fallback) {
return fallback;
}
return fetch(request);
}
})());
return;
}
// Non-hashed built assets (unhashed scripts/styles, anything under /assets/
// that cannot prove it carries a content hash) keep the network-first path:
// their URL is not a content identity, so a cached copy can genuinely go
// stale and blank the app after an update. Cache stays an offline fallback.
if (isBuiltAssetRequest) {
event.respondWith((async () => {
try {

View File

@@ -25,6 +25,14 @@ const CLIENT_KEEPALIVE_TIMEOUT_MS = 5_000;
const VISIBILITY_REOPEN_DEDUPE_MS = 1_000;
const CLIENT_ID_STORAGE_KEY = "fusion:sse-client-id";
/*
FNXC:DashboardSSE 2026-07-26-10:05:
Mobile browsers (iOS Safari tabs and installed PWAs, Chrome Android) discard a backgrounded page when it keeps doing network/timer work, which the operator sees as a full white-splash reload on returning to the dashboard. An open EventSource plus its 30s keepalive probe and 45s heartbeat-timeout reconnect churn keeps the tab permanently "active", so the page is a prime discard candidate.
The bus therefore goes fully quiet while the document is hidden: after this grace delay it tears the sockets and timers down exactly like the pagehide path. The delay must be long enough that a quick app-switch (glance at a notification, copy a token) does not thrash reconnects, and short enough that a backgrounded tab is idle well before the OS starts reclaiming it — 60s satisfies both.
Exported so tests and future tuning share one source of truth.
*/
export const SSE_HIDDEN_SUSPEND_DELAY_MS = 60_000;
let memoryClientId: string | null = null;
interface Subscriber {
@@ -45,10 +53,20 @@ interface Channel {
hasOpenedOnce: boolean;
/** Set true at the start of closeChannel to prevent reconnect after teardown. */
closed: boolean;
/**
* FNXC:DashboardSSE 2026-07-26-10:12:
* Set while the tab has been hidden past SSE_HIDDEN_SUSPEND_DELAY_MS. Distinct from `closed`:
* the socket and every timer are gone, but the subscriber set is intact and the channel stays
* registered, so the visibilitychange reopen path can bring it back for the same consumers.
* `openChannel` refuses to run while it is set so nothing (reconnect timers, late subscribes)
* can re-establish traffic behind the suspend.
*/
suspended: boolean;
}
const channels = new Map<string, Channel>();
let lastVisibilityReopenAt = 0;
let hiddenSuspendTimer: ReturnType<typeof setTimeout> | null = null;
function createClientId(): string {
const cryptoApi = typeof globalThis !== "undefined" ? globalThis.crypto : undefined;
@@ -190,33 +208,60 @@ function startClientKeepalive(channel: Channel): void {
}, CLIENT_KEEPALIVE_INTERVAL_MS);
}
// Close every EventSource when the page is unloading. Without this,
// browsers keep the underlying TCP sockets open in their HTTP/1.1
// keep-alive pool even though the JS EventSource object is gone —
// the server never sees a close, connections pile up, and within a
// few refreshes the browser hits its 6-connection-per-origin limit
// and every subsequent fetch stalls. Using `pagehide` (fires reliably
// on bfcache navigations too) plus `beforeunload` as a fallback.
/*
* Close every EventSource when the page is unloading. Without this,
* browsers keep the underlying TCP sockets open in their HTTP/1.1
* keep-alive pool even though the JS EventSource object is gone —
* the server never sees a close, connections pile up, and within a
* few refreshes the browser hits its 6-connection-per-origin limit
* and every subsequent fetch stalls. `pagehide` fires reliably on
* bfcache navigations too, so it alone covers the teardown.
*
* FNXC:DashboardSSE 2026-07-26-10:20:
* The companion `beforeunload` listener was REMOVED and must not be re-added. Registering a
* beforeunload handler disqualifies the page from Safari's page cache / bfcache, so the mobile
* dashboard paid a full cold restore (white splash, 2.8MB entry chunk re-parse) on every back
* navigation and tab restore in exchange for nothing: `pagehide` already fires in every case
* `beforeunload` does, including the unload path, and it is the only one of the two that fires
* when the page is frozen into bfcache. Restore performance is the point of this deletion.
*/
if (typeof window !== "undefined" && typeof document !== "undefined") {
const closeAllChannels = () => {
console.info("[sse-bus] pagehide", { channelCount: channels.size });
pushTrace("sse-bus", "pagehide", { channelCount: channels.size });
for (const channel of Array.from(channels.values())) {
if (channel.closed) continue;
stopClientKeepalive(channel);
sendDisconnectBeacon(channel);
if (channel.es) {
try {
channel.es.close();
} catch {
// ignore
}
channel.es = null;
}
tearDownChannelTransport(channel);
channel.closed = true;
}
};
/*
FNXC:DashboardSSE 2026-07-26-10:28:
Hidden-tab suspend. Only tears the transport down if the document is STILL hidden when the grace
timer expires, so app-switching away for a few seconds costs nothing. Subscribers and channel
registration survive; `suspended` blocks any reopen until visibilitychange says otherwise.
*/
const suspendChannelsWhileHidden = () => {
hiddenSuspendTimer = null;
if (document.visibilityState === "visible") return;
const suspendable = Array.from(channels.values()).filter((c) => !c.closed && !c.suspended);
if (suspendable.length === 0) return;
console.info("[sse-bus] suspend-hidden", { channelCount: suspendable.length });
pushTrace("sse-bus", "suspend-hidden", { channelCount: suspendable.length });
for (const channel of suspendable) {
tearDownChannelTransport(channel);
channel.suspended = true;
}
};
const scheduleHiddenSuspend = () => {
if (hiddenSuspendTimer) return;
hiddenSuspendTimer = setTimeout(suspendChannelsWhileHidden, SSE_HIDDEN_SUSPEND_DELAY_MS);
};
const reopenSubscribedChannels = (event: PageTransitionEvent) => {
console.info("[sse-bus] pageshow", { persisted: event.persisted, channelCount: channels.size });
pushTrace("sse-bus", "pageshow", { persisted: event.persisted, channelCount: channels.size });
@@ -230,6 +275,14 @@ if (typeof window !== "undefined" && typeof document !== "undefined") {
for (const channel of Array.from(channels.values())) {
if (channel.subscribers.size === 0) continue;
if (channel.es !== null && !channel.closed) continue;
/*
FNXC:DashboardSSE 2026-07-26-11:02:
A bfcache restore can deliver `pageshow` without a preceding visibilitychange, so this path
must also release the hidden-suspend — otherwise a page restored from bfcache would come back
with permanently silent channels. Only release when the restored page is actually visible; a
pageshow into a still-hidden document keeps the suspend.
*/
if (document.visibilityState !== "hidden") channel.suspended = false;
channel.closed = false;
openChannel(channel);
}
@@ -253,7 +306,27 @@ if (typeof window !== "undefined" && typeof document !== "undefined") {
for (const channel of Array.from(channels.values())) {
if (channel.subscribers.size === 0) continue;
if (channel.es === null || channel.es.readyState === EventSource.CLOSED) {
if (channel.suspended || channel.es === null || channel.es.readyState === EventSource.CLOSED) {
/*
FNXC:DashboardSSE 2026-07-26-10:34:
Resume path for hidden-suspended channels. `suspended` must be cleared BEFORE openChannel or
its suspend guard would refuse the reopen. Because `hasOpenedOnce` survives the suspend, the
reopened stream's `open` handler fires each subscriber's onReconnect.
FNXC:DashboardSSE 2026-07-26-14:05:
CORRECTION of the claim previously written here. The old comment asserted that on reopen
"consumers refetch authoritative state rather than trusting the gap". That was FALSE and must
not be reintroduced: the bus cannot make a consumer resync, it can only signal. Every event
the server emitted during the suspend window is gone — /api/events has no replay/Last-Event-ID
buffer — so a subscriber with no `onReconnect` silently keeps whatever stale state it had. That
cost real work: an approval raised while suspended never rendered its banner and the agent
blocked forever on a decision nobody was shown.
The ACTUAL contract: hidden-suspend is only safe for a channel whose every subscriber resyncs
on reopen, and providing that resync is the SUBSCRIBER's responsibility (see SseSubscription:
supply `onReconnect`, or declare `replaySafe: true` when the state is genuinely rebuilt from
scratch by the stream itself).
*/
channel.suspended = false;
channel.closed = false;
if (channel.es && channel.es.readyState === EventSource.CLOSED) {
channel.es = null;
@@ -270,10 +343,54 @@ if (typeof window !== "undefined" && typeof document !== "undefined") {
}
};
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
if (hiddenSuspendTimer) {
clearTimeout(hiddenSuspendTimer);
hiddenSuspendTimer = null;
}
reopenVisibleChannels();
return;
}
scheduleHiddenSuspend();
};
window.addEventListener("pagehide", closeAllChannels);
window.addEventListener("beforeunload", closeAllChannels);
window.addEventListener("pageshow", reopenSubscribedChannels);
document.addEventListener("visibilitychange", reopenVisibleChannels);
document.addEventListener("visibilitychange", handleVisibilityChange);
}
/**
* FNXC:DashboardSSE 2026-07-26-10:24:
* Shared transport teardown for pagehide and hidden-suspend: stop the keepalive interval and the
* heartbeat/reconnect timers (a backgrounded tab must schedule NO work — pending timers are what
* kept the page "active" and discardable), tell the server to reap the stream, and drop the socket
* plus its native listeners. Native listeners are bound to the dead EventSource, so they must be
* cleared or `reattachNativeListeners` would consider every event type already wired on reopen and
* the resumed stream would deliver nothing.
* Leaves `subscribers`, `hasOpenedOnce`, and channel registration untouched; callers decide whether
* this was a permanent close or a resumable suspend.
*/
function tearDownChannelTransport(channel: Channel): void {
stopClientKeepalive(channel);
if (channel.heartbeatTimer) {
clearTimeout(channel.heartbeatTimer);
channel.heartbeatTimer = null;
}
if (channel.reconnectTimer) {
clearTimeout(channel.reconnectTimer);
channel.reconnectTimer = null;
}
sendDisconnectBeacon(channel);
if (channel.es) {
try {
channel.es.close();
} catch {
// ignore
}
channel.es = null;
}
channel.nativeListeners.clear();
}
function resetHeartbeat(channel: Channel): void {
@@ -314,7 +431,9 @@ function forceReconnect(channel: Channel, cause: "heartbeat-timeout" | "error" |
stopClientKeepalive(channel);
channel.nativeListeners.clear();
if (channel.closed) return;
// A suspended channel is deliberately quiet: never let a late error/heartbeat callback from the
// torn-down EventSource schedule a reconnect while the tab is hidden.
if (channel.closed || channel.suspended) return;
if (channel.subscribers.size === 0 || channel.reconnectTimer) return;
// Guard against calling onReconnect callbacks for a channel that has been
@@ -368,6 +487,10 @@ function openChannel(channel: Channel): void {
});
if (channel.es) return;
if (channel.closed) return;
// Hidden-suspended: the only legitimate reopen is the visibilitychange resume path, which clears
// `suspended` first. Everything else (reconnect timers, a component subscribing while the tab is
// backgrounded) must stay quiet so the page keeps no live connection while hidden.
if (channel.suspended) return;
if (channel.reconnectTimer) {
clearTimeout(channel.reconnectTimer);
channel.reconnectTimer = null;
@@ -457,15 +580,58 @@ function closeChannel(channel: Channel): void {
channels.delete(channel.url);
}
/*
FNXC:DashboardSSE 2026-07-26-14:12:
Missed-event contract for every subscriber. The stream is lossy by construction: an error/heartbeat
reconnect and the hidden-tab suspend both drop the socket, and the server keeps no replay buffer, so
events emitted while the socket is down are never delivered. A subscriber that mutates state ONLY
from event handlers therefore diverges permanently from the server.
Every subscription must declare how it survives that gap: supply `onReconnect` (refetch authoritative
state — the normal answer), or set `replaySafe: true` with a reason when the subscriber genuinely has
nothing to resync. Non-compliant subscriptions warn in dev and are reported by `__sseBusResyncAudit`.
*/
export interface SseSubscription {
/** Map of named SSE event type → handler. */
events?: Record<string, MessageListener>;
/** Fires on every successful open (initial + reconnect). */
onOpen?: OpenListener;
/** Fires only on reconnects (not the initial open). Use for resync-on-recovery. */
/**
* Fires only on reconnects (not the initial open), including the hidden-suspend resume.
* Refetch authoritative state here — anything derived only from events is stale by then.
*/
onReconnect?: OpenListener;
/** Forwarded EventSource error events. */
onError?: ErrorListener;
/**
* Explicit opt-out of the resync contract, for subscribers that hold no state a missed event
* could corrupt (pure relays, connection-status-only consumers). Must carry a reason so the
* exemption is reviewable rather than a silent omission.
*/
replaySafe?: { reason: string };
}
/** Subscriptions seen without a resync path, keyed by URL. Test/diagnostics only. */
const resyncGapsByUrl = new Map<string, number>();
function auditResyncContract(url: string, sub: SseSubscription): void {
const hasEvents = !!sub.events && Object.keys(sub.events).length > 0;
if (!hasEvents) return;
if (sub.onReconnect || sub.replaySafe) return;
resyncGapsByUrl.set(url, (resyncGapsByUrl.get(url) ?? 0) + 1);
// Dev-only: loud enough to catch a new non-resyncing subscriber during development, silent in the
// test build so it cannot spam unrelated suites.
if (import.meta.env?.DEV && import.meta.env?.MODE !== "test") {
console.warn(
"[sse-bus] subscriber has no resync path; events missed during a reconnect or hidden-tab suspend will be lost. Add onReconnect (refetch) or replaySafe: { reason }.",
{ url, events: Object.keys(sub.events ?? {}) },
);
}
}
/** Test-only: URLs whose subscribers declared neither onReconnect nor replaySafe. */
export function __sseBusResyncAudit(): Record<string, number> {
return Object.fromEntries(resyncGapsByUrl);
}
/**
@@ -474,6 +640,7 @@ export interface SseSubscription {
* subscriber unsubscribes, the connection is closed.
*/
export function subscribeSse(url: string, sub: SseSubscription = {}): () => void {
auditResyncContract(url, sub);
let channel = channels.get(url);
if (!channel) {
channel = {
@@ -486,6 +653,7 @@ export function subscribeSse(url: string, sub: SseSubscription = {}): () => void
reconnectTimer: null,
hasOpenedOnce: false,
closed: false,
suspended: false,
};
channels.set(url, channel);
}
@@ -538,11 +706,32 @@ export function subscribeSse(url: string, sub: SseSubscription = {}): () => void
/** Test-only: tear down every open channel. */
export function __resetSseBus(): void {
for (const channel of Array.from(channels.values())) closeChannel(channel);
resyncGapsByUrl.clear();
memoryClientId = null;
lastVisibilityReopenAt = 0;
// The hidden-suspend grace timer outlives channels; leaving it armed retains a handle and can
// fire into a later test file's bus.
if (hiddenSuspendTimer) {
clearTimeout(hiddenSuspendTimer);
hiddenSuspendTimer = null;
}
}
/** Test-only: inspect the number of live channels. */
export function __sseBusChannelCount(): number {
return channels.size;
}
/** Test-only: observe hidden-suspend state and live timers for a channel. */
export function __sseBusChannelState(
url: string,
): { suspended: boolean; closed: boolean; hasEventSource: boolean; hasKeepaliveTimer: boolean } | undefined {
const channel = channels.get(url);
if (!channel) return undefined;
return {
suspended: channel.suspended,
closed: channel.closed,
hasEventSource: channel.es !== null,
hasKeepaliveTimer: channel.keepaliveTimer !== null,
};
}

View File

@@ -145,9 +145,15 @@ describe("swrCache", () => {
expect(SWR_CACHE_KEYS.MAILBOX_INBOX_PREFIX).toBe("kb-dashboard-mailbox-inbox-cache:");
expect(SWR_CACHE_KEYS.MAILBOX_OUTBOX_PREFIX).toBe("kb-dashboard-mailbox-outbox-cache:");
expect(SWR_CACHE_KEYS.MAILBOX_UNREAD_COUNT_PREFIX).toBe("kb-dashboard-mailbox-unread-cache:");
expect(SWR_DEFAULT_MAX_AGE_MS).toBe(10 * 60 * 1000);
expect(SWR_TASKS_MAX_AGE_MS).toBe(60_000);
// FNXC:MobileTabDiscard 2026-07-26-12:12: the hydration TTLs are sized to OUTLIVE a mobile tab
// discard (minutes-to-hours in the background), not to bound freshness — every consumer
// revalidates immediately after hydrating. They must stay strictly below the 24h boot prune
// window or the entry is deleted before any hook can read it.
expect(SWR_DEFAULT_MAX_AGE_MS).toBe(6 * 60 * 60 * 1000);
expect(SWR_TASKS_MAX_AGE_MS).toBe(12 * 60 * 60 * 1000);
expect(SWR_LONG_MAX_AGE_MS).toBe(24 * 60 * 60 * 1000);
expect(SWR_DEFAULT_MAX_AGE_MS).toBeLessThan(SWR_LONG_MAX_AGE_MS);
expect(SWR_TASKS_MAX_AGE_MS).toBeLessThan(SWR_LONG_MAX_AGE_MS);
});
it("swallows quota errors", () => {

View File

@@ -49,6 +49,13 @@ export function captureBoardScrollSnapshot(doc?: Document): BoardScrollSnapshot
};
}
/*
FNXC:BoardNavigation 2026-07-26-10:05:
Mobile browsers (iOS Safari tab + installed PWA, Chrome Android) DISCARD a backgrounded dashboard tab and reload it from scratch when the user returns. The restore must land the user back where they were, so the snapshot is also replayed against a freshly hydrated board — not just against a board→detail→back remount.
A freshly reloaded board is EMPTY for as long as its first fetch takes, and scrolling an empty board silently scrolls to nothing and burns the restore. Treat a board with no rendered columns as "not ready yet" (return false so the caller retries) rather than as a successful restore.
Also refuse to replay a snapshot whose columns no longer exist at all (project switched, columns renamed/removed): a snapshot that matches nothing is a stale position, not a restorable one.
Scroll offsets themselves are NOT clamped here — assigning past the maximum is clamped natively by the engine, and computing a max from scrollWidth/clientWidth is meaningless in the layout-less test DOM.
*/
export function restoreBoardScrollSnapshot(snapshot: BoardScrollSnapshot | null, doc?: Document): boolean {
if (!snapshot) return false;
const ownerDocument = getBoardDocument(doc);
@@ -56,6 +63,17 @@ export function restoreBoardScrollSnapshot(snapshot: BoardScrollSnapshot | null,
const board = ownerDocument.getElementById("board") as HTMLElement | null;
if (!board) return false;
const columns = Array.from(board.querySelectorAll<HTMLElement>(".column[data-column]"));
if (columns.length === 0) return false;
const snapshotColumnIds = Object.keys(snapshot.columnTops ?? {});
if (
snapshotColumnIds.length > 0
&& !snapshotColumnIds.some((columnId) => columns.some((column) => column.dataset.column === columnId))
) {
return false;
}
const projectContent = ownerDocument.querySelector<HTMLElement>(".project-content");
const scrollingElement = ownerDocument.scrollingElement as HTMLElement | null;
const defaultView = ownerDocument.defaultView;
@@ -79,7 +97,7 @@ export function restoreBoardScrollSnapshot(snapshot: BoardScrollSnapshot | null,
board.scrollLeft = snapshot.boardLeft;
board.scrollTop = snapshot.boardTop;
board.querySelectorAll<HTMLElement>(".column[data-column]").forEach((column) => {
columns.forEach((column) => {
const columnId = column.dataset.column;
const body = column.querySelector<HTMLElement>(".column-body");
if (columnId && body && Object.prototype.hasOwnProperty.call(snapshot.columnTops, columnId)) {
@@ -89,3 +107,94 @@ export function restoreBoardScrollSnapshot(snapshot: BoardScrollSnapshot | null,
return true;
}
/*
FNXC:BoardNavigation 2026-07-26-10:12:
The board scroll snapshot lived only in a useRef, so an OS/browser tab discard (the mobile white-splash reload) wiped it and dropped the user at the top of the board.
sessionStorage is the correct store for it: it survives a reload AND a discard-restore of the same tab, but is NOT inherited by a brand-new tab — a new tab is a fresh boot and must start at the top of the board. localStorage would leak yesterday's scroll position into every new tab.
The payload is scroll offsets only (numbers keyed by column id); it never carries task content.
*/
const BOARD_SCROLL_SESSION_KEY = "kb-dashboard-board-scroll";
function getSessionStorage(): Storage | null {
if (typeof window === "undefined") return null;
try {
const storage = window.sessionStorage;
if (!storage || typeof storage.getItem !== "function" || typeof storage.setItem !== "function") return null;
return storage;
} catch {
// Safari private mode / disabled storage: scroll restore is a nicety, never a hard failure.
return null;
}
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function parseBoardScrollSnapshot(raw: string): BoardScrollSnapshot | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") return null;
const candidate = parsed as Record<string, unknown>;
const numericKeys = [
"boardLeft",
"boardTop",
"projectContentLeft",
"projectContentTop",
"documentLeft",
"documentTop",
] as const;
if (!numericKeys.every((key) => isFiniteNumber(candidate[key]))) return null;
const rawColumnTops = candidate.columnTops;
if (!rawColumnTops || typeof rawColumnTops !== "object") return null;
const columnTops: Record<string, number> = {};
for (const [columnId, top] of Object.entries(rawColumnTops as Record<string, unknown>)) {
if (!isFiniteNumber(top)) return null;
columnTops[columnId] = top;
}
return {
boardLeft: candidate.boardLeft as number,
boardTop: candidate.boardTop as number,
columnTops,
projectContentLeft: candidate.projectContentLeft as number,
projectContentTop: candidate.projectContentTop as number,
documentLeft: candidate.documentLeft as number,
documentTop: candidate.documentTop as number,
};
}
export function persistBoardScrollSnapshot(snapshot: BoardScrollSnapshot | null): void {
const storage = getSessionStorage();
if (!storage) return;
try {
if (!snapshot) {
storage.removeItem(BOARD_SCROLL_SESSION_KEY);
return;
}
storage.setItem(BOARD_SCROLL_SESSION_KEY, JSON.stringify(snapshot));
} catch {
// Quota/serialization failures must never break navigation.
}
}
export function readPersistedBoardScrollSnapshot(): BoardScrollSnapshot | null {
const storage = getSessionStorage();
if (!storage) return null;
try {
const raw = storage.getItem(BOARD_SCROLL_SESSION_KEY);
return raw ? parseBoardScrollSnapshot(raw) : null;
} catch {
return null;
}
}
export function clearPersistedBoardScrollSnapshot(): void {
persistBoardScrollSnapshot(null);
}

View File

@@ -1,8 +1,8 @@
/**
* Lightweight stale-while-revalidate cache helpers for dashboard reload hydration.
*
* Board task hydration uses a dedicated soft bound (`SWR_TASKS_MAX_AGE_MS`) so reloads do not present obviously stale task snapshots.
* Chat messages and chat agents maps reuse that short TTL for fast-moving thread state, while models and discovered skills use the default 10-minute window for effectively session-static hydration.
* Board task hydration uses a dedicated bound (`SWR_TASKS_MAX_AGE_MS`) sized to outlive a mobile tab discard; see the TTL block below.
* Chat messages and chat agents maps reuse that bound for thread state, while models and discovered skills use the shared default window for effectively session-static hydration.
* Room message/member hydration uses `SWR_CHAT_ROOM_MAX_AGE_MS` for warm-first room opens while keeping background revalidation mandatory.
* Failed task revalidation clears the per-project tasks envelope to avoid re-hydrating stale data on the next reload.
*
@@ -48,11 +48,30 @@ interface CacheEnvelope<T> {
data: T;
}
// Shared default for non-live dashboard hydration paths.
export const SWR_DEFAULT_MAX_AGE_MS = 10 * 60 * 1000;
// Board hydration soft bound: keep stale tasks visible briefly while forcing immediate revalidation.
export const SWR_TASKS_MAX_AGE_MS = 60_000;
export const SWR_CHAT_ROOM_MAX_AGE_MS = 60_000;
/*
FNXC:MobileTabDiscard 2026-07-26-10:20:
These hydration TTLs exist to survive an OS/browser TAB DISCARD, not to bound data freshness.
On iOS Safari, iOS PWAs, and Chrome Android the dashboard tab is evicted after a few minutes in the
background; returning to it re-executes the bundle from scratch. The previous 60s task/room bounds
guaranteed the snapshot was ALWAYS expired on that return (the user was away "a few minutes"), so the
board painted empty and re-fetched from [] — the reported white-then-empty restore.
Correctness comes from the revalidation that every consumer issues immediately after hydrating, plus
the failure path that CLEARS the entry when that revalidation fails; it does NOT come from a short TTL.
A stale-for-one-frame board behind a visible revalidation indicator is strictly better than an empty one.
All values stay strictly below SWR_LONG_MAX_AGE_MS (24h), which is the boot-time
`pruneStaleCacheEntries()` window — a TTL at or above that window would be unreachable in practice
because the prune deletes the entry before any hydration hook reads it.
*/
// Shared default for non-live dashboard hydration paths (projects, agents, documents, todos, models,
// insights, evals, artifacts, room members). All of these revalidate on mount.
export const SWR_DEFAULT_MAX_AGE_MS = 6 * 60 * 60 * 1000;
// Board hydration bound: paint the last known board instantly on restore, then revalidate.
export const SWR_TASKS_MAX_AGE_MS = 12 * 60 * 60 * 1000;
// Room message hydration bound; `loadRoomData` always refetches members+messages after hydrating.
export const SWR_CHAT_ROOM_MAX_AGE_MS = 12 * 60 * 60 * 1000;
export const SWR_LONG_MAX_AGE_MS = 24 * 60 * 60 * 1000;
function getLocalStorage(): Storage | null {
@@ -65,7 +84,20 @@ function getLocalStorage(): Storage | null {
return null;
}
export function readCache<T>(key: string, options?: { maxAgeMs?: number }): T | null {
/**
* FNXC:MobileTabDiscard 2026-07-26-14:05:
* Envelope-aware read. `readCache` deliberately discards `savedAt`, which was fine while every
* hydration TTL was ~60s (any hydrated snapshot was younger than every downstream freshness
* threshold, so "as of now" was a harmless approximation). Raising `SWR_TASKS_MAX_AGE_MS` to hours
* for the mobile tab-discard restore broke that assumption: a consumer that hydrates an hours-old
* board and then reasons about elapsed time against `Date.now()` reports every in-progress card as
* stuck. Consumers deriving time-sensitive state from a hydrated snapshot MUST read through this
* function and carry `savedAt` as their "data as of" clock.
*
* Returns `null` for exactly the cases `readCache` treats as a miss (absent, malformed, expired);
* `readCache` is a thin wrapper so existing call sites are unchanged.
*/
export function readCacheEntry<T>(key: string, options?: { maxAgeMs?: number }): CacheReadResult<T> | null {
const storage = getLocalStorage();
if (!storage) {
return null;
@@ -79,17 +111,17 @@ export function readCache<T>(key: string, options?: { maxAgeMs?: number }): T |
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object") {
return parsed as T;
return { data: parsed as T, savedAt: undefined };
}
const hasEnvelopeMarkers = "savedAt" in parsed || "data" in parsed;
if (!hasEnvelopeMarkers) {
return parsed as T;
return { data: parsed as T, savedAt: undefined };
}
const envelope = parsed as Partial<CacheEnvelope<T>>;
if (typeof envelope.savedAt !== "number" || Number.isNaN(envelope.savedAt)) {
return envelope.data ?? null;
return { data: (envelope.data ?? null) as T, savedAt: undefined };
}
const maxAgeMs = options?.maxAgeMs;
@@ -112,16 +144,30 @@ export function readCache<T>(key: string, options?: { maxAgeMs?: number }): T |
}
}
return envelope.data ?? null;
return { data: (envelope.data ?? null) as T, savedAt: envelope.savedAt };
} catch {
return null;
}
}
export function writeCache<T>(key: string, value: T, options?: { maxBytes?: number }): void {
export function readCache<T>(key: string, options?: { maxAgeMs?: number }): T | null {
const entry = readCacheEntry<T>(key, options);
return entry === null ? null : entry.data;
}
/**
* FNXC:MobileTabDiscard 2026-07-26-10:26:
* Returns whether the snapshot was actually persisted. An over-budget payload is still dropped
* silently (that bound protects the localStorage quota), but the drop can no longer be INVISIBLE to
* the caller: a large board used to exceed `maxBytes`, write nothing, and therefore have no snapshot
* at all to hydrate from after a mobile tab discard — the exact restore this cache exists to fix.
* Callers that can shrink their payload (see `writeTaskCacheSnapshot` in useTasks) retry on `false`.
* The return value is additive; existing callers ignore it.
*/
export function writeCache<T>(key: string, value: T, options?: { maxBytes?: number }): boolean {
const storage = getLocalStorage();
if (!storage) {
return;
return false;
}
try {
@@ -131,12 +177,14 @@ export function writeCache<T>(key: string, value: T, options?: { maxBytes?: numb
} satisfies CacheEnvelope<T>);
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
if (new TextEncoder().encode(serialized).length > maxBytes) {
return;
return false;
}
storage.setItem(key, serialized);
return true;
} catch {
// Ignore quota and storage errors.
return false;
}
}

View File

@@ -207,6 +207,21 @@ beforeEach(() => {
clearDaemonAuthEnv();
MockEventSource.instances = [];
(globalThis as any).EventSource = MockEventSource;
/*
FNXC:DashboardTests 2026-07-26-13:05:
Every test must start as a FRESH TAB. useViewState (and the board scroll snapshot) mirror per-tab
state into sessionStorage so an involuntary mobile tab discard/reload restores the operator's real
view and scroll position instead of bouncing to the landing view. sessionStorage is per-tab in a
browser, but a vitest worker shares ONE jsdom session store across every test in the file — so
without this reset, one test's view silently wins over the localStorage view the next test seeds
(this leaked into 30+ App/navigation/task-detail cases). Tests that need a mid-test "separate boot"
still clear it themselves; seeds written inside a test body are unaffected because this runs first.
*/
try {
globalThis.sessionStorage?.clear();
} catch {
// sessionStorage is absent in the node (non-jsdom) route/server test environment.
}
});
// Clean up after each test