[eric] settings: a dead backend greeted a configured user as new and tried to sell them a subscription

This commit is contained in:
ciregenz
2026-07-31 21:33:00 -07:00
parent 37ba03316c
commit f2b3bf0cd3
4 changed files with 23 additions and 6 deletions
+3 -2
View File
@@ -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}</>;
};
@@ -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<boolean>(() => {
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 = () => {
<Typography sx={{ fontSize: '0.875rem', color: '#ef4444', flex: 1, fontWeight: 500, letterSpacing: '0.01em' }}>
{!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.{' '}
@@ -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<{
</>
) : (
<Typography sx={{ color: c.text.ghost, fontSize: c.font.size.base, textAlign: 'center' }}>
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...'}
</Typography>
)}
</Box>
+9 -1
View File
@@ -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.