mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Bucket chat threads by source (#3330)
This commit is contained in:
@@ -9,6 +9,7 @@ interface PillTabBarProps<T extends string> {
|
||||
activeClassName?: string;
|
||||
containerClassName?: string;
|
||||
inactiveClassName?: string;
|
||||
itemClassName?: string;
|
||||
items: PillTabBarItem<T>[];
|
||||
onChange: (value: T) => void;
|
||||
renderItem?: (item: PillTabBarItem<T>, active: boolean) => ReactNode;
|
||||
@@ -19,6 +20,7 @@ export default function PillTabBar<T extends string>({
|
||||
activeClassName = 'border-primary-200 dark:border-primary-500/40 bg-primary-50 dark:bg-primary-500/15 text-primary-700 dark:text-primary-300',
|
||||
containerClassName = 'flex gap-2 overflow-x-auto pb-1 scrollbar-hide',
|
||||
inactiveClassName = 'border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-600 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800/60',
|
||||
itemClassName = 'px-3',
|
||||
items,
|
||||
onChange,
|
||||
renderItem,
|
||||
@@ -39,7 +41,7 @@ export default function PillTabBar<T extends string>({
|
||||
aria-selected={active}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => onChange(item.value)}
|
||||
className={`flex-shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
|
||||
className={`flex-shrink-0 rounded-full border py-1 text-xs font-medium transition-colors ${itemClassName} ${
|
||||
active ? activeClassName : inactiveClassName
|
||||
}`}>
|
||||
{renderItem ? renderItem(item, active) : item.label}
|
||||
|
||||
@@ -49,7 +49,7 @@ import type { TaskBoard, TaskBoardCard, TaskBoardCardStatus } from '../../types/
|
||||
import { UserTaskComposer } from './UserTaskComposer';
|
||||
|
||||
const log = debug('intelligence:tasks');
|
||||
const AGENT_TASK_THREAD_LABEL = 'agent-task';
|
||||
const AGENT_TASK_THREAD_LABEL = 'tasks';
|
||||
const CHAT_MODEL_ID = 'reasoning-v1';
|
||||
|
||||
interface ThreadTaskBoard {
|
||||
|
||||
@@ -182,7 +182,7 @@ describe('IntelligenceTasksTab', () => {
|
||||
hoisted.createNewThread.mockResolvedValue({
|
||||
id: 'thread-agent-task',
|
||||
title: 'Agent task',
|
||||
labels: ['agent-task'],
|
||||
labels: ['tasks'],
|
||||
chatId: null,
|
||||
isActive: true,
|
||||
messageCount: 0,
|
||||
@@ -192,7 +192,7 @@ describe('IntelligenceTasksTab', () => {
|
||||
hoisted.updateTitle.mockResolvedValue({
|
||||
id: 'thread-agent-task',
|
||||
title: 'Agent task: My personal task',
|
||||
labels: ['agent-task'],
|
||||
labels: ['tasks'],
|
||||
chatId: null,
|
||||
isActive: true,
|
||||
messageCount: 0,
|
||||
@@ -407,7 +407,7 @@ describe('IntelligenceTasksTab', () => {
|
||||
expect(hoisted.todosUpdateStatus).toHaveBeenCalledWith('user-tasks', 'card-0', 'in_progress');
|
||||
});
|
||||
|
||||
test('starts a labeled agent-task thread from a personal task', async () => {
|
||||
test('starts a labeled Tasks thread from a personal task', async () => {
|
||||
hoisted.todosList.mockImplementation((threadId: string) =>
|
||||
Promise.resolve(
|
||||
threadId === 'user-tasks'
|
||||
@@ -445,7 +445,7 @@ describe('IntelligenceTasksTab', () => {
|
||||
|
||||
fireEvent.click(screen.getByText('stub-work-task'));
|
||||
|
||||
await waitFor(() => expect(hoisted.createNewThread).toHaveBeenCalledWith(['agent-task']));
|
||||
await waitFor(() => expect(hoisted.createNewThread).toHaveBeenCalledWith(['tasks']));
|
||||
expect(hoisted.updateTitle).toHaveBeenCalledWith(
|
||||
'thread-agent-task',
|
||||
'Agent task: Implement task source worker'
|
||||
|
||||
@@ -1681,16 +1681,13 @@ const messages: TranslationMap = {
|
||||
'common.disable': 'تعطيل',
|
||||
'common.enable': 'تفعيل',
|
||||
'chat.safetyTimeout': 'لا استجابة من الوكيل بعد دقيقتين. حاول مرة أخرى أو تحقق من اتصالك.',
|
||||
'chat.filter.all': 'الكل',
|
||||
'chat.filter.general': 'عام',
|
||||
'chat.filter.briefing': 'الإحاطة',
|
||||
'chat.filter.notification': 'الإشعار',
|
||||
'chat.filter.workers': 'العمال',
|
||||
'chat.filter.subconscious': 'اللاوعي',
|
||||
'chat.filter.tasks': 'المهام',
|
||||
'chat.selectThread': 'اختر محادثة',
|
||||
'chat.threads': 'المحادثات',
|
||||
'chat.noThreads': 'لا توجد محادثات بعد',
|
||||
'chat.noLabelThreads': 'لا توجد محادثات "{label}"',
|
||||
'chat.noWorkerThreads': 'لا توجد محادثات عمال بعد',
|
||||
'chat.deleteThread': 'حذف المحادثة',
|
||||
'chat.deleteThreadConfirm': 'هل أنت متأكد من أنك تريد حذف "{title}"؟',
|
||||
'chat.untitledThread': 'محادثة بلا عنوان',
|
||||
|
||||
@@ -1715,16 +1715,13 @@ const messages: TranslationMap = {
|
||||
'common.enable': 'সক্রিয় করুন',
|
||||
'chat.safetyTimeout':
|
||||
'২ মিনিট পরেও এজেন্টের কোনো সাড়া নেই। আবার চেষ্টা করুন বা সংযোগ পরীক্ষা করুন।',
|
||||
'chat.filter.all': 'সব',
|
||||
'chat.filter.general': 'সাধারণ',
|
||||
'chat.filter.briefing': 'ব্রিফিং',
|
||||
'chat.filter.notification': 'বিজ্ঞপ্তি',
|
||||
'chat.filter.workers': 'ওয়ার্কার',
|
||||
'chat.filter.subconscious': 'সাবকনশাস',
|
||||
'chat.filter.tasks': 'টাস্ক',
|
||||
'chat.selectThread': 'একটি থ্রেড বেছে নিন',
|
||||
'chat.threads': 'থ্রেড',
|
||||
'chat.noThreads': 'এখনো কোনো থ্রেড নেই',
|
||||
'chat.noLabelThreads': '"{label}" থ্রেড নেই',
|
||||
'chat.noWorkerThreads': 'এখনো কোনো ওয়ার্কার থ্রেড নেই',
|
||||
'chat.deleteThread': 'থ্রেড মুছুন',
|
||||
'chat.deleteThreadConfirm': 'আপনি কি "{title}" মুছতে চান?',
|
||||
'chat.untitledThread': 'শিরোনামহীন থ্রেড',
|
||||
|
||||
@@ -1760,16 +1760,13 @@ const messages: TranslationMap = {
|
||||
'common.enable': 'Aktivieren',
|
||||
'chat.safetyTimeout':
|
||||
'Keine Antwort vom Agenten nach 2 Minuten. Versuche es erneut oder prüfe deine Verbindung.',
|
||||
'chat.filter.all': 'Alle',
|
||||
'chat.filter.general': 'Allgemein',
|
||||
'chat.filter.briefing': 'Briefing',
|
||||
'chat.filter.notification': 'Benachrichtigung',
|
||||
'chat.filter.workers': 'Arbeiter',
|
||||
'chat.filter.subconscious': 'Unterbewusstsein',
|
||||
'chat.filter.tasks': 'Aufgaben',
|
||||
'chat.selectThread': 'Wähle einen Thread aus',
|
||||
'chat.threads': 'Themen',
|
||||
'chat.noThreads': 'Noch keine Threads',
|
||||
'chat.noLabelThreads': 'Keine „{label}“-Threads',
|
||||
'chat.noWorkerThreads': 'Noch keine Arbeitsthreads',
|
||||
'chat.deleteThread': 'Thread löschen',
|
||||
'chat.deleteThreadConfirm': 'Bist du sicher, dass du „{title}“ löschen möchtest?',
|
||||
'chat.untitledThread': 'Thread ohne Titel',
|
||||
|
||||
@@ -1963,16 +1963,13 @@ const en: TranslationMap = {
|
||||
// Chat (additional)
|
||||
'chat.safetyTimeout':
|
||||
'No response from the agent after 2 minutes. Try again or check your connection.',
|
||||
'chat.filter.all': 'All',
|
||||
'chat.filter.general': 'General',
|
||||
'chat.filter.briefing': 'Briefing',
|
||||
'chat.filter.notification': 'Notification',
|
||||
'chat.filter.workers': 'Workers',
|
||||
'chat.filter.subconscious': 'Subconscious',
|
||||
'chat.filter.tasks': 'Tasks',
|
||||
'chat.selectThread': 'Select a thread',
|
||||
'chat.threads': 'Threads',
|
||||
'chat.noThreads': 'No threads yet',
|
||||
'chat.noLabelThreads': 'No "{label}" threads',
|
||||
'chat.noWorkerThreads': 'No worker threads yet',
|
||||
'chat.deleteThread': 'Delete thread',
|
||||
'chat.deleteThreadConfirm': 'Are you sure you want to delete "{title}"?',
|
||||
'chat.untitledThread': 'Untitled thread',
|
||||
|
||||
@@ -1754,16 +1754,13 @@ const messages: TranslationMap = {
|
||||
'common.enable': 'Activar',
|
||||
'chat.safetyTimeout':
|
||||
'Sin respuesta del agente después de 2 minutos. Intenta de nuevo o verifica tu conexión.',
|
||||
'chat.filter.all': 'Todos',
|
||||
'chat.filter.general': 'General',
|
||||
'chat.filter.briefing': 'Resumen',
|
||||
'chat.filter.notification': 'Notificación',
|
||||
'chat.filter.workers': 'Trabajadores',
|
||||
'chat.filter.subconscious': 'Subconsciente',
|
||||
'chat.filter.tasks': 'Tareas',
|
||||
'chat.selectThread': 'Selecciona un hilo',
|
||||
'chat.threads': 'Hilos',
|
||||
'chat.noThreads': 'Sin hilos aún',
|
||||
'chat.noLabelThreads': 'Sin hilos "{label}"',
|
||||
'chat.noWorkerThreads': 'Sin hilos de worker aún',
|
||||
'chat.deleteThread': 'Eliminar hilo',
|
||||
'chat.deleteThreadConfirm': '¿Seguro que quieres eliminar "{title}"?',
|
||||
'chat.untitledThread': 'Hilo sin título',
|
||||
|
||||
@@ -1758,16 +1758,13 @@ const messages: TranslationMap = {
|
||||
'common.enable': 'Activer',
|
||||
'chat.safetyTimeout':
|
||||
"Aucune réponse de l'agent après 2 minutes. Réessaie ou vérifie ta connexion.",
|
||||
'chat.filter.all': 'Tous',
|
||||
'chat.filter.general': 'Général',
|
||||
'chat.filter.briefing': 'Briefing',
|
||||
'chat.filter.notification': 'Notification',
|
||||
'chat.filter.workers': 'Travailleurs',
|
||||
'chat.filter.subconscious': 'Subconscient',
|
||||
'chat.filter.tasks': 'Tâches',
|
||||
'chat.selectThread': 'Sélectionne un fil',
|
||||
'chat.threads': 'Fils',
|
||||
'chat.noThreads': "Aucun fil pour l'instant",
|
||||
'chat.noLabelThreads': 'Aucun fil « {label} »',
|
||||
'chat.noWorkerThreads': "Aucun fil worker pour l'instant",
|
||||
'chat.deleteThread': 'Supprimer le fil',
|
||||
'chat.deleteThreadConfirm': 'Es-tu sûr de vouloir supprimer « {title} » ?',
|
||||
'chat.untitledThread': 'Fil sans titre',
|
||||
|
||||
@@ -1716,16 +1716,13 @@ const messages: TranslationMap = {
|
||||
'common.enable': 'चालू करें',
|
||||
'chat.safetyTimeout':
|
||||
'2 मिनट बाद भी एजेंट से कोई जवाब नहीं मिला। दोबारा कोशिश करें या अपना कनेक्शन चेक करें।',
|
||||
'chat.filter.all': 'सभी',
|
||||
'chat.filter.general': 'सामान्य',
|
||||
'chat.filter.briefing': 'ब्रीफिंग',
|
||||
'chat.filter.notification': 'नोटिफिकेशन',
|
||||
'chat.filter.workers': 'वर्कर्स',
|
||||
'chat.filter.subconscious': 'सबकॉन्शस',
|
||||
'chat.filter.tasks': 'टास्क',
|
||||
'chat.selectThread': 'एक थ्रेड चुनें',
|
||||
'chat.threads': 'थ्रेड्स',
|
||||
'chat.noThreads': 'अभी कोई थ्रेड नहीं',
|
||||
'chat.noLabelThreads': 'कोई "{label}" थ्रेड नहीं',
|
||||
'chat.noWorkerThreads': 'अभी कोई वर्कर थ्रेड नहीं',
|
||||
'chat.deleteThread': 'थ्रेड डिलीट करें',
|
||||
'chat.deleteThreadConfirm': 'क्या आप वाकई "{title}" डिलीट करना चाहते हैं?',
|
||||
'chat.untitledThread': 'बिना शीर्षक की थ्रेड',
|
||||
|
||||
@@ -1718,16 +1718,13 @@ const messages: TranslationMap = {
|
||||
'common.disable': 'Nonaktifkan',
|
||||
'common.enable': 'Aktifkan',
|
||||
'chat.safetyTimeout': 'Tidak ada respons dari agen setelah 2 menit. Coba lagi atau cek koneksi.',
|
||||
'chat.filter.all': 'Semua',
|
||||
'chat.filter.general': 'Umum',
|
||||
'chat.filter.briefing': 'Ringkasan',
|
||||
'chat.filter.notification': 'Notifikasi',
|
||||
'chat.filter.workers': 'Worker',
|
||||
'chat.filter.subconscious': 'Bawah sadar',
|
||||
'chat.filter.tasks': 'Tugas',
|
||||
'chat.selectThread': 'Pilih thread',
|
||||
'chat.threads': 'Thread',
|
||||
'chat.noThreads': 'Belum ada thread',
|
||||
'chat.noLabelThreads': 'Tidak ada thread "{label}"',
|
||||
'chat.noWorkerThreads': 'Belum ada thread worker',
|
||||
'chat.deleteThread': 'Hapus thread',
|
||||
'chat.deleteThreadConfirm': 'Yakin ingin menghapus "{title}"?',
|
||||
'chat.untitledThread': 'Thread tanpa judul',
|
||||
|
||||
@@ -1744,16 +1744,13 @@ const messages: TranslationMap = {
|
||||
'common.enable': 'Abilita',
|
||||
'chat.safetyTimeout':
|
||||
"Nessuna risposta dall'agente dopo 2 minuti. Riprova o controlla la connessione.",
|
||||
'chat.filter.all': 'Tutti',
|
||||
'chat.filter.general': 'Generale',
|
||||
'chat.filter.briefing': 'Briefing',
|
||||
'chat.filter.notification': 'Notifica',
|
||||
'chat.filter.workers': 'Worker',
|
||||
'chat.filter.subconscious': 'Subconscio',
|
||||
'chat.filter.tasks': 'Attività',
|
||||
'chat.selectThread': 'Seleziona un thread',
|
||||
'chat.threads': 'Thread',
|
||||
'chat.noThreads': 'Nessun thread',
|
||||
'chat.noLabelThreads': 'Nessun thread "{label}"',
|
||||
'chat.noWorkerThreads': 'Nessun thread worker',
|
||||
'chat.deleteThread': 'Elimina thread',
|
||||
'chat.deleteThreadConfirm': 'Sei sicuro di voler eliminare "{title}"?',
|
||||
'chat.untitledThread': 'Thread senza titolo',
|
||||
|
||||
@@ -1696,16 +1696,13 @@ const messages: TranslationMap = {
|
||||
'common.disable': '비활성화',
|
||||
'common.enable': '활성화',
|
||||
'chat.safetyTimeout': '2분 후에도 에이전트의 응답이 없습니다. 다시 시도하거나 연결을 확인하세요.',
|
||||
'chat.filter.all': '전체',
|
||||
'chat.filter.general': '일반',
|
||||
'chat.filter.briefing': '브리핑',
|
||||
'chat.filter.notification': '알림',
|
||||
'chat.filter.workers': '워커',
|
||||
'chat.filter.subconscious': '잠재의식',
|
||||
'chat.filter.tasks': '작업',
|
||||
'chat.selectThread': '스레드 선택',
|
||||
'chat.threads': '스레드',
|
||||
'chat.noThreads': '아직 스레드가 없습니다',
|
||||
'chat.noLabelThreads': '"{label}" 스레드가 없습니다',
|
||||
'chat.noWorkerThreads': '아직 워커 스레드가 없습니다',
|
||||
'chat.deleteThread': '스레드 삭제',
|
||||
'chat.deleteThreadConfirm': '"{title}" 스레드를 삭제하시겠습니까?',
|
||||
'chat.untitledThread': '제목 없는 스레드',
|
||||
|
||||
@@ -1737,16 +1737,13 @@ const messages: TranslationMap = {
|
||||
'common.enable': 'Włącz',
|
||||
'chat.safetyTimeout':
|
||||
'Brak odpowiedzi agenta po 2 minutach. Spróbuj ponownie lub sprawdź połączenie.',
|
||||
'chat.filter.all': 'Wszystkie',
|
||||
'chat.filter.general': 'Ogólne',
|
||||
'chat.filter.briefing': 'Briefing',
|
||||
'chat.filter.notification': 'Powiadomienia',
|
||||
'chat.filter.workers': 'Workery',
|
||||
'chat.filter.subconscious': 'Podświadomość',
|
||||
'chat.filter.tasks': 'Zadania',
|
||||
'chat.selectThread': 'Wybierz wątek',
|
||||
'chat.threads': 'Wątki',
|
||||
'chat.noThreads': 'Brak wątków',
|
||||
'chat.noLabelThreads': 'Brak wątków „{label}”',
|
||||
'chat.noWorkerThreads': 'Brak wątków workerów',
|
||||
'chat.deleteThread': 'Usuń wątek',
|
||||
'chat.deleteThreadConfirm': 'Czy na pewno chcesz usunąć „{title}”?',
|
||||
'chat.untitledThread': 'Wątek bez tytułu',
|
||||
|
||||
@@ -1752,16 +1752,13 @@ const messages: TranslationMap = {
|
||||
'common.enable': 'Ativar',
|
||||
'chat.safetyTimeout':
|
||||
'Nenhuma resposta do agente após 2 minutos. Tente novamente ou verifique sua conexão.',
|
||||
'chat.filter.all': 'Todos',
|
||||
'chat.filter.general': 'Geral',
|
||||
'chat.filter.briefing': 'Resumo',
|
||||
'chat.filter.notification': 'Notificação',
|
||||
'chat.filter.workers': 'Trabalhadores',
|
||||
'chat.filter.subconscious': 'Subconsciente',
|
||||
'chat.filter.tasks': 'Tarefas',
|
||||
'chat.selectThread': 'Selecione uma conversa',
|
||||
'chat.threads': 'Conversas',
|
||||
'chat.noThreads': 'Nenhuma conversa ainda',
|
||||
'chat.noLabelThreads': 'Nenhuma conversa "{label}"',
|
||||
'chat.noWorkerThreads': 'Nenhuma thread de worker ainda',
|
||||
'chat.deleteThread': 'Excluir conversa',
|
||||
'chat.deleteThreadConfirm': 'Tem certeza de que deseja excluir "{title}"?',
|
||||
'chat.untitledThread': 'Conversa sem título',
|
||||
|
||||
@@ -1729,16 +1729,13 @@ const messages: TranslationMap = {
|
||||
'common.enable': 'Включить',
|
||||
'chat.safetyTimeout':
|
||||
'Агент не ответил в течение 2 минут. Попробуй снова или проверь соединение.',
|
||||
'chat.filter.all': 'Все',
|
||||
'chat.filter.general': 'Общее',
|
||||
'chat.filter.briefing': 'Брифинг',
|
||||
'chat.filter.notification': 'Уведомление',
|
||||
'chat.filter.workers': 'Воркеры',
|
||||
'chat.filter.subconscious': 'Подсознание',
|
||||
'chat.filter.tasks': 'Задачи',
|
||||
'chat.selectThread': 'Выбери чат',
|
||||
'chat.threads': 'Чаты',
|
||||
'chat.noThreads': 'Чатов пока нет',
|
||||
'chat.noLabelThreads': 'Нет чатов «{label}»',
|
||||
'chat.noWorkerThreads': 'Чатов воркеров пока нет',
|
||||
'chat.deleteThread': 'Удалить чат',
|
||||
'chat.deleteThreadConfirm': 'Удалить «{title}»?',
|
||||
'chat.untitledThread': 'Чат без названия',
|
||||
|
||||
@@ -1621,16 +1621,13 @@ const messages: TranslationMap = {
|
||||
'common.disable': '禁用',
|
||||
'common.enable': '启用',
|
||||
'chat.safetyTimeout': '助手 2 分钟内未响应。请重试或检查你的连接。',
|
||||
'chat.filter.all': '全部',
|
||||
'chat.filter.general': '常规',
|
||||
'chat.filter.briefing': '简报',
|
||||
'chat.filter.notification': '通知',
|
||||
'chat.filter.workers': '工作线程',
|
||||
'chat.filter.subconscious': '潜意识',
|
||||
'chat.filter.tasks': '任务',
|
||||
'chat.selectThread': '选择一个对话',
|
||||
'chat.threads': '对话列表',
|
||||
'chat.noThreads': '暂无对话',
|
||||
'chat.noLabelThreads': '此标签下暂无对话',
|
||||
'chat.noWorkerThreads': '暂无工作线程',
|
||||
'chat.deleteThread': '删除对话',
|
||||
'chat.deleteThreadConfirm': '确定要删除"{title}"吗?',
|
||||
'chat.untitledThread': '未命名对话',
|
||||
|
||||
@@ -100,7 +100,8 @@ import {
|
||||
import {
|
||||
GENERAL_TAB_VALUE,
|
||||
isThreadVisibleInTab,
|
||||
WORKERS_TAB_VALUE,
|
||||
SUBCONSCIOUS_TAB_VALUE,
|
||||
TASKS_TAB_VALUE,
|
||||
} from './conversations/utils/threadFilter';
|
||||
|
||||
// Chat uses the reasoning model; `agentic-v1` is reserved for sub-agents
|
||||
@@ -255,6 +256,7 @@ const Conversations = ({
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [editTitleValue, setEditTitleValue] = useState('');
|
||||
const editTitleInputRef = useRef<HTMLInputElement>(null);
|
||||
const ignoreNextTitleBlurRef = useRef(false);
|
||||
|
||||
const {
|
||||
teamUsage,
|
||||
@@ -351,9 +353,12 @@ const Conversations = ({
|
||||
if (!selectedThreadId) return;
|
||||
const thr = threads.find(t => t.id === selectedThreadId);
|
||||
setEditTitleValue(thr?.title ?? '');
|
||||
ignoreNextTitleBlurRef.current = true;
|
||||
setEditingTitle(true);
|
||||
window.requestAnimationFrame(() => {
|
||||
const scheduleSelect = window.requestAnimationFrame ?? window.setTimeout;
|
||||
scheduleSelect(() => {
|
||||
editTitleInputRef.current?.select();
|
||||
ignoreNextTitleBlurRef.current = false;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -361,6 +366,8 @@ const Conversations = ({
|
||||
const trimmed = editTitleValue.trim();
|
||||
setEditingTitle(false);
|
||||
if (!selectedThreadId || !trimmed) return;
|
||||
const currentTitle = threads.find(t => t.id === selectedThreadId)?.title?.trim();
|
||||
if (trimmed === currentTitle) return;
|
||||
void dispatch(updateThreadTitle({ threadId: selectedThreadId, title: trimmed }));
|
||||
};
|
||||
|
||||
@@ -1232,19 +1239,6 @@ const Conversations = ({
|
||||
};
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
// Worker/subagent threads (any thread with `parentThreadId`) are
|
||||
// surfaced through two intentional paths (issue #1624):
|
||||
// 1. The dedicated `Workers` tab in the sidebar — pick that tab to
|
||||
// see only background work and jump into a worker transcript.
|
||||
// 2. Inline inside the parent thread via `WorkerThreadRefCard`,
|
||||
// which now also renders a live running/completed/failed badge
|
||||
// derived from the parent timeline entry's status.
|
||||
// The default ("All") and label-scoped tabs hide them so the main
|
||||
// sidebar is dominated by user-initiated conversations rather than
|
||||
// background reasoning threads. The actual rule lives in
|
||||
// `isThreadVisibleInTab` so it is pure, unit-testable, and stays
|
||||
// in lockstep with the sidebar tab definition (`labelTabs` below)
|
||||
// via the shared `WORKERS_TAB_VALUE` sentinel.
|
||||
return threads.filter(t => isThreadVisibleInTab(t, selectedLabel));
|
||||
}, [threads, selectedLabel]);
|
||||
|
||||
@@ -1254,19 +1248,12 @@ const Conversations = ({
|
||||
);
|
||||
}, [filteredThreads]);
|
||||
|
||||
// Fixed tab set so categories don't disappear when empty and the active
|
||||
// Fixed bucket set so categories don't disappear when empty and the active
|
||||
// filter state remains unambiguous regardless of what threads exist.
|
||||
// The `workers` tab (issue #1624) is the deliberate UI surface for
|
||||
// background sub-agent / worker threads — selecting it inverts the
|
||||
// default `parentThreadId` filter in `filteredThreads` above so only
|
||||
// worker threads show. Without this tab the only way into a worker
|
||||
// transcript is the inline `WorkerThreadRefCard` inside the parent.
|
||||
const labelTabs = [
|
||||
{ label: t('chat.filter.all'), value: 'all' },
|
||||
{ label: t('chat.filter.general'), value: GENERAL_TAB_VALUE },
|
||||
{ label: t('chat.filter.briefing'), value: 'briefing' },
|
||||
{ label: t('chat.filter.notification'), value: 'notification' },
|
||||
{ label: t('chat.filter.workers'), value: WORKERS_TAB_VALUE },
|
||||
{ label: t('chat.filter.subconscious'), value: SUBCONSCIOUS_TAB_VALUE },
|
||||
{ label: t('chat.filter.tasks'), value: TASKS_TAB_VALUE },
|
||||
];
|
||||
const selectedLabelDisplay =
|
||||
labelTabs.find(tab => tab.value === selectedLabel)?.label ?? selectedLabel;
|
||||
@@ -1283,8 +1270,8 @@ const Conversations = ({
|
||||
|
||||
// Resolve the parent of the currently-selected thread, if any. Used to
|
||||
// render the back-to-parent breadcrumb in the chat header so a user who
|
||||
// dropped into a worker thread (via `WorkerThreadRefCard` or the
|
||||
// `Workers` sidebar tab) can return to the conversation that spawned it
|
||||
// dropped into a worker thread (via `WorkerThreadRefCard` or the Tasks
|
||||
// bucket) can return to the conversation that spawned it
|
||||
// — issue #1624 acceptance criterion "Parent ↔ worker navigation is
|
||||
// bidirectional". Returns `null` when the active thread is a top-level
|
||||
// conversation (no parent), so the header stays unchanged in the
|
||||
@@ -1339,16 +1326,13 @@ const Conversations = ({
|
||||
selected={selectedLabel}
|
||||
onChange={setSelectedLabel}
|
||||
containerClassName="flex flex-wrap gap-1 py-1"
|
||||
itemClassName="px-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sortedThreads.length === 0 ? (
|
||||
<p className="px-4 py-6 text-xs text-stone-400 dark:text-neutral-500 text-center">
|
||||
{selectedLabel === 'all'
|
||||
? t('chat.noThreads')
|
||||
: selectedLabel === WORKERS_TAB_VALUE
|
||||
? t('chat.noWorkerThreads')
|
||||
: t('chat.noLabelThreads').replace('{label}', selectedLabelDisplay)}
|
||||
{t('chat.noLabelThreads').replace('{label}', selectedLabelDisplay)}
|
||||
</p>
|
||||
) : (
|
||||
sortedThreads.map(thread => (
|
||||
@@ -1498,7 +1482,13 @@ const Conversations = ({
|
||||
setEditingTitle(false);
|
||||
}
|
||||
}}
|
||||
onBlur={handleCommitTitle}
|
||||
onBlur={() => {
|
||||
if (ignoreNextTitleBlurRef.current) {
|
||||
ignoreNextTitleBlurRef.current = false;
|
||||
return;
|
||||
}
|
||||
handleCommitTitle();
|
||||
}}
|
||||
aria-label={t('chat.editThreadTitle')}
|
||||
className="h-5 text-sm font-medium text-stone-700 dark:text-neutral-200 bg-transparent border-b border-primary-400 outline-none w-full min-w-0 leading-none py-0"
|
||||
autoFocus
|
||||
@@ -1512,6 +1502,10 @@ const Conversations = ({
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-id="chat-header-edit-thread-title"
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
handleStartEditTitle();
|
||||
}}
|
||||
onClick={handleStartEditTitle}
|
||||
aria-label={t('chat.editThreadTitle')}
|
||||
title={t('chat.editThreadTitle')}
|
||||
|
||||
@@ -327,16 +327,15 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
});
|
||||
|
||||
// Covers line 941 empty branch
|
||||
it('shows "No threads yet" when All is selected and the thread list is empty', async () => {
|
||||
it('shows the General empty message when the default bucket has no threads', async () => {
|
||||
await act(async () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// Sidebar is hidden by default — open it first.
|
||||
await openSidebar();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'All' }));
|
||||
|
||||
expect(screen.getByText('No threads yet')).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByText('No "General" threads')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Covers lines 1002-1004, 1007, 1011-1012, 1014: thread list items rendered unconditionally
|
||||
@@ -1304,7 +1303,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
//
|
||||
// The tab set is fixed so categories do not disappear when the thread list
|
||||
// is empty, and the active-filter state remains unambiguous.
|
||||
it('renders all fixed category tabs with stable labels', async () => {
|
||||
it('renders the fixed chat bucket tabs with stable labels', async () => {
|
||||
await act(async () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
@@ -1312,12 +1311,14 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
// Sidebar is hidden by default — open it first.
|
||||
await openSidebar();
|
||||
|
||||
// All tabs must be present regardless of thread count.
|
||||
expect(screen.getByRole('tab', { name: 'All' })).toBeInTheDocument();
|
||||
// Bucket tabs must be present regardless of thread count.
|
||||
expect(screen.getByRole('tab', { name: 'General' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Briefing' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Notification' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Workers' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Subconscious' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Tasks' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('tab', { name: 'All' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('tab', { name: 'Briefing' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('tab', { name: 'Notification' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('tab', { name: 'Workers' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('tablist')).toHaveClass('flex-wrap');
|
||||
});
|
||||
|
||||
@@ -1330,20 +1331,10 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
await openSidebar();
|
||||
|
||||
expect(screen.getByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('tab', { name: 'All' })).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
|
||||
it('shows "No threads yet" placeholder when All tab is active and list is empty', async () => {
|
||||
await act(async () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
|
||||
// Sidebar is hidden by default — open it first.
|
||||
await openSidebar();
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'All' }));
|
||||
|
||||
expect(screen.getByText('No threads yet')).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Subconscious' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows category-specific empty message when a label tab is selected and no threads match', async () => {
|
||||
@@ -1361,11 +1352,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #1624 — Workers tab is the dedicated entry-point for sub-agent threads.
|
||||
// When the active workspace has no worker threads (parentThreadId set), the
|
||||
// empty state must use the friendly "No worker threads yet" copy rather
|
||||
// than `No "workers" threads`.
|
||||
it('shows the worker-specific empty message when the Workers tab is selected', async () => {
|
||||
it('shows a category-specific empty message when the Tasks tab is selected', async () => {
|
||||
await act(async () => {
|
||||
await renderConversations({ thread: emptyThreadState });
|
||||
});
|
||||
@@ -1373,10 +1360,10 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
// Sidebar is hidden by default — open it first.
|
||||
await openSidebar();
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Workers' }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Tasks' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No worker threads yet')).toBeInTheDocument();
|
||||
expect(screen.getByText(/"Tasks" threads/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1409,10 +1396,9 @@ describe('Conversations — worker thread back-to-parent navigation (#1624)', ()
|
||||
});
|
||||
});
|
||||
|
||||
// The mount effect resumes onto a *visible* (non-worker) thread, so even
|
||||
// though the preloaded state pointed at the child, the page auto-selects
|
||||
// the parent. Re-select the worker thread now that mount has settled to
|
||||
// mimic the user clicking through to a worker from the Workers tab.
|
||||
// The mount effect resumes onto the first visible General thread. Re-select
|
||||
// the worker thread now that mount has settled to mimic opening it from the
|
||||
// Tasks bucket or parent reference card.
|
||||
await act(async () => {
|
||||
store!.dispatch(setSelectedThread('t-child'));
|
||||
});
|
||||
@@ -1610,10 +1596,10 @@ describe('Conversations — thread title editing', () => {
|
||||
expect(editBtn).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(editBtn);
|
||||
fireEvent.mouseDown(editBtn);
|
||||
});
|
||||
|
||||
const input = screen.getByLabelText('Edit thread title');
|
||||
const input = screen.getByRole('textbox', { name: 'Edit thread title' });
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveValue('Original Title');
|
||||
});
|
||||
@@ -1635,10 +1621,10 @@ describe('Conversations — thread title editing', () => {
|
||||
|
||||
const editBtn = screen.getByRole('button', { name: 'Edit thread title' });
|
||||
await act(async () => {
|
||||
fireEvent.click(editBtn);
|
||||
fireEvent.mouseDown(editBtn);
|
||||
});
|
||||
|
||||
const input = screen.getByLabelText('Edit thread title');
|
||||
const input = screen.getByRole('textbox', { name: 'Edit thread title' });
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: 'New Title' } });
|
||||
});
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Thread } from '../../../types/thread';
|
||||
import { GENERAL_TAB_VALUE, isThreadVisibleInTab, WORKERS_TAB_VALUE } from './threadFilter';
|
||||
|
||||
// Issue #1624: this is the pure rule that backs the sidebar
|
||||
// `filteredThreads` memo + the `Workers` tab. The tests pin both halves
|
||||
// of the contract so a future tweak to one branch can never silently
|
||||
// regress the other:
|
||||
// 1. Default tabs hide worker threads (parentThreadId set).
|
||||
// 2. The `Workers` sentinel tab inverts that filter.
|
||||
import {
|
||||
GENERAL_TAB_VALUE,
|
||||
isThreadVisibleInTab,
|
||||
SUBCONSCIOUS_TAB_VALUE,
|
||||
TASKS_TAB_VALUE,
|
||||
} from './threadFilter';
|
||||
|
||||
function thread(overrides: Partial<Thread>): Thread {
|
||||
return {
|
||||
@@ -25,64 +23,86 @@ function thread(overrides: Partial<Thread>): Thread {
|
||||
}
|
||||
|
||||
describe('isThreadVisibleInTab', () => {
|
||||
describe('default `all` tab', () => {
|
||||
it('keeps top-level conversations', () => {
|
||||
expect(isThreadVisibleInTab(thread({ id: 'top' }), 'all')).toBe(true);
|
||||
describe('General bucket', () => {
|
||||
it('keeps general and legacy work-labeled threads', () => {
|
||||
expect(isThreadVisibleInTab(thread({ labels: [GENERAL_TAB_VALUE] }), GENERAL_TAB_VALUE)).toBe(
|
||||
true
|
||||
);
|
||||
expect(isThreadVisibleInTab(thread({ labels: ['work', 'urgent'] }), GENERAL_TAB_VALUE)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('hides worker threads (parentThreadId set)', () => {
|
||||
expect(isThreadVisibleInTab(thread({ id: 'w', parentThreadId: 'p' }), 'all')).toBe(false);
|
||||
it('keeps unlabeled and unknown-label threads as the fallback bucket', () => {
|
||||
expect(isThreadVisibleInTab(thread({ labels: [] }), GENERAL_TAB_VALUE)).toBe(true);
|
||||
expect(isThreadVisibleInTab(thread({ labels: ['briefing'] }), GENERAL_TAB_VALUE)).toBe(true);
|
||||
expect(isThreadVisibleInTab(thread({ labels: ['notification'] }), GENERAL_TAB_VALUE)).toBe(
|
||||
true
|
||||
);
|
||||
expect(isThreadVisibleInTab(thread({ labels: ['custom'] }), GENERAL_TAB_VALUE)).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes threads that belong to explicit non-General buckets', () => {
|
||||
expect(
|
||||
isThreadVisibleInTab(thread({ labels: [SUBCONSCIOUS_TAB_VALUE] }), GENERAL_TAB_VALUE)
|
||||
).toBe(false);
|
||||
expect(isThreadVisibleInTab(thread({ labels: [TASKS_TAB_VALUE] }), GENERAL_TAB_VALUE)).toBe(
|
||||
false
|
||||
);
|
||||
expect(isThreadVisibleInTab(thread({ parentThreadId: 'parent' }), GENERAL_TAB_VALUE)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('label-scoped tabs (general, briefing, notification, ...)', () => {
|
||||
it('keeps a non-worker thread that carries the matching label', () => {
|
||||
const t = thread({ id: 'a', labels: [GENERAL_TAB_VALUE, 'urgent'] });
|
||||
expect(isThreadVisibleInTab(t, GENERAL_TAB_VALUE)).toBe(true);
|
||||
describe('Subconscious bucket', () => {
|
||||
it('keeps canonical and legacy subconscious-generated threads', () => {
|
||||
expect(
|
||||
isThreadVisibleInTab(thread({ labels: [SUBCONSCIOUS_TAB_VALUE] }), SUBCONSCIOUS_TAB_VALUE)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isThreadVisibleInTab(thread({ labels: ['from_reflection'] }), SUBCONSCIOUS_TAB_VALUE)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isThreadVisibleInTab(thread({ labels: ['subconscious_tick'] }), SUBCONSCIOUS_TAB_VALUE)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps legacy work-labeled threads in the General tab', () => {
|
||||
const t = thread({ id: 'legacy', labels: ['work', 'urgent'] });
|
||||
expect(isThreadVisibleInTab(t, GENERAL_TAB_VALUE)).toBe(true);
|
||||
});
|
||||
|
||||
it('drops a non-worker thread that does not carry the matching label', () => {
|
||||
const t = thread({ id: 'a', labels: ['briefing'] });
|
||||
expect(isThreadVisibleInTab(t, GENERAL_TAB_VALUE)).toBe(false);
|
||||
});
|
||||
|
||||
it('still hides worker threads even when the label would otherwise match', () => {
|
||||
const t = thread({ id: 'w', parentThreadId: 'p', labels: [GENERAL_TAB_VALUE] });
|
||||
expect(isThreadVisibleInTab(t, GENERAL_TAB_VALUE)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats threads with no labels array as not matching', () => {
|
||||
const t = thread({ id: 'a' });
|
||||
expect(isThreadVisibleInTab(t, 'briefing')).toBe(false);
|
||||
it('excludes ordinary and task threads', () => {
|
||||
expect(
|
||||
isThreadVisibleInTab(thread({ labels: [GENERAL_TAB_VALUE] }), SUBCONSCIOUS_TAB_VALUE)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isThreadVisibleInTab(thread({ labels: [TASKS_TAB_VALUE] }), SUBCONSCIOUS_TAB_VALUE)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workers tab (intentional surface for sub-agent work)', () => {
|
||||
it('uses the shared sentinel value', () => {
|
||||
// Lock the constant — Conversations.tsx wires the same string into
|
||||
// the sidebar tab definition, so a rename without updating both
|
||||
// sides would silently break the surface.
|
||||
expect(WORKERS_TAB_VALUE).toBe('workers');
|
||||
describe('Tasks bucket', () => {
|
||||
it('keeps task-board, legacy agent-task, and legacy worker-labeled threads', () => {
|
||||
expect(isThreadVisibleInTab(thread({ labels: [TASKS_TAB_VALUE] }), TASKS_TAB_VALUE)).toBe(
|
||||
true
|
||||
);
|
||||
expect(isThreadVisibleInTab(thread({ labels: ['agent-task'] }), TASKS_TAB_VALUE)).toBe(true);
|
||||
expect(isThreadVisibleInTab(thread({ labels: ['worker'] }), TASKS_TAB_VALUE)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a worker thread', () => {
|
||||
const t = thread({ id: 'w', parentThreadId: 'p' });
|
||||
expect(isThreadVisibleInTab(t, WORKERS_TAB_VALUE)).toBe(true);
|
||||
it('keeps parented worker/sub-agent threads regardless of labels', () => {
|
||||
expect(
|
||||
isThreadVisibleInTab(
|
||||
thread({ parentThreadId: 'parent', labels: [GENERAL_TAB_VALUE] }),
|
||||
TASKS_TAB_VALUE
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('hides top-level conversations', () => {
|
||||
const t = thread({ id: 'top' });
|
||||
expect(isThreadVisibleInTab(t, WORKERS_TAB_VALUE)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a worker thread regardless of the worker thread's own labels", () => {
|
||||
const t = thread({ id: 'w', parentThreadId: 'p', labels: ['work'] });
|
||||
expect(isThreadVisibleInTab(t, WORKERS_TAB_VALUE)).toBe(true);
|
||||
it('excludes ordinary and subconscious threads', () => {
|
||||
expect(isThreadVisibleInTab(thread({ labels: [GENERAL_TAB_VALUE] }), TASKS_TAB_VALUE)).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
isThreadVisibleInTab(thread({ labels: [SUBCONSCIOUS_TAB_VALUE] }), TASKS_TAB_VALUE)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import type { Thread } from '../../../types/thread';
|
||||
|
||||
/**
|
||||
* Sentinel label-tab value that surfaces only worker / sub-agent threads
|
||||
* (any thread whose `parentThreadId` is set). Issue #1624: the
|
||||
* `Workers` tab in the conversation sidebar is the deliberate UI surface
|
||||
* for background sub-agent activity — picking it inverts the default
|
||||
* `parentThreadId` filter so users can scan, open, and inspect worker
|
||||
* transcripts that the main `All` view intentionally hides.
|
||||
*
|
||||
* Lives at module scope so the sidebar tab definition, the filter
|
||||
* function, and the Vitest specs all reference the same string and a
|
||||
* future rename can never silently desync the three.
|
||||
*/
|
||||
export const WORKERS_TAB_VALUE = 'workers';
|
||||
export const GENERAL_TAB_VALUE = 'general';
|
||||
export const SUBCONSCIOUS_TAB_VALUE = 'subconscious';
|
||||
export const TASKS_TAB_VALUE = 'tasks';
|
||||
export const LEGACY_GENERAL_LABEL = 'work';
|
||||
export const LEGACY_SUBCONSCIOUS_LABELS = ['from_reflection', 'subconscious_tick'];
|
||||
export const LEGACY_TASK_LABELS = ['agent-task', 'worker'];
|
||||
|
||||
function hasAnyLabel(thread: Thread, labels: readonly string[]): boolean {
|
||||
return Boolean(thread.labels?.some(label => labels.includes(label)));
|
||||
}
|
||||
|
||||
function isSubconsciousThread(thread: Thread): boolean {
|
||||
return hasAnyLabel(thread, [SUBCONSCIOUS_TAB_VALUE, ...LEGACY_SUBCONSCIOUS_LABELS]);
|
||||
}
|
||||
|
||||
function isTaskThread(thread: Thread): boolean {
|
||||
return Boolean(
|
||||
thread.parentThreadId || hasAnyLabel(thread, [TASKS_TAB_VALUE, ...LEGACY_TASK_LABELS])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure, side-effect-free thread filter shared between
|
||||
@@ -23,27 +28,19 @@ export const LEGACY_GENERAL_LABEL = 'work';
|
||||
* filter rule lands in one place with explicit unit coverage instead
|
||||
* of a buried `useMemo` body.
|
||||
*
|
||||
* Rules (issue #1624):
|
||||
* - When `selectedLabel === WORKERS_TAB_VALUE`, return only worker
|
||||
* threads (those with `parentThreadId`). The Workers tab is the
|
||||
* intentional surface for background sub-agent activity.
|
||||
* - Otherwise, hide every worker thread so the main sidebar stays
|
||||
* dominated by user-initiated conversations and isn't polluted by
|
||||
* orchestrator-spawned background work.
|
||||
* - Within the non-Workers tabs, `selectedLabel === 'all'` keeps every
|
||||
* non-worker thread; any other value scopes by the existing thread
|
||||
* `labels[]` array (`general`, `briefing`, `notification`, …). The
|
||||
* General tab also accepts legacy `work` labels from older cores.
|
||||
* Rules:
|
||||
* - Tasks includes task-board threads plus legacy worker/sub-agent threads.
|
||||
* - Subconscious includes new and legacy reflection/tick-generated threads.
|
||||
* - General is the fallback bucket for everything else, including legacy
|
||||
* `work`, unknown labels, and unlabeled historical threads.
|
||||
*/
|
||||
export function isThreadVisibleInTab(thread: Thread, selectedLabel: string): boolean {
|
||||
const isWorker = Boolean(thread.parentThreadId);
|
||||
if (selectedLabel === WORKERS_TAB_VALUE) return isWorker;
|
||||
if (isWorker) return false;
|
||||
if (selectedLabel === 'all') return true;
|
||||
const isSubconscious = isSubconsciousThread(thread);
|
||||
const isTask = isTaskThread(thread);
|
||||
if (selectedLabel === SUBCONSCIOUS_TAB_VALUE) return isSubconscious;
|
||||
if (selectedLabel === TASKS_TAB_VALUE) return isTask;
|
||||
if (selectedLabel === GENERAL_TAB_VALUE) {
|
||||
return Boolean(
|
||||
thread.labels?.includes(GENERAL_TAB_VALUE) || thread.labels?.includes(LEGACY_GENERAL_LABEL)
|
||||
);
|
||||
return !isSubconscious && !isTask;
|
||||
}
|
||||
return Boolean(thread.labels?.includes(selectedLabel));
|
||||
}
|
||||
|
||||
@@ -621,7 +621,7 @@ fn persist_worker_thread(
|
||||
title,
|
||||
created_at: now.clone(),
|
||||
parent_thread_id: None,
|
||||
labels: Some(vec!["worker".to_string()]),
|
||||
labels: Some(vec!["tasks".to_string()]),
|
||||
personality_id: None,
|
||||
},
|
||||
)
|
||||
@@ -861,7 +861,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_worker_thread_creates_thread_with_worker_label_and_messages() {
|
||||
fn persist_worker_thread_creates_thread_with_tasks_label_and_messages() {
|
||||
let temp = TempDir::new().expect("tempdir");
|
||||
let outcome = sample_outcome("the answer is 42");
|
||||
let thread_id = persist_worker_thread(
|
||||
@@ -879,7 +879,7 @@ mod tests {
|
||||
.iter()
|
||||
.find(|t| t.id == thread_id)
|
||||
.expect("worker thread present");
|
||||
assert!(worker.labels.contains(&"worker".to_string()));
|
||||
assert!(worker.labels.contains(&"tasks".to_string()));
|
||||
assert!(worker.title.starts_with("draft a long research plan"));
|
||||
|
||||
let messages =
|
||||
|
||||
@@ -158,13 +158,15 @@ impl Tool for SpawnWorkerThreadTool {
|
||||
let threads = conversations::list_threads(parent.workspace_dir.clone())
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
if let Some(current_thread) = threads.iter().find(|t| t.id == current_thread_id) {
|
||||
if current_thread.labels.contains(&"worker".to_string())
|
||||
|| current_thread.parent_thread_id.is_some()
|
||||
{
|
||||
let is_delegated_label = current_thread
|
||||
.labels
|
||||
.iter()
|
||||
.any(|label| label == "tasks" || label == "worker" || label == "agent-task");
|
||||
if is_delegated_label || current_thread.parent_thread_id.is_some() {
|
||||
tracing::warn!(
|
||||
agent_id = %agent_id,
|
||||
current_thread_id = %current_thread_id,
|
||||
is_worker_label = current_thread.labels.contains(&"worker".to_string()),
|
||||
is_delegated_label,
|
||||
has_parent_thread_id = current_thread.parent_thread_id.is_some(),
|
||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||
"[spawn_worker_thread] depth guard blocked spawn from worker thread"
|
||||
@@ -385,7 +387,7 @@ mod tests {
|
||||
title: "Worker".into(),
|
||||
created_at: "now".into(),
|
||||
parent_thread_id: None,
|
||||
labels: Some(vec!["worker".to_string()]),
|
||||
labels: Some(vec!["tasks".to_string()]),
|
||||
personality_id: None,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -161,7 +161,7 @@ async fn spawn_worker_thread_tool_persists_worker_thread_e2e() {
|
||||
conversations::list_threads(workspace.path().to_path_buf()).expect("worker threads");
|
||||
let worker = threads
|
||||
.iter()
|
||||
.find(|thread| thread.labels.contains(&"worker".to_string()))
|
||||
.find(|thread| thread.labels.contains(&"tasks".to_string()))
|
||||
.expect("worker thread was persisted");
|
||||
assert_eq!(worker.title, "Long delegated task");
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ pub(crate) fn create_worker_thread(
|
||||
title: title.to_string(),
|
||||
created_at: now.clone(),
|
||||
parent_thread_id: Some(parent_thread_id.to_string()),
|
||||
labels: Some(vec!["worker".to_string()]),
|
||||
labels: Some(vec!["tasks".to_string()]),
|
||||
personality_id: None,
|
||||
},
|
||||
)?;
|
||||
@@ -94,15 +94,15 @@ mod tests {
|
||||
)
|
||||
.expect("thread should be created");
|
||||
|
||||
// The new thread is labelled `worker` and linked to the parent — that
|
||||
// link is what hides it from the main sidebar (Workers tab only).
|
||||
// The new thread is labelled `tasks` and linked to the parent so it
|
||||
// stays grouped with delegated task work in the chat sidebar.
|
||||
let threads = conversations::list_threads(dir.clone()).unwrap();
|
||||
let thread = threads
|
||||
.iter()
|
||||
.find(|t| t.id == id)
|
||||
.expect("thread persisted");
|
||||
assert_eq!(thread.parent_thread_id.as_deref(), Some("parent-thread-1"));
|
||||
assert!(thread.labels.contains(&"worker".to_string()));
|
||||
assert!(thread.labels.contains(&"tasks".to_string()));
|
||||
|
||||
// It opens with the delegation prompt as the user message, so the
|
||||
// drawer can render the parent↔subagent chat from memory on reopen.
|
||||
|
||||
@@ -893,10 +893,11 @@ fn infer_labels(thread_id: &str) -> Vec<String> {
|
||||
fn normalize_labels(labels: Vec<String>) -> Vec<String> {
|
||||
let mut normalized = Vec::with_capacity(labels.len());
|
||||
for label in labels {
|
||||
let next = if label == "work" {
|
||||
"general".to_string()
|
||||
} else {
|
||||
label
|
||||
let next = match label.as_str() {
|
||||
"work" => "general".to_string(),
|
||||
"from_reflection" | "subconscious_tick" => "subconscious".to_string(),
|
||||
"agent-task" | "worker" => "tasks".to_string(),
|
||||
_ => label,
|
||||
};
|
||||
if !normalized.contains(&next) {
|
||||
normalized.push(next);
|
||||
|
||||
@@ -365,7 +365,7 @@ fn store_handles_labels_and_inference() {
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// 5. Legacy explicit "work" labels normalize into General.
|
||||
// 5. Legacy explicit labels normalize into their canonical buckets.
|
||||
store
|
||||
.ensure_thread(CreateConversationThread {
|
||||
parent_thread_id: None,
|
||||
@@ -380,6 +380,29 @@ fn store_handles_labels_and_inference() {
|
||||
personality_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.ensure_thread(CreateConversationThread {
|
||||
parent_thread_id: None,
|
||||
id: "legacy-subconscious-thread".to_string(),
|
||||
title: "Legacy Subconscious Chat".to_string(),
|
||||
created_at: "2026-04-10T12:00:00Z".to_string(),
|
||||
labels: Some(vec![
|
||||
"from_reflection".to_string(),
|
||||
"subconscious_tick".to_string(),
|
||||
]),
|
||||
personality_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.ensure_thread(CreateConversationThread {
|
||||
parent_thread_id: None,
|
||||
id: "legacy-task-thread".to_string(),
|
||||
title: "Legacy Task Chat".to_string(),
|
||||
created_at: "2026-04-10T12:00:00Z".to_string(),
|
||||
labels: Some(vec!["agent-task".to_string(), "worker".to_string()]),
|
||||
personality_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let threads = store.list_threads().unwrap();
|
||||
{
|
||||
@@ -408,6 +431,20 @@ fn store_handles_labels_and_inference() {
|
||||
.unwrap();
|
||||
assert_eq!(legacy.labels, vec!["general", "urgent"]);
|
||||
}
|
||||
{
|
||||
let legacy = threads
|
||||
.iter()
|
||||
.find(|t| t.id == "legacy-subconscious-thread")
|
||||
.unwrap();
|
||||
assert_eq!(legacy.labels, vec!["subconscious"]);
|
||||
}
|
||||
{
|
||||
let legacy = threads
|
||||
.iter()
|
||||
.find(|t| t.id == "legacy-task-thread")
|
||||
.unwrap();
|
||||
assert_eq!(legacy.labels, vec!["tasks"]);
|
||||
}
|
||||
|
||||
// 6. Update labels
|
||||
store
|
||||
|
||||
@@ -379,7 +379,7 @@ impl SubconsciousEngine {
|
||||
title: thread_title,
|
||||
created_at: now_iso.clone(),
|
||||
parent_thread_id: None,
|
||||
labels: Some(vec!["subconscious_tick".to_string()]),
|
||||
labels: Some(vec!["subconscious".to_string()]),
|
||||
personality_id: None,
|
||||
},
|
||||
) {
|
||||
|
||||
@@ -247,7 +247,7 @@ fn handle_reflections_act(params: Map<String, Value>) -> ControllerFuture {
|
||||
title: thread_title,
|
||||
created_at: now_iso.clone(),
|
||||
parent_thread_id: None,
|
||||
labels: Some(vec!["from_reflection".to_string()]),
|
||||
labels: Some(vec!["subconscious".to_string()]),
|
||||
personality_id: None,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user