diff --git a/app/src/lib/i18n/chunks/ar-4.ts b/app/src/lib/i18n/chunks/ar-4.ts index 4201f2c202..a8542964f1 100644 --- a/app/src/lib/i18n/chunks/ar-4.ts +++ b/app/src/lib/i18n/chunks/ar-4.ts @@ -151,7 +151,14 @@ const ar4: TranslationMap = { 'onboarding.contextGathering.buildingProfile': 'جارٍ بناء ملفك الشخصي...', 'onboarding.contextGathering.continueToChat': 'المتابعة إلى المحادثة', 'onboarding.contextGathering.errorDesc': - 'لم نتمكّن من بناء ملفك الشخصي بالكامل الآن، لكن لا بأس — يمكنك المتابعة وسيكتمل ملفك تدريجيًا مع مرور الوقت.', + 'تعذّر إنشاء ملفك الكامل الآن، لكن لا بأس — يمكنك المتابعة وسيُبنى ملفك مع الوقت.', + 'onboarding.contextGathering.coreAlive': 'النواة متاحة — قد يستغرق التشغيل الأول دقيقة.', + 'onboarding.contextGathering.coreAliveProbing': 'يجري التحقق من اتصال النواة…', + 'onboarding.contextGathering.coreUnreachable': + 'النواة لا تستجيب. يمكنك المتابعة والمحاولة لاحقًا.', + 'onboarding.contextGathering.stillWorkingDesc': + 'قد يستغرق التشغيل الأول 30–60 ثانية أثناء تهيئة نموذجك المحلي والأدوات. يمكنك المتابعة إلى المحادثة في أي وقت — يستمر بناء الملف الشخصي في الخلفية.', + 'onboarding.contextGathering.stillWorkingTitle': 'لا يزال العمل جاريًا على ملفك الشخصي…', 'onboarding.contextGathering.title': 'جمع السياق', 'openhuman.team_list_teams': 'قائمة الفرق', 'overlay.ariaAttention': 'رسالة انتباه', diff --git a/app/src/lib/i18n/chunks/bn-4.ts b/app/src/lib/i18n/chunks/bn-4.ts index 62fc4708a3..68ce23e3a0 100644 --- a/app/src/lib/i18n/chunks/bn-4.ts +++ b/app/src/lib/i18n/chunks/bn-4.ts @@ -152,7 +152,14 @@ const bn4: TranslationMap = { 'onboarding.contextGathering.buildingProfile': 'আপনার প্রোফাইল তৈরি হচ্ছে...', 'onboarding.contextGathering.continueToChat': 'চ্যাটে চালিয়ে যান', 'onboarding.contextGathering.errorDesc': - 'আমরা এই মুহূর্তে আপনার সম্পূর্ণ প্রোফাইল তৈরি করতে পারিনি, কিন্তু সমস্যা নেই — আপনি চালিয়ে যেতে পারেন, আপনার প্রোফাইল সময়ের সাথে তৈরি হতে থাকবে।', + 'আমরা এখনই আপনার পূর্ণ প্রোফাইল তৈরি করতে পারিনি, কিন্তু সমস্যা নেই — আপনি চালিয়ে যেতে পারেন এবং আপনার প্রোফাইল সময়ের সাথে তৈরি হবে।', + 'onboarding.contextGathering.coreAlive': 'কোর সংযোগযোগ্য — প্রথম লঞ্চ এক মিনিট সময় নিতে পারে।', + 'onboarding.contextGathering.coreAliveProbing': 'কোর সংযোগ যাচাই করা হচ্ছে…', + 'onboarding.contextGathering.coreUnreachable': + 'কোর সাড়া দিচ্ছে না। আপনি চালিয়ে যেতে পারেন এবং পরে আবার চেষ্টা করতে পারেন।', + 'onboarding.contextGathering.stillWorkingDesc': + 'আমরা আপনার লোকাল মডেল এবং টুলস প্রস্তুত করছি, প্রথম লঞ্চ ৩০–৬০ সেকেন্ড সময় নিতে পারে। আপনি যেকোনো সময় চ্যাটে যেতে পারেন — প্রোফাইল তৈরি ব্যাকগ্রাউন্ডে চলতে থাকবে।', + 'onboarding.contextGathering.stillWorkingTitle': 'এখনও আপনার প্রোফাইলে কাজ চলছে…', 'onboarding.contextGathering.title': 'কন্টেক্সট সংগ্রহ', 'openhuman.team_list_teams': 'টিম তালিকা', 'overlay.ariaAttention': 'মনোযোগের বার্তা', diff --git a/app/src/lib/i18n/chunks/en-4.ts b/app/src/lib/i18n/chunks/en-4.ts index 4d4c67f578..b5b3e70cff 100644 --- a/app/src/lib/i18n/chunks/en-4.ts +++ b/app/src/lib/i18n/chunks/en-4.ts @@ -153,6 +153,13 @@ const en4: TranslationMap = { 'onboarding.contextGathering.continueToChat': 'Continue to chat', 'onboarding.contextGathering.errorDesc': "Your chat is ready. We'll keep building your full profile in the background, so you can continue now and refine it over time.", + 'onboarding.contextGathering.coreAlive': 'Core is reachable — first launch can take a minute.', + 'onboarding.contextGathering.coreAliveProbing': 'Checking core connection…', + 'onboarding.contextGathering.coreUnreachable': + 'Core is not responding. You can continue and try again later.', + 'onboarding.contextGathering.stillWorkingDesc': + 'First launch can take 30–60 seconds while we warm up your local model and tools. You can continue to chat at any time — profile build keeps running in the background.', + 'onboarding.contextGathering.stillWorkingTitle': 'Still working on your profile…', 'onboarding.contextGathering.title': 'Context Gathering', 'openhuman.team_list_teams': 'Team list teams', 'overlay.ariaAttention': 'Attention message', diff --git a/app/src/lib/i18n/chunks/es-4.ts b/app/src/lib/i18n/chunks/es-4.ts index 21e3d260dc..f0e60635f7 100644 --- a/app/src/lib/i18n/chunks/es-4.ts +++ b/app/src/lib/i18n/chunks/es-4.ts @@ -152,7 +152,15 @@ const es4: TranslationMap = { 'onboarding.contextGathering.buildingProfile': 'Construyendo tu perfil...', 'onboarding.contextGathering.continueToChat': 'Continuar al chat', 'onboarding.contextGathering.errorDesc': - 'No pudimos construir tu perfil completo en este momento, pero no pasa nada: puedes continuar y tu perfil se irá construyendo con el tiempo.', + 'No pudimos construir tu perfil completo ahora mismo, pero no pasa nada — puedes continuar y tu perfil se construirá con el tiempo.', + 'onboarding.contextGathering.coreAlive': + 'El núcleo está disponible — el primer arranque puede tardar un minuto.', + 'onboarding.contextGathering.coreAliveProbing': 'Comprobando la conexión con el núcleo…', + 'onboarding.contextGathering.coreUnreachable': + 'El núcleo no responde. Puedes continuar e intentarlo más tarde.', + 'onboarding.contextGathering.stillWorkingDesc': + 'El primer arranque puede tardar 30–60 segundos mientras preparamos tu modelo local y tus herramientas. Puedes continuar al chat en cualquier momento — la construcción del perfil sigue en segundo plano.', + 'onboarding.contextGathering.stillWorkingTitle': 'Seguimos trabajando en tu perfil…', 'onboarding.contextGathering.title': 'Recopilación de contexto', 'openhuman.team_list_teams': 'Lista de equipos', 'overlay.ariaAttention': 'Mensaje de atención', diff --git a/app/src/lib/i18n/chunks/fr-4.ts b/app/src/lib/i18n/chunks/fr-4.ts index a809286e09..a8da2d56d0 100644 --- a/app/src/lib/i18n/chunks/fr-4.ts +++ b/app/src/lib/i18n/chunks/fr-4.ts @@ -152,7 +152,15 @@ const fr4: TranslationMap = { 'onboarding.contextGathering.buildingProfile': 'Construction de ton profil…', 'onboarding.contextGathering.continueToChat': 'Accéder au chat', 'onboarding.contextGathering.errorDesc': - "Nous n'avons pas pu construire ton profil complet pour le moment, mais ce n'est pas grave — tu peux continuer et ton profil se construira au fil du temps.", + "Nous n'avons pas pu créer votre profil complet pour l'instant, mais ce n'est pas grave — vous pouvez continuer et votre profil se construira au fil du temps.", + 'onboarding.contextGathering.coreAlive': + 'Le cœur est accessible — le premier lancement peut prendre une minute.', + 'onboarding.contextGathering.coreAliveProbing': 'Vérification de la connexion au cœur…', + 'onboarding.contextGathering.coreUnreachable': + 'Le cœur ne répond pas. Tu peux continuer et réessayer plus tard.', + 'onboarding.contextGathering.stillWorkingDesc': + 'Le premier lancement peut prendre 30 à 60 secondes pendant que nous préparons ton modèle local et tes outils. Tu peux accéder au chat à tout moment — la construction du profil continue en arrière-plan.', + 'onboarding.contextGathering.stillWorkingTitle': 'Construction de ton profil en cours…', 'onboarding.contextGathering.title': 'Collecte de contexte', 'openhuman.team_list_teams': 'Liste des équipes', 'overlay.ariaAttention': "Message d'attention", diff --git a/app/src/lib/i18n/chunks/hi-4.ts b/app/src/lib/i18n/chunks/hi-4.ts index 4388634509..5892e10b94 100644 --- a/app/src/lib/i18n/chunks/hi-4.ts +++ b/app/src/lib/i18n/chunks/hi-4.ts @@ -153,6 +153,14 @@ const hi4: TranslationMap = { 'onboarding.contextGathering.continueToChat': 'चैट पर जाएं', 'onboarding.contextGathering.errorDesc': 'हम अभी आपकी पूरी प्रोफ़ाइल नहीं बना सके, लेकिन कोई बात नहीं — आप जारी रख सकते हैं और आपकी प्रोफ़ाइल समय के साथ बनती जाएगी।', + 'onboarding.contextGathering.coreAlive': + 'कोर पहुँच योग्य है — पहली बार लॉन्च करने में एक मिनट लग सकता है।', + 'onboarding.contextGathering.coreAliveProbing': 'कोर कनेक्शन की जाँच की जा रही है…', + 'onboarding.contextGathering.coreUnreachable': + 'कोर प्रतिक्रिया नहीं दे रहा है। आप जारी रख सकते हैं और बाद में पुनः प्रयास कर सकते हैं।', + 'onboarding.contextGathering.stillWorkingDesc': + 'हम आपके स्थानीय मॉडल और टूल्स को तैयार कर रहे हैं, पहली बार लॉन्च करने में 30–60 सेकंड लग सकते हैं। आप कभी भी चैट पर जा सकते हैं — प्रोफ़ाइल पृष्ठभूमि में बनती रहेगी।', + 'onboarding.contextGathering.stillWorkingTitle': 'आपकी प्रोफ़ाइल पर अब भी काम चल रहा है…', 'onboarding.contextGathering.title': 'कॉन्टेक्स्ट गैदरिंग', 'openhuman.team_list_teams': 'टीम सूची टीमें', 'overlay.ariaAttention': 'ध्यान संदेश', diff --git a/app/src/lib/i18n/chunks/id-4.ts b/app/src/lib/i18n/chunks/id-4.ts index b5f0a733e7..32a1e0968f 100644 --- a/app/src/lib/i18n/chunks/id-4.ts +++ b/app/src/lib/i18n/chunks/id-4.ts @@ -152,7 +152,15 @@ const id4: TranslationMap = { 'onboarding.contextGathering.buildingProfile': 'Membangun profil Anda...', 'onboarding.contextGathering.continueToChat': 'Lanjutkan ke chat', 'onboarding.contextGathering.errorDesc': - 'Kami belum bisa membangun profil lengkap Anda sekarang, tetapi tidak apa-apa — Anda bisa melanjutkan dan profil Anda akan terbangun seiring waktu.', + 'Kami tidak bisa membangun profil lengkap Anda sekarang, tapi tidak apa-apa — Anda bisa lanjut dan profil Anda akan terbentuk seiring waktu.', + 'onboarding.contextGathering.coreAlive': + 'Core dapat diakses — peluncuran pertama bisa memakan waktu satu menit.', + 'onboarding.contextGathering.coreAliveProbing': 'Memeriksa koneksi core…', + 'onboarding.contextGathering.coreUnreachable': + 'Core tidak merespons. Anda bisa melanjutkan dan coba lagi nanti.', + 'onboarding.contextGathering.stillWorkingDesc': + 'Peluncuran pertama bisa memakan waktu 30–60 detik sementara kami menyiapkan model dan alat lokal Anda. Anda bisa melanjutkan ke chat kapan saja — pembuatan profil tetap berjalan di latar belakang.', + 'onboarding.contextGathering.stillWorkingTitle': 'Masih membangun profil Anda…', 'onboarding.contextGathering.title': 'Pengumpulan Konteks', 'openhuman.team_list_teams': 'Daftar tim', 'overlay.ariaAttention': 'Pesan perhatian', diff --git a/app/src/lib/i18n/chunks/it-4.ts b/app/src/lib/i18n/chunks/it-4.ts index 1c10213751..d31922e657 100644 --- a/app/src/lib/i18n/chunks/it-4.ts +++ b/app/src/lib/i18n/chunks/it-4.ts @@ -153,6 +153,14 @@ const it4: TranslationMap = { 'onboarding.contextGathering.continueToChat': 'Continua alla chat', 'onboarding.contextGathering.errorDesc': 'Non siamo riusciti a costruire il tuo profilo completo ora, ma va bene — puoi continuare e il tuo profilo si svilupperà nel tempo.', + 'onboarding.contextGathering.coreAlive': + 'Il core è raggiungibile — il primo avvio può richiedere un minuto.', + 'onboarding.contextGathering.coreAliveProbing': 'Verifica della connessione al core…', + 'onboarding.contextGathering.coreUnreachable': + 'Il core non risponde. Puoi continuare e riprovare più tardi.', + 'onboarding.contextGathering.stillWorkingDesc': + 'Il primo avvio può richiedere 30–60 secondi mentre prepariamo il tuo modello locale e gli strumenti. Puoi continuare la chat in qualsiasi momento — la costruzione del profilo continua in background.', + 'onboarding.contextGathering.stillWorkingTitle': 'Stiamo ancora preparando il tuo profilo…', 'onboarding.contextGathering.title': 'Raccolta del contesto', 'openhuman.team_list_teams': 'Elenco team', 'overlay.ariaAttention': 'Messaggio di attenzione', diff --git a/app/src/lib/i18n/chunks/pt-4.ts b/app/src/lib/i18n/chunks/pt-4.ts index 30cf247825..5bd2212b56 100644 --- a/app/src/lib/i18n/chunks/pt-4.ts +++ b/app/src/lib/i18n/chunks/pt-4.ts @@ -153,7 +153,15 @@ const pt4: TranslationMap = { 'onboarding.contextGathering.buildingProfile': 'Construindo seu perfil...', 'onboarding.contextGathering.continueToChat': 'Continuar para o chat', 'onboarding.contextGathering.errorDesc': - 'Não conseguimos construir seu perfil completo agora, mas tudo bem — você pode continuar e seu perfil será construído com o tempo.', + 'Não conseguimos montar seu perfil completo agora, mas tudo bem — você pode continuar e seu perfil será construído ao longo do tempo.', + 'onboarding.contextGathering.coreAlive': + 'Núcleo acessível — a primeira inicialização pode demorar um minuto.', + 'onboarding.contextGathering.coreAliveProbing': 'Verificando a conexão com o núcleo…', + 'onboarding.contextGathering.coreUnreachable': + 'O núcleo não está respondendo. Você pode continuar e tentar novamente mais tarde.', + 'onboarding.contextGathering.stillWorkingDesc': + 'A primeira inicialização pode levar 30–60 segundos enquanto preparamos seu modelo local e ferramentas. Você pode continuar para o chat a qualquer momento — a construção do perfil continua em segundo plano.', + 'onboarding.contextGathering.stillWorkingTitle': 'Ainda construindo seu perfil…', 'onboarding.contextGathering.title': 'Coleta de Contexto', 'openhuman.team_list_teams': 'Listar equipes', 'overlay.ariaAttention': 'Mensagem de atenção', diff --git a/app/src/lib/i18n/chunks/ru-4.ts b/app/src/lib/i18n/chunks/ru-4.ts index ae1ad2e540..f5671fb928 100644 --- a/app/src/lib/i18n/chunks/ru-4.ts +++ b/app/src/lib/i18n/chunks/ru-4.ts @@ -152,7 +152,14 @@ const ru4: TranslationMap = { 'onboarding.contextGathering.buildingProfile': 'Составление профиля...', 'onboarding.contextGathering.continueToChat': 'Перейти в чат', 'onboarding.contextGathering.errorDesc': - 'Сейчас не удалось построить ваш полный профиль, но это нормально — вы можете продолжить, и ваш профиль будет формироваться со временем.', + 'Мы не смогли построить ваш полный профиль прямо сейчас, но это нормально — вы можете продолжить, и профиль будет дополняться со временем.', + 'onboarding.contextGathering.coreAlive': 'Ядро доступно — первый запуск может занять минуту.', + 'onboarding.contextGathering.coreAliveProbing': 'Проверка соединения с ядром…', + 'onboarding.contextGathering.coreUnreachable': + 'Ядро не отвечает. Можно продолжить и попробовать позже.', + 'onboarding.contextGathering.stillWorkingDesc': + 'Первый запуск может занять 30–60 секунд, пока мы прогреваем локальную модель и инструменты. Вы можете перейти в чат в любой момент — построение профиля продолжится в фоне.', + 'onboarding.contextGathering.stillWorkingTitle': 'Профиль ещё составляется…', 'onboarding.contextGathering.title': 'Сбор контекста', 'openhuman.team_list_teams': 'Список команд', 'overlay.ariaAttention': 'Сообщение', diff --git a/app/src/lib/i18n/chunks/zh-CN-4.ts b/app/src/lib/i18n/chunks/zh-CN-4.ts index 0961666677..3f2f6d737e 100644 --- a/app/src/lib/i18n/chunks/zh-CN-4.ts +++ b/app/src/lib/i18n/chunks/zh-CN-4.ts @@ -149,7 +149,13 @@ const zhCN4: TranslationMap = { 'onboarding.contextGathering.buildingProfile': '正在构建你的档案...', 'onboarding.contextGathering.continueToChat': '前往对话', 'onboarding.contextGathering.errorDesc': - '暂时无法完整构建你的档案,但没关系——你可以继续,档案会随时间逐步完善。', + '我们暂时无法构建你的完整资料,但没关系——你可以继续,资料会随时间逐步完善。', + 'onboarding.contextGathering.coreAlive': '核心可访问 — 首次启动可能需要一分钟。', + 'onboarding.contextGathering.coreAliveProbing': '正在检查核心连接…', + 'onboarding.contextGathering.coreUnreachable': '核心未响应。你可以继续,稍后再试。', + 'onboarding.contextGathering.stillWorkingDesc': + '我们正在预热本地模型与工具,首次启动可能需要 30–60 秒。你可以随时进入对话 — 档案构建会在后台继续进行。', + 'onboarding.contextGathering.stillWorkingTitle': '仍在生成你的档案…', 'onboarding.contextGathering.title': '上下文收集', 'openhuman.team_list_teams': '团队列表', 'overlay.ariaAttention': '注意消息', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index d44da56f88..abf4d88ee9 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1493,8 +1493,15 @@ const en: TranslationMap = { 'onboarding.contextGathering.buildingDesc': 'Gathering context from your connected accounts…', 'onboarding.contextGathering.buildingProfile': 'Building your profile...', 'onboarding.contextGathering.continueToChat': 'Continue to chat', + 'onboarding.contextGathering.coreAlive': 'Core is reachable — first launch can take a minute.', + 'onboarding.contextGathering.coreAliveProbing': 'Checking core connection…', + 'onboarding.contextGathering.coreUnreachable': + 'Core is not responding. You can continue and try again later.', 'onboarding.contextGathering.errorDesc': "Your chat is ready. We'll keep building your full profile in the background, so you can continue now and refine it over time.", + 'onboarding.contextGathering.stillWorkingDesc': + 'First launch can take 30–60 seconds while we warm up your local model and tools. You can continue to chat at any time — profile build keeps running in the background.', + 'onboarding.contextGathering.stillWorkingTitle': 'Still working on your profile…', 'onboarding.contextGathering.title': 'Context Gathering', 'openhuman.team_list_teams': 'Team list teams', 'overlay.ariaAttention': 'Attention message', diff --git a/app/src/pages/onboarding/steps/ContextGatheringStep.tsx b/app/src/pages/onboarding/steps/ContextGatheringStep.tsx index 4ebc1d8af5..06a14b6669 100644 --- a/app/src/pages/onboarding/steps/ContextGatheringStep.tsx +++ b/app/src/pages/onboarding/steps/ContextGatheringStep.tsx @@ -15,9 +15,30 @@ import { useEffect, useRef, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; -import { callCoreRpc } from '../../../services/coreRpcClient'; +import { callCoreRpc, getCoreRpcUrl, testCoreRpcConnection } from '../../../services/coreRpcClient'; import OnboardingNextButton from '../components/OnboardingNextButton'; +/** + * Threshold after which the building-profile UI swaps to the "still working" + * staged copy (#2156). Real first-launch completions on slow M-series Macs + * can take 30–40s, so users would otherwise see what looks like a stall at + * the global RPC timeout boundary. + */ +const STILL_WORKING_THRESHOLD_MS = 30_000; + +/** How often we probe `core.ping` after entering the still-working state. */ +const ALIVE_PROBE_INTERVAL_MS = 5_000; + +/** + * Per-probe network budget. `testCoreRpcConnection` uses raw `fetch()` with + * no built-in timeout, so we have to bound the probe ourselves — otherwise a + * TCP black-hole (firewall drop, suspended laptop wake) lets the first probe + * hang forever and the single-flight guard blocks every subsequent tick. + */ +const PROBE_TIMEOUT_MS = 3_000; + +type AliveState = 'unknown' | 'probing' | 'alive' | 'unreachable'; + interface ContextGatheringStepProps { connectedSources: string[]; onNext: () => void | Promise; @@ -138,10 +159,20 @@ async function findLinkedInUrlViaComposio(): Promise { return null; } +/** + * First-launch profile compression on slow hardware (#2156) can run past the + * global 30s RPC timeout while the core LLM compressor finishes. Use a + * longer-but-still-bounded budget so users with a slow-but-alive core are + * not parked on the post-login fallback when the call would otherwise + * succeed. Real failures still abort within `SAVE_PROFILE_TIMEOUT_MS`. + */ +const SAVE_PROFILE_TIMEOUT_MS = 90_000; + async function saveProfile(markdown: string): Promise { await callCoreRpc({ method: 'openhuman.learning_save_profile', params: { markdown, summarize: true }, + timeoutMs: SAVE_PROFILE_TIMEOUT_MS, }); } @@ -158,6 +189,10 @@ const ContextGatheringStep = ({ ); const [finished, setFinished] = useState(false); const [hasError, setHasError] = useState(false); + // Staged "still working" mode kicks in after STILL_WORKING_THRESHOLD_MS so + // a slow-but-alive first launch no longer looks like a stall (#2156). + const [stillWorking, setStillWorking] = useState(false); + const [aliveState, setAliveState] = useState('unknown'); const backgroundClickedRef = useRef(false); const ranRef = useRef(false); @@ -271,6 +306,66 @@ const ContextGatheringStep = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Staged "still working" trigger (#2156). After STILL_WORKING_THRESHOLD_MS + // of pipeline runtime, swap to the calmer copy and start probing + // `core.ping` so we can tell users whether the core is slow-but-alive vs + // truly unreachable. Cleared on finish/error so completed runs never flip + // to the slow-path UI retroactively. + useEffect(() => { + if (finished || !hasGmail || hasError) return; + const timer = window.setTimeout(() => { + setStillWorking(true); + }, STILL_WORKING_THRESHOLD_MS); + return () => window.clearTimeout(timer); + }, [finished, hasGmail, hasError]); + + // Periodic alive probe while in still-working state. `core.ping` resolves + // quickly even when the busy snapshot RPC is holding the worker, so a + // green ping during a slow snapshot is exactly the alive-but-slow signal + // users need to see. On cold start the bearer token may not be resolved + // yet (IPC race) and the probe will come back 401 — that means auth is + // still warming up, not that the core is down, so we treat 401 as `alive`. + useEffect(() => { + if (!stillWorking || finished || hasError) return; + let cancelled = false; + // Single-flight guard so a slow probe never gets shadowed by the next + // 5s tick. Each probe also bounds itself with its own AbortController + // (PROBE_TIMEOUT_MS) so a TCP-black-hole core (firewall, suspended + // laptop) cannot leave us with a permanently in-flight fetch and a + // permanently blocked guard — that would re-create exactly the + // "Checking core connection…" forever failure mode this UI exists to + // fix. + let inFlight = false; + + const probe = async () => { + if (cancelled || inFlight) return; + inFlight = true; + setAliveState(prev => (prev === 'unknown' ? 'probing' : prev)); + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); + try { + const url = await getCoreRpcUrl(); + const response = await testCoreRpcConnection(url, undefined, { signal: controller.signal }); + if (!cancelled) { + // 401 = auth not ready yet (cold-start IPC race), not a dead core. + setAliveState(response.ok || response.status === 401 ? 'alive' : 'unreachable'); + } + } catch { + if (!cancelled) setAliveState('unreachable'); + } finally { + window.clearTimeout(timer); + inFlight = false; + } + }; + + void probe(); + const interval = window.setInterval(probe, ALIVE_PROBE_INTERVAL_MS); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [stillWorking, finished, hasError]); + // Auto-navigate on successful completion (skip if user already clicked background link) useEffect(() => { if (finished && !hasError && !backgroundClickedRef.current) { @@ -305,6 +400,24 @@ const ContextGatheringStep = ({ ); } + // The slow-path UI must vanish as soon as the pipeline resolves, even + // during the 800ms auto-advance window — otherwise a slow-success user + // sees "still working…" copy after the work has actually finished. + const showStillWorking = stillWorking && !finished && !hasError; + const titleKey = showStillWorking + ? 'onboarding.contextGathering.stillWorkingTitle' + : 'onboarding.contextGathering.buildingProfile'; + const descKey = showStillWorking + ? 'onboarding.contextGathering.stillWorkingDesc' + : 'onboarding.contextGathering.buildingDesc'; + + const aliveLabelKey: Record = { + unknown: 'onboarding.contextGathering.coreAliveProbing', + probing: 'onboarding.contextGathering.coreAliveProbing', + alive: 'onboarding.contextGathering.coreAlive', + unreachable: 'onboarding.contextGathering.coreUnreachable', + }; + return (
@@ -312,11 +425,15 @@ const ContextGatheringStep = ({
{/* Title */} -

- {t('onboarding.contextGathering.buildingProfile')} +

+ {t(titleKey)}

-

- {t('onboarding.contextGathering.buildingDesc')} +

+ {t(descKey)}

{/* Skeleton bars */} @@ -326,6 +443,28 @@ const ContextGatheringStep = ({
+ {/* Alive indicator — only while still-working state is active AND + the pipeline hasn't finished/errored. Avoids flashing the probe + during the 800ms auto-advance window after a slow success. */} + {showStillWorking && ( +
+
+ )} + {hasGmail && !finished && ( vi.fn()); -vi.mock('../../../../services/coreRpcClient', () => ({ callCoreRpc })); +const getCoreRpcUrl = vi.hoisted(() => vi.fn(async () => 'http://127.0.0.1:7788/rpc')); +const testCoreRpcConnection = vi.hoisted(() => + vi.fn(async () => ({ ok: true, status: 200 }) as Response) +); +vi.mock('../../../../services/coreRpcClient', () => ({ + callCoreRpc, + getCoreRpcUrl, + testCoreRpcConnection, +})); describe('ContextGatheringStep', () => { beforeEach(() => { callCoreRpc.mockReset(); + getCoreRpcUrl.mockClear(); + testCoreRpcConnection.mockClear(); + testCoreRpcConnection.mockResolvedValue({ ok: true, status: 200 } as Response); }); it('no-Gmail branch: auto-navigates without any RPC', async () => { @@ -277,4 +288,228 @@ describe('ContextGatheringStep', () => { // fireEvent not needed — onNext is available via the button but user can also // just verify the friendly message is shown }); + + // -------------------------------------------------------------------------- + // #2156 — slow-but-alive snapshot / save_profile path + // -------------------------------------------------------------------------- + describe('staged still-working UI (#2156)', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('passes a 90s timeoutMs override on learning_save_profile so slow first launches finish', async () => { + const onNext = vi.fn().mockResolvedValue(undefined); + callCoreRpc.mockImplementation(async (req: { method: string }) => { + if (req.method === 'openhuman.tools_composio_execute') { + return { + successful: true, + data: { messages: [{ messageText: 'https://www.linkedin.com/in/jane-doe' }] }, + }; + } + if (req.method === 'openhuman.learning_save_profile') { + return { path: '/tmp/PROFILE.md', bytes: 1 }; + } + throw new Error(`unexpected RPC ${req.method}`); + }); + + renderWithProviders( + + ); + + await waitFor(() => expect(onNext).toHaveBeenCalled(), { timeout: 5000 }); + + const saveCall = callCoreRpc.mock.calls.find( + (c: Array<{ method: string; timeoutMs?: number }>) => + c[0].method === 'openhuman.learning_save_profile' + ); + expect(saveCall![0].timeoutMs).toBeGreaterThan(30_000); + expect(saveCall![0].timeoutMs).toBeLessThanOrEqual(10 * 60 * 1_000); + }); + + it('swaps to the still-working copy after 30s while the pipeline is still pending', async () => { + vi.useFakeTimers(); + let resolveGmail!: (v: unknown) => void; + callCoreRpc.mockImplementation( + () => + new Promise(res => { + resolveGmail = res; + }) + ); + + renderWithProviders( + + ); + + // Initial copy — fast happy path. + expect(screen.getByTestId('context-gathering-title').textContent).toMatch( + /building your profile/i + ); + expect(screen.queryByTestId('core-alive-indicator')).not.toBeInTheDocument(); + + // Cross the 30s threshold. + await act(async () => { + await vi.advanceTimersByTimeAsync(30_500); + }); + + expect(screen.getByTestId('context-gathering-title').textContent).toMatch(/still working/i); + // Indicator appears so users see whether core is alive or unreachable. + expect(screen.getByTestId('core-alive-indicator')).toBeInTheDocument(); + + await act(async () => { + resolveGmail({ successful: true, data: { messages: [] } }); + }); + }); + + it('reports the core as alive when core.ping returns ok', async () => { + // Fake timers must be active before render so the 30s still-working + // setTimeout registers against the fake clock. + vi.useFakeTimers(); + let resolveGmail!: (v: unknown) => void; + callCoreRpc.mockImplementation( + () => + new Promise(res => { + resolveGmail = res; + }) + ); + + renderWithProviders( + + ); + + // Drive the still-working transition under fake timers, then flip + // back to real timers so waitFor can poll the React commit for the + // resolved indicator state (waitFor doesn't observe fake-time + // microtasks reliably). + await act(async () => { + await vi.advanceTimersByTimeAsync(30_500); + }); + vi.useRealTimers(); + + const indicator = await screen.findByTestId('core-alive-indicator'); + await waitFor(() => { + expect(indicator.getAttribute('data-alive-state')).toBe('alive'); + }); + expect(testCoreRpcConnection).toHaveBeenCalled(); + expect(indicator.textContent ?? '').toMatch(/core is reachable/i); + + await act(async () => { + resolveGmail({ successful: true, data: { messages: [] } }); + }); + }); + + it('treats HTTP 401 as alive (auth not ready yet, core is up)', async () => { + vi.useFakeTimers(); + testCoreRpcConnection.mockResolvedValue({ ok: false, status: 401 } as Response); + + let resolveGmail!: (v: unknown) => void; + callCoreRpc.mockImplementation( + () => + new Promise(res => { + resolveGmail = res; + }) + ); + + renderWithProviders( + + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(30_500); + }); + vi.useRealTimers(); + + const indicator = await screen.findByTestId('core-alive-indicator'); + await waitFor(() => { + expect(indicator.getAttribute('data-alive-state')).toBe('alive'); + }); + + await act(async () => { + resolveGmail({ successful: true, data: { messages: [] } }); + }); + }); + + it('passes an AbortSignal so a TCP black-hole probe cannot hang forever', async () => { + vi.useFakeTimers(); + // Hang the probe indefinitely so the only way it can resolve is via + // the controller-driven abort path. Capture whether the signal was + // forwarded. + testCoreRpcConnection.mockImplementation( + ((_url: string, _token?: string, init?: { signal?: AbortSignal }) => + new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')) + ); + })) as never + ); + + let resolveGmail!: (v: unknown) => void; + callCoreRpc.mockImplementation( + () => + new Promise(res => { + resolveGmail = res; + }) + ); + + renderWithProviders( + + ); + + // Enter still-working state. + await act(async () => { + await vi.advanceTimersByTimeAsync(30_500); + }); + // Drive the per-probe 3s timeout to fire the abort. + await act(async () => { + await vi.advanceTimersByTimeAsync(3_500); + }); + vi.useRealTimers(); + + expect(testCoreRpcConnection).toHaveBeenCalled(); + const lastCall = testCoreRpcConnection.mock.calls[ + testCoreRpcConnection.mock.calls.length - 1 + ] as unknown as [string, string | undefined, { signal?: AbortSignal } | undefined]; + expect(lastCall[2]?.signal).toBeInstanceOf(AbortSignal); + + const indicator = await screen.findByTestId('core-alive-indicator'); + await waitFor(() => { + expect(indicator.getAttribute('data-alive-state')).toBe('unreachable'); + }); + + await act(async () => { + resolveGmail({ successful: true, data: { messages: [] } }); + }); + }); + + it('reports unreachable when core.ping rejects', async () => { + vi.useFakeTimers(); + testCoreRpcConnection.mockRejectedValue(new Error('ECONNREFUSED')); + + let resolveGmail!: (v: unknown) => void; + callCoreRpc.mockImplementation( + () => + new Promise(res => { + resolveGmail = res; + }) + ); + + renderWithProviders( + + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(30_500); + }); + vi.useRealTimers(); + + const indicator = await screen.findByTestId('core-alive-indicator'); + await waitFor(() => { + expect(indicator.getAttribute('data-alive-state')).toBe('unreachable'); + }); + expect(indicator.textContent ?? '').toMatch(/core is not responding/i); + + await act(async () => { + resolveGmail({ successful: true, data: { messages: [] } }); + }); + }); + }); }); diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts index e291ff0bdb..6b9bc67c48 100644 --- a/app/src/services/__tests__/coreRpcClient.test.ts +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -342,6 +342,131 @@ describe('coreRpcClient', () => { } }); + test('honors per-call timeoutMs override instead of the global default (#2156)', async () => { + vi.useFakeTimers(); + try { + const fetchMock = vi.mocked(fetch); + fetchMock.mockImplementationOnce( + (_url, init) => + new Promise((_resolve, reject) => { + const signal = (init as RequestInit).signal as AbortSignal | undefined; + if (!signal) return; + const onAbort = () => { + const err = new Error('The operation was aborted'); + err.name = 'AbortError'; + reject(err); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + }) + ); + + const pending = callCoreRpc({ method: 'openhuman.app_state_snapshot', timeoutMs: 60_000 }); + let settled = false; + pending + .catch(() => {}) + .finally(() => { + settled = true; + }); + + // 30s passes — global default would have aborted by now, but the + // per-call 60s override keeps the request alive. Assert the pending + // promise is still in flight so an early-abort regression on the + // override path cannot slip through (CodeRabbit #2179 review). + await vi.advanceTimersByTimeAsync(31_000); + expect(settled).toBe(false); + + // Advance to the override boundary — now the abort fires. + await vi.advanceTimersByTimeAsync(30_000); + + await expect(pending).rejects.toThrow( + 'Core RPC openhuman.app_state_snapshot timed out after 60000ms' + ); + } finally { + vi.useRealTimers(); + } + }); + + test('clamps an oversize timeoutMs to the MAX bound (10 minutes)', async () => { + vi.useFakeTimers(); + try { + const fetchMock = vi.mocked(fetch); + fetchMock.mockImplementationOnce( + (_url, init) => + new Promise((_resolve, reject) => { + const signal = (init as RequestInit).signal as AbortSignal | undefined; + if (!signal) return; + const onAbort = () => { + const err = new Error('The operation was aborted'); + err.name = 'AbortError'; + reject(err); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + }) + ); + + const pending = callCoreRpc({ + method: 'openhuman.app_state_snapshot', + // 2 hours — far beyond the 10 minute clamp; should be reduced. + timeoutMs: 2 * 60 * 60 * 1_000, + }); + let settled = false; + pending + .catch(() => {}) + .finally(() => { + settled = true; + }); + + const MAX_MS = 10 * 60 * 1_000; + // 1ms before the clamp boundary: still pending. Guards against an + // off-by-one where the clamp accidentally lowers the budget further + // (CodeRabbit #2179 review). + await vi.advanceTimersByTimeAsync(MAX_MS - 1); + expect(settled).toBe(false); + + // Cross the clamp boundary — abort fires. + await vi.advanceTimersByTimeAsync(2); + + await expect(pending).rejects.toThrow( + `Core RPC openhuman.app_state_snapshot timed out after ${MAX_MS}ms` + ); + } finally { + vi.useRealTimers(); + } + }); + + test('falls back to the global default when timeoutMs is undefined', async () => { + vi.useFakeTimers(); + try { + const fetchMock = vi.mocked(fetch); + fetchMock.mockImplementationOnce( + (_url, init) => + new Promise((_resolve, reject) => { + const signal = (init as RequestInit).signal as AbortSignal | undefined; + if (!signal) return; + const onAbort = () => { + const err = new Error('The operation was aborted'); + err.name = 'AbortError'; + reject(err); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + }) + ); + + const pending = callCoreRpc({ method: 'openhuman.threads_list' }); + pending.catch(() => {}); + + await vi.advanceTimersByTimeAsync(CORE_RPC_TIMEOUT_MS + 1); + await expect(pending).rejects.toThrow( + `Core RPC openhuman.threads_list timed out after ${CORE_RPC_TIMEOUT_MS}ms` + ); + } finally { + vi.useRealTimers(); + } + }); + test('does not trigger the timeout path when fetch resolves promptly', async () => { const fetchMock = vi.mocked(fetch); fetchMock.mockResolvedValueOnce({ diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index 5ff285e05f..dfc1e71247 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -12,6 +12,27 @@ interface CoreRpcRelayRequest { method: string; params?: unknown; serviceManaged?: boolean; + /** + * Per-call timeout override in milliseconds. When omitted, defaults to the + * global `CORE_RPC_TIMEOUT_MS` (30s). Use for slow-but-alive RPCs such as + * first-launch `openhuman.app_state_snapshot` (#2156). Clamped to the same + * [MIN, MAX] window as the global default. + */ + timeoutMs?: number; +} + +/** Mirror of `parseCoreRpcTimeoutMs` bounds in `utils/config.ts`. */ +const PER_CALL_TIMEOUT_MIN_MS = 1_000; +const PER_CALL_TIMEOUT_MAX_MS = 10 * 60 * 1_000; + +function resolvePerCallTimeoutMs(override: number | undefined): number { + if (override === undefined) return CORE_RPC_TIMEOUT_MS; + if (!Number.isFinite(override)) return CORE_RPC_TIMEOUT_MS; + const clamped = Math.min( + Math.max(Math.round(override), PER_CALL_TIMEOUT_MIN_MS), + PER_CALL_TIMEOUT_MAX_MS + ); + return clamped; } interface JsonRpcRequestBody { @@ -344,7 +365,8 @@ export async function getCoreRpcToken(): Promise { */ export async function testCoreRpcConnection( url: string, - tokenOverride?: string + tokenOverride?: string, + init?: { signal?: AbortSignal } ): Promise { const token = tokenOverride?.trim() || (await getCoreRpcToken()); const headers: Record = { 'Content-Type': 'application/json' }; @@ -355,6 +377,7 @@ export async function testCoreRpcConnection( method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'core.ping', params: {} }), + signal: init?.signal, }); } @@ -393,6 +416,7 @@ export async function callCoreRpc({ method, params, serviceManaged = false, // kept for compatibility; direct frontend RPC does not use relay-level routing. + timeoutMs, }: CoreRpcRelayRequest): Promise { void serviceManaged; @@ -401,6 +425,7 @@ export async function callCoreRpc({ } const normalizedMethod = normalizeRpcMethod(method); + const effectiveTimeoutMs = resolvePerCallTimeoutMs(timeoutMs); const payload: JsonRpcRequestBody = { jsonrpc: '2.0', id: nextJsonRpcId++, @@ -419,12 +444,14 @@ export async function callCoreRpc({ if (token) { headers['Authorization'] = `Bearer ${token}`; } - // Bound the fetch to CORE_RPC_TIMEOUT_MS. Without this a hung core - // sidecar will block every caller (and the UI) forever. We use a - // manual AbortController + setTimeout rather than AbortSignal.timeout() - // so test fake timers can drive the abort deterministically. + // Bound the fetch. Without this a hung core sidecar would block every + // caller (and the UI) forever. We use a manual AbortController + + // setTimeout rather than AbortSignal.timeout() so test fake timers can + // drive the abort deterministically. Per-call `timeoutMs` (clamped) lets + // legitimately-slow RPCs such as first-launch `app_state_snapshot` + // (#2156) opt into a longer-but-still-bounded budget. const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), CORE_RPC_TIMEOUT_MS); + const timeoutId = setTimeout(() => controller.abort(), effectiveTimeoutMs); let response: Response; try { response = await fetch(rpcUrl, { @@ -437,9 +464,11 @@ export async function callCoreRpc({ if (controller.signal.aborted) { // Throw a fully-classified `CoreRpcError` here so the outer catch // doesn't re-wrap a bare `Error` and so callers can branch on - // `err.kind === 'timeout'` (Sentry filter, soft toast skip). + // `err.kind === 'timeout'` (Sentry filter, soft toast skip). Use + // the per-call `effectiveTimeoutMs` so the message reflects the + // actual budget (#2156 raised the snapshot path to 90s). throw new CoreRpcError( - `Core RPC ${payload.method} timed out after ${CORE_RPC_TIMEOUT_MS}ms`, + `Core RPC ${payload.method} timed out after ${effectiveTimeoutMs}ms`, 'timeout' ); } diff --git a/app/src/services/coreStateApi.test.ts b/app/src/services/coreStateApi.test.ts index 10a34b78c0..92f2f2350f 100644 --- a/app/src/services/coreStateApi.test.ts +++ b/app/src/services/coreStateApi.test.ts @@ -67,13 +67,21 @@ describe('coreStateApi.fetchCoreAppSnapshot', () => { mockCallCoreRpc.mockReset(); }); - it('calls the correct RPC method', async () => { + it('calls the correct RPC method with the slow-snapshot timeout override (#2156)', async () => { mockCallCoreRpc.mockResolvedValueOnce({ result: makeSnapshotResult() }); - const { fetchCoreAppSnapshot } = await import('./coreStateApi'); + const { fetchCoreAppSnapshot, SNAPSHOT_TIMEOUT_MS } = await import('./coreStateApi'); await fetchCoreAppSnapshot(); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.app_state_snapshot' }); + // The first-launch snapshot legitimately runs past the global 30s default + // on slow M-series machines; verify the longer-but-still-bounded budget + // is threaded through to callCoreRpc instead of relying on the default. + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.app_state_snapshot', + timeoutMs: SNAPSHOT_TIMEOUT_MS, + }); + expect(SNAPSHOT_TIMEOUT_MS).toBeGreaterThan(30_000); + expect(SNAPSHOT_TIMEOUT_MS).toBeLessThanOrEqual(10 * 60 * 1_000); }); it('returns the inner result from the RPC envelope', async () => { diff --git a/app/src/services/coreStateApi.ts b/app/src/services/coreStateApi.ts index a4c6296b6d..b65dd823d1 100644 --- a/app/src/services/coreStateApi.ts +++ b/app/src/services/coreStateApi.ts @@ -48,9 +48,21 @@ interface AppStateSnapshotResult { }; } +/** + * First-launch `app_state_snapshot` can take 30–40s on M-series Macs while + * memory tree init, Composio registry warmup, and other boot work compete + * for the snapshot critical path (#2156). The global `CORE_RPC_TIMEOUT_MS` + * default of 30s caused users with merely slow-but-alive cores to be parked + * on the post-login fallback. Use a longer-but-still-bounded budget here so + * legitimate slow-success completes inline, while real failures still abort + * within `SNAPSHOT_TIMEOUT_MS` rather than hanging forever. + */ +export const SNAPSHOT_TIMEOUT_MS = 90_000; + export const fetchCoreAppSnapshot = async (): Promise => { const response = await callCoreRpc<{ result: AppStateSnapshotResult }>({ method: 'openhuman.app_state_snapshot', + timeoutMs: SNAPSHOT_TIMEOUT_MS, }); // Normalise the optional #1299 field at the API boundary so older core // builds without `meetAutoOrchestratorHandoff` still surface the