diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 7b9ece9f..8823ec31 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -215,6 +215,25 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = useEffect(() => { dispatch(fetchSettings()); dispatch(fetchModels()); + // Boot race (ENG-207): the renderer can beat the backend up by many seconds, and a hidden + // window's timers get App-Napped, so a one-shot fetch that lost the race stayed lost until a + // manual reload. Retry the two bootstrap reads until settings answer, and re-kick on focus / + // visibility so a napped window heals the moment anyone looks at it. + let bootTimer: ReturnType | null = null; + const bootRetry = () => { + const st = store.getState(); + const settingsOk = st.settings.loaded; + const modelsOk = st.models.loaded && !st.models.failed; + if (settingsOk && modelsOk) { + if (bootTimer) { clearInterval(bootTimer); bootTimer = null; } + return; + } + if (!settingsOk) dispatch(fetchSettings()); + if (!modelsOk) dispatch(fetchModels()); + }; + bootTimer = setInterval(bootRetry, 3000); + window.addEventListener('focus', bootRetry); + document.addEventListener('visibilitychange', bootRetry); // Report the app launch with the browser's canonical tz/locale so the backend can emit analytics app_lifecycle.opened with values that work in packaged, dev, and open-source builds. Guarded once per page load; backend dedupes per process. reportAppOpened(); // Connected subscriptions live in their own slice; without this the dashboard (and the onboarding gate) think no model is connected until the user opens Settings > Models, so a fresh launch shows a false "connect a model" empty state and the welcome cursor never fires. Refetched after sync + on focus below. @@ -231,6 +250,11 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = // The backend arms server-side regardless of whether the browser can read the mint response (a transient boot-time CORS/timing miss makes `data` unreadable), so refetch unconditionally, the GET is the only reliable signal the UI gets that it armed. .finally(() => { dispatch(fetchSettings()); dispatch(fetchSubscriptionStatus()); dispatch(markFreeTrialArmSettled()); }); }); + return () => { + if (bootTimer) clearInterval(bootTimer); + window.removeEventListener('focus', bootRetry); + document.removeEventListener('visibilitychange', bootRetry); + }; }, [dispatch]); useEffect(() => { diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 753cba04..a125a4d3 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -68,7 +68,7 @@ const AppShell: React.FC = () => { }; }, []); - const modelsLoaded = useAppSelector((s) => s.models.loaded); + const modelsLoaded = useAppSelector((s) => s.models.loaded && !s.models.failed); // The models list is marked loaded even when its fetch fails, so it alone can't tell "no model" from "couldn't ask". Settings is where the user's own key/sub lives, so the banner waits for it. const settingsKnown = useAppSelector((s) => s.settings.loaded); // "Connected" = the user's OWN model (key/sub/pro/custom), NOT a non-empty /models list: the free-trial Haiku is always in that list now, so a byProvider-length check would falsely read as connected and hide the out-of-runs banner. diff --git a/frontend/src/app/pages/DashboardAutoEnter/DashboardAutoEnter.tsx b/frontend/src/app/pages/DashboardAutoEnter/DashboardAutoEnter.tsx index abfe9abc..88a8a1b5 100644 --- a/frontend/src/app/pages/DashboardAutoEnter/DashboardAutoEnter.tsx +++ b/frontend/src/app/pages/DashboardAutoEnter/DashboardAutoEnter.tsx @@ -33,9 +33,16 @@ const DashboardAutoEnter: React.FC = () => { if (!cancelled) timer = setTimeout(enter, 3000); }; enter(); + // A hidden window's timers get App-Napped, so the 3s retry could stall forever while the + // backend finished booting behind it (ENG-207). Focus/visibility re-kick heals it on sight. + const rekick = (): void => { if (!timer) return; clearTimeout(timer); timer = null; enter(); }; + window.addEventListener('focus', rekick); + document.addEventListener('visibilitychange', rekick); return () => { cancelled = true; if (timer) clearTimeout(timer); + window.removeEventListener('focus', rekick); + document.removeEventListener('visibilitychange', rekick); }; }, [dispatch, navigate]); diff --git a/frontend/src/shared/config.ts b/frontend/src/shared/config.ts index 1aba8b17..019d7219 100644 --- a/frontend/src/shared/config.ts +++ b/frontend/src/shared/config.ts @@ -48,7 +48,12 @@ export async function refreshAuthToken(): Promise { /** Resolve auth token once; concurrent callers share the same promise. */ export function ensureAuthToken(): Promise { if (_authTokenPromise) return _authTokenPromise; - _authTokenPromise = refreshAuthToken(); + _authTokenPromise = refreshAuthToken().then((tok) => { + // A boot race can resolve EMPTY (backend hadn't written the token file yet); memoizing that + // left the renderer auth-dead until a manual reload (ENG-207). Empty = not an answer; retry. + if (!tok) _authTokenPromise = null; + return tok; + }); return _authTokenPromise; } diff --git a/frontend/src/shared/state/modelsSlice.ts b/frontend/src/shared/state/modelsSlice.ts index 2e576418..639e3276 100644 --- a/frontend/src/shared/state/modelsSlice.ts +++ b/frontend/src/shared/state/modelsSlice.ts @@ -21,11 +21,13 @@ export interface ModelOption { interface ModelsState { byProvider: Record; loaded: boolean; + failed: boolean; } const initialState: ModelsState = { byProvider: {}, loaded: false, + failed: false, }; export const fetchModels = createAsyncThunk('models/fetchModels', async () => { @@ -49,6 +51,11 @@ const modelsSlice = createSlice({ .addCase(fetchModels.rejected, (state) => { // Mark loaded even on failure so callers fall back to hardcoded options. state.loaded = true; + // ...but remember it FAILED: a boot-race miss used to read as "loaded with no models" and lit the red banner on a fully configured install (ENG-207). + state.failed = true; + }) + .addCase(fetchModels.pending, (state) => { + state.failed = false; }); }, });