feat(app): user-actionable runtime error panel (minimal slice) (#3983)

This commit is contained in:
Mega Mind
2026-06-23 11:49:02 -07:00
committed by GitHub
parent 232d28e05a
commit c96bd3db3f
28 changed files with 946 additions and 0 deletions
+6
View File
@@ -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 && <SettingsModal />}
<OpenhumanLinkModal />
{/* 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. */}
<UserErrorCenter />
{/* 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
@@ -29,6 +29,10 @@ const baseState = {
logs: {},
overlayOpen: false,
},
// The desktop shell now mounts <UserErrorCenter/>, 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;
@@ -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<Exclude<UserErrorAction, 'dismiss'>, string> = {
open_billing: '/settings/billing',
open_provider_settings: '/settings/llm',
};
/** i18n key for each primary action's button label. */
const ACTION_LABEL_KEY: Record<Exclude<UserErrorAction, 'dismiss'>, 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 (
<div
className="fixed bottom-4 right-4 z-50 flex flex-col items-end gap-2"
data-testid="user-error-center">
{open && (
<div
role="dialog"
aria-label={t('userErrors.title')}
data-testid="user-error-panel"
className="w-80 max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-amber-300 bg-white shadow-lg dark:border-amber-700 dark:bg-neutral-900">
<div className="flex items-center justify-between border-b border-amber-200 px-3 py-2 dark:border-amber-800">
<span className="text-sm font-semibold text-ink dark:text-neutral-100">
{t('userErrors.title')}
</span>
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-900 dark:bg-amber-900 dark:text-amber-100">
{active.length}
</span>
</div>
<ul className="max-h-96 divide-y divide-neutral-100 overflow-auto dark:divide-neutral-800">
{active.map(entry => (
<li key={entry.id} className="px-3 py-2.5" data-testid="user-error-item">
<p className="text-sm font-semibold text-ink dark:text-neutral-100">
{t(entry.titleKey)}
</p>
<p className="mt-0.5 text-xs text-ink-soft dark:text-neutral-300">
{t(entry.bodyKey)}
</p>
<p className="mt-1 text-[11px] text-neutral-500 dark:text-neutral-400">
{t(`userErrors.scope.${entry.scope}`, entry.scope)}
{' · '}
{new Date(entry.lastSeenAt).toLocaleTimeString()}
{entry.count > 1 ? ` · ×${entry.count}` : ''}
</p>
<div className="mt-2 flex items-center gap-2">
{entry.action !== 'dismiss' && (
<button
type="button"
data-testid="user-error-action"
onClick={() => runAction(entry)}
className="rounded-md bg-ocean px-2.5 py-1 text-xs font-medium text-white hover:bg-ocean/90">
{t(ACTION_LABEL_KEY[entry.action])}
</button>
)}
<button
type="button"
data-testid="user-error-dismiss"
onClick={() => dispatch(dismissUserError({ id: entry.id }))}
className="rounded-md px-2.5 py-1 text-xs font-medium text-ink-soft hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800">
{t('userErrors.dismiss')}
</button>
</div>
</li>
))}
</ul>
</div>
)}
<button
type="button"
data-testid="user-error-trigger"
aria-label={t('userErrors.title')}
onClick={() => setOpen(o => !o)}
className="relative flex h-11 w-11 items-center justify-center rounded-full border border-amber-300 bg-white text-lg shadow-md hover:bg-amber-50 dark:border-amber-700 dark:bg-neutral-900 dark:hover:bg-neutral-800">
<span aria-hidden></span>
<span
data-testid="user-error-badge"
className="absolute -right-1 -top-1 min-w-[18px] rounded-full bg-coral px-1 text-center text-[11px] font-bold leading-[18px] text-white">
{active.length}
</span>
</button>
</div>
);
}
export default UserErrorCenter;
@@ -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 <div data-testid="pathname">{pathname}</div>;
}
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(
<Provider store={store}>
<MemoryRouter initialEntries={['/chat']}>
<UserErrorCenter />
<LocationProbe />
</MemoryRouter>
</Provider>
);
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();
});
});
+11
View File
@@ -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;
+12
View File
@@ -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;
+13
View File
@@ -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;
+13
View File
@@ -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;
+13
View File
@@ -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;
+13
View File
@@ -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;
+12
View File
@@ -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;
+13
View File
@@ -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;
+13
View File
@@ -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;
+11
View File
@@ -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;
+13
View File
@@ -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;
+13
View File
@@ -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;
+11
View File
@@ -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;
+11
View File
@@ -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;
@@ -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'));
});
});
@@ -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<typeof vi.fn>).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);
});
});
+104
View File
@@ -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;
}
+43
View File
@@ -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;
}
}
+14
View File
@@ -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.
@@ -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);
});
});
+5
View File
@@ -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({
+30
View File
@@ -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
);
+90
View File
@@ -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<string, UserActionableError>;
/** 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;
+59
View File
@@ -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;
}