Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
eed676e
feat(task-sources): enrich ingested cards with brief fields + source_…
sanil-23 May 29, 2026
bad5b66
feat(agent): task dispatcher — autonomous runner, board poller, board…
sanil-23 May 29, 2026
e0e0ae0
feat(agent): plan-approval gate for task dispatch (G8)
sanil-23 May 29, 2026
aba1daf
feat(app): task-plan approval surface on the kanban board (G8 FE)
sanil-23 May 29, 2026
d3af38e
feat(agent): resolve assigned executor — personality / skill / agent …
sanil-23 May 29, 2026
17db8c0
feat(task-sources): static executor routing — assigned_executor (G7)
sanil-23 May 29, 2026
e6815b0
feat(agent): agent-driven external write-back + memory recall pointer…
sanil-23 May 29, 2026
f46947b
fix(agent): address review — claim CAS, threat-model note, tests, nits
sanil-23 May 29, 2026
b80e88a
test(app): cover task-plan approval FE for the diff-coverage gate
sanil-23 May 29, 2026
55251dc
Merge branch 'main' into pr/2974
senamakel May 29, 2026
e7902e0
fix(triage): gate dropped/acknowledged task-cards so the poller skips…
senamakel May 29, 2026
b8a4244
fix(task-sources): include url in content_hash so URL-only edits re-i…
senamakel May 29, 2026
c00ac5a
test(task-sources): cover add_card objective/assigned_agent/source_me…
senamakel May 29, 2026
14830b2
fix(app): task brief status select covers approval-flow statuses
senamakel May 29, 2026
e2e6666
test(agent): cover resolve_executor personality branch
senamakel May 29, 2026
5d88c1d
fix: address CodeRabbit review on the proactive pipeline
sanil-23 May 29, 2026
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
3 changes: 3 additions & 0 deletions app/src/lib/i18n/chunks/ar-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,9 @@ const ar5: TranslationMap = {
'skills.uninstall.confirmTitle': 'إلغاء تثبيت {name}؟',
'conversations.taskKanban.blocked': 'محظور',
'conversations.taskKanban.done': 'مكتمل',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
'conversations.taskKanban.inProgress': 'قيد التنفيذ',
'intelligence.memoryChunk.detail.copiedHint': 'تم النسخ',
'settings.composio.notYetRouted': 'لم يتم توجيهه بعد',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/chunks/bn-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,9 @@ const bn5: TranslationMap = {
'skills.uninstall.confirmTitle': '{name} আনইনস্টল করবেন?',
'conversations.taskKanban.blocked': 'ব্লকড',
'conversations.taskKanban.done': 'সম্পন্ন',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
'conversations.taskKanban.inProgress': 'চলমান',
'intelligence.memoryChunk.detail.copiedHint': 'কপি হয়েছে',
'settings.composio.notYetRouted': 'এখনও রুট করা হয়নি',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/chunks/de-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,9 @@ const de5: TranslationMap = {
'skills.uninstall.confirmTitle': '{name} deinstallieren?',
'conversations.taskKanban.blocked': 'Blockiert',
'conversations.taskKanban.done': 'Fertig',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
'conversations.taskKanban.inProgress': 'In Bearbeitung',
'intelligence.memoryChunk.detail.copiedHint': 'kopiert',
'settings.composio.notYetRouted': 'noch nicht geroutet',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/chunks/en-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,9 @@ const en5: TranslationMap = {
'skills.uninstall.confirmTitle': 'Uninstall {name}?',
'conversations.taskKanban.blocked': 'Blocked',
'conversations.taskKanban.done': 'Done',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
'conversations.taskKanban.inProgress': 'In progress',
'intelligence.memoryChunk.detail.copiedHint': 'copied',
'settings.composio.notYetRouted': 'not yet routed',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/chunks/es-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,9 @@ const es5: TranslationMap = {
'skills.uninstall.confirmTitle': '¿Desinstalar {name}?',
'conversations.taskKanban.blocked': 'Bloqueado',
'conversations.taskKanban.done': 'Completado',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
'conversations.taskKanban.inProgress': 'En progreso',
'intelligence.memoryChunk.detail.copiedHint': 'copiado',
'settings.composio.notYetRouted': 'aún sin enrutar',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/chunks/fr-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,9 @@ const fr5: TranslationMap = {
'skills.uninstall.confirmTitle': 'Désinstaller {name} ?',
'conversations.taskKanban.blocked': 'Bloqué',
'conversations.taskKanban.done': 'Terminé',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
'conversations.taskKanban.inProgress': 'En cours',
'intelligence.memoryChunk.detail.copiedHint': 'copié',
'settings.composio.notYetRouted': 'pas encore routé',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/chunks/hi-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,9 @@ const hi5: TranslationMap = {
'skills.uninstall.confirmTitle': '{name} अनइंस्टॉल करें?',
'conversations.taskKanban.blocked': 'अवरुद्ध',
'conversations.taskKanban.done': 'पूर्ण',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
'conversations.taskKanban.inProgress': 'प्रगति पर',
'intelligence.memoryChunk.detail.copiedHint': 'कॉपी हो गया',
'settings.composio.notYetRouted': 'अभी तक रूट नहीं हुआ',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/chunks/id-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,9 @@ const id5: TranslationMap = {
'skills.uninstall.confirmTitle': 'Copot {name}?',
'conversations.taskKanban.blocked': 'Terhambat',
'conversations.taskKanban.done': 'Selesai',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
'conversations.taskKanban.inProgress': 'Sedang berjalan',
'intelligence.memoryChunk.detail.copiedHint': 'disalin',
'settings.composio.notYetRouted': 'belum dirutekan',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/chunks/it-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,9 @@ const it5: TranslationMap = {
'skills.uninstall.confirmTitle': 'Disinstallare {name}?',
'conversations.taskKanban.blocked': 'Bloccato',
'conversations.taskKanban.done': 'Fatto',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
'conversations.taskKanban.inProgress': 'In corso',
'intelligence.memoryChunk.detail.copiedHint': 'copiato',
'settings.composio.notYetRouted': 'non ancora instradato',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/chunks/ko-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,9 @@ const ko5: TranslationMap = {
'skills.uninstall.confirmTitle': '{name}을(를) 제거하시겠습니까?',
'conversations.taskKanban.blocked': '차단됨',
'conversations.taskKanban.done': '완료',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
'conversations.taskKanban.inProgress': '진행 중',
'intelligence.memoryChunk.detail.copiedHint': '복사됨',
'settings.composio.notYetRouted': '아직 라우팅되지 않음',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/chunks/pl-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,9 @@ const pl5: TranslationMap = {
'skills.uninstall.confirmTitle': 'Odinstalować {name}?',
'conversations.taskKanban.blocked': 'Zablokowane',
'conversations.taskKanban.done': 'Zrobione',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
'conversations.taskKanban.inProgress': 'W toku',
'intelligence.memoryChunk.detail.copiedHint': 'skopiowano',
'settings.composio.notYetRouted': 'jeszcze nie trasowane',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/chunks/pt-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,9 @@ const pt5: TranslationMap = {
'skills.uninstall.confirmTitle': 'Desinstalar {name}?',
'conversations.taskKanban.blocked': 'Bloqueado',
'conversations.taskKanban.done': 'Concluído',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
'conversations.taskKanban.inProgress': 'Em andamento',
'intelligence.memoryChunk.detail.copiedHint': 'copiado',
'settings.composio.notYetRouted': 'ainda não roteado',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/chunks/ru-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,9 @@ const ru5: TranslationMap = {
'skills.uninstall.confirmTitle': 'Удалить {name}?',
'conversations.taskKanban.blocked': 'Заблокировано',
'conversations.taskKanban.done': 'Готово',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
'conversations.taskKanban.inProgress': 'В работе',
'intelligence.memoryChunk.detail.copiedHint': 'скопировано',
'settings.composio.notYetRouted': 'пока не маршрутизируется',
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/i18n/chunks/zh-CN-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,9 @@ const zhCN5: TranslationMap = {
'skills.uninstall.confirmTitle': '卸载 {name}?',
'conversations.taskKanban.blocked': '已阻塞',
'conversations.taskKanban.done': '已完成',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
'conversations.taskKanban.inProgress': '进行中',
'intelligence.memoryChunk.detail.copiedHint': '已复制',
'settings.composio.notYetRouted': '尚未路由',
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 @@ -4025,6 +4025,9 @@ const en: TranslationMap = {
'skills.uninstall.confirmTitle': 'Uninstall {name}?',
'conversations.taskKanban.blocked': 'Blocked',
'conversations.taskKanban.done': 'Done',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
'conversations.taskKanban.inProgress': 'In progress',
'intelligence.memoryChunk.detail.copiedHint': 'copied',
'settings.composio.notYetRouted': 'not yet routed',
Expand Down
11 changes: 11 additions & 0 deletions app/src/pages/Conversations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import {
getComposerBlockedSendFeedback,
handleComposerSlashCommand,
} from './conversations/composerSendDecision';
import { runDecidePlan } from './conversations/taskPlanActions';
import {
type AgentBubblePosition,
buildAcceptedInlineCompletion,
Expand Down Expand Up @@ -1627,6 +1628,16 @@ const Conversations = ({
onUpdateCard={(card, nextCard) => {
void handleUpdateTaskCard(card, nextCard);
}}
onDecidePlan={(card, approve) => {
void runDecidePlan({
threadId: selectedThreadId,
card,
approve,
dispatch,
notify: setSendAdvisory,
t,
});
}}
/>
)}
{visibleMessages.map(msg => (
Expand Down
78 changes: 78 additions & 0 deletions app/src/pages/conversations/components/TaskKanbanBoard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import type { TaskBoard, TaskBoardCard } from '../../../types/turnState';
import { TaskKanbanBoard } from './TaskKanbanBoard';

// Echo i18n keys so we can query by the stable key strings.
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));

function card(partial: Partial<TaskBoardCard>): TaskBoardCard {
return {
id: 'c1',
title: 'Do thing',
status: 'todo',
order: 0,
updatedAt: '',
...partial,
} as TaskBoardCard;
}

function board(cards: TaskBoardCard[]): TaskBoard {
return { threadId: 't1', cards, updatedAt: '' };
}

describe('TaskKanbanBoard approval surface', () => {
it('renders Approve/Reject on an awaiting_approval card and calls onDecidePlan', () => {
const onDecidePlan = vi.fn();
render(
<TaskKanbanBoard
board={board([card({ id: 'a', status: 'awaiting_approval', title: 'Needs approval' })])}
onDecidePlan={onDecidePlan}
/>
);

fireEvent.click(screen.getByTitle('chat.approval.approve'));
expect(onDecidePlan).toHaveBeenCalledWith(expect.objectContaining({ id: 'a' }), true);

fireEvent.click(screen.getByTitle('chat.approval.deny'));
expect(onDecidePlan).toHaveBeenCalledWith(expect.objectContaining({ id: 'a' }), false);
});

it('buckets ready→todo and rejected→blocked columns so the cards still render', () => {
render(
<TaskKanbanBoard
board={board([
card({ id: 'r', status: 'ready', title: 'Ready card' }),
card({ id: 'x', status: 'rejected', title: 'Rejected card' }),
])}
/>
);

expect(screen.getByText('Ready card')).toBeInTheDocument();
expect(screen.getByText('Rejected card')).toBeInTheDocument();
// An approval-flow card without onDecidePlan shows no approve/reject controls.
expect(screen.queryByTitle('chat.approval.approve')).toBeNull();
});

it('edit dialog status select has a matching option for approval-flow statuses', () => {
// Regression: the dialog <select> must carry an <option> for every status,
// not just the four column statuses — otherwise an awaiting_approval card
// renders a controlled select with no matching option (React warns and the
// value silently shows as the first option, hiding the real status).
render(
<TaskKanbanBoard
board={board([card({ id: 'a', status: 'awaiting_approval', title: 'Needs approval' })])}
onUpdateCard={vi.fn()}
/>
);

fireEvent.click(screen.getByText('conversations.taskKanban.briefButton'));

// The status select shows the awaiting_approval label as its selected
// value, proving a matching option exists (no fallback to 'todo').
expect(
screen.getByDisplayValue('conversations.taskKanban.awaitingApproval')
).toBeInTheDocument();
});
});
73 changes: 67 additions & 6 deletions app/src/pages/conversations/components/TaskKanbanBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,60 @@ const COLUMN_DEFS: ColumnDef[] = [

const STATUS_INDEX = new Map(COLUMN_DEFS.map((column, index) => [column.status, index]));

/** Label key for *every* status, including the approval-flow statuses that
* don't own a kanban column. Drives the edit dialog's status `<select>` so a
* card whose status is `awaiting_approval`/`ready`/`rejected` renders a
* matching option instead of a controlled-select value with no option (which
* React warns about and which renders as the first option, hiding the real
* status from the user). */
const STATUS_LABEL_KEYS: Record<TaskBoardCardStatus, string> = {
todo: 'conversations.taskKanban.todo',
awaiting_approval: 'conversations.taskKanban.awaitingApproval',
ready: 'conversations.taskKanban.ready',
in_progress: 'conversations.taskKanban.inProgress',
blocked: 'conversations.taskKanban.blocked',
done: 'conversations.taskKanban.done',
rejected: 'conversations.taskKanban.rejected',
};

const ALL_STATUSES = Object.keys(STATUS_LABEL_KEYS) as TaskBoardCardStatus[];

/** Whether a status owns a kanban column (vs the approval-flow statuses that
* are bucketed into an existing column). */
function isColumnStatus(status: TaskBoardCardStatus): boolean {
return STATUS_INDEX.has(status);
}

/** Map a card status to the column it renders under. The approval-flow
* statuses don't get their own columns: pre-execution ones sit in `todo`,
* `rejected` sits with `blocked`. */
function columnFor(status: TaskBoardCardStatus): TaskBoardCardStatus {
switch (status) {
case 'awaiting_approval':
case 'ready':
return 'todo';
case 'rejected':
return 'blocked';
default:
return status;
}
}

interface TaskKanbanBoardProps {
board: TaskBoard;
disabled?: boolean;
onMove?: (card: TaskBoardCard, status: TaskBoardCardStatus) => void;
onUpdateCard?: (card: TaskBoardCard, nextCard: TaskBoardCard) => void;
/** Approve/reject a card awaiting plan approval. */
onDecidePlan?: (card: TaskBoardCard, approve: boolean) => void;
}

export function TaskKanbanBoard({
board,
disabled = false,
onMove,
onUpdateCard,
onDecidePlan,
}: TaskKanbanBoardProps) {
const { t } = useT();
const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
Expand All @@ -55,7 +97,7 @@ export function TaskKanbanBoard({
);

for (const card of [...board.cards].sort((a, b) => a.order - b.order)) {
cardsByStatus[card.status]?.push(card);
cardsByStatus[columnFor(card.status)]?.push(card);
}

const moveCard = (card: TaskBoardCard, direction: -1 | 1) => {
Expand Down Expand Up @@ -97,7 +139,26 @@ export function TaskKanbanBoard({
<p className="min-w-0 flex-1 break-words text-xs font-medium leading-snug text-stone-800 dark:text-neutral-100">
{card.title}
</p>
{onMove && (
{card.status === 'awaiting_approval' && onDecidePlan ? (
<div className="flex flex-shrink-0 items-center gap-1">
<button
type="button"
title={t('chat.approval.approve')}
disabled={disabled}
onClick={() => onDecidePlan(card, true)}
className="rounded-md bg-ocean-600 px-1.5 py-0.5 text-[10px] font-medium text-white transition-colors hover:bg-ocean-700 disabled:opacity-40">
{t('chat.approval.approve')}
</button>
<button
type="button"
title={t('chat.approval.deny')}
disabled={disabled}
onClick={() => onDecidePlan(card, false)}
className="rounded-md border border-stone-200 px-1.5 py-0.5 text-[10px] font-medium text-stone-600 transition-colors hover:bg-stone-100 disabled:opacity-40 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800">
{t('chat.approval.deny')}
</button>
</div>
) : onMove && isColumnStatus(card.status) ? (
<div className="flex flex-shrink-0 items-center gap-0.5">
<button
type="button"
Expand All @@ -118,7 +179,7 @@ export function TaskKanbanBoard({
<LuArrowRight className="h-3 w-3" />
</button>
</div>
)}
) : null}
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{card.assignedAgent && (
Expand Down Expand Up @@ -286,9 +347,9 @@ function TaskBriefDialog({
value={status}
onChange={e => setStatus(e.target.value as TaskBoardCardStatus)}
className="w-full rounded-md border border-stone-200 bg-white px-2 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-50">
{COLUMN_DEFS.map(column => (
<option key={column.status} value={column.status}>
{t(column.labelKey)}
{ALL_STATUSES.map(s => (
<option key={s} value={s}>
{t(STATUS_LABEL_KEYS[s])}
</option>
))}
</select>
Expand Down
Loading
Loading