diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index a9209932..43fed04a 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -208,6 +208,7 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = const accentColor = useAppSelector((s) => s.settings.data.accent_color); const accentGradient = useAppSelector((s) => s.settings.data.accent_gradient); const loaded = useAppSelector((s) => s.settings.loaded); + const settled = useAppSelector((s) => s.settings.settled); const allowExperimentalUpdates = useAppSelector((s) => s.settings.data.allow_experimental_updates); useEffect(() => { dispatch(fetchSettings()); @@ -275,8 +276,8 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = if (!loaded) return; (window as any).openswarm?.setAllowPrerelease?.(allowExperimentalUpdates); }, [loaded, allowExperimentalUpdates]); - // Hold paint until settings land so the user's theme renders first; Electron's ready-to-show relies on this. - if (!loaded) return null; + // Hold paint until the settings fetch SETTLES so the user's theme renders first; Electron's ready-to-show relies on this. Settling, not succeeding: a backend that never answers used to leave a blank window forever. + if (!settled) return null; return <>{children}; }; diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 08db74c3..aa312afd 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -88,6 +88,8 @@ const AppShell: React.FC = () => { }, []); const modelsLoaded = useAppSelector((s) => s.models.loaded); + // 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. const hasModelConnected = useAppSelector(selectHasModelConnected); // While onboarding owns the window, the floating sidebar (and its hover-peek strip) must not exist; both out-z the overlay. @@ -147,12 +149,14 @@ const AppShell: React.FC = () => { // Hold the banner until the boot free-trial mint settles, else a brand-new user sees it flash red for the ~1-3s the trial takes to arm. (Offline shows immediately, it's its own signal.) const freeTrialArmSettled = useAppSelector((s) => s.settings.freeTrialArmSettled); // The red wall is for genuine "no way to run" only; the free-trial states get the quiet nudge below. - const showWarningBanner = !isOnline || (modelsLoaded && freeTrialArmSettled && !hasModelConnected && !freeTrialActive && !freeTrialSpent); + const settingsSettled = useAppSelector((s) => s.settings.settled); + const backendUnreachable = settingsSettled && !settingsKnown; + const showWarningBanner = !isOnline || backendUnreachable || (settingsKnown && modelsLoaded && freeTrialArmSettled && !hasModelConnected && !freeTrialActive && !freeTrialSpent); const [ftNudgeDismissed, setFtNudgeDismissed] = useState(() => { try { return localStorage.getItem('os_ft_nudge_dismissed') === '1'; } catch { return false; } }); // Spent nudge hides the moment they connect a real model; the post-wow nudge only shows on the trial lane (so it already implies no own model) and is dismissible. - const showFreeTrialNudge = isOnline && ((freeTrialSpent && !hasModelConnected) || (freeTrialUsed && !ftNudgeDismissed)); + const showFreeTrialNudge = isOnline && settingsKnown && ((freeTrialSpent && !hasModelConnected) || (freeTrialUsed && !ftNudgeDismissed)); const bannerDismissedForVersion = availableVersion != null && dismissedVersion === availableVersion; const isUpdateActionable = updateStatus === 'available' || updateStatus === 'downloaded' || updateStatus === 'downloading'; @@ -478,6 +482,8 @@ const AppShell: React.FC = () => { {!isOnline ? 'No internet connection; agents cannot reach AI models or external services' + : backendUnreachable + ? 'Cannot reach the OpenSwarm backend; your settings and agents are unavailable until it comes back' : ( <> No AI model connected.{' '} diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx index 93bf2374..f86bf680 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx @@ -66,6 +66,7 @@ const DashboardEmptyState: React.FC<{ const model = useAppSelector((s) => s.settings.data.default_model); const mode = useAppSelector((s) => s.settings.data.default_mode); const canRun = useAppSelector((s) => hasFreeTrialActive(s) || hasModelConnected(s)); + const settingsKnown = useAppSelector((s) => s.settings.loaded); const personalized = useAppSelector((s) => s.settings.data.personalized_starters ?? []); const personalizedMenu = useAppSelector((s) => s.settings.data.personalized_menu ?? null); const userName = useAppSelector((s) => s.settings.data.user_name ?? null); @@ -248,7 +249,8 @@ const DashboardEmptyState: React.FC<{ ) : ( - Connect a model in Settings to get started. + {/* With the backend down we don't know what the user has connected, so don't tell them they have nothing. */} + {settingsKnown ? 'Connect a model in Settings to get started.' : 'Waiting for the OpenSwarm backend...'} )} diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index 7fb32645..95d4cb11 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -139,7 +139,12 @@ export interface BrowseResult { interface SettingsState { data: AppSettings; loading: boolean; + /** We have the user's real settings. Anything that judges the user (no model connected, onboarding + * not done, free runs spent) must read THIS, never `settled`: a failed fetch is not an answer. */ loaded: boolean; + /** The first fetch finished, either way. Only the paint gate wants this, so a dead backend shows a + * window with an honest error instead of a blank one. */ + settled: boolean; modalOpen: boolean; /** When non-null, Settings opens to this tab instead of 'general'. */ initialTab: string | null; @@ -182,6 +187,7 @@ const initialState: SettingsState = { data: DEFAULT_SETTINGS, loading: false, loaded: false, + settled: false, modalOpen: false, initialTab: null, draft: null, @@ -334,6 +340,7 @@ const settingsSlice = createSlice({ .addCase(fetchSettings.fulfilled, (state, action) => { state.loading = false; state.loaded = true; + state.settled = true; // Drop a stale response: on boot three fetches race (initial, sub-sync, free-trial mint); if the pre-mint one resolves last it would wipe the armed trial. Newest wins. if (state.latestWriteId && action.meta.requestId !== state.latestWriteId) return; // Fill any field an older backend shape omitted so no consumer reads undefined; the payload still wins for everything it does send. @@ -347,7 +354,8 @@ const settingsSlice = createSlice({ }) .addCase(fetchSettings.rejected, (state) => { state.loading = false; - state.loaded = true; + state.settled = true; + // `loaded` deliberately stays put. A failed fetch is not an answer, and claiming one made every gate read DEFAULT_SETTINGS as fact: with the backend down the app greeted a configured user as a brand-new one and offered to sell them a subscription. A refresh that fails keeps the last good copy; a first fetch that fails stays unknown. }) .addCase(updateSettingsPatch.fulfilled, (state, action) => { // A user save is authoritative; claim newest so an in-flight GET can't overwrite it, and consume the draft so reopening shows the saved state.