diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index e4fd2c20..9f5041cb 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -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(() => { try { return localStorage.getItem('os_ft_nudge_dismissed') === '1'; } catch { return false; } }); @@ -462,53 +454,6 @@ 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.{' '} - dispatch(openSettingsCard({ tab: 'models' }))} - sx={{ - textDecoration: 'underline', - cursor: 'pointer', - fontWeight: 600, - '&:hover': { opacity: 0.8 }, - transition: 'opacity 0.15s', - }} - > - Configure models - - {' '}to get started - - )} - - - - @@ -562,6 +507,7 @@ const AppShell: React.FC = () => { {!fsHideChrome && } + {!fsHideChrome && } {/* Deliberately NOT behind fsHideChrome: a dead backend must be visible even in fullscreen, or the app reads as frozen (ENG-242). */} {!fsHideChrome && } diff --git a/frontend/src/app/components/Layout/ModelErrorPill.tsx b/frontend/src/app/components/Layout/ModelErrorPill.tsx new file mode 100644 index 00000000..f2261411 --- /dev/null +++ b/frontend/src/app/components/Layout/ModelErrorPill.tsx @@ -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 ( + + 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', + }} + > + + + + {title} + + + {sub} + + + {clickable && ( + + )} + + + ); +}; + +export default ModelErrorPill; diff --git a/frontend/src/app/components/Layout/modelErrorState.test.ts b/frontend/src/app/components/Layout/modelErrorState.test.ts new file mode 100644 index 00000000..4066285a --- /dev/null +++ b/frontend/src/app/components/Layout/modelErrorState.test.ts @@ -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); +}); diff --git a/frontend/src/app/components/Layout/modelErrorState.ts b/frontend/src/app/components/Layout/modelErrorState.ts new file mode 100644 index 00000000..9f7dba78 --- /dev/null +++ b/frontend/src/app/components/Layout/modelErrorState.ts @@ -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; +}