[eric] shell: the models error banner becomes a clickable red pill that heals itself when connectivity returns (ENG-328)

This commit is contained in:
ciregenz
2026-08-16 20:17:15 -07:00
parent 824bc10566
commit d0d60ca8f9
4 changed files with 206 additions and 56 deletions
@@ -25,6 +25,7 @@ import { ackRun, runWorkflowNow } from '@/shared/state/workflowsSlice';
import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice';
import { fetchOutputs } from '@/shared/state/outputsSlice';
import UpdateReadyPill from '@/app/components/Layout/UpdateReadyPill';
import ModelErrorPill from '@/app/components/Layout/ModelErrorPill';
import ReconnectingPill from '@/app/components/Layout/ReconnectingPill';
import SafeModePill from '@/app/components/Layout/SafeModePill';
import WhatsNewCard from '@/app/components/Layout/WhatsNewCard';
@@ -37,7 +38,6 @@ import { useClaudeTokens, useThemeAccent, useThemeWash } from '@/shared/styles/T
import SpacesStrip from '@/app/pages/Dashboard/desktop/SpacesStrip';
import { washBackgroundLayers, washUnderlayColor, effectiveWashStops } from '@/shared/styles/washBackground';
import { useGrainTileUrl } from '@/shared/styles/useGrainTileUrl';
import { ErrorSlime } from '@/app/components/feedback/ErrorSlime';
const AppShell: React.FC = () => {
const c = useClaudeTokens();
@@ -71,8 +71,6 @@ const AppShell: React.FC = () => {
};
}, []);
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.
const hasModelConnected = useAppSelector(selectHasModelConnected);
@@ -131,12 +129,6 @@ const AppShell: React.FC = () => {
const h = Math.floor(secs / 3600);
return h >= 1 ? `~${h}h` : `~${Math.max(1, Math.round(secs / 60))}m`;
}, [proUsage]);
// 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 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; }
});
@@ -462,53 +454,6 @@ const AppShell: React.FC = () => {
<DynamicIsland />
</Box>
<Collapse in={showWarningBanner && !fsHideChrome} timeout={350} unmountOnExit>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2,
py: 0.6,
bgcolor: 'rgba(239, 68, 68, 0.08)',
borderBottom: '1px solid rgba(239, 68, 68, 0.18)',
flexShrink: 0,
animation: showWarningBanner ? 'warning-fade-in 0.4s ease-out' : undefined,
'@keyframes warning-fade-in': {
from: { opacity: 0 },
to: { opacity: 1 },
},
}}
>
<ErrorSlime size={22} />
<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.{' '}
<Box
component="span"
onClick={() => dispatch(openSettingsCard({ tab: 'models' }))}
sx={{
textDecoration: 'underline',
cursor: 'pointer',
fontWeight: 600,
'&:hover': { opacity: 0.8 },
transition: 'opacity 0.15s',
}}
>
Configure models
</Box>
{' '}to get started
</>
)}
</Typography>
</Box>
</Collapse>
<Collapse in={showFreeTrialNudge && !fsHideChrome} timeout={300} unmountOnExit>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 2, py: 0.5, flexShrink: 0 }}>
<Typography sx={{ fontSize: '0.8125rem', color: c.text.secondary, flex: 1, letterSpacing: '0.01em' }}>
@@ -562,6 +507,7 @@ const AppShell: React.FC = () => {
</Collapse>
{!fsHideChrome && <UpdateReadyPill />}
{!fsHideChrome && <ModelErrorPill />}
{/* Deliberately NOT behind fsHideChrome: a dead backend must be visible even in fullscreen, or the app reads as frozen (ENG-242). */}
<ReconnectingPill />
{!fsHideChrome && <SafeModePill />}
@@ -0,0 +1,140 @@
import React, { useEffect, useState } from 'react';
import Box from '@mui/material/Box';
import Grow from '@mui/material/Grow';
import Typography from '@mui/material/Typography';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { openSettingsCard } from '@/shared/state/dashboardLayoutSlice';
import { hasModelConnected as selectHasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates';
import { fetchSettings } from '@/shared/state/settingsSlice';
import { fetchModels } from '@/shared/state/modelsSlice';
import { onBackendReachability } from '@/shared/backendConnection';
import { ErrorSlime } from '@/app/components/feedback/ErrorSlime';
import { modelErrorState } from '@/app/components/Layout/modelErrorState';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
// The old top banner for "no model / offline / backend gone" was a dead end that could also lie:
// a network blip failed the settings+models fetches, nothing ever re-fetched, and the red wall sat
// there claiming models were unconfigured until the user happened to open Settings (Haik's report,
// 2026-08-16). This is its replacement: the update-pill's quiet-card shape in red, whole card
// clickable into Settings -> Models, and it heals ITSELF because recovery re-runs the fetches.
const ModelErrorPill: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const [hovered, setHovered] = useState(false);
const [isOnline, setIsOnline] = useState(navigator.onLine);
const settingsKnown = useAppSelector((s) => s.settings.loaded);
const settingsSettled = useAppSelector((s) => s.settings.settled);
const modelsLoaded = useAppSelector((s) => s.models.loaded && !s.models.failed);
const hasModel = useAppSelector(selectHasModelConnected);
const freeTrialArmSettled = useAppSelector((s) => s.settings.freeTrialArmSettled);
const freeTrialActive = useAppSelector((s) => {
const d = s.settings.data as any;
return !!(d && d.connection_mode === 'free-trial' && d.free_trial_token);
});
const freeTrialSpent = useAppSelector((s) => {
const d = s.settings.data as any;
return !!(d && (d.free_trial_runs_limit ?? 0) > 0 && d.free_trial_remaining === 0 && d.connection_mode !== 'free-trial');
});
// Sit below the update pill only while it actually owns the corner.
const updatePillShowing = useAppSelector((s) => s.update.status === 'downloaded');
const state = modelErrorState({
isOnline, settingsKnown, settingsSettled, modelsOk: modelsLoaded,
hasModel, freeTrialArmSettled, freeTrialActive, freeTrialSpent,
});
useEffect(() => {
const goOnline = () => setIsOnline(true);
const goOffline = () => setIsOnline(false);
window.addEventListener('online', goOnline);
window.addEventListener('offline', goOffline);
return () => {
window.removeEventListener('online', goOnline);
window.removeEventListener('offline', goOffline);
};
}, []);
// The self-heal: any recovery signal re-runs the fetches whose stale failure kept the error up.
useEffect(() => {
const refetch = () => {
dispatch(fetchSettings());
dispatch(fetchModels());
};
const onOnline = () => refetch();
window.addEventListener('online', onOnline);
const offReach = onBackendReachability((reachable) => { if (reachable) refetch(); });
return () => {
window.removeEventListener('online', onOnline);
offReach();
};
}, [dispatch]);
const clickable = state === 'no-model';
const title =
state === 'offline' ? 'No internet connection'
: state === 'backend' ? 'OpenSwarm backend unreachable'
: 'No AI model connected';
const sub =
state === 'offline' ? "Agents can't reach AI models"
: state === 'backend' ? 'Reconnecting…'
: 'Configure models in Settings';
return (
<Grow in={state !== null} unmountOnExit>
<Box
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={clickable ? () => dispatch(openSettingsCard({ tab: 'models' })) : undefined}
role={clickable ? 'button' : undefined}
aria-label={title}
sx={{
position: 'fixed',
top: updatePillShowing ? 66 : 14,
right: 16,
zIndex: 1399,
WebkitAppRegion: 'no-drag',
display: 'flex',
alignItems: 'center',
gap: 1.25,
pl: 1.25,
pr: 1.5,
py: 1,
borderRadius: '12px',
bgcolor: c.bg.surface,
border: `1px solid ${hovered && clickable ? 'rgba(239, 68, 68, 0.75)' : 'rgba(239, 68, 68, 0.45)'}`,
boxShadow: hovered && clickable ? c.shadow.lg : c.shadow.md,
cursor: clickable ? 'pointer' : 'default',
userSelect: 'none',
transition: 'box-shadow 0.18s ease, border-color 0.18s ease, transform 0.18s ease, top 0.18s ease',
transform: hovered && clickable ? 'translateY(-1px)' : 'none',
}}
>
<ErrorSlime size={22} />
<Box sx={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 600, lineHeight: 1.25, color: '#ef4444', whiteSpace: 'nowrap' }}>
{title}
</Typography>
<Typography sx={{ fontSize: '0.6875rem', lineHeight: 1.3, color: c.text.tertiary, whiteSpace: 'nowrap' }}>
{sub}
</Typography>
</Box>
{clickable && (
<ArrowForwardIcon
sx={{
fontSize: 16,
color: c.text.tertiary,
ml: 0.5,
flexShrink: 0,
transition: 'transform 0.18s ease',
transform: hovered ? 'translateX(2px)' : 'none',
}}
/>
)}
</Box>
</Grow>
);
};
export default ModelErrorPill;
@@ -0,0 +1,41 @@
// The lying-banner class (Haik, 2026-08-16): a transient outage failed the settings/models fetches
// and the old banner then claimed "no model configured" forever. The ladder must never report
// no-model while any loading gate is unsettled, and each state must clear on its recovery signal.
import { test } from 'node:test';
import assert from 'node:assert';
import { modelErrorState, ModelErrorInputs } from './modelErrorState';
const healthy: ModelErrorInputs = {
isOnline: true, settingsKnown: true, settingsSettled: true, modelsOk: true,
hasModel: true, freeTrialArmSettled: true, freeTrialActive: false, freeTrialSpent: false,
};
test('healthy install shows nothing', () => {
assert.strictEqual(modelErrorState(healthy), null);
});
test('offline outranks everything', () => {
assert.strictEqual(modelErrorState({ ...healthy, isOnline: false, hasModel: false }), 'offline');
});
test('unreachable backend says backend, never no-model', () => {
assert.strictEqual(modelErrorState({ ...healthy, settingsKnown: false, hasModel: false }), 'backend');
});
test('a failed models fetch must NOT claim no-model (the lying-banner bug)', () => {
// Settings answered, but the /models fetch failed during a blip: without data there is no claim.
assert.strictEqual(modelErrorState({ ...healthy, modelsOk: false, hasModel: false }), null);
});
test('genuine no-model fires only with every gate settled', () => {
assert.strictEqual(modelErrorState({ ...healthy, hasModel: false }), 'no-model');
assert.strictEqual(modelErrorState({ ...healthy, hasModel: false, freeTrialArmSettled: false }), null);
assert.strictEqual(modelErrorState({ ...healthy, hasModel: false, freeTrialActive: true }), null);
assert.strictEqual(modelErrorState({ ...healthy, hasModel: false, freeTrialSpent: true }), null);
});
test('recovery clears it: same inputs back to healthy read null again', () => {
const broken = { ...healthy, settingsKnown: false, hasModel: false };
assert.strictEqual(modelErrorState(broken), 'backend');
assert.strictEqual(modelErrorState({ ...broken, settingsKnown: true, hasModel: true }), null);
});
@@ -0,0 +1,23 @@
export interface ModelErrorInputs {
isOnline: boolean;
settingsKnown: boolean;
settingsSettled: boolean;
modelsOk: boolean;
hasModel: boolean;
freeTrialArmSettled: boolean;
freeTrialActive: boolean;
freeTrialSpent: boolean;
}
export type ModelErrorState = 'offline' | 'backend' | 'no-model' | null;
// Priority ladder for the red pill: offline is its own signal, an unreachable backend means we
// KNOW nothing about models (never claim "not configured" on missing data, that was the lying
// banner), and "no model" only fires once every loading gate has genuinely settled.
export function modelErrorState(i: ModelErrorInputs): ModelErrorState {
if (!i.isOnline) return 'offline';
if (i.settingsSettled && !i.settingsKnown) return 'backend';
const noModel = i.settingsKnown && i.modelsOk && i.freeTrialArmSettled
&& !i.hasModel && !i.freeTrialActive && !i.freeTrialSpent;
return noModel ? 'no-model' : null;
}