From a83a2a1439b6fa911e3825ae40ce57943ac1e7e7 Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:38:51 +0530 Subject: [PATCH] fix(onboarding,billing): surface completeAndExit errors and suppress near-limit banner for custom providers (#3278) --- app/src/hooks/useUsageState.test.ts | 40 ++++++++++ app/src/hooks/useUsageState.ts | 6 +- app/src/lib/i18n/ar.ts | 1 + app/src/lib/i18n/bn.ts | 1 + app/src/lib/i18n/de.ts | 2 + app/src/lib/i18n/en.ts | 1 + app/src/lib/i18n/es.ts | 2 + app/src/lib/i18n/fr.ts | 1 + app/src/lib/i18n/hi.ts | 1 + app/src/lib/i18n/id.ts | 1 + app/src/lib/i18n/it.ts | 1 + app/src/lib/i18n/ko.ts | 1 + app/src/lib/i18n/pl.ts | 1 + app/src/lib/i18n/pt.ts | 2 + app/src/lib/i18n/ru.ts | 2 + app/src/lib/i18n/zh-CN.ts | 1 + .../pages/RuntimeChoicePage.test.tsx | 78 +++++++++++++++++++ .../onboarding/pages/RuntimeChoicePage.tsx | 48 ++++++++---- 18 files changed, 172 insertions(+), 18 deletions(-) create mode 100644 app/src/pages/onboarding/pages/RuntimeChoicePage.test.tsx diff --git a/app/src/hooks/useUsageState.test.ts b/app/src/hooks/useUsageState.test.ts index 4e0836d84..7d131586d 100644 --- a/app/src/hooks/useUsageState.test.ts +++ b/app/src/hooks/useUsageState.test.ts @@ -542,6 +542,46 @@ describe('useUsageState', () => { expect(mockGetTeamUsage).not.toHaveBeenCalled(); }); + it('suppresses near-limit banner when chat is fully routed away but background workloads remain (#3097)', async () => { + // Background workloads (memory, heartbeat, …) keep the billing API call + // alive (ALL_WORKLOADS check), but isFullyRoutedAway (CHAT_WORKLOADS) is + // true so the near-limit banner must NOT show — the user's chat is not + // on OpenHuman's budget. + const { useUsageState } = await import('./useUsageState'); + + mockGetCurrentPlan.mockResolvedValue(freePlan()); + // Usage at 90% — would trigger isNearLimit for OpenHuman-routed users. + mockGetTeamUsage.mockResolvedValue(buildUsage({ remainingUsd: 1, cycleBudgetUsd: 10 })); + mockLoadAISettings.mockResolvedValue({ + cloudProviders: [], + routing: { + chat: { kind: 'local' as const, model: 'qwen3:8b' }, + reasoning: { kind: 'local' as const, model: 'qwen3:8b' }, + agentic: { kind: 'local' as const, model: 'qwen3:8b' }, + coding: { kind: 'local' as const, model: 'qwen3:8b' }, + // background workloads still on OpenHuman → billing API is still called + memory: { kind: 'openhuman' as const }, + embeddings: { kind: 'openhuman' as const }, + heartbeat: { kind: 'openhuman' as const }, + learning: { kind: 'openhuman' as const }, + subconscious: { kind: 'openhuman' as const }, + }, + }); + + const { result } = renderHook(() => useUsageState()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isFullyRoutedAway).toBe(true); + // usagePct ~90% but user is routed away from OpenHuman — no near-limit warning. + expect(result.current.isNearLimit).toBe(false); + expect(result.current.isAtLimit).toBe(false); + // billing was still fetched because background workloads remain on OpenHuman + expect(mockGetTeamUsage).toHaveBeenCalledTimes(1); + }); + it('still fetches billing when a background workload remains on OpenHuman', async () => { const { useUsageState } = await import('./useUsageState'); diff --git a/app/src/hooks/useUsageState.ts b/app/src/hooks/useUsageState.ts index 534c4ffbb..1c3e6fdb5 100644 --- a/app/src/hooks/useUsageState.ts +++ b/app/src/hooks/useUsageState.ts @@ -189,7 +189,11 @@ export function useUsageState(): UsageState { const isAtLimit = isBudgetExhausted; - const isNearLimit = !isAtLimit && teamUsage !== null && usagePct >= 0.8; + // Mirror the isAtLimit guard: when every chat workload is routed away from + // OpenHuman the included-budget cycle does not gate the user, so the + // near-limit warning is equally irrelevant (#3097 — top-up banner shown + // despite custom provider). + const isNearLimit = !isAtLimit && !isFullyRoutedAway && teamUsage !== null && usagePct >= 0.8; return { teamUsage, diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 100e11edb..d7436f73c 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -486,6 +486,7 @@ const messages: TranslationMap = { 'onboarding.runtimeChoice.continueCloud': 'المتابعة بالخيار البسيط', 'onboarding.runtimeChoice.continueCustom': 'المتابعة بالخيار المخصص', 'onboarding.runtimeChoice.recommended': 'موصى به', + 'onboarding.runtimeChoice.exitError': 'تعذّر إتمام الإعداد. يُرجى المحاولة مجددًا.', 'onboarding.apiKeys.title': 'لنضف مفاتيح API الخاصة بك', 'onboarding.apiKeys.subtitle': 'يمكنك لصقها الآن أو تخطيها وإضافتها لاحقًا من الإعدادات › الذكاء الاصطناعي. تُحفظ المفاتيح على هذا الجهاز مشفرةً.', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index efb20d17f..ed054313e 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -494,6 +494,7 @@ const messages: TranslationMap = { 'onboarding.runtimeChoice.continueCloud': 'সহজ দিয়ে চালিয়ে যান', 'onboarding.runtimeChoice.continueCustom': 'কাস্টম দিয়ে চালিয়ে যান', 'onboarding.runtimeChoice.recommended': 'প্রস্তাবিত', + 'onboarding.runtimeChoice.exitError': 'অনবোর্ডিং শেষ করা যায়নি। আবার চেষ্টা করুন।', 'onboarding.apiKeys.title': 'আপনার API কী যোগ করুন', 'onboarding.apiKeys.subtitle': 'এখনই পেস্ট করুন বা এড়িয়ে গিয়ে পরে Settings › AI-এ যোগ করুন। কীগুলো এই ডিভাইসে এনক্রিপ্ট করে সংরক্ষিত থাকে।', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 617f3a776..85825be42 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -508,6 +508,8 @@ const messages: TranslationMap = { 'onboarding.runtimeChoice.continueCloud': 'Fahre mit „Einfach“ fort', 'onboarding.runtimeChoice.continueCustom': 'Fahre mit Benutzerdefiniert fort', 'onboarding.runtimeChoice.recommended': 'Empfohlen', + 'onboarding.runtimeChoice.exitError': + 'Onboarding konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.', 'onboarding.apiKeys.title': 'Fügen wir deine API-Schlüssel hinzu', 'onboarding.apiKeys.subtitle': 'Du kannst sie jetzt einfügen oder überspringen und später unter „Einstellungen“ > „KI“ hinzufügen. Schlüssel werden auf diesem Gerät gespeichert und im Ruhezustand verschlüsselt.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index a7c16a740..2851ecec3 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -613,6 +613,7 @@ const en: TranslationMap = { 'onboarding.runtimeChoice.continueCloud': 'Continue with Simple', 'onboarding.runtimeChoice.continueCustom': 'Continue with Custom', 'onboarding.runtimeChoice.recommended': 'Recommended', + 'onboarding.runtimeChoice.exitError': 'Could not finish onboarding. Please try again.', // Onboarding: API keys step (only when Custom is picked) 'onboarding.apiKeys.title': "Let's Add Your API Keys", diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index f7fe2a20c..0df0716e1 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -506,6 +506,8 @@ const messages: TranslationMap = { 'onboarding.runtimeChoice.continueCloud': 'Continuar con Simple', 'onboarding.runtimeChoice.continueCustom': 'Continuar con Personalizado', 'onboarding.runtimeChoice.recommended': 'Recomendado', + 'onboarding.runtimeChoice.exitError': + 'No se pudo completar el proceso de incorporación. Por favor, inténtalo de nuevo.', 'onboarding.apiKeys.title': 'Agreguemos tus claves API', 'onboarding.apiKeys.subtitle': 'Puedes pegarlas ahora u omitir y agregarlas luego en Configuración › IA. Las claves se guardan en este dispositivo, cifradas en reposo.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 4d2137cee..4d7a75bb0 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -504,6 +504,7 @@ const messages: TranslationMap = { 'onboarding.runtimeChoice.continueCloud': 'Continuer avec Simple', 'onboarding.runtimeChoice.continueCustom': 'Continuer avec Personnalisé', 'onboarding.runtimeChoice.recommended': 'Recommandé', + 'onboarding.runtimeChoice.exitError': "Impossible de terminer l'intégration. Veuillez réessayer.", 'onboarding.apiKeys.title': 'Ajoutons tes clés API', 'onboarding.apiKeys.subtitle': 'Tu peux les coller maintenant ou passer et les ajouter plus tard dans Paramètres › IA. Les clés sont stockées sur cet appareil, chiffrées au repos.', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index f4578e1a5..699bb949f 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -493,6 +493,7 @@ const messages: TranslationMap = { 'onboarding.runtimeChoice.continueCloud': 'सिंपल के साथ जारी रखें', 'onboarding.runtimeChoice.continueCustom': 'कस्टम के साथ जारी रखें', 'onboarding.runtimeChoice.recommended': 'सुझावित', + 'onboarding.runtimeChoice.exitError': 'ऑनबोर्डिंग पूरी नहीं हो सकी। कृपया पुनः प्रयास करें।', 'onboarding.apiKeys.title': 'अपनी API Keys जोड़ें', 'onboarding.apiKeys.subtitle': 'अभी पेस्ट करें या बाद में Settings › AI में जोड़ें। Keys इस डिवाइस पर एन्क्रिप्टेड रहती हैं।', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 9e843d467..22a132337 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -497,6 +497,7 @@ const messages: TranslationMap = { 'onboarding.runtimeChoice.continueCloud': 'Lanjutkan dengan Sederhana', 'onboarding.runtimeChoice.continueCustom': 'Lanjutkan dengan Kustom', 'onboarding.runtimeChoice.recommended': 'Direkomendasikan', + 'onboarding.runtimeChoice.exitError': 'Tidak dapat menyelesaikan orientasi. Silakan coba lagi.', 'onboarding.apiKeys.title': 'Mari Tambahkan API Key Anda', 'onboarding.apiKeys.subtitle': 'Anda dapat menempelkannya sekarang atau lewati dan tambahkan nanti di Pengaturan › AI. Key disimpan di perangkat ini, dienkripsi saat penyimpanan.', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index a67e54694..17c81649b 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -503,6 +503,7 @@ const messages: TranslationMap = { 'onboarding.runtimeChoice.continueCloud': 'Continua con Semplice', 'onboarding.runtimeChoice.continueCustom': 'Continua con Personalizzato', 'onboarding.runtimeChoice.recommended': 'Consigliato', + 'onboarding.runtimeChoice.exitError': "Impossibile completare l'onboarding. Riprova.", 'onboarding.apiKeys.title': 'Aggiungiamo le tue chiavi API', 'onboarding.apiKeys.subtitle': 'Puoi incollarle ora o saltare e aggiungerle dopo in Impostazioni › AI. Le chiavi sono memorizzate su questo dispositivo, crittografate a riposo.', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index f9765b655..6b2e7c0c3 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -493,6 +493,7 @@ const messages: TranslationMap = { 'onboarding.runtimeChoice.continueCloud': '간단 모드로 계속', 'onboarding.runtimeChoice.continueCustom': '사용자 지정으로 계속', 'onboarding.runtimeChoice.recommended': '추천', + 'onboarding.runtimeChoice.exitError': '온보딩을 완료할 수 없습니다. 다시 시도해 주세요.', 'onboarding.apiKeys.title': 'API 키를 추가해 봅시다', 'onboarding.apiKeys.subtitle': '지금 붙여넣거나 건너뛰고 나중에 설정 › AI에서 추가할 수 있습니다. 키는 이 기기에 저장되며 저장 시 암호화됩니다.', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 5359b126b..4668bd666 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -501,6 +501,7 @@ const messages: TranslationMap = { 'onboarding.runtimeChoice.continueCloud': 'Kontynuuj w trybie Prosto', 'onboarding.runtimeChoice.continueCustom': 'Kontynuuj w trybie Niestandardowo', 'onboarding.runtimeChoice.recommended': 'Polecane', + 'onboarding.runtimeChoice.exitError': 'Nie udało się zakończyć wdrożenia. Spróbuj ponownie.', 'onboarding.apiKeys.title': 'Dodajmy Twoje klucze API', 'onboarding.apiKeys.subtitle': 'Możesz wkleić je teraz lub pominąć i dodać później w Ustawieniach › AI. Klucze są przechowywane na tym urządzeniu, zaszyfrowane w spoczynku.', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 8f0c1be81..a306c4304 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -504,6 +504,8 @@ const messages: TranslationMap = { 'onboarding.runtimeChoice.continueCloud': 'Continuar com Simples', 'onboarding.runtimeChoice.continueCustom': 'Continuar com Personalizado', 'onboarding.runtimeChoice.recommended': 'Recomendado', + 'onboarding.runtimeChoice.exitError': + 'Não foi possível concluir o processo de integração. Por favor, tente novamente.', 'onboarding.apiKeys.title': 'Vamos Adicionar Suas Chaves de API', 'onboarding.apiKeys.subtitle': 'Você pode colá-las agora ou pular e adicioná-las depois em Configurações › IA. As chaves são armazenadas neste dispositivo, criptografadas em repouso.', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 296dab923..d24a7c03d 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -498,6 +498,8 @@ const messages: TranslationMap = { 'onboarding.runtimeChoice.continueCloud': 'Продолжить с простым режимом', 'onboarding.runtimeChoice.continueCustom': 'Продолжить со своими настройками', 'onboarding.runtimeChoice.recommended': 'Рекомендуется', + 'onboarding.runtimeChoice.exitError': + 'Не удалось завершить настройку. Пожалуйста, попробуйте ещё раз.', 'onboarding.apiKeys.title': 'Добавь свои API-ключи', 'onboarding.apiKeys.subtitle': 'Вставь их сейчас или пропусти и добавь позже в Настройки › AI. Ключи хранятся на этом устройстве в зашифрованном виде.', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index a01c54488..fa2c590ae 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -477,6 +477,7 @@ const messages: TranslationMap = { 'onboarding.runtimeChoice.continueCloud': '继续使用简单模式', 'onboarding.runtimeChoice.continueCustom': '继续使用自定义模式', 'onboarding.runtimeChoice.recommended': '推荐', + 'onboarding.runtimeChoice.exitError': '无法完成引导流程,请重试。', 'onboarding.apiKeys.title': '添加你的 API 密钥', 'onboarding.apiKeys.subtitle': '你可以现在粘贴,也可以跳过并稍后在设置 › AI 中添加。密钥会加密存储在此设备上。', diff --git a/app/src/pages/onboarding/pages/RuntimeChoicePage.test.tsx b/app/src/pages/onboarding/pages/RuntimeChoicePage.test.tsx new file mode 100644 index 000000000..02633b985 --- /dev/null +++ b/app/src/pages/onboarding/pages/RuntimeChoicePage.test.tsx @@ -0,0 +1,78 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../test/test-utils'; +import RuntimeChoicePage from './RuntimeChoicePage'; + +const navigateMock = vi.hoisted(() => vi.fn()); +const setDraftMock = vi.hoisted(() => vi.fn()); +const completeAndExitMock = vi.hoisted(() => vi.fn()); +const isLocalSessionTokenMock = vi.hoisted(() => vi.fn(() => false)); + +vi.mock('react-router-dom', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => navigateMock }; +}); + +vi.mock('../../../utils/localSession', () => ({ isLocalSessionToken: isLocalSessionTokenMock })); + +vi.mock('../../../services/analytics', () => ({ trackEvent: vi.fn() })); + +vi.mock('../OnboardingContext', () => ({ + useOnboardingContext: () => ({ setDraft: setDraftMock, completeAndExit: completeAndExitMock }), +})); + +vi.mock('../steps/RuntimeChoiceStep', () => ({ + default: ({ onNext }: { onNext: (mode: string) => void }) => ( +
+ + +
+ ), +})); + +describe('RuntimeChoicePage', () => { + beforeEach(() => { + navigateMock.mockReset(); + setDraftMock.mockReset(); + completeAndExitMock.mockReset(); + isLocalSessionTokenMock.mockReturnValue(false); + }); + + it('redirects to custom inference and renders nothing for local sessions', () => { + isLocalSessionTokenMock.mockReturnValue(true); + const { container } = renderWithProviders(); + expect(navigateMock).toHaveBeenCalledWith('/onboarding/custom/inference', { replace: true }); + expect(container.firstChild).toBeNull(); + }); + + it('renders the choice step for cloud sessions', () => { + renderWithProviders(); + expect(screen.getByTestId('runtime-choice-step')).toBeInTheDocument(); + }); + + it('navigates to custom inference when custom mode is picked', async () => { + renderWithProviders(); + fireEvent.click(screen.getByRole('button', { name: 'Pick Custom' })); + expect(setDraftMock).toHaveBeenCalled(); + expect(navigateMock).toHaveBeenCalledWith('/onboarding/custom/inference'); + expect(completeAndExitMock).not.toHaveBeenCalled(); + }); + + it('calls completeAndExit for cloud mode and shows no error on success', async () => { + completeAndExitMock.mockResolvedValue(undefined); + renderWithProviders(); + fireEvent.click(screen.getByRole('button', { name: 'Pick Cloud' })); + await waitFor(() => expect(completeAndExitMock).toHaveBeenCalledTimes(1)); + expect(screen.queryByTestId('onboarding-runtime-choice-exit-error')).not.toBeInTheDocument(); + }); + + it('shows error banner when completeAndExit throws', async () => { + completeAndExitMock.mockRejectedValue(new Error('network failure')); + renderWithProviders(); + fireEvent.click(screen.getByRole('button', { name: 'Pick Cloud' })); + await waitFor(() => + expect(screen.getByTestId('onboarding-runtime-choice-exit-error')).toBeInTheDocument() + ); + }); +}); diff --git a/app/src/pages/onboarding/pages/RuntimeChoicePage.tsx b/app/src/pages/onboarding/pages/RuntimeChoicePage.tsx index 22e629e9d..290633461 100644 --- a/app/src/pages/onboarding/pages/RuntimeChoicePage.tsx +++ b/app/src/pages/onboarding/pages/RuntimeChoicePage.tsx @@ -1,6 +1,7 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useT } from '../../../lib/i18n/I18nContext'; import { useCoreState } from '../../../providers/CoreStateProvider'; import { trackEvent } from '../../../services/analytics'; import { isLocalSessionToken } from '../../../utils/localSession'; @@ -8,10 +9,12 @@ import { useOnboardingContext } from '../OnboardingContext'; import RuntimeChoiceStep from '../steps/RuntimeChoiceStep'; const RuntimeChoicePage = () => { + const { t } = useT(); const navigate = useNavigate(); const { setDraft, completeAndExit } = useOnboardingContext(); const { snapshot } = useCoreState(); const isLocalSession = isLocalSessionToken(snapshot.sessionToken); + const [exitError, setExitError] = useState(null); useEffect(() => { if (isLocalSession) { @@ -24,23 +27,34 @@ const RuntimeChoicePage = () => { } return ( - { - setDraft(prev => ({ ...prev, aiMode: mode })); - trackEvent('onboarding_step_complete', { step_name: 'runtime_choice', ai_mode: mode }); + <> + { + setExitError(null); + setDraft(prev => ({ ...prev, aiMode: mode })); + trackEvent('onboarding_step_complete', { step_name: 'runtime_choice', ai_mode: mode }); - if (mode === 'custom') { - navigate('/onboarding/custom/inference'); - return; - } - // Cloud path: nothing else to configure, finish onboarding. - try { - await completeAndExit(); - } catch (err) { - console.error('[onboarding:runtime-choice-page] completeAndExit failed', err); - } - }} - /> + if (mode === 'custom') { + navigate('/onboarding/custom/inference'); + return; + } + // Cloud path: nothing else to configure, finish onboarding. + try { + await completeAndExit(); + } catch (err) { + console.error('[onboarding:runtime-choice-page] completeAndExit failed', err); + setExitError(err instanceof Error ? err.message : String(err)); + } + }} + /> + {exitError ? ( +
+ {t('onboarding.runtimeChoice.exitError')} +
+ ) : null} + ); };