Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
22 changes: 21 additions & 1 deletion app/src/components/intelligence/IntelligenceTasksTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,26 @@ export default function IntelligenceTasksTab() {
};
}, [loadAll]);

// Background board mutations — the dispatcher poller claiming a card,
// `update_task` from an autonomous run, `write_back`, triage, stale-reclaim —
// update the persisted board but emit no live socket event to this tab (the
// progress→socket bridge only fires inside an interactive, client-connected
// turn). So the user-tasks + task-sources boards would otherwise look frozen
// while work happens in the background. Re-read just those two on a light
// interval while the tab is visible; they're local in-process RPC, so it's
// cheap. (A push-based fix would need a core DomainEvent + socket broadcast;
// this keeps the fix isolated to the tab and catches every mutation source.)
useEffect(() => {
const POLL_MS = 4000;
const tick = () => {
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
void fetchPersonalBoard();
void fetchTaskSourcesBoard();
};
const handle = window.setInterval(tick, POLL_MS);
return () => window.clearInterval(handle);
}, [fetchPersonalBoard, fetchTaskSourcesBoard]);

// A task created from the composer lands either on the personal board or
// on a chosen conversation thread. `add` returns the updated board, so we
// merge it directly — re-fetching listTurnStates would return a stale
Expand Down Expand Up @@ -397,7 +417,7 @@ export default function IntelligenceTasksTab() {
status: 'todo',
objective: draft.objective,
notes: draft.notes,
assignedAgent: 'agent_coder',
assignedAgent: 'orchestrator',
approvalMode: 'not_required',
plan: draft.plan,
allowedTools: draft.allowedTools,
Expand Down
64 changes: 63 additions & 1 deletion app/src/components/intelligence/UserTaskComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ vi.mock('../../store/hooks', () => ({

vi.mock('../../services/api/todosApi', () => ({
USER_TASKS_THREAD_ID: 'user-tasks',
todosApi: { add: vi.fn() },
todosApi: { add: vi.fn(), edit: vi.fn() },
}));

const mockAdd = vi.mocked(todosApi.add);
const mockEdit = vi.mocked(todosApi.edit);

function emptyBoard(threadId: string) {
return { threadId, cards: [], updatedAt: '' };
Expand Down Expand Up @@ -83,6 +84,67 @@ describe('UserTaskComposer', () => {
expect(mockAdd.mock.calls[0][0].threadId).toBe('t-1');
});

it('assigns the new card to the orchestrator when "assign to agent" is on', async () => {
const created = {
id: 'card-1',
title: 'Ship it',
status: 'todo',
updatedAt: '2099-01-01T00:00:00Z',
};
mockAdd.mockResolvedValueOnce({ threadId: USER_TASKS_THREAD_ID, cards: [created], updatedAt: '' });
mockEdit.mockResolvedValueOnce({
threadId: USER_TASKS_THREAD_ID,
cards: [{ ...created, assignedAgent: 'orchestrator' }],
updatedAt: '',
});
render(<UserTaskComposer onCreated={vi.fn()} onClose={vi.fn()} />);

fireEvent.change(screen.getByPlaceholderText('What needs to be done?'), {
target: { value: 'Ship it' },
});
fireEvent.click(screen.getByRole('checkbox'));
fireEvent.click(screen.getByRole('button', { name: 'Create task' }));

await waitFor(() => expect(mockEdit).toHaveBeenCalledTimes(1));
// Assigned to the orchestrator + approval waived so the poller auto-runs it.
expect(mockEdit).toHaveBeenCalledWith(
expect.objectContaining({
threadId: USER_TASKS_THREAD_ID,
id: 'card-1',
assignedAgent: 'orchestrator',
approvalMode: 'not_required',
})
);
});

it('does not assign an agent when the toggle is left off', async () => {
mockAdd.mockResolvedValueOnce(emptyBoard(USER_TASKS_THREAD_ID));
render(<UserTaskComposer onCreated={vi.fn()} onClose={vi.fn()} />);

fireEvent.change(screen.getByPlaceholderText('What needs to be done?'), {
target: { value: 'Buy milk' },
});
fireEvent.click(screen.getByRole('button', { name: 'Create task' }));

await waitFor(() => expect(mockAdd).toHaveBeenCalledTimes(1));
expect(mockEdit).not.toHaveBeenCalled();
});

it('disables assign-to-agent when the task is attached to a conversation', () => {
render(<UserTaskComposer onCreated={vi.fn()} onClose={vi.fn()} />);
fireEvent.change(screen.getByPlaceholderText('What needs to be done?'), {
target: { value: 'Book hotel' },
});
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeEnabled();
// Attaching to a thread takes it off the personal board — the poller doesn't
// poll conversation threads, so auto-run is disabled there.
fireEvent.change(screen.getByDisplayValue('Personal (no conversation)'), {
target: { value: 't-1' },
});
expect(checkbox).toBeDisabled();
});

it('surfaces an error and keeps the modal open on failure', async () => {
mockAdd.mockRejectedValueOnce(new Error('boom'));
const onClose = vi.fn();
Expand Down
49 changes: 47 additions & 2 deletions app/src/components/intelligence/UserTaskComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ export function UserTaskComposer({ onCreated, onClose }: UserTaskComposerProps)
const [objective, setObjective] = useState('');
const [notes, setNotes] = useState('');
const [attachThreadId, setAttachThreadId] = useState('');
// When on, the new personal-board card is assigned to the orchestrator so the
// task dispatcher's poller auto-picks and runs it. Off → a plain manual todo
// the poller never touches. Only meaningful on the personal board (the poller
// doesn't poll attached conversation threads), so it's disabled when attaching.
const [assignToAgent, setAssignToAgent] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

Expand All @@ -53,17 +58,39 @@ export function UserTaskComposer({ onCreated, onClose }: UserTaskComposerProps)
const trimmedTitle = title.trim();
if (!trimmedTitle || submitting) return;
const threadId = attachThreadId || USER_TASKS_THREAD_ID;
// Auto-pick only works on the personal board (the poller doesn't poll
// attached conversation threads), so ignore the toggle when attaching.
const assign = assignToAgent && !attachThreadId;
const createdAt = new Date().toISOString();
setSubmitting(true);
setError(null);
log('submit threadId=%s status=%s', threadId, status);
log('submit threadId=%s status=%s assign=%s', threadId, status, assign);
try {
const board = await todosApi.add({
let board = await todosApi.add({
threadId,
content: trimmedTitle,
status,
objective: objective.trim() || null,
notes: notes.trim() || null,
});

// Assign to the orchestrator + waive the per-card approval gate so the
// dispatcher's poller picks it up and runs it (mirrors the source-plan
// approve flow). `add` can't set these, so edit the just-created card.
if (assign) {
const created =
board.cards.find(card => card.title === trimmedTitle && card.updatedAt >= createdAt) ??
board.cards[board.cards.length - 1];
if (created) {
board = await todosApi.edit({
threadId,
id: created.id,
assignedAgent: 'orchestrator',
approvalMode: 'not_required',
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
}

onCreated(threadId, board);
onClose();
} catch (err) {
Expand Down Expand Up @@ -168,6 +195,24 @@ export function UserTaskComposer({ onCreated, onClose }: UserTaskComposerProps)
/>
</label>

<label className="flex items-start gap-2">
<input
type="checkbox"
checked={assignToAgent && !attachThreadId}
disabled={attachThreadId !== ''}
onChange={e => setAssignToAgent(e.target.checked)}
className="mt-0.5 h-4 w-4 flex-none rounded border-stone-300 text-ocean-600 focus:ring-ocean-500 disabled:opacity-50 dark:border-neutral-600 dark:bg-neutral-950"
/>
<span className="text-xs text-stone-600 dark:text-neutral-300">
<span className="font-semibold text-stone-700 dark:text-neutral-200">
{t('intelligence.tasks.composer.assignAgentLabel')}
</span>
<span className="mt-0.5 block text-stone-500 dark:text-neutral-400">
{t('intelligence.tasks.composer.assignAgentHint')}
</span>
</span>
</label>

{error && (
<p className="rounded-md border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
{t('intelligence.tasks.composer.createFailed')}: {error}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ describe('IntelligenceTasksTab', () => {
expect.objectContaining({
threadId: 'user-tasks',
id: 'agent-task-1',
assignedAgent: 'agent_coder',
assignedAgent: 'orchestrator',
approvalMode: 'not_required',
})
);
Expand Down Expand Up @@ -497,6 +497,35 @@ describe('IntelligenceTasksTab', () => {
expect(hoisted.todosRemove).toHaveBeenCalledWith('user-tasks', 'card-0');
});

test('re-polls the personal + task-source boards on an interval (background runs show live)', async () => {
vi.useFakeTimers();
try {
hoisted.todosList.mockImplementation((threadId: string) =>
Promise.resolve(makeBoard(threadId, []))
);
vi.resetModules();
const Tab = await importTab();
renderTab(Tab);

// Flush the mount effect's setTimeout(0) + the initial loadAll fetches.
await vi.advanceTimersByTimeAsync(0);
const userBefore = hoisted.todosList.mock.calls.filter(c => c[0] === 'user-tasks').length;
const sourceBefore = hoisted.todosList.mock.calls.filter(c => c[0] === 'task-sources').length;
expect(userBefore).toBeGreaterThan(0);
expect(sourceBefore).toBeGreaterThan(0);

// One poll interval later, both boards are re-read (so a background poller
// run's board changes surface without a manual refresh).
await vi.advanceTimersByTimeAsync(4000);
const userAfter = hoisted.todosList.mock.calls.filter(c => c[0] === 'user-tasks').length;
const sourceAfter = hoisted.todosList.mock.calls.filter(c => c[0] === 'task-sources').length;
expect(userAfter).toBeGreaterThan(userBefore);
expect(sourceAfter).toBeGreaterThan(sourceBefore);
} finally {
vi.useRealTimers();
}
});

test('opens the composer and applies the created personal board', async () => {
vi.resetModules();
const Tab = await importTab();
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2600,6 +2600,9 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': 'إنشاء مهمة',
'intelligence.tasks.composer.creating': 'خلق...',
'intelligence.tasks.composer.createFailed': 'لا يمكن خلق المهمة',
'intelligence.tasks.composer.assignAgentLabel': 'دع وكيلًا يعمل على هذه المهمة تلقائيًا',
'intelligence.tasks.composer.assignAgentHint':
'تلتقطها لوحة المهام وتنفّذها نيابةً عنك. اتركها مغلقة للحصول على مهمة شخصية عادية.',
'intelligence.tasks.sourceList.subtitle': 'مهام المصادر التي تنتظر التحول إلى عمل للوكيل.',
'intelligence.tasks.sourceList.empty': 'لا توجد مهام مصادر في الانتظار.',
'intelligence.tasks.sourceList.queued': 'في قائمة الانتظار',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/bn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2649,6 +2649,9 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': 'কাজ তৈরি করুন ( T)',
'intelligence.tasks.composer.creating': 'তৈরি করা হচ্ছে...',
'intelligence.tasks.composer.createFailed': 'কাজ তৈরি করতে ব্যর্থ',
'intelligence.tasks.composer.assignAgentLabel': 'একজন এজেন্টকে স্বয়ংক্রিয়ভাবে এটি করতে দিন',
'intelligence.tasks.composer.assignAgentHint':
'টাস্ক বোর্ড এটি তুলে নিয়ে আপনার হয়ে চালাবে। সাধারণ ব্যক্তিগত কাজের জন্য বন্ধ রাখুন।',
'intelligence.tasks.sourceList.subtitle': 'এজেন্টের কাজে রূপান্তরের অপেক্ষায় থাকা উৎস কাজ।',
'intelligence.tasks.sourceList.empty': 'অপেক্ষায় কোনো উৎস কাজ নেই।',
'intelligence.tasks.sourceList.queued': 'সারিবদ্ধ',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2715,6 +2715,9 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': 'Aufgabe erstellen',
'intelligence.tasks.composer.creating': 'Erstellen…',
'intelligence.tasks.composer.createFailed': 'Die Aufgabe konnte nicht erstellt werden',
'intelligence.tasks.composer.assignAgentLabel': 'Einen Agenten automatisch daran arbeiten lassen',
'intelligence.tasks.composer.assignAgentHint':
'Das Aufgabenboard übernimmt sie und erledigt sie für dich. Für eine einfache persönliche Aufgabe ausgeschaltet lassen.',
'intelligence.tasks.sourceList.subtitle':
'Quellaufgaben, die in Agentenarbeit umgewandelt werden sollen.',
'intelligence.tasks.sourceList.empty': 'Keine Quellaufgaben in der Warteschlange.',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3035,6 +3035,9 @@ const en: TranslationMap = {
'intelligence.tasks.composer.create': 'Create task',
'intelligence.tasks.composer.creating': 'Creating…',
'intelligence.tasks.composer.createFailed': "Couldn't create the task",
'intelligence.tasks.composer.assignAgentLabel': 'Let an agent work on this automatically',
'intelligence.tasks.composer.assignAgentHint':
'The task board picks it up and runs it for you. Leave off for a plain personal to-do.',
'intelligence.tasks.sourceList.subtitle': 'Source tasks waiting to become agent work.',
'intelligence.tasks.sourceList.empty': 'No source tasks waiting.',
'intelligence.tasks.sourceList.queued': 'Queued',
Expand Down
4 changes: 4 additions & 0 deletions app/src/lib/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2699,6 +2699,10 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': 'Crear tarea',
'intelligence.tasks.composer.creating': 'Creando…',
'intelligence.tasks.composer.createFailed': 'No se pudo crear la tarea',
'intelligence.tasks.composer.assignAgentLabel':
'Deja que un agente se encargue de esto automáticamente',
'intelligence.tasks.composer.assignAgentHint':
'El tablero de tareas la toma y la ejecuta por ti. Déjalo desactivado para una tarea personal simple.',
'intelligence.tasks.sourceList.subtitle':
'Tareas de fuentes esperando convertirse en trabajo del agente.',
'intelligence.tasks.sourceList.empty': 'No hay tareas de fuentes en espera.',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2708,6 +2708,9 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': 'Créer une tâche',
'intelligence.tasks.composer.creating': 'Création…',
'intelligence.tasks.composer.createFailed': 'Impossible de créer la tâche',
'intelligence.tasks.composer.assignAgentLabel': "Laisser un agent s'en charger automatiquement",
'intelligence.tasks.composer.assignAgentHint':
"Le tableau des tâches la prend en charge et l'exécute pour vous. Laissez désactivé pour une simple tâche personnelle.",
'intelligence.tasks.sourceList.subtitle':
"Tâches de sources en attente de devenir du travail d'agent.",
'intelligence.tasks.sourceList.empty': 'Aucune tâche de source en attente.',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/hi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2654,6 +2654,9 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': 'कार्य',
'intelligence.tasks.composer.creating': 'बनाना',
'intelligence.tasks.composer.createFailed': 'काम नहीं कर सका',
'intelligence.tasks.composer.assignAgentLabel': 'किसी एजेंट को इसे स्वचालित रूप से करने दें',
'intelligence.tasks.composer.assignAgentHint':
'टास्क बोर्ड इसे उठाकर आपके लिए चला देगा। सामान्य निजी कार्य के लिए इसे बंद रखें।',
'intelligence.tasks.sourceList.subtitle': 'एजेंट कार्य बनने की प्रतीक्षा कर रहे स्रोत कार्य।',
'intelligence.tasks.sourceList.empty': 'कोई स्रोत कार्य प्रतीक्षा में नहीं है।',
'intelligence.tasks.sourceList.queued': 'कतार में',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2656,6 +2656,9 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': 'Buat tugas',
'intelligence.tasks.composer.creating': 'Membuat…',
'intelligence.tasks.composer.createFailed': 'Gagal membuat tugas',
'intelligence.tasks.composer.assignAgentLabel': 'Biarkan agen mengerjakan ini secara otomatis',
'intelligence.tasks.composer.assignAgentHint':
'Papan tugas akan mengambil dan menjalankannya untuk Anda. Biarkan nonaktif untuk tugas pribadi biasa.',
'intelligence.tasks.sourceList.subtitle': 'Tugas sumber yang menunggu menjadi pekerjaan agen.',
'intelligence.tasks.sourceList.empty': 'Tidak ada tugas sumber yang menunggu.',
'intelligence.tasks.sourceList.queued': 'Dalam antrean',
Expand Down
4 changes: 4 additions & 0 deletions app/src/lib/i18n/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2689,6 +2689,10 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': 'Crea attività',
'intelligence.tasks.composer.creating': 'Creando…',
'intelligence.tasks.composer.createFailed': 'Impossibile creare il task',
'intelligence.tasks.composer.assignAgentLabel':
'Lascia che un agente se ne occupi automaticamente',
'intelligence.tasks.composer.assignAgentHint':
'La bacheca delle attività la prende in carico e la esegue per te. Lascia disattivato per una semplice attività personale.',
'intelligence.tasks.sourceList.subtitle':
"Attività dalle fonti in attesa di diventare lavoro dell'agente.",
'intelligence.tasks.sourceList.empty': 'Nessuna attività da fonti in attesa.',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2629,6 +2629,9 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': '작업 만들기',
'intelligence.tasks.composer.creating': '만드는 중…',
'intelligence.tasks.composer.createFailed': '작업을 만들 수 없습니다',
'intelligence.tasks.composer.assignAgentLabel': '에이전트가 이 작업을 자동으로 처리하도록 하기',
'intelligence.tasks.composer.assignAgentHint':
'작업 보드가 이를 가져와 대신 실행합니다. 일반 개인 할 일은 꺼 두세요.',
'intelligence.tasks.sourceList.subtitle': '에이전트 작업으로 전환될 소스 작업입니다.',
'intelligence.tasks.sourceList.empty': '대기 중인 소스 작업이 없습니다.',
'intelligence.tasks.sourceList.queued': '대기열에 추가됨',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2686,6 +2686,9 @@ const messages: TranslationMap = {
'intelligence.tasks.composer.create': 'Utwórz zadanie',
'intelligence.tasks.composer.creating': 'Tworzenie…',
'intelligence.tasks.composer.createFailed': 'Nie udało się utworzyć zadania',
'intelligence.tasks.composer.assignAgentLabel': 'Pozwól agentowi zająć się tym automatycznie',
'intelligence.tasks.composer.assignAgentHint':
'Tablica zadań przejmie je i wykona za Ciebie. Wyłącz dla zwykłego zadania osobistego.',
'intelligence.tasks.sourceList.subtitle':
'Zadania ze źródeł czekające na przekształcenie w pracę agenta.',
'intelligence.tasks.sourceList.empty': 'Brak oczekujących zadań ze źródeł.',
Expand Down
Loading
Loading