feat(chat): surface cron, subconscious & memory sync in Background tasks panel (#4086)

This commit is contained in:
Steven Enamakel
2026-06-24 20:30:25 -07:00
committed by GitHub
parent 43914f225d
commit 8719ac57e4
23 changed files with 1364 additions and 12 deletions
+27
View File
@@ -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': 'المحادثة',
+27
View File
@@ -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': 'চ্যাট',
+27
View File
@@ -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',
+31
View File
@@ -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': 'Hasnt 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': 'Hasnt 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',
+27
View File
@@ -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',
+27
View File
@@ -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 dattente',
'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 dattente',
'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',
+27
View File
@@ -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': 'चैट',
+27
View File
@@ -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',
+27
View File
@@ -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',
+27
View File
@@ -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': '채팅',
+27
View File
@@ -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',
+27
View File
@@ -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',
+27
View File
@@ -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': 'Чат',
+27
View File
@@ -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': '对话',
+11 -2
View File
@@ -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('Hasnt 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`);
});
});
+1
View File
@@ -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