Bucket chat threads by source (#3330)

This commit is contained in:
Steven Enamakel
2026-06-03 23:08:03 -04:00
committed by GitHub
parent 20b60070bd
commit 57dd868f29
29 changed files with 248 additions and 251 deletions
+3 -1
View File
@@ -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'
+2 -5
View File
@@ -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': 'محادثة بلا عنوان',
+2 -5
View File
@@ -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': 'শিরোনামহীন থ্রেড',
+2 -5
View File
@@ -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',
+2 -5
View File
@@ -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',
+2 -5
View File
@@ -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',
+2 -5
View File
@@ -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',
+2 -5
View File
@@ -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': 'बिना शीर्षक की थ्रेड',
+2 -5
View File
@@ -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',
+2 -5
View File
@@ -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',
+2 -5
View File
@@ -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': '제목 없는 스레드',
+2 -5
View File
@@ -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',
+2 -5
View File
@@ -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',
+2 -5
View File
@@ -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': 'Чат без названия',
+2 -5
View File
@@ -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': '未命名对话',
+27 -33
View File
@@ -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.
+5 -4
View File
@@ -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
+1 -1
View File
@@ -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,
},
) {
+1 -1
View File
@@ -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,
},
)