Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 77 additions & 8 deletions app/src/components/intelligence/IntelligenceTasksTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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: [] }));
Expand Down Expand Up @@ -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) ??
Expand Down Expand Up @@ -504,24 +533,44 @@ export default function IntelligenceTasksTab() {
if (mountedRef.current) setActionError(t('intelligence.tasks.sourcePlan.createFailed'));
}
},
[t]
[t, personalBoard]
);

// ── derived agent board list (read-only) ─────────────────────────────

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
Expand Down Expand Up @@ -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}
/>
) : (
Expand All @@ -594,7 +661,7 @@ export default function IntelligenceTasksTab() {
{taskSourcesBoard && (
<section className="space-y-2">
<TaskSourceTaskList
board={taskSourcesBoard}
board={visibleTaskSourcesBoard ?? taskSourcesBoard}
disabled={loading}
onWorkOnTask={setRefiningCard}
/>
Expand Down Expand Up @@ -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]
Expand All @@ -789,11 +857,12 @@ function TaskSourceTaskList({
{t('intelligence.tasks.sourceList.subtitle')}
</p>
</div>
<a
href="#/settings/task-sources"
<button
type="button"
onClick={() => 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')}
</a>
</button>
</div>

{sortedCards.length === 0 ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> },
thread: { threads: [] as unknown[] },
Expand Down Expand Up @@ -57,6 +59,7 @@ vi.mock('../../../services/api/todosApi', () => ({
add: hoisted.todosAdd,
edit: hoisted.todosEdit,
updateStatus: hoisted.todosUpdateStatus,
setSessionThread: hoisted.todosSetSessionThread,
remove: hoisted.todosRemove,
},
}));
Expand All @@ -69,7 +72,7 @@ vi.mock('../../../store/hooks', () => ({

vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return { ...actual, useNavigate: () => vi.fn() };
return { ...actual, useNavigate: () => hoisted.navigate };
});

// Stub the composer so we can drive its `onCreated` callback without
Expand Down Expand Up @@ -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;
}) => (
<div data-testid="kanban-stub">
<span>{board.threadId}</span>
Expand All @@ -137,6 +145,11 @@ vi.mock('../../../pages/conversations/components/TaskKanbanBoard', () => ({
stub-work-task
</button>
)}
{onViewSession && (
<button type="button" onClick={() => onViewSession(board.cards[0])}>
stub-view-session
</button>
)}
</div>
),
}));
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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']) },
Expand Down Expand Up @@ -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',
})
)
);
});

Expand Down
2 changes: 2 additions & 0 deletions app/src/components/settings/hooks/useSettingsNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export type SettingsRoute =
| 'webhooks-triggers'
| 'composio-triggers'
| 'composio-routing'
| 'task-sources'
| 'mcp-server'
| 'dev-workflow'
| 'sandbox-settings'
Expand Down Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions app/src/lib/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': 'المصادر',
Expand Down
1 change: 1 addition & 0 deletions app/src/lib/i18n/bn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': 'উৎস',
Expand Down
1 change: 1 addition & 0 deletions app/src/lib/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
Loading
Loading