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')}
-
+