mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(chat): surface cron, subconscious & memory sync in Background tasks panel (#4086)
This commit is contained in:
@@ -58,6 +58,33 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'conversations.backgroundTasks.sectionThisChat': 'في هذه المحادثة',
|
||||
'conversations.backgroundTasks.sectionScheduled': 'المهام المجدولة',
|
||||
'conversations.backgroundTasks.sectionSubconscious': 'التفكير اللاواعي',
|
||||
'conversations.backgroundTasks.sectionMemory': 'مزامنة الذاكرة',
|
||||
'conversations.backgroundTasks.cronEmpty': 'لا توجد مهام مجدولة.',
|
||||
'conversations.backgroundTasks.cronUnnamed': 'مهمة بدون عنوان',
|
||||
'conversations.backgroundTasks.cronPaused': 'متوقفة مؤقتًا',
|
||||
'conversations.backgroundTasks.cronNext': 'التالي {time}',
|
||||
'conversations.backgroundTasks.cronLast': 'آخر تشغيل {time}',
|
||||
'conversations.backgroundTasks.cronNever': 'لم تُشغَّل بعد',
|
||||
'conversations.backgroundTasks.cronSchedCron': 'Cron {expr}',
|
||||
'conversations.backgroundTasks.cronSchedEvery': 'كل {duration}',
|
||||
'conversations.backgroundTasks.cronSchedAt': 'مرة واحدة',
|
||||
'conversations.backgroundTasks.subOff': 'متوقف',
|
||||
'conversations.backgroundTasks.subWorking': 'يعمل…',
|
||||
'conversations.backgroundTasks.subIdle': 'خامل',
|
||||
'conversations.backgroundTasks.subLastRan': 'آخر تشغيل {time}',
|
||||
'conversations.backgroundTasks.subNeverRan': 'لم يُشغَّل بعد',
|
||||
'conversations.backgroundTasks.subTicks': '{count} عمليات تشغيل',
|
||||
'conversations.backgroundTasks.subQueued': '{count} في قائمة الانتظار',
|
||||
'conversations.backgroundTasks.memUpToDate': 'كل الذكريات محدَّثة',
|
||||
'conversations.backgroundTasks.memIngesting': 'فهرسة {title}',
|
||||
'conversations.backgroundTasks.memIngestingUntitled': 'فهرسة الذكريات',
|
||||
'conversations.backgroundTasks.memQueued': '{count} في قائمة الانتظار',
|
||||
'conversations.backgroundTasks.memProviderActive': 'تتم المزامنة الآن',
|
||||
'conversations.backgroundTasks.memProviderRecent': 'تمت المزامنة مؤخرًا',
|
||||
'conversations.backgroundTasks.memProviderIdle': 'خامل',
|
||||
'nav.home': 'الرئيسية',
|
||||
'nav.human': 'إنسان',
|
||||
'nav.chat': 'المحادثة',
|
||||
|
||||
@@ -59,6 +59,33 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'conversations.backgroundTasks.sectionThisChat': 'এই চ্যাটে',
|
||||
'conversations.backgroundTasks.sectionScheduled': 'নির্ধারিত কাজ',
|
||||
'conversations.backgroundTasks.sectionSubconscious': 'অবচেতন চিন্তা',
|
||||
'conversations.backgroundTasks.sectionMemory': 'মেমরি সিঙ্ক হচ্ছে',
|
||||
'conversations.backgroundTasks.cronEmpty': 'কোনো নির্ধারিত কাজ নেই।',
|
||||
'conversations.backgroundTasks.cronUnnamed': 'শিরোনামহীন কাজ',
|
||||
'conversations.backgroundTasks.cronPaused': 'বিরতি দেওয়া',
|
||||
'conversations.backgroundTasks.cronNext': 'পরবর্তী {time}',
|
||||
'conversations.backgroundTasks.cronLast': 'শেষ চালানো {time}',
|
||||
'conversations.backgroundTasks.cronNever': 'এখনও চালানো হয়নি',
|
||||
'conversations.backgroundTasks.cronSchedCron': 'Cron {expr}',
|
||||
'conversations.backgroundTasks.cronSchedEvery': 'প্রতি {duration}',
|
||||
'conversations.backgroundTasks.cronSchedAt': 'একবার',
|
||||
'conversations.backgroundTasks.subOff': 'বন্ধ',
|
||||
'conversations.backgroundTasks.subWorking': 'কাজ করছে…',
|
||||
'conversations.backgroundTasks.subIdle': 'নিষ্ক্রিয়',
|
||||
'conversations.backgroundTasks.subLastRan': 'শেষ চালানো {time}',
|
||||
'conversations.backgroundTasks.subNeverRan': 'এখনও চালানো হয়নি',
|
||||
'conversations.backgroundTasks.subTicks': '{count} বার চালানো',
|
||||
'conversations.backgroundTasks.subQueued': '{count} সারিবদ্ধ',
|
||||
'conversations.backgroundTasks.memUpToDate': 'সব মেমরি হালনাগাদ',
|
||||
'conversations.backgroundTasks.memIngesting': '{title} সূচিবদ্ধ হচ্ছে',
|
||||
'conversations.backgroundTasks.memIngestingUntitled': 'মেমরি সূচিবদ্ধ হচ্ছে',
|
||||
'conversations.backgroundTasks.memQueued': '{count} সারিবদ্ধ',
|
||||
'conversations.backgroundTasks.memProviderActive': 'এখন সিঙ্ক হচ্ছে',
|
||||
'conversations.backgroundTasks.memProviderRecent': 'সম্প্রতি সিঙ্ক হয়েছে',
|
||||
'conversations.backgroundTasks.memProviderIdle': 'নিষ্ক্রিয়',
|
||||
'nav.home': 'হোম',
|
||||
'nav.human': 'হিউম্যান',
|
||||
'nav.chat': 'চ্যাট',
|
||||
|
||||
@@ -60,6 +60,33 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'conversations.backgroundTasks.sectionThisChat': 'In diesem Chat',
|
||||
'conversations.backgroundTasks.sectionScheduled': 'Geplante Aufgaben',
|
||||
'conversations.backgroundTasks.sectionSubconscious': 'Unterbewusstes Denken',
|
||||
'conversations.backgroundTasks.sectionMemory': 'Speicher wird synchronisiert',
|
||||
'conversations.backgroundTasks.cronEmpty': 'Keine geplanten Aufgaben.',
|
||||
'conversations.backgroundTasks.cronUnnamed': 'Unbenannte Aufgabe',
|
||||
'conversations.backgroundTasks.cronPaused': 'Pausiert',
|
||||
'conversations.backgroundTasks.cronNext': 'Nächste {time}',
|
||||
'conversations.backgroundTasks.cronLast': 'Zuletzt {time}',
|
||||
'conversations.backgroundTasks.cronNever': 'Noch nicht ausgeführt',
|
||||
'conversations.backgroundTasks.cronSchedCron': 'Cron {expr}',
|
||||
'conversations.backgroundTasks.cronSchedEvery': 'Alle {duration}',
|
||||
'conversations.backgroundTasks.cronSchedAt': 'Einmalig',
|
||||
'conversations.backgroundTasks.subOff': 'Aus',
|
||||
'conversations.backgroundTasks.subWorking': 'Arbeitet…',
|
||||
'conversations.backgroundTasks.subIdle': 'Inaktiv',
|
||||
'conversations.backgroundTasks.subLastRan': 'Zuletzt ausgeführt {time}',
|
||||
'conversations.backgroundTasks.subNeverRan': 'Noch nicht ausgeführt',
|
||||
'conversations.backgroundTasks.subTicks': '{count} Durchläufe',
|
||||
'conversations.backgroundTasks.subQueued': '{count} in Warteschlange',
|
||||
'conversations.backgroundTasks.memUpToDate': 'Alle Erinnerungen aktuell',
|
||||
'conversations.backgroundTasks.memIngesting': '{title} wird indexiert',
|
||||
'conversations.backgroundTasks.memIngestingUntitled': 'Erinnerungen werden indexiert',
|
||||
'conversations.backgroundTasks.memQueued': '{count} in Warteschlange',
|
||||
'conversations.backgroundTasks.memProviderActive': 'Wird jetzt synchronisiert',
|
||||
'conversations.backgroundTasks.memProviderRecent': 'Kürzlich synchronisiert',
|
||||
'conversations.backgroundTasks.memProviderIdle': 'Inaktiv',
|
||||
'nav.home': 'Start',
|
||||
'nav.human': 'Mensch',
|
||||
'nav.chat': 'Chat',
|
||||
|
||||
@@ -3658,6 +3658,37 @@ const en: TranslationMap = {
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
// Section headers for the broader background-activity panel.
|
||||
'conversations.backgroundTasks.sectionThisChat': 'In this chat',
|
||||
'conversations.backgroundTasks.sectionScheduled': 'Scheduled jobs',
|
||||
'conversations.backgroundTasks.sectionSubconscious': 'Subconscious thinking',
|
||||
'conversations.backgroundTasks.sectionMemory': 'Memory syncing',
|
||||
// Scheduled (cron) jobs.
|
||||
'conversations.backgroundTasks.cronEmpty': 'No scheduled jobs.',
|
||||
'conversations.backgroundTasks.cronUnnamed': 'Untitled job',
|
||||
'conversations.backgroundTasks.cronPaused': 'Paused',
|
||||
'conversations.backgroundTasks.cronNext': 'Next {time}',
|
||||
'conversations.backgroundTasks.cronLast': 'Last run {time}',
|
||||
'conversations.backgroundTasks.cronNever': 'Hasn’t run yet',
|
||||
'conversations.backgroundTasks.cronSchedCron': 'Cron {expr}',
|
||||
'conversations.backgroundTasks.cronSchedEvery': 'Every {duration}',
|
||||
'conversations.backgroundTasks.cronSchedAt': 'Once',
|
||||
// Subconscious / heartbeat loop.
|
||||
'conversations.backgroundTasks.subOff': 'Off',
|
||||
'conversations.backgroundTasks.subWorking': 'Working…',
|
||||
'conversations.backgroundTasks.subIdle': 'Idle',
|
||||
'conversations.backgroundTasks.subLastRan': 'Last ran {time}',
|
||||
'conversations.backgroundTasks.subNeverRan': 'Hasn’t run yet',
|
||||
'conversations.backgroundTasks.subTicks': '{count} runs',
|
||||
'conversations.backgroundTasks.subQueued': '{count} queued',
|
||||
// Memory syncing / ingestion.
|
||||
'conversations.backgroundTasks.memUpToDate': 'All memories up to date',
|
||||
'conversations.backgroundTasks.memIngesting': 'Indexing {title}',
|
||||
'conversations.backgroundTasks.memIngestingUntitled': 'Indexing memories',
|
||||
'conversations.backgroundTasks.memQueued': '{count} queued',
|
||||
'conversations.backgroundTasks.memProviderActive': 'Syncing now',
|
||||
'conversations.backgroundTasks.memProviderRecent': 'Synced recently',
|
||||
'conversations.backgroundTasks.memProviderIdle': 'Idle',
|
||||
'conversations.subagent.statusRunning': 'running',
|
||||
'conversations.subagent.statusCompleted': 'completed',
|
||||
'conversations.subagent.statusFailed': 'failed',
|
||||
|
||||
@@ -59,6 +59,33 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'conversations.backgroundTasks.sectionThisChat': 'En este chat',
|
||||
'conversations.backgroundTasks.sectionScheduled': 'Tareas programadas',
|
||||
'conversations.backgroundTasks.sectionSubconscious': 'Pensamiento subconsciente',
|
||||
'conversations.backgroundTasks.sectionMemory': 'Sincronizando memoria',
|
||||
'conversations.backgroundTasks.cronEmpty': 'No hay tareas programadas.',
|
||||
'conversations.backgroundTasks.cronUnnamed': 'Tarea sin título',
|
||||
'conversations.backgroundTasks.cronPaused': 'En pausa',
|
||||
'conversations.backgroundTasks.cronNext': 'Próxima {time}',
|
||||
'conversations.backgroundTasks.cronLast': 'Última ejecución {time}',
|
||||
'conversations.backgroundTasks.cronNever': 'Aún no se ha ejecutado',
|
||||
'conversations.backgroundTasks.cronSchedCron': 'Cron {expr}',
|
||||
'conversations.backgroundTasks.cronSchedEvery': 'Cada {duration}',
|
||||
'conversations.backgroundTasks.cronSchedAt': 'Una vez',
|
||||
'conversations.backgroundTasks.subOff': 'Desactivado',
|
||||
'conversations.backgroundTasks.subWorking': 'Trabajando…',
|
||||
'conversations.backgroundTasks.subIdle': 'Inactivo',
|
||||
'conversations.backgroundTasks.subLastRan': 'Última ejecución {time}',
|
||||
'conversations.backgroundTasks.subNeverRan': 'Aún no se ha ejecutado',
|
||||
'conversations.backgroundTasks.subTicks': '{count} ejecuciones',
|
||||
'conversations.backgroundTasks.subQueued': '{count} en cola',
|
||||
'conversations.backgroundTasks.memUpToDate': 'Todas las memorias actualizadas',
|
||||
'conversations.backgroundTasks.memIngesting': 'Indexando {title}',
|
||||
'conversations.backgroundTasks.memIngestingUntitled': 'Indexando memorias',
|
||||
'conversations.backgroundTasks.memQueued': '{count} en cola',
|
||||
'conversations.backgroundTasks.memProviderActive': 'Sincronizando ahora',
|
||||
'conversations.backgroundTasks.memProviderRecent': 'Sincronizado recientemente',
|
||||
'conversations.backgroundTasks.memProviderIdle': 'Inactivo',
|
||||
'nav.home': 'Inicio',
|
||||
'nav.human': 'Humano',
|
||||
'nav.chat': 'Charla',
|
||||
|
||||
@@ -59,6 +59,33 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'conversations.backgroundTasks.sectionThisChat': 'Dans ce chat',
|
||||
'conversations.backgroundTasks.sectionScheduled': 'Tâches planifiées',
|
||||
'conversations.backgroundTasks.sectionSubconscious': 'Pensée subconsciente',
|
||||
'conversations.backgroundTasks.sectionMemory': 'Synchronisation de la mémoire',
|
||||
'conversations.backgroundTasks.cronEmpty': 'Aucune tâche planifiée.',
|
||||
'conversations.backgroundTasks.cronUnnamed': 'Tâche sans titre',
|
||||
'conversations.backgroundTasks.cronPaused': 'En pause',
|
||||
'conversations.backgroundTasks.cronNext': 'Prochaine {time}',
|
||||
'conversations.backgroundTasks.cronLast': 'Dernière exécution {time}',
|
||||
'conversations.backgroundTasks.cronNever': 'Pas encore exécutée',
|
||||
'conversations.backgroundTasks.cronSchedCron': 'Cron {expr}',
|
||||
'conversations.backgroundTasks.cronSchedEvery': 'Toutes les {duration}',
|
||||
'conversations.backgroundTasks.cronSchedAt': 'Une fois',
|
||||
'conversations.backgroundTasks.subOff': 'Désactivé',
|
||||
'conversations.backgroundTasks.subWorking': 'En cours…',
|
||||
'conversations.backgroundTasks.subIdle': 'Inactif',
|
||||
'conversations.backgroundTasks.subLastRan': 'Dernière exécution {time}',
|
||||
'conversations.backgroundTasks.subNeverRan': 'Pas encore exécutée',
|
||||
'conversations.backgroundTasks.subTicks': '{count} exécutions',
|
||||
'conversations.backgroundTasks.subQueued': '{count} en file d’attente',
|
||||
'conversations.backgroundTasks.memUpToDate': 'Tous les souvenirs à jour',
|
||||
'conversations.backgroundTasks.memIngesting': 'Indexation de {title}',
|
||||
'conversations.backgroundTasks.memIngestingUntitled': 'Indexation des souvenirs',
|
||||
'conversations.backgroundTasks.memQueued': '{count} en file d’attente',
|
||||
'conversations.backgroundTasks.memProviderActive': 'Synchronisation en cours',
|
||||
'conversations.backgroundTasks.memProviderRecent': 'Synchronisé récemment',
|
||||
'conversations.backgroundTasks.memProviderIdle': 'Inactif',
|
||||
'nav.home': 'Accueil',
|
||||
'nav.human': 'Humain',
|
||||
'nav.chat': 'Chat',
|
||||
|
||||
@@ -59,6 +59,33 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'conversations.backgroundTasks.sectionThisChat': 'इस चैट में',
|
||||
'conversations.backgroundTasks.sectionScheduled': 'शेड्यूल किए गए कार्य',
|
||||
'conversations.backgroundTasks.sectionSubconscious': 'अवचेतन सोच',
|
||||
'conversations.backgroundTasks.sectionMemory': 'मेमोरी सिंक हो रही है',
|
||||
'conversations.backgroundTasks.cronEmpty': 'कोई शेड्यूल किए गए कार्य नहीं।',
|
||||
'conversations.backgroundTasks.cronUnnamed': 'शीर्षकहीन कार्य',
|
||||
'conversations.backgroundTasks.cronPaused': 'रुका हुआ',
|
||||
'conversations.backgroundTasks.cronNext': 'अगला {time}',
|
||||
'conversations.backgroundTasks.cronLast': 'अंतिम बार चला {time}',
|
||||
'conversations.backgroundTasks.cronNever': 'अभी तक नहीं चला',
|
||||
'conversations.backgroundTasks.cronSchedCron': 'Cron {expr}',
|
||||
'conversations.backgroundTasks.cronSchedEvery': 'हर {duration}',
|
||||
'conversations.backgroundTasks.cronSchedAt': 'एक बार',
|
||||
'conversations.backgroundTasks.subOff': 'बंद',
|
||||
'conversations.backgroundTasks.subWorking': 'काम कर रहा है…',
|
||||
'conversations.backgroundTasks.subIdle': 'निष्क्रिय',
|
||||
'conversations.backgroundTasks.subLastRan': 'अंतिम बार चला {time}',
|
||||
'conversations.backgroundTasks.subNeverRan': 'अभी तक नहीं चला',
|
||||
'conversations.backgroundTasks.subTicks': '{count} बार चला',
|
||||
'conversations.backgroundTasks.subQueued': '{count} कतार में',
|
||||
'conversations.backgroundTasks.memUpToDate': 'सभी यादें अद्यतित',
|
||||
'conversations.backgroundTasks.memIngesting': '{title} अनुक्रमित हो रहा है',
|
||||
'conversations.backgroundTasks.memIngestingUntitled': 'यादें अनुक्रमित हो रही हैं',
|
||||
'conversations.backgroundTasks.memQueued': '{count} कतार में',
|
||||
'conversations.backgroundTasks.memProviderActive': 'अभी सिंक हो रहा है',
|
||||
'conversations.backgroundTasks.memProviderRecent': 'हाल ही में सिंक हुआ',
|
||||
'conversations.backgroundTasks.memProviderIdle': 'निष्क्रिय',
|
||||
'nav.home': 'होम',
|
||||
'nav.human': 'मानव',
|
||||
'nav.chat': 'चैट',
|
||||
|
||||
@@ -58,6 +58,33 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'conversations.backgroundTasks.sectionThisChat': 'Di obrolan ini',
|
||||
'conversations.backgroundTasks.sectionScheduled': 'Tugas terjadwal',
|
||||
'conversations.backgroundTasks.sectionSubconscious': 'Pemikiran bawah sadar',
|
||||
'conversations.backgroundTasks.sectionMemory': 'Menyinkronkan memori',
|
||||
'conversations.backgroundTasks.cronEmpty': 'Tidak ada tugas terjadwal.',
|
||||
'conversations.backgroundTasks.cronUnnamed': 'Tugas tanpa judul',
|
||||
'conversations.backgroundTasks.cronPaused': 'Dijeda',
|
||||
'conversations.backgroundTasks.cronNext': 'Berikutnya {time}',
|
||||
'conversations.backgroundTasks.cronLast': 'Terakhir dijalankan {time}',
|
||||
'conversations.backgroundTasks.cronNever': 'Belum pernah dijalankan',
|
||||
'conversations.backgroundTasks.cronSchedCron': 'Cron {expr}',
|
||||
'conversations.backgroundTasks.cronSchedEvery': 'Setiap {duration}',
|
||||
'conversations.backgroundTasks.cronSchedAt': 'Sekali',
|
||||
'conversations.backgroundTasks.subOff': 'Mati',
|
||||
'conversations.backgroundTasks.subWorking': 'Bekerja…',
|
||||
'conversations.backgroundTasks.subIdle': 'Diam',
|
||||
'conversations.backgroundTasks.subLastRan': 'Terakhir dijalankan {time}',
|
||||
'conversations.backgroundTasks.subNeverRan': 'Belum pernah dijalankan',
|
||||
'conversations.backgroundTasks.subTicks': '{count} kali dijalankan',
|
||||
'conversations.backgroundTasks.subQueued': '{count} dalam antrean',
|
||||
'conversations.backgroundTasks.memUpToDate': 'Semua memori terkini',
|
||||
'conversations.backgroundTasks.memIngesting': 'Mengindeks {title}',
|
||||
'conversations.backgroundTasks.memIngestingUntitled': 'Mengindeks memori',
|
||||
'conversations.backgroundTasks.memQueued': '{count} dalam antrean',
|
||||
'conversations.backgroundTasks.memProviderActive': 'Menyinkronkan sekarang',
|
||||
'conversations.backgroundTasks.memProviderRecent': 'Baru saja disinkronkan',
|
||||
'conversations.backgroundTasks.memProviderIdle': 'Diam',
|
||||
'nav.home': 'Beranda',
|
||||
'nav.human': 'Manusia',
|
||||
'nav.chat': 'Obrolan',
|
||||
|
||||
@@ -59,6 +59,33 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'conversations.backgroundTasks.sectionThisChat': 'In questa chat',
|
||||
'conversations.backgroundTasks.sectionScheduled': 'Attività pianificate',
|
||||
'conversations.backgroundTasks.sectionSubconscious': 'Pensiero subconscio',
|
||||
'conversations.backgroundTasks.sectionMemory': 'Sincronizzazione memoria',
|
||||
'conversations.backgroundTasks.cronEmpty': 'Nessuna attività pianificata.',
|
||||
'conversations.backgroundTasks.cronUnnamed': 'Attività senza titolo',
|
||||
'conversations.backgroundTasks.cronPaused': 'In pausa',
|
||||
'conversations.backgroundTasks.cronNext': 'Prossima {time}',
|
||||
'conversations.backgroundTasks.cronLast': 'Ultima esecuzione {time}',
|
||||
'conversations.backgroundTasks.cronNever': 'Non ancora eseguita',
|
||||
'conversations.backgroundTasks.cronSchedCron': 'Cron {expr}',
|
||||
'conversations.backgroundTasks.cronSchedEvery': 'Ogni {duration}',
|
||||
'conversations.backgroundTasks.cronSchedAt': 'Una volta',
|
||||
'conversations.backgroundTasks.subOff': 'Disattivato',
|
||||
'conversations.backgroundTasks.subWorking': 'In corso…',
|
||||
'conversations.backgroundTasks.subIdle': 'Inattivo',
|
||||
'conversations.backgroundTasks.subLastRan': 'Ultima esecuzione {time}',
|
||||
'conversations.backgroundTasks.subNeverRan': 'Non ancora eseguita',
|
||||
'conversations.backgroundTasks.subTicks': '{count} esecuzioni',
|
||||
'conversations.backgroundTasks.subQueued': '{count} in coda',
|
||||
'conversations.backgroundTasks.memUpToDate': 'Tutti i ricordi aggiornati',
|
||||
'conversations.backgroundTasks.memIngesting': 'Indicizzazione di {title}',
|
||||
'conversations.backgroundTasks.memIngestingUntitled': 'Indicizzazione dei ricordi',
|
||||
'conversations.backgroundTasks.memQueued': '{count} in coda',
|
||||
'conversations.backgroundTasks.memProviderActive': 'Sincronizzazione in corso',
|
||||
'conversations.backgroundTasks.memProviderRecent': 'Sincronizzato di recente',
|
||||
'conversations.backgroundTasks.memProviderIdle': 'Inattivo',
|
||||
'nav.home': 'Home',
|
||||
'nav.human': 'Umano',
|
||||
'nav.chat': 'Chat',
|
||||
|
||||
@@ -59,6 +59,33 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'conversations.backgroundTasks.sectionThisChat': '이 채팅에서',
|
||||
'conversations.backgroundTasks.sectionScheduled': '예약된 작업',
|
||||
'conversations.backgroundTasks.sectionSubconscious': '잠재의식 사고',
|
||||
'conversations.backgroundTasks.sectionMemory': '메모리 동기화 중',
|
||||
'conversations.backgroundTasks.cronEmpty': '예약된 작업이 없습니다.',
|
||||
'conversations.backgroundTasks.cronUnnamed': '제목 없는 작업',
|
||||
'conversations.backgroundTasks.cronPaused': '일시 중지됨',
|
||||
'conversations.backgroundTasks.cronNext': '다음 {time}',
|
||||
'conversations.backgroundTasks.cronLast': '마지막 실행 {time}',
|
||||
'conversations.backgroundTasks.cronNever': '아직 실행되지 않음',
|
||||
'conversations.backgroundTasks.cronSchedCron': 'Cron {expr}',
|
||||
'conversations.backgroundTasks.cronSchedEvery': '{duration}마다',
|
||||
'conversations.backgroundTasks.cronSchedAt': '한 번',
|
||||
'conversations.backgroundTasks.subOff': '꺼짐',
|
||||
'conversations.backgroundTasks.subWorking': '작업 중…',
|
||||
'conversations.backgroundTasks.subIdle': '대기 중',
|
||||
'conversations.backgroundTasks.subLastRan': '마지막 실행 {time}',
|
||||
'conversations.backgroundTasks.subNeverRan': '아직 실행되지 않음',
|
||||
'conversations.backgroundTasks.subTicks': '{count}회 실행',
|
||||
'conversations.backgroundTasks.subQueued': '{count}개 대기 중',
|
||||
'conversations.backgroundTasks.memUpToDate': '모든 메모리가 최신 상태',
|
||||
'conversations.backgroundTasks.memIngesting': '{title} 색인 중',
|
||||
'conversations.backgroundTasks.memIngestingUntitled': '메모리 색인 중',
|
||||
'conversations.backgroundTasks.memQueued': '{count}개 대기 중',
|
||||
'conversations.backgroundTasks.memProviderActive': '지금 동기화 중',
|
||||
'conversations.backgroundTasks.memProviderRecent': '최근 동기화됨',
|
||||
'conversations.backgroundTasks.memProviderIdle': '대기 중',
|
||||
'nav.home': '홈',
|
||||
'nav.human': '휴먼',
|
||||
'nav.chat': '채팅',
|
||||
|
||||
@@ -59,6 +59,33 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'conversations.backgroundTasks.sectionThisChat': 'W tym czacie',
|
||||
'conversations.backgroundTasks.sectionScheduled': 'Zaplanowane zadania',
|
||||
'conversations.backgroundTasks.sectionSubconscious': 'Myślenie podświadome',
|
||||
'conversations.backgroundTasks.sectionMemory': 'Synchronizacja pamięci',
|
||||
'conversations.backgroundTasks.cronEmpty': 'Brak zaplanowanych zadań.',
|
||||
'conversations.backgroundTasks.cronUnnamed': 'Zadanie bez nazwy',
|
||||
'conversations.backgroundTasks.cronPaused': 'Wstrzymane',
|
||||
'conversations.backgroundTasks.cronNext': 'Następne {time}',
|
||||
'conversations.backgroundTasks.cronLast': 'Ostatnie uruchomienie {time}',
|
||||
'conversations.backgroundTasks.cronNever': 'Jeszcze nie uruchomiono',
|
||||
'conversations.backgroundTasks.cronSchedCron': 'Cron {expr}',
|
||||
'conversations.backgroundTasks.cronSchedEvery': 'Co {duration}',
|
||||
'conversations.backgroundTasks.cronSchedAt': 'Jednorazowo',
|
||||
'conversations.backgroundTasks.subOff': 'Wyłączone',
|
||||
'conversations.backgroundTasks.subWorking': 'Pracuje…',
|
||||
'conversations.backgroundTasks.subIdle': 'Bezczynne',
|
||||
'conversations.backgroundTasks.subLastRan': 'Ostatnie uruchomienie {time}',
|
||||
'conversations.backgroundTasks.subNeverRan': 'Jeszcze nie uruchomiono',
|
||||
'conversations.backgroundTasks.subTicks': '{count} uruchomień',
|
||||
'conversations.backgroundTasks.subQueued': '{count} w kolejce',
|
||||
'conversations.backgroundTasks.memUpToDate': 'Wszystkie wspomnienia aktualne',
|
||||
'conversations.backgroundTasks.memIngesting': 'Indeksowanie {title}',
|
||||
'conversations.backgroundTasks.memIngestingUntitled': 'Indeksowanie wspomnień',
|
||||
'conversations.backgroundTasks.memQueued': '{count} w kolejce',
|
||||
'conversations.backgroundTasks.memProviderActive': 'Synchronizowanie teraz',
|
||||
'conversations.backgroundTasks.memProviderRecent': 'Niedawno zsynchronizowano',
|
||||
'conversations.backgroundTasks.memProviderIdle': 'Bezczynne',
|
||||
'nav.home': 'Start',
|
||||
'nav.human': 'Człowiek',
|
||||
'nav.chat': 'Czat',
|
||||
|
||||
@@ -59,6 +59,33 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'conversations.backgroundTasks.sectionThisChat': 'Neste chat',
|
||||
'conversations.backgroundTasks.sectionScheduled': 'Tarefas agendadas',
|
||||
'conversations.backgroundTasks.sectionSubconscious': 'Pensamento subconsciente',
|
||||
'conversations.backgroundTasks.sectionMemory': 'Sincronizando memória',
|
||||
'conversations.backgroundTasks.cronEmpty': 'Nenhuma tarefa agendada.',
|
||||
'conversations.backgroundTasks.cronUnnamed': 'Tarefa sem título',
|
||||
'conversations.backgroundTasks.cronPaused': 'Pausada',
|
||||
'conversations.backgroundTasks.cronNext': 'Próxima {time}',
|
||||
'conversations.backgroundTasks.cronLast': 'Última execução {time}',
|
||||
'conversations.backgroundTasks.cronNever': 'Ainda não executada',
|
||||
'conversations.backgroundTasks.cronSchedCron': 'Cron {expr}',
|
||||
'conversations.backgroundTasks.cronSchedEvery': 'A cada {duration}',
|
||||
'conversations.backgroundTasks.cronSchedAt': 'Uma vez',
|
||||
'conversations.backgroundTasks.subOff': 'Desligado',
|
||||
'conversations.backgroundTasks.subWorking': 'Trabalhando…',
|
||||
'conversations.backgroundTasks.subIdle': 'Ocioso',
|
||||
'conversations.backgroundTasks.subLastRan': 'Última execução {time}',
|
||||
'conversations.backgroundTasks.subNeverRan': 'Ainda não executada',
|
||||
'conversations.backgroundTasks.subTicks': '{count} execuções',
|
||||
'conversations.backgroundTasks.subQueued': '{count} na fila',
|
||||
'conversations.backgroundTasks.memUpToDate': 'Todas as memórias atualizadas',
|
||||
'conversations.backgroundTasks.memIngesting': 'Indexando {title}',
|
||||
'conversations.backgroundTasks.memIngestingUntitled': 'Indexando memórias',
|
||||
'conversations.backgroundTasks.memQueued': '{count} na fila',
|
||||
'conversations.backgroundTasks.memProviderActive': 'Sincronizando agora',
|
||||
'conversations.backgroundTasks.memProviderRecent': 'Sincronizado recentemente',
|
||||
'conversations.backgroundTasks.memProviderIdle': 'Ocioso',
|
||||
'nav.home': 'Início',
|
||||
'nav.human': 'Humano',
|
||||
'nav.chat': 'Bate-papo',
|
||||
|
||||
@@ -59,6 +59,33 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'conversations.backgroundTasks.sectionThisChat': 'В этом чате',
|
||||
'conversations.backgroundTasks.sectionScheduled': 'Запланированные задачи',
|
||||
'conversations.backgroundTasks.sectionSubconscious': 'Подсознательное мышление',
|
||||
'conversations.backgroundTasks.sectionMemory': 'Синхронизация памяти',
|
||||
'conversations.backgroundTasks.cronEmpty': 'Нет запланированных задач.',
|
||||
'conversations.backgroundTasks.cronUnnamed': 'Задача без названия',
|
||||
'conversations.backgroundTasks.cronPaused': 'Приостановлено',
|
||||
'conversations.backgroundTasks.cronNext': 'Следующий запуск {time}',
|
||||
'conversations.backgroundTasks.cronLast': 'Последний запуск {time}',
|
||||
'conversations.backgroundTasks.cronNever': 'Ещё не запускалась',
|
||||
'conversations.backgroundTasks.cronSchedCron': 'Cron {expr}',
|
||||
'conversations.backgroundTasks.cronSchedEvery': 'Каждые {duration}',
|
||||
'conversations.backgroundTasks.cronSchedAt': 'Один раз',
|
||||
'conversations.backgroundTasks.subOff': 'Выключено',
|
||||
'conversations.backgroundTasks.subWorking': 'Работает…',
|
||||
'conversations.backgroundTasks.subIdle': 'Простаивает',
|
||||
'conversations.backgroundTasks.subLastRan': 'Последний запуск {time}',
|
||||
'conversations.backgroundTasks.subNeverRan': 'Ещё не запускалась',
|
||||
'conversations.backgroundTasks.subTicks': '{count} запусков',
|
||||
'conversations.backgroundTasks.subQueued': '{count} в очереди',
|
||||
'conversations.backgroundTasks.memUpToDate': 'Все воспоминания актуальны',
|
||||
'conversations.backgroundTasks.memIngesting': 'Индексация {title}',
|
||||
'conversations.backgroundTasks.memIngestingUntitled': 'Индексация воспоминаний',
|
||||
'conversations.backgroundTasks.memQueued': '{count} в очереди',
|
||||
'conversations.backgroundTasks.memProviderActive': 'Синхронизация сейчас',
|
||||
'conversations.backgroundTasks.memProviderRecent': 'Недавно синхронизировано',
|
||||
'conversations.backgroundTasks.memProviderIdle': 'Простаивает',
|
||||
'nav.home': 'Главная',
|
||||
'nav.human': 'Человек',
|
||||
'nav.chat': 'Чат',
|
||||
|
||||
@@ -58,6 +58,33 @@ const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'conversations.backgroundTasks.sectionThisChat': '在此对话中',
|
||||
'conversations.backgroundTasks.sectionScheduled': '计划任务',
|
||||
'conversations.backgroundTasks.sectionSubconscious': '潜意识思考',
|
||||
'conversations.backgroundTasks.sectionMemory': '正在同步记忆',
|
||||
'conversations.backgroundTasks.cronEmpty': '没有计划任务。',
|
||||
'conversations.backgroundTasks.cronUnnamed': '未命名任务',
|
||||
'conversations.backgroundTasks.cronPaused': '已暂停',
|
||||
'conversations.backgroundTasks.cronNext': '下次 {time}',
|
||||
'conversations.backgroundTasks.cronLast': '上次运行 {time}',
|
||||
'conversations.backgroundTasks.cronNever': '尚未运行',
|
||||
'conversations.backgroundTasks.cronSchedCron': 'Cron {expr}',
|
||||
'conversations.backgroundTasks.cronSchedEvery': '每 {duration}',
|
||||
'conversations.backgroundTasks.cronSchedAt': '一次',
|
||||
'conversations.backgroundTasks.subOff': '关闭',
|
||||
'conversations.backgroundTasks.subWorking': '工作中…',
|
||||
'conversations.backgroundTasks.subIdle': '空闲',
|
||||
'conversations.backgroundTasks.subLastRan': '上次运行 {time}',
|
||||
'conversations.backgroundTasks.subNeverRan': '尚未运行',
|
||||
'conversations.backgroundTasks.subTicks': '{count} 次运行',
|
||||
'conversations.backgroundTasks.subQueued': '{count} 个排队中',
|
||||
'conversations.backgroundTasks.memUpToDate': '所有记忆均为最新',
|
||||
'conversations.backgroundTasks.memIngesting': '正在索引 {title}',
|
||||
'conversations.backgroundTasks.memIngestingUntitled': '正在索引记忆',
|
||||
'conversations.backgroundTasks.memQueued': '{count} 个排队中',
|
||||
'conversations.backgroundTasks.memProviderActive': '正在同步',
|
||||
'conversations.backgroundTasks.memProviderRecent': '最近已同步',
|
||||
'conversations.backgroundTasks.memProviderIdle': '空闲',
|
||||
'nav.home': '首页',
|
||||
'nav.human': '助手',
|
||||
'nav.chat': '对话',
|
||||
|
||||
@@ -105,6 +105,7 @@ import {
|
||||
getComposerBlockedSendFeedback,
|
||||
handleComposerSlashCommand,
|
||||
} from './conversations/composerSendDecision';
|
||||
import { useMemorySyncActive } from './conversations/hooks/useBackgroundActivity';
|
||||
import { runDecidePlan } from './conversations/taskPlanActions';
|
||||
import {
|
||||
type AgentBubblePosition,
|
||||
@@ -1458,6 +1459,9 @@ const Conversations = ({
|
||||
[selectedThreadToolTimeline]
|
||||
);
|
||||
const runningBackgroundCount = backgroundProcesses.filter(p => p.status === 'running').length;
|
||||
// Poll-free live signal: lights the badge when memories are syncing even if
|
||||
// no sub-agent is running and the panel is closed.
|
||||
const memorySyncActive = useMemorySyncActive();
|
||||
// Re-derive the open subagent's live activity (and its row status) from the
|
||||
// timeline on every render so the drawer streams token-by-token as
|
||||
// subagent_text_delta / subagent_thinking_delta events land in Redux.
|
||||
@@ -2808,11 +2812,16 @@ const Conversations = ({
|
||||
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
|
||||
/>
|
||||
</svg>
|
||||
{runningBackgroundCount > 0 && (
|
||||
{runningBackgroundCount > 0 ? (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-500 px-0.5 text-[9px] font-semibold leading-none text-white">
|
||||
{runningBackgroundCount}
|
||||
</span>
|
||||
)}
|
||||
) : memorySyncActive ? (
|
||||
<span
|
||||
data-testid="background-activity-dot"
|
||||
className="absolute -right-0.5 -top-0.5 h-2 w-2 animate-pulse rounded-full bg-amber-500"
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
)}
|
||||
{(selectedThreadId ?? firstActiveThreadId) && (
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import type { CoreCronJob, CoreCronSchedule } from '../../../utils/tauriCommands/cron';
|
||||
import type { MemorySyncStatusRow } from '../../../utils/tauriCommands/memoryTree';
|
||||
import type { MemorySyncSummary, SubconsciousSummary } from '../hooks/useBackgroundActivity';
|
||||
import { formatRelativeTime, formatResetTime } from '../utils/format';
|
||||
|
||||
/** Small, grey section divider shared across the background-activity sections. */
|
||||
export function SectionHeader({ title, hint }: { title: string; hint?: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2.5 pb-1 pt-3">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
|
||||
{title}
|
||||
</span>
|
||||
{hint ? (
|
||||
<span className="text-[11px] text-stone-400 dark:text-neutral-500">{hint}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A coloured status dot. */
|
||||
function Dot({ className }: { className: string }) {
|
||||
return <span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${className}`} />;
|
||||
}
|
||||
|
||||
/** Localised, human-readable summary of a cron schedule. */
|
||||
function scheduleLabel(schedule: CoreCronSchedule, t: ReturnType<typeof useT>['t']): string {
|
||||
switch (schedule.kind) {
|
||||
case 'cron':
|
||||
return t('conversations.backgroundTasks.cronSchedCron').replace('{expr}', schedule.expr);
|
||||
case 'every':
|
||||
return t('conversations.backgroundTasks.cronSchedEvery').replace(
|
||||
'{duration}',
|
||||
formatDuration(schedule.every_ms)
|
||||
);
|
||||
case 'at':
|
||||
return t('conversations.backgroundTasks.cronSchedAt');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const mins = Math.round(ms / 60_000);
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hours = Math.round(mins / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.round(hours / 24)}d`;
|
||||
}
|
||||
|
||||
/** One read-only scheduled (cron) job. */
|
||||
export function CronJobRow({ job }: { job: CoreCronJob }) {
|
||||
const { t } = useT();
|
||||
const name =
|
||||
(job.name && job.name.trim()) ||
|
||||
(job.prompt && job.prompt.trim()) ||
|
||||
(job.command && job.command.trim()) ||
|
||||
t('conversations.backgroundTasks.cronUnnamed');
|
||||
|
||||
const lastDot =
|
||||
job.last_status === 'error'
|
||||
? 'bg-red-500'
|
||||
: job.last_status === 'ok'
|
||||
? 'bg-sage-500'
|
||||
: 'bg-stone-300 dark:bg-neutral-600';
|
||||
|
||||
const lastLabel = job.last_run
|
||||
? t('conversations.backgroundTasks.cronLast').replace(
|
||||
'{time}',
|
||||
formatRelativeTime(job.last_run)
|
||||
)
|
||||
: t('conversations.backgroundTasks.cronNever');
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="background-cron-row"
|
||||
className={`mb-1 flex items-start gap-2.5 rounded-lg px-2.5 py-2 ${
|
||||
job.enabled ? '' : 'opacity-50'
|
||||
}`}>
|
||||
<Dot className={lastDot} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium text-stone-800 dark:text-neutral-100">
|
||||
{name}
|
||||
</span>
|
||||
{job.enabled ? (
|
||||
<span className="shrink-0 text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{job.next_run
|
||||
? t('conversations.backgroundTasks.cronNext').replace(
|
||||
'{time}',
|
||||
formatResetTime(job.next_run)
|
||||
)
|
||||
: ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 text-[11px] font-medium text-stone-500 dark:text-neutral-400">
|
||||
{t('conversations.backgroundTasks.cronPaused')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="mt-0.5 block truncate text-[12px] text-stone-500 dark:text-neutral-400">
|
||||
{scheduleLabel(job.schedule, t)}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{lastLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Single status row for the subconscious / background-thinking loop. */
|
||||
export function SubconsciousRow({ summary }: { summary: SubconsciousSummary }) {
|
||||
const { t } = useT();
|
||||
const off = !summary.enabled || summary.mode === 'off';
|
||||
|
||||
let dot: string;
|
||||
let pill: string;
|
||||
let pillClass: string;
|
||||
if (off) {
|
||||
dot = 'bg-stone-300 dark:bg-neutral-600';
|
||||
pill = t('conversations.backgroundTasks.subOff');
|
||||
pillClass = 'text-stone-400 dark:text-neutral-500';
|
||||
} else if (summary.working) {
|
||||
dot = 'bg-amber-500 animate-pulse';
|
||||
pill = t('conversations.backgroundTasks.subWorking');
|
||||
pillClass = 'text-amber-700 dark:text-amber-300';
|
||||
} else {
|
||||
dot = 'bg-sage-500';
|
||||
pill = t('conversations.backgroundTasks.subIdle');
|
||||
pillClass = 'text-sage-700 dark:text-sage-300';
|
||||
}
|
||||
|
||||
const lastLabel =
|
||||
summary.lastTickAt != null
|
||||
? t('conversations.backgroundTasks.subLastRan').replace(
|
||||
'{time}',
|
||||
// last_tick_at is epoch *seconds*; formatRelativeTime wants a date string.
|
||||
formatRelativeTime(new Date(summary.lastTickAt * 1000).toISOString())
|
||||
)
|
||||
: t('conversations.backgroundTasks.subNeverRan');
|
||||
|
||||
const meta = [
|
||||
lastLabel,
|
||||
t('conversations.backgroundTasks.subTicks').replace('{count}', String(summary.totalTicks)),
|
||||
summary.queueDepth && summary.queueDepth > 0
|
||||
? t('conversations.backgroundTasks.subQueued').replace('{count}', String(summary.queueDepth))
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="background-subconscious-row"
|
||||
className={`mb-1 flex items-start gap-2.5 rounded-lg px-2.5 py-2 ${off ? 'opacity-60' : ''}`}>
|
||||
<Dot className={dot} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium text-stone-800 dark:text-neutral-100">
|
||||
{t('conversations.backgroundTasks.sectionSubconscious')}
|
||||
</span>
|
||||
<span className={`shrink-0 text-[11px] font-medium ${pillClass}`}>{pill}</span>
|
||||
</div>
|
||||
<span className="mt-0.5 block text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{meta.join(' · ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-provider status pill, driven *only* by freshness (recency of the last
|
||||
* ingested chunk). Deliberately NOT keyed off `batch_total > batch_processed`:
|
||||
* an incomplete embedding wave can sit un-drained for days, and treating that
|
||||
* as "Syncing now" falsely implies live activity. A stalled backlog is
|
||||
* surfaced separately as a muted progress hint — see {@link MemorySection}.
|
||||
*/
|
||||
function providerFreshnessLabel(
|
||||
row: MemorySyncStatusRow,
|
||||
t: ReturnType<typeof useT>['t']
|
||||
): { dot: string; label: string; pillClass: string } {
|
||||
if (row.freshness === 'active') {
|
||||
return {
|
||||
dot: 'bg-amber-500 animate-pulse',
|
||||
label: t('conversations.backgroundTasks.memProviderActive'),
|
||||
pillClass: 'text-amber-700 dark:text-amber-300',
|
||||
};
|
||||
}
|
||||
if (row.freshness === 'recent') {
|
||||
return {
|
||||
dot: 'bg-sage-500',
|
||||
label: t('conversations.backgroundTasks.memProviderRecent'),
|
||||
pillClass: 'text-sage-700 dark:text-sage-300',
|
||||
};
|
||||
}
|
||||
return {
|
||||
dot: 'bg-stone-300 dark:bg-neutral-600',
|
||||
label: t('conversations.backgroundTasks.memProviderIdle'),
|
||||
pillClass: 'text-stone-400 dark:text-neutral-500',
|
||||
};
|
||||
}
|
||||
|
||||
/** Memory ingestion worker row + per-provider freshness rows. */
|
||||
export function MemorySection({ memory }: { memory: MemorySyncSummary }) {
|
||||
const { t } = useT();
|
||||
const hasActivity = memory.ingesting || memory.queueDepth > 0 || memory.providers.length > 0;
|
||||
|
||||
if (!hasActivity) {
|
||||
return (
|
||||
<div className="px-2.5 py-2 text-[12px] text-stone-400 dark:text-neutral-500">
|
||||
{t('conversations.backgroundTasks.memUpToDate')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{memory.ingesting ? (
|
||||
<div
|
||||
data-testid="background-memory-ingesting"
|
||||
className="mb-1 flex items-start gap-2.5 rounded-lg px-2.5 py-2">
|
||||
<Dot className="bg-amber-500 animate-pulse" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-stone-800 dark:text-neutral-100">
|
||||
{memory.currentTitle
|
||||
? t('conversations.backgroundTasks.memIngesting').replace(
|
||||
'{title}',
|
||||
memory.currentTitle
|
||||
)
|
||||
: t('conversations.backgroundTasks.memIngestingUntitled')}
|
||||
</span>
|
||||
{memory.queueDepth > 0 ? (
|
||||
<span className="mt-0.5 block text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{t('conversations.backgroundTasks.memQueued').replace(
|
||||
'{count}',
|
||||
String(memory.queueDepth)
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{memory.providers.map(row => {
|
||||
const f = providerFreshnessLabel(row, t);
|
||||
// An incomplete embedding wave that is NOT live (freshness !== active):
|
||||
// a backlog the index worker hasn't drained, shown as muted progress —
|
||||
// never as "Syncing now".
|
||||
const backlog =
|
||||
row.freshness !== 'active' && row.batch_total > row.batch_processed
|
||||
? `${row.batch_processed}/${row.batch_total} indexed`
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={row.provider}
|
||||
data-testid="background-memory-provider-row"
|
||||
className="mb-1 flex items-start gap-2.5 rounded-lg px-2.5 py-2">
|
||||
<Dot className={f.dot} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium capitalize text-stone-800 dark:text-neutral-100">
|
||||
{row.provider}
|
||||
</span>
|
||||
<span className={`shrink-0 text-[11px] font-medium ${f.pillClass}`}>{f.label}</span>
|
||||
</div>
|
||||
{backlog ? (
|
||||
<span className="mt-0.5 block text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{backlog}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,13 @@ import type {
|
||||
ToolTimelineEntry,
|
||||
ToolTimelineEntryStatus,
|
||||
} from '../../../store/chatRuntimeSlice';
|
||||
import { useBackgroundActivity } from '../hooks/useBackgroundActivity';
|
||||
import {
|
||||
CronJobRow,
|
||||
MemorySection,
|
||||
SectionHeader,
|
||||
SubconsciousRow,
|
||||
} from './BackgroundActivityRows';
|
||||
|
||||
/**
|
||||
* A background process = a *detached* sub-agent spawned with
|
||||
@@ -119,6 +126,8 @@ export function BackgroundProcessesPanel({
|
||||
onOpenProcess,
|
||||
}: BackgroundProcessesPanelProps) {
|
||||
const { t } = useT();
|
||||
// Cron jobs + subconscious + memory syncing — fetched only while open.
|
||||
const activity = useBackgroundActivity(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -170,13 +179,15 @@ export function BackgroundProcessesPanel({
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{processes.length === 0 ? (
|
||||
<div className="px-4 py-6 text-sm text-stone-400 dark:text-neutral-500">
|
||||
{t('conversations.backgroundTasks.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{processes.map(p => {
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{/* Section 1 — detached sub-agents spawned in this chat. */}
|
||||
<SectionHeader title={t('conversations.backgroundTasks.sectionThisChat')} />
|
||||
{processes.length === 0 ? (
|
||||
<div className="px-2.5 py-2 text-[12px] text-stone-400 dark:text-neutral-500">
|
||||
{t('conversations.backgroundTasks.empty')}
|
||||
</div>
|
||||
) : (
|
||||
processes.map(p => {
|
||||
const s = statusStyle(p.status);
|
||||
const toolCallLabel = (
|
||||
p.toolCount === 1
|
||||
@@ -215,9 +226,31 @@ export function BackgroundProcessesPanel({
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Section 2 — scheduled (cron) jobs, global, view-only. */}
|
||||
<SectionHeader title={t('conversations.backgroundTasks.sectionScheduled')} />
|
||||
{activity.cronJobs.length === 0 ? (
|
||||
<div className="px-2.5 py-2 text-[12px] text-stone-400 dark:text-neutral-500">
|
||||
{t('conversations.backgroundTasks.cronEmpty')}
|
||||
</div>
|
||||
) : (
|
||||
activity.cronJobs.map(job => <CronJobRow key={job.id} job={job} />)
|
||||
)}
|
||||
|
||||
{/* Section 3 — subconscious / background-thinking loop. */}
|
||||
{activity.subconscious ? (
|
||||
<>
|
||||
<SectionHeader title={t('conversations.backgroundTasks.sectionSubconscious')} />
|
||||
<SubconsciousRow summary={activity.subconscious} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Section 4 — memory syncing / ingestion. */}
|
||||
<SectionHeader title={t('conversations.backgroundTasks.sectionMemory')} />
|
||||
<MemorySection memory={activity.memory} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { CoreCronJob } from '../../../../utils/tauriCommands/cron';
|
||||
import type { MemorySyncStatusRow } from '../../../../utils/tauriCommands/memoryTree';
|
||||
import type { MemorySyncSummary, SubconsciousSummary } from '../../hooks/useBackgroundActivity';
|
||||
import { CronJobRow, MemorySection, SubconsciousRow } from '../BackgroundActivityRows';
|
||||
|
||||
function cronJob(partial: Partial<CoreCronJob> & { id: string }): CoreCronJob {
|
||||
return {
|
||||
expression: '',
|
||||
schedule: { kind: 'cron', expr: '0 9 * * *' },
|
||||
command: '',
|
||||
job_type: 'agent',
|
||||
session_target: 'isolated',
|
||||
enabled: true,
|
||||
delivery: { mode: 'silent', best_effort: true },
|
||||
delete_after_run: false,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
next_run: '2999-01-01T00:00:00Z',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CronJobRow', () => {
|
||||
it('renders an enabled job with name, schedule and a next-run hint', () => {
|
||||
render(<CronJobRow job={cronJob({ id: 'j1', name: 'Daily standup' })} />);
|
||||
expect(screen.getByText('Daily standup')).toBeInTheDocument();
|
||||
expect(screen.getByText(/0 9 \* \* \*/)).toBeInTheDocument();
|
||||
// formatResetTime renders a future "in …" hint for an enabled job.
|
||||
expect(screen.getByText(/^Next in /)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Paused')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the prompt, then command, then a generic title', () => {
|
||||
const { rerender } = render(
|
||||
<CronJobRow job={cronJob({ id: 'j2', prompt: 'summarize my inbox' })} />
|
||||
);
|
||||
expect(screen.getByText('summarize my inbox')).toBeInTheDocument();
|
||||
|
||||
rerender(<CronJobRow job={cronJob({ id: 'j3', command: 'echo hi' })} />);
|
||||
expect(screen.getByText('echo hi')).toBeInTheDocument();
|
||||
|
||||
rerender(<CronJobRow job={cronJob({ id: 'j4' })} />);
|
||||
expect(screen.getByText('Untitled job')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks a disabled job as Paused and shows the never-run state', () => {
|
||||
const { container } = render(
|
||||
<CronJobRow job={cronJob({ id: 'j5', name: 'Paused job', enabled: false })} />
|
||||
);
|
||||
expect(screen.getByText('Paused')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hasn’t run yet')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-testid="background-cron-row"]')?.className).toContain(
|
||||
'opacity-50'
|
||||
);
|
||||
});
|
||||
|
||||
it('summarizes "every" and "at" schedules', () => {
|
||||
const { rerender } = render(
|
||||
<CronJobRow
|
||||
job={cronJob({
|
||||
id: 'e1',
|
||||
name: 'Interval',
|
||||
schedule: { kind: 'every', every_ms: 900_000 },
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Every 15m')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<CronJobRow
|
||||
job={cronJob({
|
||||
id: 'a1',
|
||||
name: 'One-off run',
|
||||
schedule: { kind: 'at', at: '2999-01-01T00:00:00Z' },
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('One-off run')).toBeInTheDocument();
|
||||
expect(screen.getByText('Once')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SubconsciousRow', () => {
|
||||
function summary(partial: Partial<SubconsciousSummary>): SubconsciousSummary {
|
||||
return {
|
||||
enabled: true,
|
||||
mode: 'event_driven',
|
||||
lastTickAt: null,
|
||||
totalTicks: 0,
|
||||
working: false,
|
||||
queueDepth: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
it('shows a live "Working…" pill when the orchestrator is busy', () => {
|
||||
render(<SubconsciousRow summary={summary({ working: true, queueDepth: 3, totalTicks: 7 })} />);
|
||||
expect(screen.getByText('Working…')).toBeInTheDocument();
|
||||
expect(screen.getByText(/3 queued/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/7 runs/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Idle when enabled but not working', () => {
|
||||
render(<SubconsciousRow summary={summary({ working: false })} />);
|
||||
expect(screen.getByText('Idle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Off when disabled or mode is off', () => {
|
||||
const { rerender } = render(<SubconsciousRow summary={summary({ enabled: false })} />);
|
||||
expect(screen.getByText('Off')).toBeInTheDocument();
|
||||
rerender(<SubconsciousRow summary={summary({ mode: 'off' })} />);
|
||||
expect(screen.getByText('Off')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MemorySection', () => {
|
||||
function provider(
|
||||
partial: Partial<MemorySyncStatusRow> & { provider: string }
|
||||
): MemorySyncStatusRow {
|
||||
return {
|
||||
chunks_synced: 0,
|
||||
chunks_pending: 0,
|
||||
batch_total: 0,
|
||||
batch_processed: 0,
|
||||
last_chunk_at_ms: null,
|
||||
freshness: 'idle',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
it('renders the up-to-date empty state when nothing is happening', () => {
|
||||
const memory: MemorySyncSummary = { ingesting: false, queueDepth: 0, providers: [] };
|
||||
render(<MemorySection memory={memory} />);
|
||||
expect(screen.getByText('All memories up to date')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the ingesting row and per-provider freshness', () => {
|
||||
const memory: MemorySyncSummary = {
|
||||
ingesting: true,
|
||||
currentTitle: 'Team channel',
|
||||
queueDepth: 2,
|
||||
providers: [
|
||||
provider({ provider: 'slack', freshness: 'active' }),
|
||||
provider({ provider: 'gmail', freshness: 'recent' }),
|
||||
provider({ provider: 'notion', freshness: 'idle' }),
|
||||
],
|
||||
};
|
||||
render(<MemorySection memory={memory} />);
|
||||
expect(screen.getByText('Indexing Team channel')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 queued')).toBeInTheDocument();
|
||||
expect(screen.getByText('slack')).toBeInTheDocument();
|
||||
expect(screen.getByText('Syncing now')).toBeInTheDocument();
|
||||
expect(screen.getByText('Synced recently')).toBeInTheDocument();
|
||||
expect(screen.getByText('Idle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does NOT call a stale, un-drained backlog "Syncing now" (idle freshness)', () => {
|
||||
// Regression: a fetch wave from days ago whose chunks never finished
|
||||
// embedding (batch_total > batch_processed) is a backlog, not live activity.
|
||||
const memory: MemorySyncSummary = {
|
||||
ingesting: false,
|
||||
queueDepth: 0,
|
||||
providers: [
|
||||
provider({ provider: 'gmail', freshness: 'idle', batch_total: 18, batch_processed: 0 }),
|
||||
],
|
||||
};
|
||||
render(<MemorySection memory={memory} />);
|
||||
expect(screen.queryByText('Syncing now')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Idle')).toBeInTheDocument();
|
||||
// The incomplete wave is surfaced as a muted, non-alarming progress hint.
|
||||
expect(screen.getByText('0/18 indexed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still shows "Syncing now" for genuinely live (active) freshness', () => {
|
||||
const memory: MemorySyncSummary = {
|
||||
ingesting: false,
|
||||
queueDepth: 0,
|
||||
providers: [provider({ provider: 'slack', freshness: 'active' })],
|
||||
};
|
||||
render(<MemorySection memory={memory} />);
|
||||
expect(screen.getByText('Syncing now')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -174,4 +174,17 @@ describe('BackgroundProcessesPanel', () => {
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the broader activity sections (cron + memory) alongside sub-agents', () => {
|
||||
// Outside Tauri the activity hook is a no-op, so these sections fall back to
|
||||
// their empty states — which is exactly what we assert here.
|
||||
render(
|
||||
<BackgroundProcessesPanel open processes={procs} onClose={vi.fn()} onOpenProcess={vi.fn()} />
|
||||
);
|
||||
expect(screen.getByText('In this chat')).toBeInTheDocument();
|
||||
expect(screen.getByText('Scheduled jobs')).toBeInTheDocument();
|
||||
expect(screen.getByText('No scheduled jobs.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory syncing')).toBeInTheDocument();
|
||||
expect(screen.getByText('All memories up to date')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { callCoreRpc } from '../../../services/coreRpcClient';
|
||||
import { isTauri } from '../../../utils/tauriCommands/common';
|
||||
import { openhumanCronList } from '../../../utils/tauriCommands/cron';
|
||||
import { memorySyncStatusList } from '../../../utils/tauriCommands/memoryTree';
|
||||
import {
|
||||
subconsciousStatus,
|
||||
subconsciousTriggersStatus,
|
||||
} from '../../../utils/tauriCommands/subconscious';
|
||||
import { useBackgroundActivity, useMemorySyncActive } from './useBackgroundActivity';
|
||||
|
||||
vi.mock('../../../utils/tauriCommands/common', () => ({ isTauri: vi.fn(() => true) }));
|
||||
vi.mock('../../../utils/tauriCommands/cron', () => ({ openhumanCronList: vi.fn() }));
|
||||
vi.mock('../../../utils/tauriCommands/memoryTree', () => ({ memorySyncStatusList: vi.fn() }));
|
||||
vi.mock('../../../utils/tauriCommands/subconscious', () => ({
|
||||
subconsciousStatus: vi.fn(),
|
||||
subconsciousTriggersStatus: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
|
||||
|
||||
const mockCron = vi.mocked(openhumanCronList);
|
||||
const mockSyncList = vi.mocked(memorySyncStatusList);
|
||||
const mockSubStatus = vi.mocked(subconsciousStatus);
|
||||
const mockTriggers = vi.mocked(subconsciousTriggersStatus);
|
||||
const mockRpc = vi.mocked(callCoreRpc);
|
||||
const mockIsTauri = vi.mocked(isTauri);
|
||||
|
||||
function fixtures() {
|
||||
mockCron.mockResolvedValue({
|
||||
result: [
|
||||
{
|
||||
id: 'j1',
|
||||
expression: '',
|
||||
schedule: { kind: 'cron', expr: '0 9 * * *' },
|
||||
command: '',
|
||||
job_type: 'agent',
|
||||
session_target: 'isolated',
|
||||
enabled: true,
|
||||
delivery: { mode: 'silent', best_effort: true },
|
||||
delete_after_run: false,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
next_run: '2999-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
logs: [],
|
||||
});
|
||||
mockSubStatus.mockResolvedValue({
|
||||
result: {
|
||||
enabled: true,
|
||||
mode: 'event_driven',
|
||||
provider_available: true,
|
||||
provider_unavailable_reason: null,
|
||||
interval_minutes: 5,
|
||||
last_tick_at: 1_700_000_000,
|
||||
total_ticks: 12,
|
||||
consecutive_failures: 0,
|
||||
},
|
||||
logs: [],
|
||||
});
|
||||
mockTriggers.mockResolvedValue({
|
||||
result: {
|
||||
triggers_enabled: true,
|
||||
mode: 'event_driven',
|
||||
max_promotions_per_hour: 10,
|
||||
orchestrator_running: true,
|
||||
queue_depth: 2,
|
||||
orchestrator_thread_id: 'subconscious:orchestrator',
|
||||
user_thread_id: 'subconscious:user',
|
||||
},
|
||||
logs: [],
|
||||
});
|
||||
mockRpc.mockResolvedValue({ running: true, current_title: 'Inbox', queue_depth: 1 });
|
||||
mockSyncList.mockResolvedValue([
|
||||
{
|
||||
provider: 'slack',
|
||||
chunks_synced: 5,
|
||||
chunks_pending: 0,
|
||||
batch_total: 0,
|
||||
batch_processed: 0,
|
||||
last_chunk_at_ms: null,
|
||||
freshness: 'active',
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
describe('useBackgroundActivity', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
fixtures();
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('does not fetch while closed', () => {
|
||||
renderHook(() => useBackgroundActivity(false));
|
||||
expect(mockCron).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads cron, subconscious and memory once open', async () => {
|
||||
const { result } = renderHook(() => useBackgroundActivity(true));
|
||||
await waitFor(() => expect(result.current.cronJobs).toHaveLength(1));
|
||||
expect(result.current.subconscious).toMatchObject({
|
||||
mode: 'event_driven',
|
||||
totalTicks: 12,
|
||||
working: true,
|
||||
queueDepth: 2,
|
||||
});
|
||||
expect(result.current.memory).toMatchObject({
|
||||
ingesting: true,
|
||||
currentTitle: 'Inbox',
|
||||
queueDepth: 1,
|
||||
});
|
||||
expect(result.current.memory.providers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('surfaces nothing (and stops loading) outside Tauri', async () => {
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
const { result } = renderHook(() => useBackgroundActivity(true));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(mockCron).not.toHaveBeenCalled();
|
||||
expect(result.current.cronJobs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('tolerates a failing source without dropping the others', async () => {
|
||||
mockCron.mockRejectedValue(new Error('boom'));
|
||||
const { result } = renderHook(() => useBackgroundActivity(true));
|
||||
await waitFor(() => expect(result.current.subconscious).not.toBeNull());
|
||||
expect(result.current.cronJobs).toHaveLength(0);
|
||||
expect(result.current.memory.ingesting).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMemorySyncActive', () => {
|
||||
function dispatch(detail: Record<string, unknown>) {
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent('openhuman:memory-sync-stage', { detail }));
|
||||
});
|
||||
}
|
||||
|
||||
it('lights up on a non-terminal stage and clears on a terminal one', () => {
|
||||
const { result } = renderHook(() => useMemorySyncActive());
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
dispatch({ stage: 'ingesting', source_id: 'src-1' });
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
dispatch({ stage: 'completed', source_id: 'src-1' });
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('stays active until every source settles', () => {
|
||||
const { result } = renderHook(() => useMemorySyncActive());
|
||||
dispatch({ stage: 'fetching', source_id: 'a' });
|
||||
dispatch({ stage: 'fetching', source_id: 'b' });
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
dispatch({ stage: 'failed', source_id: 'a' });
|
||||
expect(result.current).toBe(true); // b still syncing
|
||||
|
||||
dispatch({ stage: 'completed', source_id: 'b' });
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { callCoreRpc } from '../../../services/coreRpcClient';
|
||||
import { isTauri } from '../../../utils/tauriCommands/common';
|
||||
import { type CoreCronJob, openhumanCronList } from '../../../utils/tauriCommands/cron';
|
||||
import {
|
||||
memorySyncStatusList,
|
||||
type MemorySyncStatusRow,
|
||||
} from '../../../utils/tauriCommands/memoryTree';
|
||||
import {
|
||||
subconsciousStatus,
|
||||
subconsciousTriggersStatus,
|
||||
} from '../../../utils/tauriCommands/subconscious';
|
||||
|
||||
/**
|
||||
* Aggregated, view-only snapshot of the background work the app runs on the
|
||||
* user's behalf: scheduled cron jobs, the subconscious/heartbeat loop, and
|
||||
* memory syncing/ingestion. Surfaced in {@link BackgroundProcessesPanel}
|
||||
* alongside the thread's detached sub-agents so users aren't oblivious to
|
||||
* background LLM activity.
|
||||
*
|
||||
* Everything here is read-only and only fetched while the panel is open — we
|
||||
* never poll in the background to keep this off the hot path.
|
||||
*/
|
||||
|
||||
/** Distilled subconscious status for the panel row. */
|
||||
export interface SubconsciousSummary {
|
||||
enabled: boolean;
|
||||
mode: string;
|
||||
lastTickAt: number | null;
|
||||
totalTicks: number;
|
||||
/** Live indicator — orchestrator running or work queued right now. */
|
||||
working: boolean;
|
||||
queueDepth: number | null;
|
||||
}
|
||||
|
||||
/** Memory worker + per-provider freshness rows. */
|
||||
export interface MemorySyncSummary {
|
||||
/** True while the ingestion worker is processing a document. */
|
||||
ingesting: boolean;
|
||||
currentTitle?: string;
|
||||
queueDepth: number;
|
||||
providers: MemorySyncStatusRow[];
|
||||
}
|
||||
|
||||
export interface BackgroundActivity {
|
||||
cronJobs: CoreCronJob[];
|
||||
subconscious: SubconsciousSummary | null;
|
||||
memory: MemorySyncSummary;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
interface IngestionStatusEnvelope {
|
||||
running: boolean;
|
||||
current_title?: string;
|
||||
queue_depth: number;
|
||||
}
|
||||
|
||||
const EMPTY_MEMORY: MemorySyncSummary = { ingesting: false, queueDepth: 0, providers: [] };
|
||||
|
||||
const DEFAULT_POLL_MS = 5000;
|
||||
const FAST_POLL_MS = 2000;
|
||||
|
||||
/** Soonest-first, enabled jobs ahead of paused ones. */
|
||||
function sortCronJobs(jobs: CoreCronJob[]): CoreCronJob[] {
|
||||
return [...jobs].sort((a, b) => {
|
||||
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1;
|
||||
return Date.parse(a.next_run) - Date.parse(b.next_run);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch + adaptive-poll the background activity snapshot, but only while
|
||||
* `open` is true. Also refreshes immediately on `openhuman:memory-sync-stage`
|
||||
* window events (dispatched globally by `socketService` from the core's
|
||||
* `memory:sync_stage` socket event) so the memory section reacts live.
|
||||
*/
|
||||
export function useBackgroundActivity(open: boolean): BackgroundActivity {
|
||||
const [cronJobs, setCronJobs] = useState<CoreCronJob[]>([]);
|
||||
const [subconscious, setSubconscious] = useState<SubconsciousSummary | null>(null);
|
||||
const [memory, setMemory] = useState<MemorySyncSummary>(EMPTY_MEMORY);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const cancelledRef = useRef(false);
|
||||
// Snapshot of "is anything live" so the poll loop can pick its cadence.
|
||||
const busyRef = useRef(false);
|
||||
|
||||
const fetchOnce = useCallback(async () => {
|
||||
if (!isTauri()) {
|
||||
// Non-Tauri / dev preview: nothing to surface, just stop the spinner.
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const [cronRes, subRes, trigRes, ingestRes, providerRes] = await Promise.allSettled([
|
||||
openhumanCronList(),
|
||||
subconsciousStatus(),
|
||||
subconsciousTriggersStatus(),
|
||||
callCoreRpc<IngestionStatusEnvelope>({ method: 'openhuman.memory_ingestion_status' }),
|
||||
memorySyncStatusList(),
|
||||
]);
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
if (cronRes.status === 'fulfilled') {
|
||||
setCronJobs(sortCronJobs(cronRes.value.result ?? []));
|
||||
} else {
|
||||
console.debug('[background-activity] cron_list failed: %o', cronRes.reason);
|
||||
}
|
||||
|
||||
if (subRes.status === 'fulfilled') {
|
||||
const s = subRes.value.result;
|
||||
const trig = trigRes.status === 'fulfilled' ? trigRes.value.result : null;
|
||||
const queueDepth = trig?.queue_depth ?? null;
|
||||
setSubconscious({
|
||||
enabled: s.enabled,
|
||||
mode: s.mode,
|
||||
lastTickAt: s.last_tick_at,
|
||||
totalTicks: s.total_ticks,
|
||||
working: Boolean(trig?.orchestrator_running) || (queueDepth ?? 0) > 0,
|
||||
queueDepth,
|
||||
});
|
||||
} else {
|
||||
console.debug('[background-activity] subconscious_status failed: %o', subRes.reason);
|
||||
}
|
||||
|
||||
const ingest = ingestRes.status === 'fulfilled' ? ingestRes.value : null;
|
||||
const providers = providerRes.status === 'fulfilled' ? providerRes.value : [];
|
||||
if (ingestRes.status === 'rejected') {
|
||||
console.debug('[background-activity] ingestion_status failed: %o', ingestRes.reason);
|
||||
}
|
||||
setMemory({
|
||||
ingesting: Boolean(ingest?.running),
|
||||
currentTitle: ingest?.current_title,
|
||||
queueDepth: ingest?.queue_depth ?? 0,
|
||||
providers,
|
||||
});
|
||||
|
||||
// Only consider work *genuinely live* for the fast-poll cadence: the
|
||||
// ingestion worker actually running/queued, or a provider with a fresh
|
||||
// chunk (<30s). A stale, un-drained embedding wave (batch_total >
|
||||
// batch_processed but idle freshness) must NOT pin us to fast-poll.
|
||||
busyRef.current =
|
||||
Boolean(ingest?.running) ||
|
||||
(ingest?.queue_depth ?? 0) > 0 ||
|
||||
providers.some(p => p.freshness === 'active');
|
||||
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// Poll loop, gated entirely on `open`.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
cancelledRef.current = false;
|
||||
setLoading(true);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const tick = async () => {
|
||||
await fetchOnce();
|
||||
if (cancelledRef.current) return;
|
||||
timer = setTimeout(tick, busyRef.current ? FAST_POLL_MS : DEFAULT_POLL_MS);
|
||||
};
|
||||
void tick();
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [open, fetchOnce]);
|
||||
|
||||
// Live refresh on memory sync stage changes while the panel is open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onStage = () => {
|
||||
void fetchOnce();
|
||||
};
|
||||
window.addEventListener('openhuman:memory-sync-stage', onStage);
|
||||
return () => window.removeEventListener('openhuman:memory-sync-stage', onStage);
|
||||
}, [open, fetchOnce]);
|
||||
|
||||
return { cronJobs, subconscious, memory, loading };
|
||||
}
|
||||
|
||||
/** Stages that mean a sync has settled for a given source. */
|
||||
const TERMINAL_STAGES = new Set(['completed', 'failed']);
|
||||
|
||||
/**
|
||||
* Poll-free "is any memory sync in flight right now" signal, driven purely by
|
||||
* the `openhuman:memory-sync-stage` window events that `socketService`
|
||||
* dispatches. Cheap enough to keep mounted while the panel is closed so the
|
||||
* background-activity badge can light up for live syncing without polling.
|
||||
*/
|
||||
export function useMemorySyncActive(): boolean {
|
||||
const [active, setActive] = useState(false);
|
||||
const activeIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const onStage = (e: Event) => {
|
||||
const data = (e as CustomEvent).detail as {
|
||||
stage?: string;
|
||||
source_id?: string | null;
|
||||
connection_id?: string | null;
|
||||
};
|
||||
const rowId = data?.source_id ?? data?.connection_id ?? 'unknown';
|
||||
const stage = data?.stage ?? '';
|
||||
const ids = activeIdsRef.current;
|
||||
if (TERMINAL_STAGES.has(stage)) ids.delete(rowId);
|
||||
else ids.add(rowId);
|
||||
setActive(ids.size > 0);
|
||||
};
|
||||
window.addEventListener('openhuman:memory-sync-stage', onStage);
|
||||
return () => window.removeEventListener('openhuman:memory-sync-stage', onStage);
|
||||
}, []);
|
||||
|
||||
return active;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* Chat background-activity panel — end-to-end.
|
||||
*
|
||||
* Feature E2E for the chat-header "Background tasks" surface. Proves the full
|
||||
* user flow: open a thread → click the Background tasks toggle in the chat
|
||||
* header → the right-hand panel mounts and renders its stacked, view-only
|
||||
* sections (this chat's sub-agents, scheduled cron jobs, and memory syncing),
|
||||
* then closes on Escape.
|
||||
*
|
||||
* The section headers and empty states render independently of which core
|
||||
* transport is live, so this asserts the always-present scaffolding rather
|
||||
* than transport-specific data — keeping it deterministic in the web session.
|
||||
*/
|
||||
import { waitForApp } from '../helpers/app-helpers';
|
||||
import { chatMounted, clickByTitle, getSelectedThreadId } from '../helpers/chat-harness';
|
||||
import { textExists } from '../helpers/element-helpers';
|
||||
import { resetApp } from '../helpers/reset-app';
|
||||
import { navigateViaHash } from '../helpers/shared-flows';
|
||||
import { startMockServer, stopMockServer } from '../mock-server';
|
||||
|
||||
const LOG_PREFIX = '[chat-background-activity-panel]';
|
||||
const USER_ID = 'e2e-chat-background-activity-panel';
|
||||
|
||||
describe('Chat background-activity panel', () => {
|
||||
before(async function beforeSuite() {
|
||||
this.timeout(90_000);
|
||||
await startMockServer();
|
||||
await waitForApp();
|
||||
await resetApp(USER_ID);
|
||||
console.log(`${LOG_PREFIX} setup complete`);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
it('opens the Background tasks panel and renders its sections', async () => {
|
||||
await navigateViaHash('/chat');
|
||||
await browser.waitUntil(async () => await chatMounted(), {
|
||||
timeout: 15_000,
|
||||
timeoutMsg: 'Conversations panel did not mount',
|
||||
});
|
||||
|
||||
// The header toggle only renders once a thread is selected.
|
||||
expect(await clickByTitle('New thread', 8_000)).toBe(true);
|
||||
await browser.waitUntil(async () => await getSelectedThreadId(), {
|
||||
timeout: 8_000,
|
||||
timeoutMsg: 'thread.selectedThreadId never populated',
|
||||
});
|
||||
|
||||
const toggle = await $('[data-testid="background-processes-toggle"]');
|
||||
await toggle.waitForExist({ timeout: 10_000 });
|
||||
await toggle.click();
|
||||
|
||||
// The drawer mounts...
|
||||
const panel = await $('[data-testid="background-processes-panel"]');
|
||||
await panel.waitForExist({ timeout: 10_000 });
|
||||
expect(await panel.isDisplayed()).toBe(true);
|
||||
|
||||
// ...with its stacked section scaffolding present (always rendered).
|
||||
expect(await textExists('In this chat')).toBe(true);
|
||||
expect(await textExists('Scheduled jobs')).toBe(true);
|
||||
expect(await textExists('Memory syncing')).toBe(true);
|
||||
console.log(`${LOG_PREFIX} panel + sections rendered`);
|
||||
|
||||
// Escape closes the drawer.
|
||||
await browser.keys(['Escape']);
|
||||
await panel.waitForExist({ timeout: 8_000, reverse: true });
|
||||
console.log(`${LOG_PREFIX} passed — panel closed on Escape`);
|
||||
});
|
||||
});
|
||||
@@ -184,6 +184,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil
|
||||
| 4.2.3 | Streaming Responses | RI | `tests/json_rpc_e2e.rs`, `tests/agent_harness_e2e.rs` | ✅ | `tests/agent_harness_e2e.rs` adds provider-level SSE tool-arg accumulation (chunked args reassembled + parsed) and engine-level delta forwarding (#3471) |
|
||||
| 4.2.4 | Parallel inference (cross-thread + within-thread forked turns) | RU+VU | `src/openhuman/channels/providers/web_tests.rs`, `app/src/store/__tests__/chatRuntimeSlice.test.ts`, `app/src/providers/__tests__/ChatRuntimeProvider.test.tsx` | 🟡 | Concurrent same-/cross-thread dispatch, cooperative `CancellationToken` teardown, and parallel-lane stream routing covered; dedicated WD E2E is a follow-up |
|
||||
| 4.2.5 | Per-thread todo list (plan strip above composer) | RU+VU+WD | `src/openhuman/agent/tools/todo.rs`, `app/src/pages/conversations/components/ThreadTodoStrip.test.tsx`, `app/test/e2e/specs/chat-thread-todo-strip.spec.ts` | ✅ | Read-only thread-scoped todo strip fed by `task_board_updated`; agent `todo` tool guidance + thread binding; E2E drives a `todo` tool call and asserts the card renders |
|
||||
| 4.2.6 | Background-activity panel (chat-header Background tasks button) | VU+WD | `app/src/pages/conversations/hooks/useBackgroundActivity.test.ts`, `app/src/pages/conversations/components/__tests__/BackgroundActivityRows.test.tsx`, `app/test/e2e/specs/chat-background-activity-panel.spec.ts` | ✅ | View-only panel surfacing this chat's async sub-agents + global cron jobs, subconscious/heartbeat status, and memory syncing; freshness-only "Syncing now" labeling; E2E opens the panel and asserts its sections render and close |
|
||||
|
||||
### 4.3 Tool Invocation
|
||||
|
||||
|
||||
Reference in New Issue
Block a user