mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(app): user-actionable runtime error panel (minimal slice) (#3983)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user