mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] shell: the models error banner becomes a clickable red pill that heals itself when connectivity returns (ENG-328)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user