From c96bd3db3f1a704f2d167686561fd482a6b1432b Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:19:02 +0530 Subject: [PATCH] feat(app): user-actionable runtime error panel (minimal slice) (#3983) --- app/src/App.tsx | 6 + app/src/__tests__/App.webviewOverlay.test.tsx | 4 + .../components/userErrors/UserErrorCenter.tsx | 137 ++++++++++++++++++ .../__tests__/UserErrorCenter.test.tsx | 98 +++++++++++++ app/src/lib/i18n/ar.ts | 11 ++ app/src/lib/i18n/bn.ts | 12 ++ app/src/lib/i18n/de.ts | 13 ++ app/src/lib/i18n/en.ts | 13 ++ app/src/lib/i18n/es.ts | 13 ++ app/src/lib/i18n/fr.ts | 13 ++ app/src/lib/i18n/hi.ts | 12 ++ app/src/lib/i18n/id.ts | 13 ++ app/src/lib/i18n/it.ts | 13 ++ app/src/lib/i18n/ko.ts | 11 ++ app/src/lib/i18n/pl.ts | 13 ++ app/src/lib/i18n/pt.ts | 13 ++ app/src/lib/i18n/ru.ts | 11 ++ app/src/lib/i18n/zh-CN.ts | 11 ++ .../lib/userErrors/__tests__/classify.test.ts | 52 +++++++ .../lib/userErrors/__tests__/report.test.ts | 37 +++++ app/src/lib/userErrors/classify.ts | 104 +++++++++++++ app/src/lib/userErrors/report.ts | 43 ++++++ app/src/providers/ChatRuntimeProvider.tsx | 14 ++ .../store/__tests__/userErrorsSlice.test.ts | 95 ++++++++++++ app/src/store/index.ts | 5 + app/src/store/userErrorsSelectors.ts | 30 ++++ app/src/store/userErrorsSlice.ts | 90 ++++++++++++ app/src/types/userError.ts | 59 ++++++++ 28 files changed, 946 insertions(+) create mode 100644 app/src/components/userErrors/UserErrorCenter.tsx create mode 100644 app/src/components/userErrors/__tests__/UserErrorCenter.test.tsx create mode 100644 app/src/lib/userErrors/__tests__/classify.test.ts create mode 100644 app/src/lib/userErrors/__tests__/report.test.ts create mode 100644 app/src/lib/userErrors/classify.ts create mode 100644 app/src/lib/userErrors/report.ts create mode 100644 app/src/store/__tests__/userErrorsSlice.test.ts create mode 100644 app/src/store/userErrorsSelectors.ts create mode 100644 app/src/store/userErrorsSlice.ts create mode 100644 app/src/types/userError.ts 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 ( +
+ {open && ( +
+
+ + {t('userErrors.title')} + + + {active.length} + +
+ +
    + {active.map(entry => ( +
  • +

    + {t(entry.titleKey)} +

    +

    + {t(entry.bodyKey)} +

    +

    + {t(`userErrors.scope.${entry.scope}`, entry.scope)} + {' · '} + {new Date(entry.lastSeenAt).toLocaleTimeString()} + {entry.count > 1 ? ` · ×${entry.count}` : ''} +

    +
    + {entry.action !== 'dismiss' && ( + + )} + +
    +
  • + ))} +
+
+ )} + + +
+ ); +} + +export default UserErrorCenter; diff --git a/app/src/components/userErrors/__tests__/UserErrorCenter.test.tsx b/app/src/components/userErrors/__tests__/UserErrorCenter.test.tsx new file mode 100644 index 000000000..5b3710ac1 --- /dev/null +++ b/app/src/components/userErrors/__tests__/UserErrorCenter.test.tsx @@ -0,0 +1,98 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import userErrorsReducer, { reportUserError } from '../../../store/userErrorsSlice'; +import type { UserErrorDescriptor } from '../../../types/userError'; +import UserErrorCenter from '../UserErrorCenter'; + +const budget: UserErrorDescriptor = { + id: 'budget_exceeded:chat:unknown', + kind: 'budget_exceeded', + severity: 'warning', + scope: 'chat', + titleKey: 'userErrors.budgetExceeded.title', + bodyKey: 'userErrors.budgetExceeded.body', + action: 'open_billing', +}; +const credits: UserErrorDescriptor = { + id: 'insufficient_credits:chat:unknown', + kind: 'insufficient_credits', + severity: 'warning', + scope: 'chat', + titleKey: 'userErrors.insufficientCredits.title', + bodyKey: 'userErrors.insufficientCredits.body', + action: 'open_provider_settings', +}; + +/** Probe that renders the current pathname so tests can assert navigation. */ +function LocationProbe() { + const { pathname } = useLocation(); + return
{pathname}
; +} + +function renderCenter(descriptors: UserErrorDescriptor[] = []) { + const store = configureStore({ reducer: { userErrors: userErrorsReducer } }); + descriptors.forEach((d, i) => store.dispatch(reportUserError({ descriptor: d, at: 1000 + i }))); + const utils = render( + + + + + + + ); + return { store, ...utils }; +} + +describe('UserErrorCenter', () => { + beforeEach(() => { + /* fresh store per render */ + }); + + it('renders nothing when there are no active errors', () => { + renderCenter([]); + expect(screen.queryByTestId('user-error-center')).toBeNull(); + }); + + it('shows a badge with the active count and opens the panel on click', () => { + renderCenter([budget, credits]); + expect(screen.getByTestId('user-error-badge')).toHaveTextContent('2'); + expect(screen.queryByTestId('user-error-panel')).toBeNull(); + + fireEvent.click(screen.getByTestId('user-error-trigger')); + expect(screen.getByTestId('user-error-panel')).toBeInTheDocument(); + expect(screen.getAllByTestId('user-error-item')).toHaveLength(2); + // Localized copy resolves through the default (English) i18n map. + expect(screen.getByText('Managed budget reached')).toBeInTheDocument(); + expect(screen.getByText('Provider credits required')).toBeInTheDocument(); + }); + + it('routes the budget action to billing and resolves the entry', () => { + const { store } = renderCenter([budget]); + fireEvent.click(screen.getByTestId('user-error-trigger')); + fireEvent.click(screen.getByTestId('user-error-action')); + expect(screen.getByTestId('pathname')).toHaveTextContent('/settings/billing'); + // Resolved → drops out of the active list, so the center unmounts. + expect(store.getState().userErrors.byId[budget.id].resolvedAt).toBeDefined(); + expect(screen.queryByTestId('user-error-center')).toBeNull(); + }); + + it('routes the credits action to provider settings', () => { + renderCenter([credits]); + fireEvent.click(screen.getByTestId('user-error-trigger')); + fireEvent.click(screen.getByTestId('user-error-action')); + expect(screen.getByTestId('pathname')).toHaveTextContent('/settings/llm'); + }); + + it('dismiss removes the entry without navigating', () => { + const { store } = renderCenter([budget]); + fireEvent.click(screen.getByTestId('user-error-trigger')); + fireEvent.click(screen.getByTestId('user-error-dismiss')); + expect(screen.getByTestId('pathname')).toHaveTextContent('/chat'); + expect(store.getState().userErrors.order).not.toContain(budget.id); + expect(screen.queryByTestId('user-error-center')).toBeNull(); + }); +}); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 3a06ebef4..454802eb0 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -5851,6 +5851,17 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.submitting': 'جارٍ التقديم…', 'agentworld.messaging.missingSignalBundle': 'لم يفعّل هذا المستخدم الرسائل المشفرة بعد. اطلب منه فتح Agent World وتفعيل الرسائل المباشرة الآمنة قبل إرسال رسالة.', + + // User-actionable runtime errors (#3931) + 'userErrors.title': 'إجراء مطلوب', + 'userErrors.dismiss': 'تجاهل', + 'userErrors.action.openBilling': 'فتح الفوترة', + 'userErrors.action.openProviderSettings': 'إعدادات المزود', + 'userErrors.budgetExceeded.title': 'تم استنفاد الميزانية المُدارة', + 'userErrors.budgetExceeded.body': 'نفدت الميزانية المُدارة. أضف ميزانية أو غيّر خطتك.', + 'userErrors.insufficientCredits.title': 'مطلوب رصيد المزود', + 'userErrors.insufficientCredits.body': 'نفد رصيد المزود. أعد الشحن أو حدّث مفتاح API.', + 'userErrors.scope.chat': 'الدردشة', }; export default messages; diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 74d5d6b52..fac4f0305 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -5972,6 +5972,18 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.submitting': 'আবেদন করা হচ্ছে…', 'agentworld.messaging.missingSignalBundle': 'এই ব্যবহারকারী এখনো এনক্রিপ্টেড মেসেজিং চালু করেননি। বার্তা পাঠানোর আগে তাকে Agent World খুলে নিরাপদ DM চালু করতে বলুন।', + + // User-actionable runtime errors (#3931) + 'userErrors.title': 'পদক্ষেপ প্রয়োজন', + 'userErrors.dismiss': 'বাতিল করুন', + 'userErrors.action.openBilling': 'বিলিং খুলুন', + 'userErrors.action.openProviderSettings': 'প্রদানকারী সেটিংস', + 'userErrors.budgetExceeded.title': 'পরিচালিত বাজেট শেষ', + 'userErrors.budgetExceeded.body': 'পরিচালিত AI বাজেট শেষ। বাজেট যোগ করুন বা প্ল্যান বদলান।', + 'userErrors.insufficientCredits.title': 'প্রদানকারীর ক্রেডিট প্রয়োজন', + 'userErrors.insufficientCredits.body': + 'AI প্রদানকারীর ক্রেডিট শেষ। রিচার্জ করুন বা API কী বদলান।', + 'userErrors.scope.chat': 'চ্যাট', }; export default messages; diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 820d1d0b6..20e7e7862 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -6137,6 +6137,19 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.submitting': 'Wird eingereicht…', 'agentworld.messaging.missingSignalBundle': 'Dieser Benutzer hat verschlüsselte Nachrichten noch nicht aktiviert. Bitte ihn, Agent World zu öffnen und sichere DMs zu aktivieren, bevor du eine Nachricht sendest.', + + // User-actionable runtime errors (#3931) + 'userErrors.title': 'Aktion erforderlich', + 'userErrors.dismiss': 'Verwerfen', + 'userErrors.action.openBilling': 'Abrechnung öffnen', + 'userErrors.action.openProviderSettings': 'Anbietereinstellungen', + 'userErrors.budgetExceeded.title': 'Verwaltetes Budget erreicht', + 'userErrors.budgetExceeded.body': + 'Dein verwaltetes KI-Budget ist aufgebraucht. Füge Budget hinzu oder ändere deinen Tarif.', + 'userErrors.insufficientCredits.title': 'Anbieter-Guthaben erforderlich', + 'userErrors.insufficientCredits.body': + 'Deinem KI-Anbieter ist das Guthaben ausgegangen. Lade es auf oder aktualisiere den Schlüssel.', + 'userErrors.scope.chat': 'Chat', }; export default messages; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index f4ce83382..ddc42d860 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -6241,6 +6241,19 @@ const en: TranslationMap = { 'agentworld.jobs.applyModal.submitting': 'Applying…', 'agentworld.messaging.missingSignalBundle': "This user hasn't enabled encrypted messaging yet. Ask them to open Agent World and enable secure DMs before sending a message.", + + // User-actionable runtime errors (#3931) + 'userErrors.title': 'Action needed', + 'userErrors.dismiss': 'Dismiss', + 'userErrors.action.openBilling': 'Open billing', + 'userErrors.action.openProviderSettings': 'Provider settings', + 'userErrors.budgetExceeded.title': 'Managed budget reached', + 'userErrors.budgetExceeded.body': + 'Your managed AI budget is used up. Add budget or change your plan to continue.', + 'userErrors.insufficientCredits.title': 'Provider credits required', + 'userErrors.insufficientCredits.body': + 'Your AI provider is out of credits. Top it up or update its API key to continue.', + 'userErrors.scope.chat': 'Chat', }; export default en; diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 20dae07ff..391cb9a62 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -6098,6 +6098,19 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.submitting': 'Enviando…', 'agentworld.messaging.missingSignalBundle': 'Este usuario aún no ha activado la mensajería cifrada. Pídele que abra Agent World y active los DM seguros antes de enviarle un mensaje.', + + // User-actionable runtime errors (#3931) + 'userErrors.title': 'Acción necesaria', + 'userErrors.dismiss': 'Descartar', + 'userErrors.action.openBilling': 'Abrir facturación', + 'userErrors.action.openProviderSettings': 'Configuración del proveedor', + 'userErrors.budgetExceeded.title': 'Presupuesto gestionado agotado', + 'userErrors.budgetExceeded.body': + 'Tu presupuesto de IA gestionado se ha agotado. Añade presupuesto o cambia de plan.', + 'userErrors.insufficientCredits.title': 'Se requieren créditos del proveedor', + 'userErrors.insufficientCredits.body': + 'Tu proveedor de IA se quedó sin créditos. Recárgalo o actualiza su clave de API.', + 'userErrors.scope.chat': 'Chat', }; export default messages; diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 783e6d04f..a817c0551 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -6115,6 +6115,19 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.submitting': 'Envoi en cours…', 'agentworld.messaging.missingSignalBundle': "Cet utilisateur n'a pas encore activé la messagerie chiffrée. Demandez-lui d'ouvrir Agent World et d'activer les messages privés sécurisés avant d'envoyer un message.", + + // User-actionable runtime errors (#3931) + 'userErrors.title': 'Action requise', + 'userErrors.dismiss': 'Ignorer', + 'userErrors.action.openBilling': 'Ouvrir la facturation', + 'userErrors.action.openProviderSettings': 'Paramètres du fournisseur', + 'userErrors.budgetExceeded.title': 'Budget géré atteint', + 'userErrors.budgetExceeded.body': + 'Votre budget IA géré est épuisé. Ajoutez du budget ou changez de forfait.', + 'userErrors.insufficientCredits.title': 'Crédits du fournisseur requis', + 'userErrors.insufficientCredits.body': + "Votre fournisseur IA n'a plus de crédits. Rechargez-le ou mettez à jour sa clé API.", + 'userErrors.scope.chat': 'Chat', }; export default messages; diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index fb0c6cbc2..7180f2e02 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -5975,6 +5975,18 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.submitting': 'आवेदन हो रहा है…', 'agentworld.messaging.missingSignalBundle': 'इस उपयोगकर्ता ने अभी तक एन्क्रिप्टेड मैसेजिंग चालू नहीं की है। संदेश भेजने से पहले उनसे Agent World खोलकर सुरक्षित DM चालू करने को कहें।', + + // User-actionable runtime errors (#3931) + 'userErrors.title': 'कार्रवाई आवश्यक', + 'userErrors.dismiss': 'खारिज करें', + 'userErrors.action.openBilling': 'बिलिंग खोलें', + 'userErrors.action.openProviderSettings': 'प्रदाता सेटिंग्स', + 'userErrors.budgetExceeded.title': 'प्रबंधित बजट समाप्त', + 'userErrors.budgetExceeded.body': 'प्रबंधित AI बजट समाप्त। बजट जोड़ें या प्लान बदलें।', + 'userErrors.insufficientCredits.title': 'प्रदाता क्रेडिट आवश्यक', + 'userErrors.insufficientCredits.body': + 'AI प्रदाता के क्रेडिट समाप्त। रिचार्ज करें या API कुंजी बदलें।', + 'userErrors.scope.chat': 'चैट', }; export default messages; diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 45e0e746e..50af404d4 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -5992,6 +5992,19 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.submitting': 'Melamar…', 'agentworld.messaging.missingSignalBundle': 'Pengguna ini belum mengaktifkan pesan terenkripsi. Minta mereka membuka Agent World dan mengaktifkan DM aman sebelum Anda mengirim pesan.', + + // User-actionable runtime errors (#3931) + 'userErrors.title': 'Tindakan diperlukan', + 'userErrors.dismiss': 'Tutup', + 'userErrors.action.openBilling': 'Buka penagihan', + 'userErrors.action.openProviderSettings': 'Pengaturan penyedia', + 'userErrors.budgetExceeded.title': 'Anggaran terkelola habis', + 'userErrors.budgetExceeded.body': + 'Anggaran AI terkelola Anda sudah habis. Tambahkan anggaran atau ubah paket.', + 'userErrors.insufficientCredits.title': 'Kredit penyedia diperlukan', + 'userErrors.insufficientCredits.body': + 'Penyedia AI Anda kehabisan kredit. Isi ulang atau perbarui kunci API-nya.', + 'userErrors.scope.chat': 'Obrolan', }; export default messages; diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index f272729a3..08189487a 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -6083,6 +6083,19 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.submitting': 'Candidatura in corso…', 'agentworld.messaging.missingSignalBundle': 'Questo utente non ha ancora attivato la messaggistica crittografata. Chiedigli di aprire Agent World e attivare i DM sicuri prima di inviare un messaggio.', + + // User-actionable runtime errors (#3931) + 'userErrors.title': 'Azione necessaria', + 'userErrors.dismiss': 'Ignora', + 'userErrors.action.openBilling': 'Apri fatturazione', + 'userErrors.action.openProviderSettings': 'Impostazioni del provider', + 'userErrors.budgetExceeded.title': 'Budget gestito esaurito', + 'userErrors.budgetExceeded.body': + 'Il tuo budget IA gestito è esaurito. Aggiungi budget o cambia piano.', + 'userErrors.insufficientCredits.title': 'Crediti del provider necessari', + 'userErrors.insufficientCredits.body': + 'Il tuo provider IA ha esaurito i crediti. Ricaricalo o aggiorna la chiave API.', + 'userErrors.scope.chat': 'Chat', }; export default messages; diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index ba4051cb5..c0e1ac24e 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -5918,6 +5918,17 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.submitting': '지원 중…', 'agentworld.messaging.missingSignalBundle': '이 사용자는 아직 암호화 메시지를 활성화하지 않았습니다. 메시지를 보내기 전에 Agent World를 열고 보안 DM을 활성화해 달라고 요청하세요.', + + // User-actionable runtime errors (#3931) + 'userErrors.title': '조치 필요', + 'userErrors.dismiss': '닫기', + 'userErrors.action.openBilling': '결제 열기', + 'userErrors.action.openProviderSettings': '제공업체 설정', + 'userErrors.budgetExceeded.title': '관리형 예산 소진', + 'userErrors.budgetExceeded.body': '관리형 AI 예산이 모두 소진되었습니다.', + 'userErrors.insufficientCredits.title': '제공업체 크레딧 필요', + 'userErrors.insufficientCredits.body': 'AI 제공업체 크레딧이 소진되었습니다.', + 'userErrors.scope.chat': '채팅', }; export default messages; diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 7f874aca5..419a7479e 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -6063,6 +6063,19 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.submitting': 'Wysyłanie…', 'agentworld.messaging.missingSignalBundle': 'Ten użytkownik nie włączył jeszcze szyfrowanych wiadomości. Poproś go, aby otworzył Agent World i włączył bezpieczne DM przed wysłaniem wiadomości.', + + // User-actionable runtime errors (#3931) + 'userErrors.title': 'Wymagane działanie', + 'userErrors.dismiss': 'Odrzuć', + 'userErrors.action.openBilling': 'Otwórz rozliczenia', + 'userErrors.action.openProviderSettings': 'Ustawienia dostawcy', + 'userErrors.budgetExceeded.title': 'Wyczerpano zarządzany budżet', + 'userErrors.budgetExceeded.body': + 'Twój zarządzany budżet AI został wyczerpany. Dodaj budżet lub zmień plan.', + 'userErrors.insufficientCredits.title': 'Wymagane środki u dostawcy', + 'userErrors.insufficientCredits.body': + 'Twój dostawca AI nie ma już środków. Doładuj je lub zaktualizuj klucz API.', + 'userErrors.scope.chat': 'Czat', }; export default messages; diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 941aa630f..775a8b412 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -6076,6 +6076,19 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.submitting': 'A enviar…', 'agentworld.messaging.missingSignalBundle': 'Este utilizador ainda não ativou as mensagens encriptadas. Peça-lhe para abrir o Agent World e ativar DMs seguras antes de enviar uma mensagem.', + + // User-actionable runtime errors (#3931) + 'userErrors.title': 'Ação necessária', + 'userErrors.dismiss': 'Dispensar', + 'userErrors.action.openBilling': 'Abrir faturamento', + 'userErrors.action.openProviderSettings': 'Configurações do provedor', + 'userErrors.budgetExceeded.title': 'Orçamento gerenciado esgotado', + 'userErrors.budgetExceeded.body': + 'Seu orçamento de IA gerenciado acabou. Adicione orçamento ou altere seu plano.', + 'userErrors.insufficientCredits.title': 'Créditos do provedor necessários', + 'userErrors.insufficientCredits.body': + 'Seu provedor de IA ficou sem créditos. Recarregue-o ou atualize a chave de API.', + 'userErrors.scope.chat': 'Chat', }; export default messages; diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 3f3d6a1c5..bfad46ac6 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -6038,6 +6038,17 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.submitting': 'Отправка…', 'agentworld.messaging.missingSignalBundle': 'Этот пользователь еще не включил зашифрованные сообщения. Попросите его открыть Agent World и включить безопасные личные сообщения перед отправкой.', + + // User-actionable runtime errors (#3931) + 'userErrors.title': 'Требуется действие', + 'userErrors.dismiss': 'Отклонить', + 'userErrors.action.openBilling': 'Открыть оплату', + 'userErrors.action.openProviderSettings': 'Настройки провайдера', + 'userErrors.budgetExceeded.title': 'Управляемый бюджет исчерпан', + 'userErrors.budgetExceeded.body': 'Управляемый бюджет ИИ исчерпан. Измените план.', + 'userErrors.insufficientCredits.title': 'Требуются кредиты провайдера', + 'userErrors.insufficientCredits.body': 'У провайдера закончились кредиты. Пополните их.', + 'userErrors.scope.chat': 'Чат', }; export default messages; diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index df3928ad7..6303e71b3 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -5671,6 +5671,17 @@ const messages: TranslationMap = { 'agentworld.jobs.applyModal.submitting': '申请中…', 'agentworld.messaging.missingSignalBundle': '此用户尚未启用加密消息。发送消息前,请让对方打开 Agent World 并启用安全私信。', + + // User-actionable runtime errors (#3931) + 'userErrors.title': '需要操作', + 'userErrors.dismiss': '忽略', + 'userErrors.action.openBilling': '打开账单', + 'userErrors.action.openProviderSettings': '提供商设置', + 'userErrors.budgetExceeded.title': '托管预算已用尽', + 'userErrors.budgetExceeded.body': '托管 AI 预算已用尽,请增加预算或更改套餐。', + 'userErrors.insufficientCredits.title': '需要提供商额度', + 'userErrors.insufficientCredits.body': '提供商额度已用完,请充值或更新 API 密钥。', + 'userErrors.scope.chat': '聊天', }; export default messages; diff --git a/app/src/lib/userErrors/__tests__/classify.test.ts b/app/src/lib/userErrors/__tests__/classify.test.ts new file mode 100644 index 000000000..7b9309336 --- /dev/null +++ b/app/src/lib/userErrors/__tests__/classify.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { classifyUserActionableError, userErrorId } from '../classify'; + +const BUDGET_MSG = 'OpenHuman API error (400): Insufficient budget'; +const CREDITS_MSG = 'OpenRouter: this request requires more credits'; +const BALANCE_MSG = 'HTTP 402: account is out of balance'; +const GENERIC_MSG = 'Something went wrong. Please try again.'; + +describe('classifyUserActionableError', () => { + it('classifies managed-budget exhaustion (USER_INSUFFICIENT_CREDITS)', () => { + const a = classifyUserActionableError({ message: BUDGET_MSG }); + expect(a?.kind).toBe('budget_exceeded'); + expect(a?.action).toBe('open_billing'); + expect(a?.titleKey).toBe('userErrors.budgetExceeded.title'); + + const b = classifyUserActionableError({ errorType: 'USER_INSUFFICIENT_CREDITS' }); + expect(b?.kind).toBe('budget_exceeded'); + }); + + it('classifies BYO provider out-of-credits (402 / requires more credits)', () => { + const a = classifyUserActionableError({ message: CREDITS_MSG }); + expect(a?.kind).toBe('insufficient_credits'); + expect(a?.action).toBe('open_provider_settings'); + expect(a?.titleKey).toBe('userErrors.insufficientCredits.title'); + + const b = classifyUserActionableError({ message: BALANCE_MSG }); + expect(b?.kind).toBe('insufficient_credits'); + }); + + it('prefers managed-budget over BYO-credits when text says "insufficient budget"', () => { + // "insufficient budget" contains "insufficient" but must not be misread as + // the BYO-credits case. + const a = classifyUserActionableError({ message: 'insufficient budget for this request' }); + expect(a?.kind).toBe('budget_exceeded'); + }); + + it('returns null for generic / non-actionable errors and empty input', () => { + expect(classifyUserActionableError({ message: GENERIC_MSG })).toBeNull(); + expect(classifyUserActionableError({ message: '', errorType: 'inference' })).toBeNull(); + expect(classifyUserActionableError({})).toBeNull(); + }); + + it('defaults scope to chat and carries provider into a stable dedupe id', () => { + const a = classifyUserActionableError({ + message: 'requires more credits', + provider: 'openrouter', + }); + expect(a?.scope).toBe('chat'); + expect(a?.id).toBe(userErrorId('insufficient_credits', 'chat', 'openrouter')); + }); +}); diff --git a/app/src/lib/userErrors/__tests__/report.test.ts b/app/src/lib/userErrors/__tests__/report.test.ts new file mode 100644 index 000000000..3bd2bc1b1 --- /dev/null +++ b/app/src/lib/userErrors/__tests__/report.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { AppDispatch } from '../../../store'; +import { ingestRuntimeErrorSignal } from '../report'; + +describe('ingestRuntimeErrorSignal', () => { + it('classifies an actionable signal and dispatches reportUserError', () => { + const dispatch = vi.fn() as unknown as AppDispatch; + const reported = ingestRuntimeErrorSignal(dispatch, { + message: 'OpenRouter: requires more credits', + scope: 'chat', + }); + expect(reported).toBe(true); + expect(dispatch).toHaveBeenCalledTimes(1); + const action = (dispatch as unknown as ReturnType).mock.calls[0][0]; + expect(action.type).toBe('userErrors/reportUserError'); + expect(action.payload.descriptor.kind).toBe('insufficient_credits'); + expect(typeof action.payload.at).toBe('number'); + }); + + it('does nothing for a non-actionable signal', () => { + const dispatch = vi.fn() as unknown as AppDispatch; + const reported = ingestRuntimeErrorSignal(dispatch, { message: 'Something went wrong.' }); + expect(reported).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('never throws even if dispatch blows up', () => { + const dispatch = vi.fn(() => { + throw new Error('boom'); + }) as unknown as AppDispatch; + expect(() => + ingestRuntimeErrorSignal(dispatch, { message: 'Insufficient budget' }) + ).not.toThrow(); + expect(ingestRuntimeErrorSignal(dispatch, { message: 'Insufficient budget' })).toBe(false); + }); +}); diff --git a/app/src/lib/userErrors/classify.ts b/app/src/lib/userErrors/classify.ts new file mode 100644 index 000000000..0fac9cc1b --- /dev/null +++ b/app/src/lib/userErrors/classify.ts @@ -0,0 +1,104 @@ +/** + * Classifier for user-actionable runtime errors (#3931). + * + * Maps an error *signal* (the user-facing message + error type that already + * flow through the chat runtime / RPC layers) to a typed {@link UserErrorDescriptor}. + * Only the two #3913 expected-user-states are recognised in this first slice: + * + * - `budget_exceeded` — managed backend 400 / `USER_INSUFFICIENT_CREDITS` + * ("Insufficient budget") + * - `insufficient_credits` — BYO provider 402 / OpenRouter out of balance + * ("requires more credits") + * + * Anything else returns `null` (NOT user-actionable → stays in normal error + * flow / Sentry). This is deliberately conservative: a generic error must never + * be promoted into the panel. + * + * NOTE: matching raw text here is a bootstrap. The intended end state is core + * emitting a structured kind so the app does not pattern-match prose — see the + * follow-ups in the #3931 PR. Keeping the rules in this one pure module makes + * that migration a drop-in. + */ +import type { UserErrorDescriptor, UserErrorScope } from '../../types/userError'; + +export interface RuntimeErrorSignal { + /** User-facing message produced upstream (e.g. chat `event.message`). */ + message?: string | null; + /** Coarse error type/code when available (e.g. chat `event.error_type`). */ + errorType?: string | null; + /** Where the signal came from; defaults to `chat`. */ + scope?: UserErrorScope; + /** Originating core domain (metadata only). */ + sourceDomain?: string; + /** Provider slug when known (metadata only, never secrets). */ + provider?: string; +} + +/** Build the stable dedupe identity for an error. */ +export function userErrorId( + kind: UserErrorDescriptor['kind'], + scope: UserErrorScope, + provider?: string +): string { + return `${kind}:${scope}:${provider ?? 'unknown'}`; +} + +function haystack(signal: RuntimeErrorSignal): string { + return `${signal.message ?? ''}\n${signal.errorType ?? ''}`.toLowerCase(); +} + +/** + * Classify a runtime error signal. Returns a descriptor for a recognised + * user-actionable state, or `null` when the error is not one. + */ +export function classifyUserActionableError( + signal: RuntimeErrorSignal +): UserErrorDescriptor | null { + const text = haystack(signal); + if (!text.trim()) return null; + const scope: UserErrorScope = signal.scope ?? 'chat'; + + // Managed-budget exhaustion first: "insufficient budget" contains the word + // "insufficient", so it must win over the BYO-credits rule below. + const isBudget = + text.includes('user_insufficient_credits') || + text.includes('insufficient budget') || + text.includes('budget_exceeded') || + text.includes('managed budget'); + if (isBudget) { + return { + id: userErrorId('budget_exceeded', scope, signal.provider), + kind: 'budget_exceeded', + severity: 'warning', + scope, + sourceDomain: signal.sourceDomain, + provider: signal.provider, + titleKey: 'userErrors.budgetExceeded.title', + bodyKey: 'userErrors.budgetExceeded.body', + action: 'open_billing', + }; + } + + // BYO provider out of credits (OpenRouter 402, "requires more credits", etc). + const isCredits = + text.includes('requires more credits') || + text.includes('out of balance') || + text.includes('insufficient credits') || + text.includes('insufficient_credits') || + (text.includes('402') && text.includes('credit')); + if (isCredits) { + return { + id: userErrorId('insufficient_credits', scope, signal.provider), + kind: 'insufficient_credits', + severity: 'warning', + scope, + sourceDomain: signal.sourceDomain, + provider: signal.provider, + titleKey: 'userErrors.insufficientCredits.title', + bodyKey: 'userErrors.insufficientCredits.body', + action: 'open_provider_settings', + }; + } + + return null; +} diff --git a/app/src/lib/userErrors/report.ts b/app/src/lib/userErrors/report.ts new file mode 100644 index 000000000..7cdc51008 --- /dev/null +++ b/app/src/lib/userErrors/report.ts @@ -0,0 +1,43 @@ +/** + * Ingestion helper for user-actionable runtime errors (#3931). + * + * Producers (chat runtime, RPC layer, …) hand raw error signals here; this + * classifies them and, when the signal is a recognised expected-user-state, + * dispatches it into the panel store. It is strictly additive and defensive: + * it NEVER throws and returns `false` for non-actionable errors, so callers can + * drop it into existing error paths without changing their behaviour. + */ +import debug from 'debug'; + +import type { AppDispatch } from '../../store'; +import { reportUserError } from '../../store/userErrorsSlice'; +import { classifyUserActionableError, type RuntimeErrorSignal } from './classify'; + +const log = debug('openhuman:user-errors'); + +/** + * Classify `signal` and, if user-actionable, report it to the panel store. + * @returns `true` if an actionable error was reported, else `false`. + */ +export function ingestRuntimeErrorSignal( + dispatch: AppDispatch, + signal: RuntimeErrorSignal +): boolean { + try { + const descriptor = classifyUserActionableError(signal); + if (!descriptor) return false; + // Metadata-only logging: stable prefix + kind/scope/provider, never the + // raw provider message (may carry sanitized-but-noisy upstream text). + log( + 'actionable kind=%s scope=%s provider=%s', + descriptor.kind, + descriptor.scope, + descriptor.provider ?? '-' + ); + dispatch(reportUserError({ descriptor, at: Date.now() })); + return true; + } catch (err) { + log('ingest failed: %o', err); + return false; + } +} diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx index 8da525d33..9e0911bc1 100644 --- a/app/src/providers/ChatRuntimeProvider.tsx +++ b/app/src/providers/ChatRuntimeProvider.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { requestUsageRefresh } from '../hooks/usageRefresh'; import { useRefetchSnapshotOnTurnEnd } from '../hooks/useRefetchSnapshotOnTurnEnd'; +import { ingestRuntimeErrorSignal } from '../lib/userErrors/report'; import { type ChatApprovalRequestEvent, type ChatDoneEvent, @@ -1102,6 +1103,19 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { err: event.error_type, }); + // #3931: surface expected, user-actionable provider/billing states + // (insufficient BYO credits, managed-budget exhaustion) in the shell's + // dedicated error panel — in ADDITION to the inline chat message below. + // Additive + defensive: no-op for non-actionable errors, never throws. + if (event.error_type !== 'cancelled') { + ingestRuntimeErrorSignal(dispatch, { + message: event.message, + errorType: event.error_type, + scope: 'chat', + sourceDomain: 'chat', + }); + } + // Parallel (forked) turn error: resolve only its lane, leaving the // primary turn untouched. Surface a non-cancellation error as a message // so the failed branch is visible. diff --git a/app/src/store/__tests__/userErrorsSlice.test.ts b/app/src/store/__tests__/userErrorsSlice.test.ts new file mode 100644 index 000000000..b33142003 --- /dev/null +++ b/app/src/store/__tests__/userErrorsSlice.test.ts @@ -0,0 +1,95 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { describe, expect, it } from 'vitest'; + +import type { UserErrorDescriptor } from '../../types/userError'; +import type { RootState } from '../index'; +import { resetUserScopedState } from '../resetActions'; +import { selectActiveUserErrorCount, selectActiveUserErrors } from '../userErrorsSelectors'; +import reducer, { + clearResolvedUserErrors, + dismissUserError, + reportUserError, + resolveUserError, +} from '../userErrorsSlice'; + +const descriptor: UserErrorDescriptor = { + id: 'budget_exceeded:chat:unknown', + kind: 'budget_exceeded', + severity: 'warning', + scope: 'chat', + titleKey: 'userErrors.budgetExceeded.title', + bodyKey: 'userErrors.budgetExceeded.body', + action: 'open_billing', +}; + +function makeStore() { + return configureStore({ reducer: { userErrors: reducer } }); +} +const asRoot = (s: unknown) => s as RootState; + +describe('userErrorsSlice', () => { + it('records a new actionable error as active with count 1', () => { + const store = makeStore(); + store.dispatch(reportUserError({ descriptor, at: 1000 })); + const active = selectActiveUserErrors(asRoot(store.getState())); + expect(active).toHaveLength(1); + expect(active[0]).toMatchObject({ + id: descriptor.id, + count: 1, + occurredAt: 1000, + lastSeenAt: 1000, + }); + expect(selectActiveUserErrorCount(asRoot(store.getState()))).toBe(1); + }); + + it('dedupes repeats: bumps count + lastSeenAt, keeps occurredAt, no duplicate row', () => { + const store = makeStore(); + store.dispatch(reportUserError({ descriptor, at: 1000 })); + store.dispatch(reportUserError({ descriptor, at: 2500 })); + const active = selectActiveUserErrors(asRoot(store.getState())); + expect(active).toHaveLength(1); + expect(active[0]).toMatchObject({ count: 2, occurredAt: 1000, lastSeenAt: 2500 }); + }); + + it('resolve drops the entry from the active list; dismiss removes it entirely', () => { + const store = makeStore(); + store.dispatch(reportUserError({ descriptor, at: 1000 })); + + store.dispatch(resolveUserError({ id: descriptor.id, at: 1500 })); + expect(selectActiveUserErrors(asRoot(store.getState()))).toHaveLength(0); + expect(selectActiveUserErrorCount(asRoot(store.getState()))).toBe(0); + + // A resolved entry re-opens if the state recurs. + store.dispatch(reportUserError({ descriptor, at: 1800 })); + expect(selectActiveUserErrors(asRoot(store.getState()))).toHaveLength(1); + expect(selectActiveUserErrors(asRoot(store.getState()))[0].count).toBe(2); + + store.dispatch(dismissUserError({ id: descriptor.id })); + expect(selectActiveUserErrors(asRoot(store.getState()))).toHaveLength(0); + expect(store.getState().userErrors.order).not.toContain(descriptor.id); + }); + + it('clearResolvedUserErrors removes only resolved entries', () => { + const store = makeStore(); + const other: UserErrorDescriptor = { + ...descriptor, + id: 'insufficient_credits:chat:unknown', + kind: 'insufficient_credits', + }; + store.dispatch(reportUserError({ descriptor, at: 1000 })); + store.dispatch(reportUserError({ descriptor: other, at: 1000 })); + store.dispatch(resolveUserError({ id: descriptor.id, at: 1200 })); + + store.dispatch(clearResolvedUserErrors()); + const ids = store.getState().userErrors.order; + expect(ids).toEqual([other.id]); + }); + + it('clears all entries on user-scoped reset (privacy)', () => { + const store = makeStore(); + store.dispatch(reportUserError({ descriptor, at: 1000 })); + store.dispatch(resetUserScopedState()); + expect(selectActiveUserErrors(asRoot(store.getState()))).toHaveLength(0); + expect(store.getState().userErrors.order).toHaveLength(0); + }); +}); diff --git a/app/src/store/index.ts b/app/src/store/index.ts index fbaa59dc9..8d79df315 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -36,6 +36,7 @@ import { pttReducer } from './pttSlice'; import socketReducer from './socketSlice'; import themeReducer from './themeSlice'; import threadReducer from './threadSlice'; +import userErrorsReducer from './userErrorsSlice'; import { userScopedStorage } from './userScopedStorage'; // Persisted slices write through `userScopedStorage` so each user's blob @@ -227,6 +228,10 @@ export const store = configureStore({ persona: persistedPersonaReducer, theme: persistedThemeReducer, ptt: persistedPttReducer, + // In-memory only (not persisted): survives route changes / background-job + // completion, resets on restart + user switch. Durable storage is a #3931 + // follow-up. + userErrors: userErrorsReducer, }, middleware: getDefaultMiddleware => { const middleware = getDefaultMiddleware({ diff --git a/app/src/store/userErrorsSelectors.ts b/app/src/store/userErrorsSelectors.ts new file mode 100644 index 000000000..67be05ac5 --- /dev/null +++ b/app/src/store/userErrorsSelectors.ts @@ -0,0 +1,30 @@ +/** + * Selectors for the user-actionable runtime errors slice (#3931). + * + * Kept in a separate file from the slice (matching `socketSelectors` / + * `connectivitySelectors`) so the slice never imports the root store, avoiding + * a slice ↔ store dependency cycle. + */ +import { createSelector } from '@reduxjs/toolkit'; + +import type { UserActionableError } from '../types/userError'; +import type { RootState } from './index'; +import type { UserErrorsState } from './userErrorsSlice'; + +const selectSlice = (state: RootState): UserErrorsState => state.userErrors; + +/** All entries in insertion order (including resolved). */ +export const selectAllUserErrors = createSelector(selectSlice, slice => + slice.order.map(id => slice.byId[id]).filter((e): e is UserActionableError => Boolean(e)) +); + +/** Active (unresolved) entries — what the panel and badge show. */ +export const selectActiveUserErrors = createSelector(selectAllUserErrors, all => + all.filter(e => e.resolvedAt === undefined) +); + +/** Count of active entries — drives the notification badge. */ +export const selectActiveUserErrorCount = createSelector( + selectActiveUserErrors, + active => active.length +); diff --git a/app/src/store/userErrorsSlice.ts b/app/src/store/userErrorsSlice.ts new file mode 100644 index 000000000..b5e419ed1 --- /dev/null +++ b/app/src/store/userErrorsSlice.ts @@ -0,0 +1,90 @@ +/** + * Redux slice for user-actionable runtime errors (#3931). + * + * Holds the live set of expected-user-state failures (insufficient BYO credits, + * managed-budget exhaustion) surfaced in the desktop shell's error panel. + * In-memory only: entries survive route changes and background-job completion + * (the panel is mounted once in the shell) but reset on app restart and on + * user switch. Cross-restart durability is a planned follow-up — see the PR. + * + * Dedupe: entries are keyed by `descriptor.id` (`kind:scope:provider`). A repeat + * of the same state bumps `count` + `lastSeenAt` instead of stacking duplicates, + * so the panel never becomes a noisy raw-error log. + */ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +import type { UserActionableError, UserErrorDescriptor } from '../types/userError'; +import { resetUserScopedState } from './resetActions'; + +export interface UserErrorsState { + byId: Record; + /** Insertion order of ids (newest appended). */ + order: string[]; +} + +const initialState: UserErrorsState = { byId: {}, order: [] }; + +const userErrorsSlice = createSlice({ + name: 'userErrors', + initialState, + reducers: { + /** + * Record an occurrence of a classified user-actionable error. New ids are + * appended; existing active ids increment `count`; a previously-resolved id + * re-opens. `at` (epoch ms) is passed in so the reducer stays pure. + */ + reportUserError(state, action: PayloadAction<{ descriptor: UserErrorDescriptor; at: number }>) { + const { descriptor, at } = action.payload; + const existing = state.byId[descriptor.id]; + if (!existing) { + state.byId[descriptor.id] = { ...descriptor, occurredAt: at, lastSeenAt: at, count: 1 }; + state.order.push(descriptor.id); + return; + } + // Refresh presentation fields in case the descriptor copy/action changed, + // keep the original first-seen timestamp, bump recurrence bookkeeping, and + // clear any prior resolution (the state is active again). + state.byId[descriptor.id] = { + ...existing, + ...descriptor, + occurredAt: existing.occurredAt, + lastSeenAt: at, + count: existing.count + 1, + resolvedAt: undefined, + }; + }, + + /** Mark an entry resolved (acted-on). It drops out of the active list. */ + resolveUserError(state, action: PayloadAction<{ id: string; at: number }>) { + const entry = state.byId[action.payload.id]; + if (entry) entry.resolvedAt = action.payload.at; + }, + + /** Remove an entry entirely (explicit user dismissal). */ + dismissUserError(state, action: PayloadAction<{ id: string }>) { + if (state.byId[action.payload.id]) { + delete state.byId[action.payload.id]; + state.order = state.order.filter(id => id !== action.payload.id); + } + }, + + /** Drop all resolved entries (housekeeping). */ + clearResolvedUserErrors(state) { + for (const id of [...state.order]) { + if (state.byId[id]?.resolvedAt !== undefined) { + delete state.byId[id]; + state.order = state.order.filter(other => other !== id); + } + } + }, + }, + extraReducers: builder => { + // Privacy: never leak one user's actionable errors into another session. + builder.addCase(resetUserScopedState, () => initialState); + }, +}); + +export const { reportUserError, resolveUserError, dismissUserError, clearResolvedUserErrors } = + userErrorsSlice.actions; + +export default userErrorsSlice.reducer; diff --git a/app/src/types/userError.ts b/app/src/types/userError.ts new file mode 100644 index 000000000..d8007c54e --- /dev/null +++ b/app/src/types/userError.ts @@ -0,0 +1,59 @@ +/** + * User-actionable runtime errors (#3931). + * + * A small, privacy-safe contract for *expected user states* that the app must + * surface durably and actionably — e.g. a BYO provider being out of credits or + * a managed budget being exhausted (the #3913 paths). These are deliberately + * NOT Sentry-worthy crashes, but they must not vanish either: they land in a + * first-class panel in the desktop shell with a clear next action. + * + * This is intentionally a thin, additive frontend contract for the first slice. + * The structured Rust-core emission path (stable `message_key` + args emitted by + * core rather than classified from text app-side) is the planned follow-up; this + * type is shaped to accept that source without the panel changing. + */ + +/** Stable discriminator the UI branches on. Extend as new states are added. */ +export type UserErrorKind = 'insufficient_credits' | 'budget_exceeded'; + +/** Where the failure originated, for grouping/labelling (privacy-safe). */ +export type UserErrorScope = 'chat' | 'cron' | 'provider' | 'integration' | 'workspace'; + +/** Primary next-step the user can take. `dismiss` is always available too. */ +export type UserErrorAction = 'open_billing' | 'open_provider_settings' | 'dismiss'; + +export type UserErrorSeverity = 'warning' | 'error'; + +/** + * Classifier output: everything that identifies and presents an error, minus + * the runtime bookkeeping (timestamps / counts) the store owns. Carries i18n + * *keys* — never raw provider text — so all copy stays translatable. + */ +export interface UserErrorDescriptor { + /** Stable dedupe identity (`kind:scope:provider`). */ + id: string; + kind: UserErrorKind; + severity: UserErrorSeverity; + scope: UserErrorScope; + /** Originating core domain/operation, metadata only (e.g. `chat`). */ + sourceDomain?: string; + /** Provider slug when safe + useful (e.g. `openrouter`). Never secrets. */ + provider?: string; + /** i18n key for the short title. */ + titleKey: string; + /** i18n key for the one-line explanation. */ + bodyKey: string; + action: UserErrorAction; +} + +/** A live entry in the panel: a descriptor plus store-owned bookkeeping. */ +export interface UserActionableError extends UserErrorDescriptor { + /** First time this entry was seen (epoch ms). */ + occurredAt: number; + /** Most recent occurrence (epoch ms). */ + lastSeenAt: number; + /** How many times this exact state has recurred while active. */ + count: number; + /** Set when resolved/acted-on; resolved entries drop out of the active list. */ + resolvedAt?: number; +}