diff --git a/app/src/App.tsx b/app/src/App.tsx
index 8e9e4394b..bce8c4d95 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -31,6 +31,7 @@ import SecurityBanner from './components/SecurityBanner';
import SettingsModal from './components/settings/modal/SettingsModal';
import { resolveSettingsOverlay } from './components/settings/modal/settingsOverlay';
import GlobalUpsellBanner from './components/upsell/GlobalUpsellBanner';
+import UserErrorCenter from './components/userErrors/UserErrorCenter';
import AppWalkthrough from './components/walkthrough/AppWalkthrough';
import { MascotFrameProducer } from './features/meet/MascotFrameProducer';
import { useNotchBootSync } from './hooks/useNotchBootSync';
@@ -309,6 +310,11 @@ export function AppShellDesktop() {
beneath when the URL is a settings path. */}
{settingsOpen && !chromeless && }
+ {/* User-actionable runtime errors (#3931): a first-class panel for
+ expected user states (insufficient BYO credits, managed-budget
+ exhaustion). Mounted outside the routes so entries survive route
+ changes and background-job completion. */}
+
{/* Hidden Remotion-driven producer for the Meet camera. Mounts a
640×480 JPEG frame stream to the Rust frame bus while a meet
call is active; idle no-op otherwise. See
diff --git a/app/src/__tests__/App.webviewOverlay.test.tsx b/app/src/__tests__/App.webviewOverlay.test.tsx
index cef247dd2..16181c51a 100644
--- a/app/src/__tests__/App.webviewOverlay.test.tsx
+++ b/app/src/__tests__/App.webviewOverlay.test.tsx
@@ -29,6 +29,10 @@ const baseState = {
logs: {},
overlayOpen: false,
},
+ // The desktop shell now mounts , which reads this slice
+ // via selectActiveUserErrors; include its empty initial shape so the
+ // component renders null instead of throwing on an undefined slice (#3931).
+ userErrors: { byId: {}, order: [] },
};
let mockState = baseState;
diff --git a/app/src/components/userErrors/UserErrorCenter.tsx b/app/src/components/userErrors/UserErrorCenter.tsx
new file mode 100644
index 000000000..9532eeae8
--- /dev/null
+++ b/app/src/components/userErrors/UserErrorCenter.tsx
@@ -0,0 +1,137 @@
+/**
+ * UserErrorCenter (#3931)
+ * -----------------------
+ *
+ * First-class, shell-mounted surface for user-actionable runtime errors. It is
+ * rendered once in the desktop shell (outside the router) so entries stay
+ * visible across route changes and after background/cron jobs finish — there is
+ * no dependence on an active chat route.
+ *
+ * Notification affordance: while unresolved entries exist, a fixed trigger with
+ * a count badge appears. Opening it reveals the panel; each entry shows a title,
+ * one-line explanation, source + timestamp, recurrence count, a primary action
+ * that deep-links to the relevant settings flow, and a dismiss control.
+ *
+ * Privacy: only translated copy (via i18n keys) and privacy-safe metadata are
+ * shown — never raw provider responses, tokens, prompts, or PII.
+ */
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import { useT } from '../../lib/i18n/I18nContext';
+import { useAppDispatch, useAppSelector } from '../../store/hooks';
+import { selectActiveUserErrors } from '../../store/userErrorsSelectors';
+import { dismissUserError, resolveUserError } from '../../store/userErrorsSlice';
+import type { UserActionableError, UserErrorAction } from '../../types/userError';
+
+/** Deep-link target for each primary action. `dismiss` has no route. */
+const ACTION_ROUTE: Record, string> = {
+ open_billing: '/settings/billing',
+ open_provider_settings: '/settings/llm',
+};
+
+/** i18n key for each primary action's button label. */
+const ACTION_LABEL_KEY: Record, string> = {
+ open_billing: 'userErrors.action.openBilling',
+ open_provider_settings: 'userErrors.action.openProviderSettings',
+};
+
+// Wall-clock read for the resolve/dismiss timestamps. Defined at module scope
+// so the component body doesn't reference an impure function during render
+// (react-hooks/purity); the calls below run only from event handlers.
+const nowMs = (): number => Date.now();
+
+export function UserErrorCenter() {
+ const { t } = useT();
+ const dispatch = useAppDispatch();
+ const navigate = useNavigate();
+ const active = useAppSelector(selectActiveUserErrors);
+ const [open, setOpen] = useState(false);
+
+ // Nothing to surface → render nothing at all (no idle chrome in the shell).
+ if (active.length === 0) return null;
+
+ const runAction = (entry: UserActionableError) => {
+ if (entry.action !== 'dismiss') {
+ navigate(ACTION_ROUTE[entry.action]);
+ }
+ dispatch(resolveUserError({ id: entry.id, at: nowMs() }));
+ setOpen(false);
+ };
+
+ return (
+