diff --git a/app/src/components/intelligence/IntelligenceTasksTab.tsx b/app/src/components/intelligence/IntelligenceTasksTab.tsx index d2fa4e4bae..babc3a3722 100644 --- a/app/src/components/intelligence/IntelligenceTasksTab.tsx +++ b/app/src/components/intelligence/IntelligenceTasksTab.tsx @@ -28,6 +28,7 @@ import { useNavigate } from 'react-router-dom'; import { useT } from '../../lib/i18n/I18nContext'; import { TaskKanbanBoard } from '../../pages/conversations/components/TaskKanbanBoard'; +import { isTaskThread } from '../../pages/conversations/utils/threadFilter'; import type { AgentDefinitionDisplay } from '../../services/api/agentLibraryApi'; import { threadApi } from '../../services/api/threadApi'; import { @@ -379,7 +380,11 @@ export default function IntelligenceTasksTab() { card.id, 'in_progress' ); - if (mountedRef.current) setPersonalBoard(startedBoard); + // Link the card to its session thread so the board offers "View session". + const linkedBoard = await todosApi + .setSessionThread(USER_TASKS_THREAD_ID, card.id, thread.id) + .catch(() => startedBoard); + if (mountedRef.current) setPersonalBoard(linkedBoard); dispatch(setSelectedThread(thread.id)); dispatch(setToolTimelineForThread({ threadId: thread.id, entries: [] })); @@ -459,12 +464,36 @@ export default function IntelligenceTasksTab() { const now = new Date().toISOString(); setActionError(null); try { + const sourceExternalId = readSourceMetadata(sourceCard.sourceMetadata).externalId; + // Idempotent approve: if this source item was already promoted to + // user-tasks (picked up / running), don't add a second card — just + // retire the inbox card. Stops the duplicate when an edited item is + // re-offered for approval. + const alreadyPromoted = sourceExternalId + ? personalBoard?.cards.some( + c => readSourceMetadata(c.sourceMetadata).externalId === sourceExternalId + ) + : false; + if (alreadyPromoted) { + const sourceSaved = await todosApi.updateStatus( + TASK_SOURCES_THREAD_ID, + sourceCard.id, + 'done' + ); + if (mountedRef.current) { + setTaskSourcesBoard(sourceSaved); + setRefiningCard(null); + } + return; + } const added = await todosApi.add({ threadId: USER_TASKS_THREAD_ID, content: draft.title, status: 'todo', objective: draft.objective, notes: draft.notes, + // Stamp the source link so the inbox can detect it's now picked up. + sourceMetadata: sourceCard.sourceMetadata, }); const created = added.cards.find(card => card.title === draft.title && card.updatedAt >= now) ?? @@ -504,7 +533,7 @@ export default function IntelligenceTasksTab() { if (mountedRef.current) setActionError(t('intelligence.tasks.sourcePlan.createFailed')); } }, - [t] + [t, personalBoard] ); // ── derived agent board list (read-only) ───────────────────────────── @@ -512,16 +541,36 @@ export default function IntelligenceTasksTab() { const threadMap = new Map(threads.map(th => [th.id, th])); const allThreadIds = new Set([...Object.keys(liveBoards), ...Object.keys(persistedBoards)]); + // Hide task-source items already picked up (promoted to the user-tasks board) + // so an already-running task isn't re-offered for approval in the inbox. + const pickedUpExternalIds = new Set( + (personalBoard?.cards ?? []) + .map(c => readSourceMetadata(c.sourceMetadata).externalId) + .filter((id): id is string => Boolean(id)) + ); + const visibleTaskSourcesBoard: TaskBoard | null = taskSourcesBoard + ? { + ...taskSourcesBoard, + cards: taskSourcesBoard.cards.filter( + c => !pickedUpExternalIds.has(readSourceMetadata(c.sourceMetadata).externalId ?? '') + ), + } + : null; + const boardEntries: ThreadTaskBoard[] = []; for (const threadId of allThreadIds) { if (threadId === USER_TASKS_THREAD_ID) continue; // personal board rendered separately if (threadId === TASK_SOURCES_THREAD_ID) continue; // task sources rendered separately + const thread = threadMap.get(threadId); + // Skip task SESSION threads (autonomous `task-*` runs + manual task-labelled + // threads): their cards already appear on the user-tasks / task-sources + // boards and the session is reachable via the card's "View work" — rendering + // their live board here just duplicates those cards as redundant tables. + if (threadId.startsWith('task-') || (thread && isTaskThread(thread))) continue; const liveBoard = liveBoards[threadId]; const persistedBoard = persistedBoards[threadId]; const board = liveBoard ?? persistedBoard; if (!board || board.cards.length === 0) continue; - - const thread = threadMap.get(threadId); const title = thread?.title && thread.title.trim().length > 0 ? thread.title @@ -575,6 +624,24 @@ export default function IntelligenceTasksTab() { onUpdateCard={handleUpdatePersonal} onDeleteCard={handleDeletePersonal} onWorkTask={handleWorkPersonal} + onViewSession={card => { + if (!card.sessionThreadId) return; + const tid = card.sessionThreadId; + // Open the exact session — mirror the manual "Work" path's + // thread-open sequence so /chat lands on this thread, not just + // the Conversations page. + // Navigation only — do NOT mark the thread active. activeThreadId + // tracks a true in-flight turn; a completed session never emits the + // done/error lifecycle that would clear it, so forcing it active + // would wedge the composer until then. + dispatch(setSelectedThread(tid)); + void dispatch(loadThreads()); + void dispatch(loadThreadMessages(tid)); + // Pass the thread as an explicit open-intent so Conversations' + // mount-resume honors it (its default resume only considers + // General-tab threads and would otherwise drop this task session). + navigate('/chat', { state: { openThreadId: tid } }); + }} workingCardId={workingCardId} /> ) : ( @@ -594,7 +661,7 @@ export default function IntelligenceTasksTab() { {taskSourcesBoard && (
@@ -773,6 +840,7 @@ function TaskSourceTaskList({ onWorkOnTask: (card: TaskBoardCard) => void; }) { const { t } = useT(); + const navigate = useNavigate(); const sortedCards = useMemo( () => [...board.cards].sort((a, b) => a.order - b.order), [board.cards] @@ -789,11 +857,12 @@ function TaskSourceTaskList({ {t('intelligence.tasks.sourceList.subtitle')}

- navigate('/settings/task-sources')} className="text-xs font-medium text-ocean-600 hover:text-ocean-700 dark:text-ocean-300 dark:hover:text-ocean-200"> {t('conversations.taskKanban.sources.manage')} - + {sortedCards.length === 0 ? ( diff --git a/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx b/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx index 1f83383de7..f1cf7bba99 100644 --- a/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx +++ b/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx @@ -25,7 +25,9 @@ const hoisted = vi.hoisted(() => ({ todosAdd: vi.fn(), todosEdit: vi.fn(), todosUpdateStatus: vi.fn(), + todosSetSessionThread: vi.fn(), todosRemove: vi.fn(), + navigate: vi.fn(), selectorResult: { chatRuntime: { taskBoardByThread: {} as Record }, thread: { threads: [] as unknown[] }, @@ -57,6 +59,7 @@ vi.mock('../../../services/api/todosApi', () => ({ add: hoisted.todosAdd, edit: hoisted.todosEdit, updateStatus: hoisted.todosUpdateStatus, + setSessionThread: hoisted.todosSetSessionThread, remove: hoisted.todosRemove, }, })); @@ -69,7 +72,7 @@ vi.mock('../../../store/hooks', () => ({ vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom'); - return { ...actual, useNavigate: () => vi.fn() }; + return { ...actual, useNavigate: () => hoisted.navigate }; }); // Stub the composer so we can drive its `onCreated` callback without @@ -109,12 +112,17 @@ vi.mock('../../../pages/conversations/components/TaskKanbanBoard', () => ({ onMove, onDeleteCard, onWorkTask, + onViewSession, }: { - board: { threadId: string; cards: { id: string; title: string; status: string }[] }; + board: { + threadId: string; + cards: { id: string; title: string; status: string; sessionThreadId?: string }[]; + }; headerTitleKey?: string; onMove?: (card: unknown, status: string) => void; onDeleteCard?: (card: unknown) => void; onWorkTask?: (card: unknown) => void; + onViewSession?: (card: unknown) => void; }) => (
{board.threadId} @@ -137,6 +145,11 @@ vi.mock('../../../pages/conversations/components/TaskKanbanBoard', () => ({ stub-work-task )} + {onViewSession && ( + + )}
), })); @@ -178,7 +191,9 @@ describe('IntelligenceTasksTab', () => { hoisted.todosAdd.mockReset(); hoisted.todosEdit.mockReset(); hoisted.todosUpdateStatus.mockReset(); + hoisted.todosSetSessionThread.mockReset(); hoisted.todosRemove.mockReset(); + hoisted.navigate.mockReset(); hoisted.selectorResult.chatRuntime.taskBoardByThread = {}; hoisted.selectorResult.thread.threads = []; hoisted.selectorResult.agentProfiles.activeProfileId = 'agent-profile-1'; @@ -214,6 +229,11 @@ describe('IntelligenceTasksTab', () => { createdAt: '2026-01-01T00:00:00Z', }); hoisted.chatSend.mockResolvedValue(undefined); + // The "Work" flow links the card to its session thread after starting it + // (`todosApi.setSessionThread(...).catch(...)`), so resolve it to a board + // by default — otherwise the awaited `.catch()` chain stalls the handler + // before it reaches chatSend. + hoisted.todosSetSessionThread.mockResolvedValue(makeBoard('user-tasks', [])); hoisted.listAgentDefinitions.mockResolvedValue([ { id: 'researcher', @@ -276,6 +296,10 @@ describe('IntelligenceTasksTab', () => { }); expect(screen.getByText('No source tasks waiting.')).toBeInTheDocument(); expect(hoisted.todosList).toHaveBeenCalledWith('task-sources'); + + // "Manage sources" jumps to the dedicated settings page. + fireEvent.click(screen.getByText('Manage sources')); + expect(hoisted.navigate).toHaveBeenCalledWith('/settings/task-sources'); }); test('refines a source task and approves it into the personal agent board', async () => { @@ -368,6 +392,74 @@ describe('IntelligenceTasksTab', () => { expect(hoisted.todosUpdateStatus).toHaveBeenCalledWith('task-sources', 'source-1', 'done'); }); + test('work flow still completes when linking the session thread fails', async () => { + hoisted.todosList.mockImplementation((threadId: string) => + Promise.resolve( + threadId === 'user-tasks' + ? { + threadId, + cards: [ + { + id: 'personal-1', + title: 'Linkless task', + status: 'todo', + order: 0, + updatedAt: '2026-01-01T00:00:00Z', + }, + ], + updatedAt: '2026-01-01T00:00:00Z', + } + : makeBoard(threadId, []) + ) + ); + // The session-thread link rejects; the work flow must fall back and still + // dispatch the agent turn. + hoisted.todosSetSessionThread.mockRejectedValue(new Error('link offline')); + + vi.resetModules(); + const Tab = await importTab(); + renderTab(Tab); + + await waitFor(() => expect(screen.getByText('Linkless task')).toBeInTheDocument()); + fireEvent.click(screen.getByText('stub-work-task')); + + await waitFor(() => expect(hoisted.chatSend).toHaveBeenCalledTimes(1)); + }); + + test('View work on a personal card opens its exact session thread', async () => { + hoisted.todosList.mockImplementation((threadId: string) => + Promise.resolve( + threadId === 'user-tasks' + ? { + threadId, + cards: [ + { + id: 'personal-1', + title: 'Worked card', + status: 'in_progress', + sessionThreadId: 'task-session-99', + order: 0, + updatedAt: '2026-01-01T00:00:00Z', + }, + ], + updatedAt: '2026-01-01T00:00:00Z', + } + : makeBoard(threadId, []) + ) + ); + + vi.resetModules(); + const Tab = await importTab(); + renderTab(Tab); + + await waitFor(() => expect(screen.getByText('Worked card')).toBeInTheDocument()); + fireEvent.click(screen.getByText('stub-view-session')); + + expect(hoisted.navigate).toHaveBeenCalledWith('/chat', { + state: { openThreadId: 'task-session-99' }, + }); + }); + test('renders persisted agent boards from the turn-state list', async () => { hoisted.listTurnStates.mockResolvedValue([ { threadId: 'thread-x', taskBoard: makeBoard('thread-x', ['Write docs', 'Fix bug']) }, @@ -491,14 +583,18 @@ describe('IntelligenceTasksTab', () => { 'personal-1', 'in_progress' ); - expect(hoisted.chatSend).toHaveBeenCalledWith( - expect.objectContaining({ - threadId: 'thread-agent-task', - message: expect.stringContaining('Acceptance criteria:'), - model: 'reasoning-v1', - profileId: 'agent-profile-1', - locale: 'en', - }) + // chatSend is the last call in the work flow, after an extra `await` + // (session-thread link), so wait for it rather than asserting synchronously. + await waitFor(() => + expect(hoisted.chatSend).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: 'thread-agent-task', + message: expect.stringContaining('Acceptance criteria:'), + model: 'reasoning-v1', + profileId: 'agent-profile-1', + locale: 'en', + }) + ) ); }); diff --git a/app/src/components/settings/hooks/useSettingsNavigation.ts b/app/src/components/settings/hooks/useSettingsNavigation.ts index 72a6206aeb..16b7ff0836 100644 --- a/app/src/components/settings/hooks/useSettingsNavigation.ts +++ b/app/src/components/settings/hooks/useSettingsNavigation.ts @@ -45,6 +45,7 @@ export type SettingsRoute = | 'webhooks-triggers' | 'composio-triggers' | 'composio-routing' + | 'task-sources' | 'mcp-server' | 'dev-workflow' | 'sandbox-settings' @@ -115,6 +116,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { if (path.includes('/settings/webhooks-triggers')) return 'webhooks-triggers'; if (path.includes('/settings/composio-triggers')) return 'composio-triggers'; if (path.includes('/settings/composio-routing')) return 'composio-routing'; + if (path.includes('/settings/task-sources')) return 'task-sources'; if (path.includes('/settings/intelligence')) return 'intelligence'; if (path.includes('/settings/crypto')) return 'crypto'; if (path.includes('/settings/recovery-phrase')) return 'recovery-phrase'; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 52b8a32ab5..ced4525d49 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -2503,6 +2503,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.saveChanges': 'التغييرات', 'conversations.taskKanban.deleteCard': 'Delete', 'conversations.taskKanban.workTask': 'العمل على المهمة', + 'conversations.taskKanban.viewWork': 'عرض العمل', 'conversations.taskKanban.startingTask': 'جارٍ البدء…', 'conversations.taskKanban.updateFailed': 'ولم يتمكن من استكمال المهمة؛ ولم يتم توفير التغييرات.', 'conversations.taskKanban.sourcesButton': 'المصادر', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index a0fc9893c5..74c789b68f 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -2551,6 +2551,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.saveChanges': 'পরিবর্তন সংরক্ষণ করা হবে', 'conversations.taskKanban.deleteCard': 'Delete', 'conversations.taskKanban.workTask': 'কাজ শুরু করুন', + 'conversations.taskKanban.viewWork': 'কাজ দেখুন', 'conversations.taskKanban.startingTask': 'শুরু হচ্ছে...', 'conversations.taskKanban.updateFailed': 'কাজটি সংরক্ষণ করা যায়নি।', 'conversations.taskKanban.sourcesButton': 'উৎস', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 22d4cc3da9..87bb08ca17 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -2615,6 +2615,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.saveChanges': 'Änderungen speichern', 'conversations.taskKanban.deleteCard': 'Löschen', 'conversations.taskKanban.workTask': 'Aufgabe bearbeiten', + 'conversations.taskKanban.viewWork': 'Arbeit anzeigen', 'conversations.taskKanban.startingTask': 'Startet…', 'conversations.taskKanban.updateFailed': 'Aufgabe konnte nicht aktualisiert werden; Änderungen wurden nicht gespeichert.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 34e4d09db7..cbb333e2dc 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -2960,6 +2960,7 @@ const en: TranslationMap = { 'conversations.taskKanban.saveChanges': 'Save changes', 'conversations.taskKanban.deleteCard': 'Delete', 'conversations.taskKanban.workTask': 'Work task', + 'conversations.taskKanban.viewWork': 'View work', 'conversations.taskKanban.startingTask': 'Starting…', 'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.', 'conversations.taskKanban.sourcesButton': 'Sources', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 0626d0e2f1..c5e9a57e70 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -2599,6 +2599,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.saveChanges': 'Guardar cambios', 'conversations.taskKanban.deleteCard': 'Borrar', 'conversations.taskKanban.workTask': 'Trabajar tarea', + 'conversations.taskKanban.viewWork': 'Ver trabajo', 'conversations.taskKanban.startingTask': 'Iniciando…', 'conversations.taskKanban.updateFailed': 'No se pudo actualizar la tarea; los cambios no se guardaron.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 315a407df8..f078cbcc3f 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -2607,6 +2607,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.saveChanges': 'Enregistrer les modifications', 'conversations.taskKanban.deleteCard': 'Supprimer', 'conversations.taskKanban.workTask': 'Travailler la tâche', + 'conversations.taskKanban.viewWork': 'Voir le travail', 'conversations.taskKanban.startingTask': 'Démarrage…', 'conversations.taskKanban.updateFailed': "Impossible de mettre à jour la tâche; les modifications n'ont pas été enregistrées.", diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index a65b3adf7b..c846cefcd2 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -2555,6 +2555,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.saveChanges': 'परिवर्तन सहेजें', 'conversations.taskKanban.deleteCard': 'Delete', 'conversations.taskKanban.workTask': 'कार्य शुरू करें', + 'conversations.taskKanban.viewWork': 'कार्य देखें', 'conversations.taskKanban.startingTask': 'शुरू हो रहा है…', 'conversations.taskKanban.updateFailed': 'कार्य अद्यतन नहीं कर सका; परिवर्तन बचाया नहीं गया।', 'conversations.taskKanban.sourcesButton': 'स्रोत', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 1e1db5fb5f..b2c6350e9b 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -2557,6 +2557,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.saveChanges': 'Simpan perubahan', 'conversations.taskKanban.deleteCard': 'Hapus', 'conversations.taskKanban.workTask': 'Kerjakan tugas', + 'conversations.taskKanban.viewWork': 'Lihat pekerjaan', 'conversations.taskKanban.startingTask': 'Memulai…', 'conversations.taskKanban.updateFailed': 'Tak bisa memutakhirkan tugas; perubahan tak disimpan.', 'conversations.taskKanban.sourcesButton': 'Sumber', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 3de3664290..9635c46dd6 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -2589,6 +2589,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.saveChanges': 'Salva modifiche', 'conversations.taskKanban.deleteCard': 'Elimina', 'conversations.taskKanban.workTask': "Lavora sull'attività", + 'conversations.taskKanban.viewWork': 'Visualizza lavoro', 'conversations.taskKanban.startingTask': 'Avvio…', 'conversations.taskKanban.updateFailed': "Impossibile aggiornare l'attività; le modifiche non sono state salvate.", diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index e68ea653ac..f252534377 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -2529,6 +2529,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.saveChanges': '변경 사항 저장', 'conversations.taskKanban.deleteCard': '삭제', 'conversations.taskKanban.workTask': '작업 시작', + 'conversations.taskKanban.viewWork': '작업 보기', 'conversations.taskKanban.startingTask': '시작 중…', 'conversations.taskKanban.updateFailed': '작업을 업데이트할 수 없어 변경 사항이 저장되지 않았습니다.', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 3d34c9dbdb..d32b6d22b5 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -2584,6 +2584,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.saveChanges': 'Zapisz zmiany', 'conversations.taskKanban.deleteCard': 'Usuń', 'conversations.taskKanban.workTask': 'Pracuj nad zadaniem', + 'conversations.taskKanban.viewWork': 'Zobacz pracę', 'conversations.taskKanban.startingTask': 'Uruchamianie…', 'conversations.taskKanban.updateFailed': 'Nie udało się zaktualizować zadania; zmian nie zapisano.', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 1bdbaf38b3..24e869af9c 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -2596,6 +2596,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.saveChanges': 'Salvar alterações', 'conversations.taskKanban.deleteCard': 'Excluir', 'conversations.taskKanban.workTask': 'Trabalhar na tarefa', + 'conversations.taskKanban.viewWork': 'Ver trabalho', 'conversations.taskKanban.startingTask': 'Iniciando…', 'conversations.taskKanban.updateFailed': 'Não foi possível atualizar a tarefa; as alterações não foram salvas.', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 2f91e098ad..d390fe3c86 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -2572,6 +2572,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.saveChanges': 'Сохранить изменения', 'conversations.taskKanban.deleteCard': 'Удалить', 'conversations.taskKanban.workTask': 'Работать над задачей', + 'conversations.taskKanban.viewWork': 'Показать работу', 'conversations.taskKanban.startingTask': 'Запуск…', 'conversations.taskKanban.updateFailed': 'Не удалось обновить задачу; изменения не сохранились.', 'conversations.taskKanban.sourcesButton': 'Источники', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 36a983d9ff..56b9884793 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2428,6 +2428,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.saveChanges': '保存更改', 'conversations.taskKanban.deleteCard': '删除', 'conversations.taskKanban.workTask': '处理任务', + 'conversations.taskKanban.viewWork': '查看工作', 'conversations.taskKanban.startingTask': '正在启动…', 'conversations.taskKanban.updateFailed': '无法更新任务;更改未保存。', 'conversations.taskKanban.sourcesButton': '来源', diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index bc3b3cae46..2c203b8a5e 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -186,6 +186,7 @@ const Conversations = ({ const { t } = useT(); const dispatch = useAppDispatch(); const navigate = useNavigate(); + const location = useLocation(); const { threads, selectedThreadId, messages, isLoadingMessages, messagesError, activeThreadId } = useAppSelector(state => state.thread); @@ -370,6 +371,27 @@ const Conversations = ({ // Match the sidebar's default General filter here so initial/resume // selection can't auto-pick a thread hidden by the selected tab. const visibleThreads = data.threads.filter(t => isThreadVisibleInTab(t, GENERAL_TAB_VALUE)); + // An explicit "open this session" intent (e.g. View work from the Agent + // Tasks board) wins over passive resume — and bypasses the General-tab + // visibility filter so a task-labelled session thread can actually be + // opened (the resume default below only considers General threads). + const openThreadId = (location.state as { openThreadId?: string } | null)?.openThreadId; + const openThread = openThreadId ? data.threads.find(t => t.id === openThreadId) : undefined; + if (openThread) { + // Switch the sidebar tab to the bucket that contains the opened + // thread (e.g. Tasks for a task session) so it's visible/selected in + // the list instead of hidden behind the default General tab. + setSelectedLabel( + isThreadVisibleInTab(openThread, TASKS_TAB_VALUE) + ? TASKS_TAB_VALUE + : isThreadVisibleInTab(openThread, SUBCONSCIOUS_TAB_VALUE) + ? SUBCONSCIOUS_TAB_VALUE + : GENERAL_TAB_VALUE + ); + dispatch(setSelectedThread(openThread.id)); + void dispatch(loadThreadMessages(openThread.id)); + return; + } if (visibleThreads.length > 0) { // Prefer the thread the user was last viewing (persisted across // reloads via redux-persist on the `thread` slice). Only fall @@ -423,7 +445,6 @@ const Conversations = ({ }); }, [dispatch]); - const location = useLocation(); const { containerRef: messagesContainerRef, endRef: messagesEndRef } = useStickToBottom( messages, selectedThreadId, @@ -1588,6 +1609,15 @@ const Conversations = ({ t, }); }} + onViewSession={card => { + if (!card.sessionThreadId) return; + // Navigation only — do NOT mark the thread active. activeThreadId + // tracks a true in-flight turn (set on send, cleared on + // done/error). A completed session never emits that lifecycle + // event, so forcing it active would wedge the composer. + dispatch(setSelectedThread(card.sessionThreadId)); + void dispatch(loadThreadMessages(card.sessionThreadId)); + }} /> )} {visibleMessages.map(msg => ( diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx index e3ef7ee23a..0dc75fc901 100644 --- a/app/src/pages/Intelligence.tsx +++ b/app/src/pages/Intelligence.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { ConfirmationModal } from '../components/intelligence/ConfirmationModal'; import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab'; @@ -25,7 +26,28 @@ type IntelligenceTab = 'memory' | 'subconscious' | 'tasks' | 'workflows' | 'coun export default function Intelligence() { const { t } = useT(); - const [activeTab, setActiveTab] = useState('memory'); + // Tab is URL-backed (`/intelligence?tab=…`) so navigating away — e.g. to + // Settings → Task Sources from the Agent Tasks tab — and coming back via + // browser-back restores the same tab instead of resetting to Memory. + // `replace` so switching tabs doesn't stack history entries. + const [searchParams, setSearchParams] = useSearchParams(); + const tabParam = searchParams.get('tab'); + const activeTab: IntelligenceTab = + tabParam && ['memory', 'subconscious', 'tasks', 'workflows', 'council'].includes(tabParam) + ? (tabParam as IntelligenceTab) + : 'memory'; + const setActiveTab = useCallback( + (tab: IntelligenceTab) => { + setSearchParams( + prev => { + prev.set('tab', tab); + return prev; + }, + { replace: true } + ); + }, + [setSearchParams] + ); // The legacy header pills (system-status + Ingesting/Queued chips) were // sourced from `useConsciousItems` + `useMemoryIngestionStatus`. They are diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index 6dc3f4fae5..d4f45247d0 100644 --- a/app/src/pages/__tests__/Conversations.render.test.tsx +++ b/app/src/pages/__tests__/Conversations.render.test.tsx @@ -19,6 +19,7 @@ import { CoreRpcError } from '../../services/coreRpcClient'; import agentProfileReducer from '../../store/agentProfileSlice'; import chatRuntimeReducer, { setInferenceStatusForThread, + setTaskBoardForThread, setToolTimelineForThread, } from '../../store/chatRuntimeSlice'; import socketReducer from '../../store/socketSlice'; @@ -1621,3 +1622,94 @@ describe('Conversations — thread title editing', () => { expect(threadApi.updateTitle).not.toHaveBeenCalled(); }); }); + +describe('Conversations — open-session resume (View work)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 }); + }); + + it('honours location.state.openThreadId to open a task session on mount', async () => { + // A task-labelled session thread, reachable only via an explicit + // open-intent because it's hidden behind the default General tab. + const taskThread = makeThread({ + id: 'task-open-1', + title: 'Autonomous run', + labels: ['tasks'], + }); + mockGetThreads.mockResolvedValue({ threads: [taskThread], count: 1 }); + + const store = buildStore({ thread: emptyThreadState }); + const { default: Conversations } = await import('../Conversations'); + + await act(async () => { + render( + + + + + + ); + }); + + // The open-intent selects the task session (bypassing the General-tab + // filter) and loads its messages. + await waitFor(() => expect(store.getState().thread.selectedThreadId).toBe('task-open-1')); + await waitFor(() => expect(mockGetThreadMessages).toHaveBeenCalled()); + }); + + it("View work on a selected task board opens that card's session thread", async () => { + const thread = makeThread({ id: 'board-thread', title: 'Board thread' }); + mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 }); + + const store = buildStore({ thread: selectedThreadState(thread) }); + + const { default: Conversations } = await import('../Conversations'); + await act(async () => { + render( + + + + + + ); + }); + // Let the mount-resume effect settle, then seed the selected thread's task + // board with a card that has a live session (seeding before mount gets + // clobbered by turn-state hydration). + await screen.findByPlaceholderText('How can I help you today?'); + const selectedId = store.getState().thread.selectedThreadId ?? 'board-thread'; + await act(async () => { + store.dispatch( + setTaskBoardForThread({ + threadId: selectedId, + board: { + threadId: selectedId, + updatedAt: '', + cards: [ + { + id: 'tc1', + title: 'Worked card', + status: 'in_progress', + order: 0, + updatedAt: '', + sessionThreadId: 'sess-99', + }, + ], + }, + }) + ); + }); + + const viewBtn = await screen.findByTitle('View work'); + await act(async () => { + fireEvent.click(viewBtn); + }); + + // onViewSession navigates the chat view to the card's session thread. + await waitFor(() => expect(store.getState().thread.selectedThreadId).toBe('sess-99')); + }); +}); diff --git a/app/src/pages/__tests__/Intelligence.test.tsx b/app/src/pages/__tests__/Intelligence.test.tsx new file mode 100644 index 0000000000..e3c2e634c4 --- /dev/null +++ b/app/src/pages/__tests__/Intelligence.test.tsx @@ -0,0 +1,90 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Stub the heavy tab content + chrome so the test exercises only the +// URL-backed tab selection logic in Intelligence.tsx. +vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) })); +vi.mock('../../components/intelligence/MemorySection', () => ({ + default: () =>
, +})); +vi.mock('../../components/intelligence/IntelligenceSubconsciousTab', () => ({ + default: () =>
, +})); +vi.mock('../../components/intelligence/IntelligenceTasksTab', () => ({ + default: () =>
, +})); +vi.mock('../../components/intelligence/ModelCouncilTab', () => ({ + default: () =>
, +})); +vi.mock('../AgentWorkflows', () => ({ default: () =>
})); +vi.mock('../../components/intelligence/Toast', () => ({ ToastContainer: () => null })); +vi.mock('../../components/intelligence/ConfirmationModal', () => ({ + ConfirmationModal: () => null, +})); +vi.mock('../../components/PillTabBar', () => ({ + default: ({ selected, onChange }: { selected: string; onChange: (tab: string) => void }) => ( +
+ selected:{selected} + {['memory', 'subconscious', 'tasks', 'workflows', 'council'].map(tab => ( + + ))} +
+ ), +})); +vi.mock('../../hooks/useIntelligenceSocket', () => ({ + useIntelligenceSocket: () => ({ isConnected: true }), + useIntelligenceSocketManager: () => ({}), +})); +vi.mock('../../hooks/useSubconscious', () => ({ + useSubconscious: () => ({ + status: 'idle', + mode: 'manual', + intervalMinutes: 30, + triggering: false, + settingMode: false, + triggerTick: vi.fn(), + setMode: vi.fn(), + setIntervalMinutes: vi.fn(), + }), +})); + +const Intelligence = (await import('../Intelligence')).default; + +function renderAt(path: string) { + return render( + + + + ); +} + +describe('Intelligence URL-backed tab', () => { + beforeEach(() => vi.clearAllMocks()); + + it('defaults to the memory tab when no ?tab is present', () => { + renderAt('/intelligence'); + expect(screen.getByTestId('tab-memory')).toBeInTheDocument(); + expect(screen.getByText('selected:memory')).toBeInTheDocument(); + }); + + it('honours ?tab=tasks from the URL', () => { + renderAt('/intelligence?tab=tasks'); + expect(screen.getByTestId('tab-tasks')).toBeInTheDocument(); + expect(screen.getByText('selected:tasks')).toBeInTheDocument(); + }); + + it('falls back to memory for an unknown ?tab value', () => { + renderAt('/intelligence?tab=bogus'); + expect(screen.getByTestId('tab-memory')).toBeInTheDocument(); + }); + + it('switching tabs updates the active tab via the URL', () => { + renderAt('/intelligence'); + fireEvent.click(screen.getByText('go-council')); + expect(screen.getByTestId('tab-council')).toBeInTheDocument(); + expect(screen.getByText('selected:council')).toBeInTheDocument(); + }); +}); diff --git a/app/src/pages/conversations/components/TaskKanbanBoard.test.tsx b/app/src/pages/conversations/components/TaskKanbanBoard.test.tsx index b16a5da365..c2061294a5 100644 --- a/app/src/pages/conversations/components/TaskKanbanBoard.test.tsx +++ b/app/src/pages/conversations/components/TaskKanbanBoard.test.tsx @@ -21,6 +21,14 @@ vi.mock('../../../utils/tauriCommands', () => ({ openhumanTaskSourcesUpdate: vi.fn(), })); +// TaskSourceControls navigates to the settings page via useNavigate(); these +// tests render the board without a , so capture navigation via a spy. +const navigateSpy = vi.hoisted(() => vi.fn()); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => navigateSpy }; +}); + function card(partial: Partial): TaskBoardCard { return { id: 'c1', @@ -182,5 +190,40 @@ describe('TaskKanbanBoard approval surface', () => { await waitFor(() => expect(openhumanTaskSourcesUpdate).toHaveBeenCalledWith('src-1', { enabled: false }) ); + + // "Manage sources" jumps to the settings page. + fireEvent.click(screen.getByText('conversations.taskKanban.sources.manage')); + expect(navigateSpy).toHaveBeenCalledWith('/settings/task-sources'); + }); + + it('shows a "View work" button on a card with a session thread and calls onViewSession', () => { + const onViewSession = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByTitle('conversations.taskKanban.viewWork')); + expect(onViewSession).toHaveBeenCalledWith(expect.objectContaining({ id: 'c1' })); + }); + + it('omits the "View work" button when the card has no session thread', () => { + render( + + ); + + expect(screen.queryByTitle('conversations.taskKanban.viewWork')).toBeNull(); }); }); diff --git a/app/src/pages/conversations/components/TaskKanbanBoard.tsx b/app/src/pages/conversations/components/TaskKanbanBoard.tsx index f095bb59e7..536a132908 100644 --- a/app/src/pages/conversations/components/TaskKanbanBoard.tsx +++ b/app/src/pages/conversations/components/TaskKanbanBoard.tsx @@ -13,6 +13,7 @@ import { LuWrench, LuX, } from 'react-icons/lu'; +import { useNavigate } from 'react-router-dom'; import { useT } from '../../../lib/i18n/I18nContext'; import type { TaskBoard, TaskBoardCard, TaskBoardCardStatus } from '../../../types/turnState'; @@ -97,6 +98,9 @@ interface TaskKanbanBoardProps { onDecidePlan?: (card: TaskBoardCard, approve: boolean) => void; /** Start work on a card from a higher-level task board. */ onWorkTask?: (card: TaskBoardCard) => void; + /** Jump to the card's agent session in Conversations. Shown on any card that + * carries a `sessionThreadId` (a run is live or has happened). */ + onViewSession?: (card: TaskBoardCard) => void; workingCardId?: string | null; } @@ -110,6 +114,7 @@ export function TaskKanbanBoard({ onDeleteCard, onDecidePlan, onWorkTask, + onViewSession, workingCardId = null, }: TaskKanbanBoardProps) { const { t } = useT(); @@ -195,6 +200,7 @@ export function TaskKanbanBoard({ hasBriefActions={Boolean(onUpdateCard || onDeleteCard)} onDecidePlan={onDecidePlan} onWorkTask={onWorkTask} + onViewSession={onViewSession} working={workingCardId === card.id} onOpenBrief={() => setSelectedCardId(card.id)} /> @@ -224,6 +230,7 @@ function TaskBoardArticle({ hasBriefActions, onDecidePlan, onWorkTask, + onViewSession, working, onOpenBrief, }: { @@ -234,6 +241,7 @@ function TaskBoardArticle({ hasBriefActions: boolean; onDecidePlan?: (card: TaskBoardCard, approve: boolean) => void; onWorkTask?: (card: TaskBoardCard) => void; + onViewSession?: (card: TaskBoardCard) => void; working: boolean; onOpenBrief: () => void; }) { @@ -246,7 +254,16 @@ function TaskBoardArticle({

{card.title}

- {card.status === 'awaiting_approval' && onDecidePlan ? ( + {card.sessionThreadId && onViewSession ? ( + + ) : card.status === 'awaiting_approval' && onDecidePlan ? (
- navigate('/settings/task-sources')} className="text-[11px] font-medium text-ocean-600 hover:text-ocean-700 dark:text-ocean-300 dark:hover:text-ocean-200"> {t('conversations.taskKanban.sources.manage')} - +