diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 59068537e..0541b9ab4 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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': 'المحادثة', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 524f8322c..941b30393 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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': 'চ্যাট', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 319dc784e..781f4c040 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 7c696ce66..089a93126 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index e5e7c7735..427ea7c81 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 710a3dd26..6c699b27a 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 9c16843a2..8420419f8 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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': 'चैट', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 1bee6e442..0c844ef73 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 2ce02735b..be39d88e2 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index b8a3e51e6..861917969 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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': '채팅', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index ee271494c..583817dc8 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index aedb7496d..80694700f 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 6c82da9cc..c6776f0b9 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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': 'Чат', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 61d2202d9..33aee0891 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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': '对话', diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index b88214c78..17da04465 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -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" /> - {runningBackgroundCount > 0 && ( + {runningBackgroundCount > 0 ? ( {runningBackgroundCount} - )} + ) : memorySyncActive ? ( + + ) : null} )} {(selectedThreadId ?? firstActiveThreadId) && ( diff --git a/app/src/pages/conversations/components/BackgroundActivityRows.tsx b/app/src/pages/conversations/components/BackgroundActivityRows.tsx new file mode 100644 index 000000000..4f150f606 --- /dev/null +++ b/app/src/pages/conversations/components/BackgroundActivityRows.tsx @@ -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 ( +
+ + {title} + + {hint ? ( + {hint} + ) : null} +
+ ); +} + +/** A coloured status dot. */ +function Dot({ className }: { className: string }) { + return ; +} + +/** Localised, human-readable summary of a cron schedule. */ +function scheduleLabel(schedule: CoreCronSchedule, t: ReturnType['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 ( +
+ +
+
+ + {name} + + {job.enabled ? ( + + {job.next_run + ? t('conversations.backgroundTasks.cronNext').replace( + '{time}', + formatResetTime(job.next_run) + ) + : ''} + + ) : ( + + {t('conversations.backgroundTasks.cronPaused')} + + )} +
+ + {scheduleLabel(job.schedule, t)} + + + {lastLabel} + +
+
+ ); +} + +/** 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 ( +
+ +
+
+ + {t('conversations.backgroundTasks.sectionSubconscious')} + + {pill} +
+ + {meta.join(' · ')} + +
+
+ ); +} + +/** + * 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['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 ( +
+ {t('conversations.backgroundTasks.memUpToDate')} +
+ ); + } + + return ( +
+ {memory.ingesting ? ( +
+ +
+ + {memory.currentTitle + ? t('conversations.backgroundTasks.memIngesting').replace( + '{title}', + memory.currentTitle + ) + : t('conversations.backgroundTasks.memIngestingUntitled')} + + {memory.queueDepth > 0 ? ( + + {t('conversations.backgroundTasks.memQueued').replace( + '{count}', + String(memory.queueDepth) + )} + + ) : null} +
+
+ ) : 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 ( +
+ +
+
+ + {row.provider} + + {f.label} +
+ {backlog ? ( + + {backlog} + + ) : null} +
+
+ ); + })} +
+ ); +} diff --git a/app/src/pages/conversations/components/BackgroundProcessesPanel.tsx b/app/src/pages/conversations/components/BackgroundProcessesPanel.tsx index c43a391ec..770ea7d12 100644 --- a/app/src/pages/conversations/components/BackgroundProcessesPanel.tsx +++ b/app/src/pages/conversations/components/BackgroundProcessesPanel.tsx @@ -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({ - {processes.length === 0 ? ( -
- {t('conversations.backgroundTasks.empty')} -
- ) : ( -
- {processes.map(p => { +
+ {/* Section 1 — detached sub-agents spawned in this chat. */} + + {processes.length === 0 ? ( +
+ {t('conversations.backgroundTasks.empty')} +
+ ) : ( + processes.map(p => { const s = statusStyle(p.status); const toolCallLabel = ( p.toolCount === 1 @@ -215,9 +226,31 @@ export function BackgroundProcessesPanel({ ); - })} -
- )} + }) + )} + + {/* Section 2 — scheduled (cron) jobs, global, view-only. */} + + {activity.cronJobs.length === 0 ? ( +
+ {t('conversations.backgroundTasks.cronEmpty')} +
+ ) : ( + activity.cronJobs.map(job => ) + )} + + {/* Section 3 — subconscious / background-thinking loop. */} + {activity.subconscious ? ( + <> + + + + ) : null} + + {/* Section 4 — memory syncing / ingestion. */} + + +
); diff --git a/app/src/pages/conversations/components/__tests__/BackgroundActivityRows.test.tsx b/app/src/pages/conversations/components/__tests__/BackgroundActivityRows.test.tsx new file mode 100644 index 000000000..b70c92f06 --- /dev/null +++ b/app/src/pages/conversations/components/__tests__/BackgroundActivityRows.test.tsx @@ -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 & { 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(); + 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( + + ); + expect(screen.getByText('summarize my inbox')).toBeInTheDocument(); + + rerender(); + expect(screen.getByText('echo hi')).toBeInTheDocument(); + + rerender(); + expect(screen.getByText('Untitled job')).toBeInTheDocument(); + }); + + it('marks a disabled job as Paused and shows the never-run state', () => { + const { container } = render( + + ); + 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( + + ); + expect(screen.getByText('Every 15m')).toBeInTheDocument(); + + rerender( + + ); + expect(screen.getByText('One-off run')).toBeInTheDocument(); + expect(screen.getByText('Once')).toBeInTheDocument(); + }); +}); + +describe('SubconsciousRow', () => { + function summary(partial: Partial): 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(); + 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(); + expect(screen.getByText('Idle')).toBeInTheDocument(); + }); + + it('shows Off when disabled or mode is off', () => { + const { rerender } = render(); + expect(screen.getByText('Off')).toBeInTheDocument(); + rerender(); + expect(screen.getByText('Off')).toBeInTheDocument(); + }); +}); + +describe('MemorySection', () => { + function provider( + partial: Partial & { 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(); + 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(); + 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(); + 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(); + expect(screen.getByText('Syncing now')).toBeInTheDocument(); + }); +}); diff --git a/app/src/pages/conversations/components/__tests__/BackgroundProcessesPanel.test.tsx b/app/src/pages/conversations/components/__tests__/BackgroundProcessesPanel.test.tsx index 230126498..3feb487ab 100644 --- a/app/src/pages/conversations/components/__tests__/BackgroundProcessesPanel.test.tsx +++ b/app/src/pages/conversations/components/__tests__/BackgroundProcessesPanel.test.tsx @@ -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( + + ); + 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(); + }); }); diff --git a/app/src/pages/conversations/hooks/useBackgroundActivity.test.ts b/app/src/pages/conversations/hooks/useBackgroundActivity.test.ts new file mode 100644 index 000000000..b5bf3a68e --- /dev/null +++ b/app/src/pages/conversations/hooks/useBackgroundActivity.test.ts @@ -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) { + 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); + }); +}); diff --git a/app/src/pages/conversations/hooks/useBackgroundActivity.ts b/app/src/pages/conversations/hooks/useBackgroundActivity.ts new file mode 100644 index 000000000..d81f18d57 --- /dev/null +++ b/app/src/pages/conversations/hooks/useBackgroundActivity.ts @@ -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([]); + const [subconscious, setSubconscious] = useState(null); + const [memory, setMemory] = useState(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({ 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 | 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>(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; +} diff --git a/app/test/e2e/specs/chat-background-activity-panel.spec.ts b/app/test/e2e/specs/chat-background-activity-panel.spec.ts new file mode 100644 index 000000000..214f872c1 --- /dev/null +++ b/app/test/e2e/specs/chat-background-activity-panel.spec.ts @@ -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`); + }); +}); diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index f8b75cb87..54a7af511 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -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