feat(task-board): surface all statuses + drag-and-drop on the Kanban board (#3399)

Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com>
This commit is contained in:
Cyrus Gray
2026-06-05 18:45:45 -04:00
committed by GitHub
co-authored by Steven Enamakel
parent a7516a83bd
commit 1d2b6a7d06
22 changed files with 576 additions and 128 deletions
+5 -9
View File
@@ -301,16 +301,12 @@ Quick reference for anyone starting with Claude on this project. Updated by the
- **Upstream `main` has 5 Vitest failures and 4 TypeScript compile errors** — Caused by missing iOS experimental dependencies: `@noble/ciphers/chacha`, `@noble/ciphers/webcrypto`, `qrcode.react`, `@tauri-apps/plugin-barcode-scanner`. Breaks `pnpm compile`, `pnpm build`, `pnpm test:coverage` on a clean checkout. Always verify by stashing changes and running checks on the base branch before blaming your PR.
- **`cargo fmt` must run after codecrusher** — codecrusher does not reliably produce `cargo fmt`-clean Rust. Always run `cargo fmt --manifest-path Cargo.toml` after codecrusher finishes and before committing.
## Memory Source Sync Indicators (Issue #3295)
## TaskKanbanBoard (Issue #3347 — frontend-only subset)
- **`MemorySyncStageChanged` carries two distinct ids** — `connection_id` is the ingest-pipeline document_id (identity for dedup/audit); `source_id: Option<String>` is the memory-source row id used by the UI to match per-source progress. Never conflate them. Frontend (`MemorySourcesRegistry.tsx`) matches on `source_id ?? connection_id` for backward compat.
- **Chunk source_id encoding for mem-src syncs** — folder/RSS/web-page chunks use `mem_src:<source_id>:<item_id>` (`memory_sources/sync.rs`). The `<item_id>` can contain colons (URLs), so extract `<source_id>` by splitting on the **first** colon after the `mem_src:` prefix — use `find(':')`, not `rfind`. See `extract_mem_src_id` in `src/openhuman/memory/sync.rs`.
- **`MemorySyncStageBridge` re-emits stage events from ingestion events** — In `src/openhuman/memory/sync.rs`, converts `DocumentCanonicalized`/`MemoryIngestionStarted` into `MemorySyncStageChanged` with a populated `source_id`. For non-mem-src syncs (channel providers), `source_id` stays `None` — intentional; don't force a value.
- **Disk exhaustion from e2e test compilation** — `pnpm test:rust` compiles heavy integration binaries and can fill disk (`ld: errno=28 No space left on device`). To validate domain logic only, use `cargo test -p openhuman --lib -- "memory::sync"` which skips integration test binaries.
- **Terminal sync states are event-driven, NOT RPC-ack-driven** — `memory_sources_sync` RPC returns in ~4ms after merely `tokio::spawn`-ing the sync (`sync_source` in `memory_sources/sync.rs`); the actual work (e.g. a Composio incremental sync) runs 34s in the background. So the OLD `handleSync` success toast (fired on the RPC await) was misleading — it claimed success before the sync ran. Correct outcome comes from the terminal `completed`/`failed` stage event. The UI now records a per-row `SyncResult` (success → "N items synced" / "Up to date" when 0 new; failed → "Failed: <reason>") driven off that event, and the `finally`-clear of `syncingIds` was removed so the row stays "syncing" for the real duration (terminal event clears it). `isSyncing` is `syncingIds.has(id) || syncProgress.has(id)` so the spinner tracks real progress.
- **Composio item count** — `completed` detail is `"ingested N item(s)"` (formatted in `sync_source`'s Ok arm for all kinds); frontend `parseIngestedCount` regex-parses N. A no-op incremental sync (`total_fetched=0 total_persisted=0` in `[composio:<toolkit>] incremental sync complete` logs) is success with 0 new — not a failure.
- **Internal sync failures → Sentry via `core::observability::report_error_or_expected`** — Called in `sync_source`'s Err arm with `("memory_sources","sync", [source_id, kind])`. The helper classifies via `expected_error_kind`: known-expected (auth/network/rate-limit/missing-config) are logged-not-reported; genuine bugs go to Sentry. Honors the never-suppress rule without Sentry-spamming routine user/config errors.
- **`emit_sync_stage` logs at `debug`** — With the app's default `RUST_LOG=info`, `[memory-sync] emit stage=…` lines are NOT in the log file. Absence of those lines does not mean events didn't fire — verify via the socket/UI instead.
- **5 columns, 7 statuses** — `COLUMN_DEFS` in `TaskKanbanBoard.tsx` defines columns `todo / awaiting_approval / in_progress / blocked / done`. `columnFor()` maps the two virtual statuses: `ready → in_progress` (renders a "Ready to start" sage badge) and `rejected → done` (renders a "Rejected" coral badge). Anyone adding a new `TaskBoardCardStatus` must update both `COLUMN_DEFS` and `columnFor()`.
- **Native HTML5 drag-and-drop only** — No DnD library (@dnd-kit, react-beautiful-dnd). Cards set `dataTransfer` key `application/x-task-card-id`; columns handle `onDragOver` / `onDragLeave` / `onDrop`. Arrow buttons are the touch/accessibility fallback. Do not add a DnD library.
- **`mutatingCardId` is optional on `TaskKanbanBoard`** — `IntelligenceTasksTab` sets/clears it in `finally` blocks around personal-board mutations; `Conversations.tsx` (second consumer) does not pass it. Both consumers must keep new props optional to avoid compile failures.
- **Bare `vitest run <path>` fails with "document is not defined"** — Must use `--config test/vitest.config.ts` (or `pnpm debug unit <relative-path>`) to load the jsdom environment. Piping `pnpm exec vitest run` through `| tail` also buffers all output until completion, hiding progress — use the debug runner instead.
## Memory Sync Sources — Defaults ON + Per-Source UI (Issue #3293)
@@ -142,6 +142,7 @@ export default function IntelligenceTasksTab() {
const [composerOpen, setComposerOpen] = useState(false);
const [refiningCard, setRefiningCard] = useState<TaskBoardCard | null>(null);
const [workingCardId, setWorkingCardId] = useState<string | null>(null);
const [mutatingCardId, setMutatingCardId] = useState<string | null>(null);
const [runningAgentId, setRunningAgentId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -264,8 +265,14 @@ export default function IntelligenceTasksTab() {
// ── personal-board mutations (optimistic, with rollback) ─────────────
const mutatePersonal = useCallback(
async (optimistic: TaskBoard, call: () => Promise<TaskBoard>, previous: TaskBoard) => {
async (
optimistic: TaskBoard,
call: () => Promise<TaskBoard>,
previous: TaskBoard,
cardId?: string
) => {
setActionError(null);
if (cardId && mountedRef.current) setMutatingCardId(cardId);
setPersonalBoard(optimistic);
try {
const saved = await call();
@@ -277,6 +284,8 @@ export default function IntelligenceTasksTab() {
setPersonalBoard(previous);
setActionError(t('conversations.taskKanban.updateFailed'));
}
} finally {
if (mountedRef.current) setMutatingCardId(null);
}
},
[t]
@@ -296,7 +305,8 @@ export default function IntelligenceTasksTab() {
void mutatePersonal(
optimistic,
() => todosApi.updateStatus(USER_TASKS_THREAD_ID, card.id, status),
personalBoard
personalBoard,
card.id
);
},
[personalBoard, mutatePersonal]
@@ -331,7 +341,8 @@ export default function IntelligenceTasksTab() {
acceptanceCriteria: nextCard.acceptanceCriteria ?? [],
evidence: nextCard.evidence ?? [],
}),
personalBoard
personalBoard,
card.id
);
},
[personalBoard, mutatePersonal]
@@ -348,7 +359,8 @@ export default function IntelligenceTasksTab() {
void mutatePersonal(
optimistic,
() => todosApi.remove(USER_TASKS_THREAD_ID, card.id),
personalBoard
personalBoard,
card.id
);
},
[personalBoard, mutatePersonal]
@@ -570,7 +582,8 @@ export default function IntelligenceTasksTab() {
const liveBoard = liveBoards[threadId];
const persistedBoard = persistedBoards[threadId];
const board = liveBoard ?? persistedBoard;
if (!board || board.cards.length === 0) continue;
// Show live agent boards even with 0 cards so users can see activity in progress.
if (!board || (board.cards.length === 0 && !liveBoard)) continue;
const title =
thread?.title && thread.title.trim().length > 0
? thread.title
@@ -584,7 +597,8 @@ export default function IntelligenceTasksTab() {
return b.board.updatedAt.localeCompare(a.board.updatedAt);
});
const personalCards = personalBoard?.cards ?? [];
// personalCards kept for reference — board always rendered now, empty state handled inside TaskKanbanBoard
// const personalCards = personalBoard?.cards ?? [];
return (
<div className="space-y-6">
@@ -616,46 +630,34 @@ export default function IntelligenceTasksTab() {
{t('intelligence.tasks.personalBoardTitle')}
</h3>
</div>
{personalCards.length > 0 ? (
<TaskKanbanBoard
board={personalBoard as TaskBoard}
hideHeader
onMove={handleMovePersonal}
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}
/>
) : (
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-stone-200 dark:border-neutral-800 py-8 text-center text-stone-400 dark:text-neutral-500">
<p className="text-sm font-medium">{t('intelligence.tasks.personalEmpty')}</p>
<button
type="button"
onClick={() => setComposerOpen(true)}
className="inline-flex items-center gap-1.5 text-xs font-medium text-ocean-600 hover:text-ocean-700 dark:text-ocean-300 dark:hover:text-ocean-200">
<LuPlus className="h-3.5 w-3.5" />
{t('intelligence.tasks.newTask')}
</button>
</div>
)}
<TaskKanbanBoard
board={personalBoard ?? { threadId: USER_TASKS_THREAD_ID, cards: [], updatedAt: '' }}
hideHeader
onMove={handleMovePersonal}
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}
mutatingCardId={mutatingCardId}
/>
</section>
{taskSourcesBoard && (
@@ -17,11 +17,15 @@ import type { TaskBoard, TaskBoardCardStatus } from '../../types/turnState';
const log = debug('intelligence:task-composer');
// Tasks use three states only: Pending / Working / Done.
// All user-facing task statuses available in the composer.
const STATUS_OPTIONS: { value: TaskBoardCardStatus; labelKey: string }[] = [
{ value: 'todo', labelKey: 'conversations.taskKanban.pending' },
{ value: 'awaiting_approval', labelKey: 'conversations.taskKanban.awaitingApproval' },
{ value: 'ready', labelKey: 'conversations.taskKanban.ready' },
{ value: 'in_progress', labelKey: 'conversations.taskKanban.working' },
{ value: 'blocked', labelKey: 'conversations.taskKanban.blocked' },
{ value: 'done', labelKey: 'conversations.taskKanban.done' },
{ value: 'rejected', labelKey: 'conversations.taskKanban.rejected' },
];
interface UserTaskComposerProps {
@@ -276,12 +276,13 @@ describe('IntelligenceTasksTab', () => {
});
});
test('always shows the personal board with an empty-state CTA', async () => {
test('always shows the personal board (even when empty) and the new-task button', async () => {
vi.resetModules();
const Tab = await importTab();
renderTab(Tab);
await waitFor(() => {
expect(screen.getByText('No personal tasks yet')).toBeInTheDocument();
// The personal board kanban stub is always rendered — the threadId is displayed
expect(screen.getByText('user-tasks')).toBeInTheDocument();
});
expect(screen.getByText('Agent Tasks')).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: /New task/ }).length).toBeGreaterThan(0);
+10 -1
View File
@@ -4263,8 +4263,17 @@ const messages: TranslationMap = {
'conversations.taskKanban.working': 'العمل',
'conversations.taskKanban.awaitingApproval': 'في انتظار الموافقة',
'conversations.taskKanban.ready': 'جاهز',
'conversations.taskKanban.rejected': 'Rejected',
'conversations.taskKanban.rejected': 'مرفوض',
'conversations.taskKanban.inProgress': 'قيد التنفيذ',
'conversations.taskKanban.awaitingApprovalColumn': 'في انتظار الموافقة',
'conversations.taskKanban.blockedColumn': 'محظور',
'conversations.taskKanban.evidenceBadge': 'الأدلة ({count})',
'conversations.taskKanban.needsInput': 'هذه المهام تحتاج إلى مدخلاتك',
'conversations.taskKanban.emptyColumn': 'لا توجد مهام',
'conversations.taskKanban.dragHint': 'اسحب للتنقل بين الأعمدة',
'conversations.taskKanban.movingCard': 'جارٍ النقل…',
'conversations.taskKanban.statusBadge.ready': 'جاهز للبدء',
'conversations.taskKanban.statusBadge.rejected': 'مرفوض',
'intelligence.memoryChunk.detail.copiedHint': 'تم النسخ',
'settings.composio.notYetRouted': 'لم يتم توجيهه بعد',
'settings.localModel.download.manageExternal':
+9
View File
@@ -4343,6 +4343,15 @@ const messages: TranslationMap = {
'conversations.taskKanban.ready': 'প্রস্তুত',
'conversations.taskKanban.rejected': 'প্রত্যাখ্যান করা হয়েছে',
'conversations.taskKanban.inProgress': 'চলমান',
'conversations.taskKanban.awaitingApprovalColumn': 'অনুমোদনের অপেক্ষায়',
'conversations.taskKanban.blockedColumn': 'বাধাপ্রাপ্ত',
'conversations.taskKanban.evidenceBadge': 'প্রমাণ ({count})',
'conversations.taskKanban.needsInput': 'এই কাজগুলোর জন্য আপনার মতামত প্রয়োজন',
'conversations.taskKanban.emptyColumn': 'কোনো কাজ নেই',
'conversations.taskKanban.dragHint': 'কলামের মধ্যে সরাতে টেনে আনুন',
'conversations.taskKanban.movingCard': 'সরানো হচ্ছে…',
'conversations.taskKanban.statusBadge.ready': 'শুরু করতে প্রস্তুত',
'conversations.taskKanban.statusBadge.rejected': 'প্রত্যাখ্যাত',
'intelligence.memoryChunk.detail.copiedHint': 'কপি হয়েছে',
'settings.composio.notYetRouted': 'এখনও রুট করা হয়নি',
'settings.localModel.download.manageExternal': 'আপনার বাহ্যিক রানটাইমে এই মডেলটি পরিচালনা করুন।',
+9
View File
@@ -4457,6 +4457,15 @@ const messages: TranslationMap = {
'conversations.taskKanban.ready': 'Bereit',
'conversations.taskKanban.rejected': 'Abgelehnt',
'conversations.taskKanban.inProgress': 'In Bearbeitung',
'conversations.taskKanban.awaitingApprovalColumn': 'Wartet auf Genehmigung',
'conversations.taskKanban.blockedColumn': 'Blockiert',
'conversations.taskKanban.evidenceBadge': 'Belege ({count})',
'conversations.taskKanban.needsInput': 'Diese Aufgaben erfordern Ihre Eingabe',
'conversations.taskKanban.emptyColumn': 'Keine Aufgaben',
'conversations.taskKanban.dragHint': 'Ziehen zum Verschieben zwischen Spalten',
'conversations.taskKanban.movingCard': 'Wird verschoben…',
'conversations.taskKanban.statusBadge.ready': 'Bereit zum Start',
'conversations.taskKanban.statusBadge.rejected': 'Abgelehnt',
'intelligence.memoryChunk.detail.copiedHint': 'kopiert',
'settings.composio.notYetRouted': 'noch nicht geroutet',
'settings.localModel.download.manageExternal':
+9
View File
@@ -4876,6 +4876,15 @@ const en: TranslationMap = {
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
'conversations.taskKanban.inProgress': 'In progress',
'conversations.taskKanban.awaitingApprovalColumn': 'Awaiting Approval',
'conversations.taskKanban.blockedColumn': 'Blocked',
'conversations.taskKanban.evidenceBadge': 'Evidence ({count})',
'conversations.taskKanban.needsInput': 'These tasks need your input',
'conversations.taskKanban.emptyColumn': 'No tasks',
'conversations.taskKanban.dragHint': 'Drag to move between columns',
'conversations.taskKanban.movingCard': 'Moving…',
'conversations.taskKanban.statusBadge.ready': 'Ready to start',
'conversations.taskKanban.statusBadge.rejected': 'Rejected',
'intelligence.memoryChunk.detail.copiedHint': 'copied',
'settings.composio.notYetRouted': 'not yet routed',
'settings.localModel.download.manageExternal': 'Manage this model in your external runtime.',
+9
View File
@@ -4426,6 +4426,15 @@ const messages: TranslationMap = {
'conversations.taskKanban.ready': 'Listo',
'conversations.taskKanban.rejected': 'Rechazado',
'conversations.taskKanban.inProgress': 'En progreso',
'conversations.taskKanban.awaitingApprovalColumn': 'En espera de aprobación',
'conversations.taskKanban.blockedColumn': 'Bloqueado',
'conversations.taskKanban.evidenceBadge': 'Evidencia ({count})',
'conversations.taskKanban.needsInput': 'Estas tareas necesitan tu participación',
'conversations.taskKanban.emptyColumn': 'Sin tareas',
'conversations.taskKanban.dragHint': 'Arrastra para mover entre columnas',
'conversations.taskKanban.movingCard': 'Moviendo…',
'conversations.taskKanban.statusBadge.ready': 'Listo para empezar',
'conversations.taskKanban.statusBadge.rejected': 'Rechazado',
'intelligence.memoryChunk.detail.copiedHint': 'copiado',
'settings.composio.notYetRouted': 'aún sin enrutar',
'settings.localModel.download.manageExternal': 'Gestiona este modelo en tu runtime externo.',
+9
View File
@@ -4440,6 +4440,15 @@ const messages: TranslationMap = {
'conversations.taskKanban.ready': 'Prêt',
'conversations.taskKanban.rejected': 'rejeté',
'conversations.taskKanban.inProgress': 'En cours',
'conversations.taskKanban.awaitingApprovalColumn': "En attente d'approbation",
'conversations.taskKanban.blockedColumn': 'Bloqué',
'conversations.taskKanban.evidenceBadge': 'Preuves ({count})',
'conversations.taskKanban.needsInput': 'Ces tâches nécessitent votre intervention',
'conversations.taskKanban.emptyColumn': 'Aucune tâche',
'conversations.taskKanban.dragHint': 'Glisser pour déplacer entre les colonnes',
'conversations.taskKanban.movingCard': 'Déplacement…',
'conversations.taskKanban.statusBadge.ready': 'Prêt à démarrer',
'conversations.taskKanban.statusBadge.rejected': 'Rejeté',
'intelligence.memoryChunk.detail.copiedHint': 'copié',
'settings.composio.notYetRouted': 'pas encore routé',
'settings.localModel.download.manageExternal': 'Gérez ce modèle dans votre runtime externe.',
+9
View File
@@ -4353,6 +4353,15 @@ const messages: TranslationMap = {
'conversations.taskKanban.ready': 'रेडी',
'conversations.taskKanban.rejected': 'अस्वीकार',
'conversations.taskKanban.inProgress': 'प्रगति पर',
'conversations.taskKanban.awaitingApprovalColumn': 'अनुमोदन की प्रतीक्षा में',
'conversations.taskKanban.blockedColumn': 'अवरुद्ध',
'conversations.taskKanban.evidenceBadge': 'साक्ष्य ({count})',
'conversations.taskKanban.needsInput': 'इन कार्यों के लिए आपके इनपुट की आवश्यकता है',
'conversations.taskKanban.emptyColumn': 'कोई कार्य नहीं',
'conversations.taskKanban.dragHint': 'कॉलम के बीच स्थानांतरित करने के लिए खींचें',
'conversations.taskKanban.movingCard': 'स्थानांतरित हो रहा है…',
'conversations.taskKanban.statusBadge.ready': 'शुरू करने के लिए तैयार',
'conversations.taskKanban.statusBadge.rejected': 'अस्वीकृत',
'intelligence.memoryChunk.detail.copiedHint': 'कॉपी हो गया',
'settings.composio.notYetRouted': 'अभी तक रूट नहीं हुआ',
'settings.localModel.download.manageExternal': 'इस मॉडल को अपने बाहरी रनटाइम में प्रबंधित करें।',
+9
View File
@@ -4362,6 +4362,15 @@ const messages: TranslationMap = {
'conversations.taskKanban.ready': 'Siap',
'conversations.taskKanban.rejected': 'Ditolak',
'conversations.taskKanban.inProgress': 'Sedang berjalan',
'conversations.taskKanban.awaitingApprovalColumn': 'Menunggu Persetujuan',
'conversations.taskKanban.blockedColumn': 'Terhambat',
'conversations.taskKanban.evidenceBadge': 'Bukti ({count})',
'conversations.taskKanban.needsInput': 'Tugas-tugas ini membutuhkan masukan Anda',
'conversations.taskKanban.emptyColumn': 'Tidak ada tugas',
'conversations.taskKanban.dragHint': 'Seret untuk memindahkan antar kolom',
'conversations.taskKanban.movingCard': 'Memindahkan…',
'conversations.taskKanban.statusBadge.ready': 'Siap dimulai',
'conversations.taskKanban.statusBadge.rejected': 'Ditolak',
'intelligence.memoryChunk.detail.copiedHint': 'disalin',
'settings.composio.notYetRouted': 'belum dirutekan',
'settings.localModel.download.manageExternal': 'Kelola model ini di runtime eksternal Anda.',
+9
View File
@@ -4417,6 +4417,15 @@ const messages: TranslationMap = {
'conversations.taskKanban.ready': 'Pronto',
'conversations.taskKanban.rejected': 'Rifiutato',
'conversations.taskKanban.inProgress': 'In corso',
'conversations.taskKanban.awaitingApprovalColumn': 'In attesa di approvazione',
'conversations.taskKanban.blockedColumn': 'Bloccato',
'conversations.taskKanban.evidenceBadge': 'Prove ({count})',
'conversations.taskKanban.needsInput': 'Queste attività richiedono il tuo contributo',
'conversations.taskKanban.emptyColumn': 'Nessuna attività',
'conversations.taskKanban.dragHint': 'Trascina per spostare tra le colonne',
'conversations.taskKanban.movingCard': 'Spostamento…',
'conversations.taskKanban.statusBadge.ready': 'Pronto per iniziare',
'conversations.taskKanban.statusBadge.rejected': 'Rifiutato',
'intelligence.memoryChunk.detail.copiedHint': 'copiato',
'settings.composio.notYetRouted': 'non ancora instradato',
'settings.localModel.download.manageExternal': 'Gestisci questo modello nel tuo runtime esterno.',
+9
View File
@@ -4307,6 +4307,15 @@ const messages: TranslationMap = {
'conversations.taskKanban.ready': '준비됨',
'conversations.taskKanban.rejected': '거부됨',
'conversations.taskKanban.inProgress': '진행 중',
'conversations.taskKanban.awaitingApprovalColumn': '승인 대기 중',
'conversations.taskKanban.blockedColumn': '차단됨',
'conversations.taskKanban.evidenceBadge': '증거 ({count})',
'conversations.taskKanban.needsInput': '이 작업들은 귀하의 입력이 필요합니다',
'conversations.taskKanban.emptyColumn': '작업 없음',
'conversations.taskKanban.dragHint': '열 사이를 이동하려면 끌어다 놓으세요',
'conversations.taskKanban.movingCard': '이동 중…',
'conversations.taskKanban.statusBadge.ready': '시작 준비됨',
'conversations.taskKanban.statusBadge.rejected': '거부됨',
'intelligence.memoryChunk.detail.copiedHint': '복사됨',
'settings.composio.notYetRouted': '아직 라우팅되지 않음',
'settings.localModel.download.manageExternal': '외부 런타임에서 이 모델을 관리하세요.',
+9
View File
@@ -4418,6 +4418,15 @@ const messages: TranslationMap = {
'conversations.taskKanban.ready': 'Gotowe',
'conversations.taskKanban.rejected': 'Odrzucone',
'conversations.taskKanban.inProgress': 'W toku',
'conversations.taskKanban.awaitingApprovalColumn': 'Oczekuje na zatwierdzenie',
'conversations.taskKanban.blockedColumn': 'Zablokowane',
'conversations.taskKanban.evidenceBadge': 'Dowody ({count})',
'conversations.taskKanban.needsInput': 'Te zadania wymagają Twojego wkładu',
'conversations.taskKanban.emptyColumn': 'Brak zadań',
'conversations.taskKanban.dragHint': 'Przeciągnij, aby przenieść między kolumnami',
'conversations.taskKanban.movingCard': 'Przenoszenie…',
'conversations.taskKanban.statusBadge.ready': 'Gotowe do rozpoczęcia',
'conversations.taskKanban.statusBadge.rejected': 'Odrzucone',
'intelligence.memoryChunk.detail.copiedHint': 'skopiowano',
'settings.composio.notYetRouted': 'jeszcze nie trasowane',
'settings.localModel.download.manageExternal':
+9
View File
@@ -4415,6 +4415,15 @@ const messages: TranslationMap = {
'conversations.taskKanban.ready': 'Pronto',
'conversations.taskKanban.rejected': 'Rejeitado',
'conversations.taskKanban.inProgress': 'Em andamento',
'conversations.taskKanban.awaitingApprovalColumn': 'Aguardando aprovação',
'conversations.taskKanban.blockedColumn': 'Bloqueado',
'conversations.taskKanban.evidenceBadge': 'Evidência ({count})',
'conversations.taskKanban.needsInput': 'Estas tarefas precisam da sua participação',
'conversations.taskKanban.emptyColumn': 'Sem tarefas',
'conversations.taskKanban.dragHint': 'Arraste para mover entre colunas',
'conversations.taskKanban.movingCard': 'Movendo…',
'conversations.taskKanban.statusBadge.ready': 'Pronto para começar',
'conversations.taskKanban.statusBadge.rejected': 'Rejeitado',
'intelligence.memoryChunk.detail.copiedHint': 'copiado',
'settings.composio.notYetRouted': 'ainda não roteado',
'settings.localModel.download.manageExternal': 'Gerencie este modelo no seu runtime externo.',
+9
View File
@@ -4380,6 +4380,15 @@ const messages: TranslationMap = {
'conversations.taskKanban.ready': 'Готовый',
'conversations.taskKanban.rejected': 'Отклоненный',
'conversations.taskKanban.inProgress': 'В работе',
'conversations.taskKanban.awaitingApprovalColumn': 'Ожидает одобрения',
'conversations.taskKanban.blockedColumn': 'Заблокировано',
'conversations.taskKanban.evidenceBadge': 'Доказательства ({count})',
'conversations.taskKanban.needsInput': 'Эти задачи требуют вашего участия',
'conversations.taskKanban.emptyColumn': 'Нет задач',
'conversations.taskKanban.dragHint': 'Перетащите для перемещения между колонками',
'conversations.taskKanban.movingCard': 'Перемещение…',
'conversations.taskKanban.statusBadge.ready': 'Готово к запуску',
'conversations.taskKanban.statusBadge.rejected': 'Отклонено',
'intelligence.memoryChunk.detail.copiedHint': 'скопировано',
'settings.composio.notYetRouted': 'пока не маршрутизируется',
'settings.localModel.download.manageExternal':
+9
View File
@@ -4139,6 +4139,15 @@ const messages: TranslationMap = {
'conversations.taskKanban.ready': '就绪',
'conversations.taskKanban.rejected': '已拒绝',
'conversations.taskKanban.inProgress': '进行中',
'conversations.taskKanban.awaitingApprovalColumn': '等待审批',
'conversations.taskKanban.blockedColumn': '已阻塞',
'conversations.taskKanban.evidenceBadge': '证据({count}',
'conversations.taskKanban.needsInput': '这些任务需要您的参与',
'conversations.taskKanban.emptyColumn': '暂无任务',
'conversations.taskKanban.dragHint': '拖动以在列之间移动',
'conversations.taskKanban.movingCard': '移动中…',
'conversations.taskKanban.statusBadge.ready': '准备开始',
'conversations.taskKanban.statusBadge.rejected': '已拒绝',
'intelligence.memoryChunk.detail.copiedHint': '已复制',
'settings.composio.notYetRouted': '尚未路由',
'settings.localModel.download.manageExternal': '在外部运行时中管理此模型。',
@@ -1083,9 +1083,12 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
screen.getByText('Could not update task; changes were not saved.')
).toBeInTheDocument();
});
// With the 5-column model, todo → right → awaiting_approval (not in_progress)
expect(threadApi.putTaskBoard).toHaveBeenCalledWith(
'board-thread',
expect.arrayContaining([expect.objectContaining({ id: 'task-1', status: 'in_progress' })])
expect.arrayContaining([
expect.objectContaining({ id: 'task-1', status: 'awaiting_approval' }),
])
);
});
@@ -103,7 +103,7 @@ describe('TaskKanbanBoard approval surface', () => {
expect(onDecidePlan).toHaveBeenCalledWith(expect.objectContaining({ id: 'a' }), false);
});
it('buckets ready→todo and rejected→blocked columns so the cards still render', () => {
it('buckets ready→in_progress column and rejected→done column so the cards still render', () => {
render(
<TaskKanbanBoard
board={board([
@@ -32,26 +32,36 @@ type ColumnDef = { status: TaskBoardCardStatus; labelKey: string };
const TASK_SOURCES_THREAD_ID = 'task-sources';
// The board surfaces exactly three columns — Pending / Working / Done. The
// richer status set the core tracks (approval flow, blocked, rejected) is
// bucketed into these three via `columnFor`.
// The board surfaces five columns:
// Pending / Awaiting Approval / In Progress / Blocked / Done
const COLUMN_DEFS: ColumnDef[] = [
{ status: 'todo', labelKey: 'conversations.taskKanban.pending' },
{ status: 'awaiting_approval', labelKey: 'conversations.taskKanban.awaitingApprovalColumn' },
{ status: 'in_progress', labelKey: 'conversations.taskKanban.working' },
{ status: 'blocked', labelKey: 'conversations.taskKanban.blockedColumn' },
{ status: 'done', labelKey: 'conversations.taskKanban.done' },
];
/** The three statuses a user can set directly from the board. */
/** The five column statuses a user can set directly from the board. */
const COLUMN_STATUSES = COLUMN_DEFS.map(column => column.status);
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). */
/** Per-column visual accent: left-border + background tint. Empty string = no accent. */
const COLUMN_ACCENT: Record<TaskBoardCardStatus, string> = {
todo: '',
awaiting_approval:
'border-l-2 border-l-amber-400 bg-amber-50/60 dark:bg-amber-500/5 dark:border-l-amber-500/60',
in_progress: '',
blocked:
'border-l-2 border-l-coral-400 bg-coral-50/60 dark:bg-coral-500/5 dark:border-l-coral-500/60',
done: '',
ready: '',
rejected: '',
};
/** Label key for *every* status, including statuses that don't own a kanban
* column. Drives the edit dialog's status <select>. */
const STATUS_LABEL_KEYS: Record<TaskBoardCardStatus, string> = {
todo: 'conversations.taskKanban.pending',
awaiting_approval: 'conversations.taskKanban.awaitingApproval',
@@ -62,21 +72,18 @@ const STATUS_LABEL_KEYS: Record<TaskBoardCardStatus, string> = {
rejected: 'conversations.taskKanban.rejected',
};
/** Whether a status owns a kanban column (vs the approval-flow / terminal
* statuses that are bucketed into an existing column). */
/** Whether a status owns a kanban column. */
function isColumnStatus(status: TaskBoardCardStatus): boolean {
return STATUS_INDEX.has(status);
}
/** Map a card status to the column it renders under. Pre-execution approval
* statuses sit in `Pending`; `blocked` and `rejected` are surfaced under
* `Done` so the board stays a clean three-column Pending / Working / Done. */
/** Map a card status to the column it renders under.
* ready → in_progress column; rejected → done column.
* All other statuses now own their own column. */
function columnFor(status: TaskBoardCardStatus): TaskBoardCardStatus {
switch (status) {
case 'awaiting_approval':
case 'ready':
return 'todo';
case 'blocked':
return 'in_progress';
case 'rejected':
return 'done';
default:
@@ -102,6 +109,8 @@ interface TaskKanbanBoardProps {
* carries a `sessionThreadId` (a run is live or has happened). */
onViewSession?: (card: TaskBoardCard) => void;
workingCardId?: string | null;
/** Card id currently being mutated (move/update) — shows a loading indicator. */
mutatingCardId?: string | null;
}
export function TaskKanbanBoard({
@@ -116,10 +125,13 @@ export function TaskKanbanBoard({
onWorkTask,
onViewSession,
workingCardId = null,
mutatingCardId = null,
}: TaskKanbanBoardProps) {
const { t } = useT();
const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
const [sourceControlsOpen, setSourceControlsOpen] = useState(false);
const [dragOverColumn, setDragOverColumn] = useState<TaskBoardCardStatus | null>(null);
const selectedCard = useMemo(
() => board.cards.find(card => card.id === selectedCardId) ?? null,
[board.cards, selectedCardId]
@@ -128,8 +140,7 @@ export function TaskKanbanBoard({
const hasSourceCards = board.cards.some(card => readSourceMetadata(card.sourceMetadata));
const showSourceControls = isTaskSourcesBoard || hasSourceCards;
if (board.cards.length === 0 && !isTaskSourcesBoard) return null;
// Always render (even with 0 cards) so a live agent board stays visible.
const cardsByStatus = COLUMN_DEFS.reduce(
(acc, column) => {
acc[column.status] = [];
@@ -143,12 +154,44 @@ export function TaskKanbanBoard({
}
const moveCard = (card: TaskBoardCard, direction: -1 | 1) => {
const current = STATUS_INDEX.get(card.status) ?? 0;
const current = STATUS_INDEX.get(columnFor(card.status)) ?? 0;
const next = COLUMN_DEFS[current + direction]?.status;
if (!next || disabled) return;
onMove?.(card, next);
};
// Cards can only be moved when the board is enabled and a handler exists.
// Gate every drag-and-drop entry point on this so a disabled board cannot be
// mutated via drop events (parity with moveCard's `disabled` guard above).
const canMoveCards = !disabled && Boolean(onMove);
const handleDragOver = (e: React.DragEvent<HTMLElement>, columnStatus: TaskBoardCardStatus) => {
if (!canMoveCards) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverColumn(columnStatus);
};
const handleDragLeave = (e: React.DragEvent<HTMLElement>) => {
if (!canMoveCards) return;
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setDragOverColumn(null);
}
};
const handleDrop = (e: React.DragEvent<HTMLElement>, targetColumnStatus: TaskBoardCardStatus) => {
if (!canMoveCards) return;
e.preventDefault();
setDragOverColumn(null);
const cardId = e.dataTransfer.getData('application/x-task-card-id');
if (!cardId) return;
const draggedCard = board.cards.find(c => c.id === cardId);
if (!draggedCard) return;
const currentColumn = columnFor(draggedCard.status);
if (currentColumn === targetColumnStatus) return;
onMove?.(draggedCard, targetColumnStatus);
};
return (
<div className="py-3">
{!hideHeader && (
@@ -176,38 +219,62 @@ export function TaskKanbanBoard({
{showSourceControls && sourceControlsOpen && (
<TaskSourceControls disabled={disabled} compact={!isTaskSourcesBoard} />
)}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{COLUMN_DEFS.map(column => (
<section
key={column.status}
className="min-w-0 rounded-lg bg-stone-50 dark:bg-neutral-800/60 p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<h5 className="truncate text-[11px] font-medium text-stone-600 dark:text-neutral-300">
{t(column.labelKey)}
</h5>
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
{cardsByStatus[column.status].length}
</span>
</div>
<div className="space-y-2">
{cardsByStatus[column.status].map(card => (
<TaskBoardArticle
key={card.id}
card={card}
columnStatus={column.status}
disabled={disabled}
onMove={onMove ? moveCard : undefined}
hasBriefActions={Boolean(onUpdateCard || onDeleteCard)}
onDecidePlan={onDecidePlan}
onWorkTask={onWorkTask}
onViewSession={onViewSession}
working={workingCardId === card.id}
onOpenBrief={() => setSelectedCardId(card.id)}
/>
))}
</div>
</section>
))}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{COLUMN_DEFS.map(column => {
const cards = cardsByStatus[column.status];
const isBlockedColumn = column.status === 'blocked';
const isDragTarget = dragOverColumn === column.status;
const accentClass = COLUMN_ACCENT[column.status] ?? '';
return (
<section
key={column.status}
className={`min-w-0 rounded-lg bg-stone-50 dark:bg-neutral-800/60 p-2 ${accentClass} ${
isDragTarget ? 'ring-2 ring-ocean-400 bg-ocean-50/30 dark:bg-ocean-500/5' : ''
}`}
onDragOver={canMoveCards ? e => handleDragOver(e, column.status) : undefined}
onDragLeave={canMoveCards ? handleDragLeave : undefined}
onDrop={canMoveCards ? e => handleDrop(e, column.status) : undefined}>
<div className="mb-2 flex items-center justify-between gap-2">
<h5 className="truncate text-[11px] font-medium text-stone-600 dark:text-neutral-300">
{t(column.labelKey)}
</h5>
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
{cards.length}
</span>
</div>
{/* "Needs your input" banner at top of Blocked column */}
{isBlockedColumn && cards.length > 0 && (
<div className="mb-2 rounded-md bg-coral-50 px-2 py-1.5 text-[10px] font-medium text-coral-700 dark:bg-coral-500/10 dark:text-coral-300">
{t('conversations.taskKanban.needsInput')}
</div>
)}
<div className="space-y-2">
{cards.length === 0 ? (
<p className="py-2 text-center text-[10px] text-stone-400 dark:text-neutral-600">
{t('conversations.taskKanban.emptyColumn')}
</p>
) : (
cards.map(card => (
<TaskBoardArticle
key={card.id}
card={card}
columnStatus={column.status}
disabled={disabled}
onMove={onMove ? moveCard : undefined}
hasBriefActions={Boolean(onUpdateCard || onDeleteCard)}
onDecidePlan={onDecidePlan}
onWorkTask={onWorkTask}
onViewSession={onViewSession}
working={workingCardId === card.id}
mutating={mutatingCardId === card.id}
onOpenBrief={() => setSelectedCardId(card.id)}
/>
))
)}
</div>
</section>
);
})}
</div>
{selectedCard && (
<TaskBriefDialog
@@ -232,6 +299,7 @@ function TaskBoardArticle({
onWorkTask,
onViewSession,
working,
mutating,
onOpenBrief,
}: {
card: TaskBoardCard;
@@ -243,13 +311,25 @@ function TaskBoardArticle({
onWorkTask?: (card: TaskBoardCard) => void;
onViewSession?: (card: TaskBoardCard) => void;
working: boolean;
mutating: boolean;
onOpenBrief: () => void;
}) {
const { t } = useT();
const source = readSourceMetadata(card.sourceMetadata);
const isDraggable = !disabled && Boolean(onMove);
const handleDragStart = (e: React.DragEvent<HTMLElement>) => {
e.dataTransfer.setData('application/x-task-card-id', card.id);
e.dataTransfer.effectAllowed = 'move';
};
return (
<article className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2.5 py-2 shadow-sm">
<article
draggable={isDraggable}
onDragStart={isDraggable ? handleDragStart : undefined}
className={`rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2.5 py-2 shadow-sm transition-opacity ${
mutating ? 'opacity-50' : 'opacity-100'
} ${isDraggable ? 'cursor-grab active:cursor-grabbing' : ''}`}>
<div className="flex items-start gap-2">
<p className="min-w-0 flex-1 break-words text-xs font-medium leading-snug text-stone-800 dark:text-neutral-100">
{card.title}
@@ -294,7 +374,7 @@ function TaskBoardArticle({
? t('conversations.taskKanban.startingTask')
: t('conversations.taskKanban.workTask')}
</button>
) : onMove && isColumnStatus(card.status) ? (
) : onMove && isColumnStatus(columnFor(card.status)) ? (
<div className="flex flex-shrink-0 items-center gap-0.5">
<button
type="button"
@@ -302,8 +382,8 @@ function TaskBoardArticle({
aria-label={t('conversations.taskKanban.moveLeft')}
disabled={disabled || columnStatus === 'todo'}
onClick={() => onMove(card, -1)}
className="flex h-5 w-5 items-center justify-center rounded-md text-stone-400 dark:text-neutral-500 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 disabled:opacity-25">
<LuArrowLeft className="h-3 w-3" />
className="flex h-7 w-7 items-center justify-center rounded-md text-stone-400 dark:text-neutral-500 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 disabled:opacity-25">
<LuArrowLeft className="h-4 w-4" />
</button>
<button
type="button"
@@ -311,8 +391,8 @@ function TaskBoardArticle({
aria-label={t('conversations.taskKanban.moveRight')}
disabled={disabled || columnStatus === 'done'}
onClick={() => onMove(card, 1)}
className="flex h-5 w-5 items-center justify-center rounded-md text-stone-400 dark:text-neutral-500 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 disabled:opacity-25">
<LuArrowRight className="h-3 w-3" />
className="flex h-7 w-7 items-center justify-center rounded-md text-stone-400 dark:text-neutral-500 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 disabled:opacity-25">
<LuArrowRight className="h-4 w-4" />
</button>
</div>
) : null}
@@ -361,6 +441,26 @@ function TaskBoardArticle({
{card.acceptanceCriteria.length}
</span>
)}
{/* Status badge: ready → "Ready to start" (sage); rejected → "Rejected" (coral) */}
{card.status === 'ready' && (
<span className="inline-flex items-center gap-1 rounded-md bg-sage-50 px-1.5 py-0.5 text-[10px] text-sage-700 dark:bg-sage-500/10 dark:text-sage-200">
{t('conversations.taskKanban.statusBadge.ready')}
</span>
)}
{card.status === 'rejected' && (
<span className="inline-flex items-center gap-1 rounded-md bg-coral-50 px-1.5 py-0.5 text-[10px] text-coral-700 dark:bg-coral-500/10 dark:text-coral-200">
{t('conversations.taskKanban.statusBadge.rejected')}
</span>
)}
{/* Evidence badge: shown on the card when evidence is present */}
{card.evidence && card.evidence.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-md bg-sky-50 px-1.5 py-0.5 text-[10px] text-sky-700 dark:bg-sky-500/10 dark:text-sky-200">
{t('conversations.taskKanban.evidenceBadge').replace(
'{count}',
String(card.evidence.length)
)}
</span>
)}
</div>
{card.objective && (
<p className="mt-1 break-words text-[11px] leading-snug text-stone-500 dark:text-neutral-400">
@@ -372,7 +472,8 @@ function TaskBoardArticle({
{card.notes}
</p>
)}
{card.status === 'blocked' && card.blocker && (
{/* Blocker text: always shown for blocked cards (column or status) */}
{card.blocker && (card.status === 'blocked' || columnStatus === 'blocked') && (
<p className="mt-1 break-words text-[11px] leading-snug text-coral-600">{card.blocker}</p>
)}
{(hasBriefActions ||
@@ -35,17 +35,18 @@ const board: TaskBoard = {
};
describe('TaskKanbanBoard', () => {
it('renders the three columns, cards, notes, and blockers', () => {
it('renders five columns including Blocked as its own column', () => {
render(<TaskKanbanBoard board={board} />);
// The board surfaces exactly three columns; `blocked` is bucketed into Done.
// The board now surfaces five columns; Blocked is its own column.
expect(screen.getByText('Pending')).toBeInTheDocument();
expect(screen.getByText('Working')).toBeInTheDocument();
expect(screen.getByText('Done')).toBeInTheDocument();
// Blocked is now a visible column (not bucketed into Done)
expect(screen.getByText('Blocked')).toBeInTheDocument();
expect(screen.queryByText('To do')).not.toBeInTheDocument();
expect(screen.queryByText('Blocked')).not.toBeInTheDocument();
expect(screen.getByText('Draft plan')).toBeInTheDocument();
// The blocked card is still rendered (under Done) with its blocker reason.
// The blocked card is rendered in Blocked column with its blocker reason.
expect(screen.getByText('Wait for token')).toBeInTheDocument();
expect(screen.getByText('Prepare the implementation handoff')).toBeInTheDocument();
expect(screen.getByText('planner')).toBeInTheDocument();
@@ -57,7 +58,7 @@ describe('TaskKanbanBoard', () => {
it('opens a task brief with plan, tools, criteria, and evidence', () => {
render(<TaskKanbanBoard board={board} />);
fireEvent.click(screen.getByText('Task brief'));
fireEvent.click(screen.getAllByText('Task brief')[0]);
expect(screen.getByRole('heading', { name: 'Draft plan' })).toBeInTheDocument();
expect(screen.getByText('Required before execution')).toBeInTheDocument();
@@ -67,14 +68,15 @@ describe('TaskKanbanBoard', () => {
expect(screen.getByText('unit tests')).toBeInTheDocument();
});
it('calls onMove with the next status when a card is moved', () => {
it('calls onMove with awaiting_approval when a todo card is moved right', () => {
const onMove = vi.fn();
render(<TaskKanbanBoard board={board} onMove={onMove} />);
const moveRightButtons = screen.getAllByLabelText('Move right');
fireEvent.click(moveRightButtons[0]);
expect(onMove).toHaveBeenCalledWith(board.cards[0], 'in_progress');
// todo → awaiting_approval (new second column)
expect(onMove).toHaveBeenCalledWith(board.cards[0], 'awaiting_approval');
});
it('lets users edit a task brief and save the updated card', () => {
@@ -130,4 +132,217 @@ describe('TaskKanbanBoard', () => {
expect(screen.getByText('Not required')).toBeInTheDocument();
expect(screen.getByText('External dependency is down')).toHaveClass('text-coral-600');
});
it('buckets ready→in_progress column and rejected→done column', () => {
render(
<TaskKanbanBoard
board={{
...board,
cards: [
{ id: 'r', title: 'Ready card', status: 'ready', order: 0, updatedAt: '' },
{ id: 'x', title: 'Rejected card', status: 'rejected', order: 1, updatedAt: '' },
],
}}
/>
);
expect(screen.getByText('Ready card')).toBeInTheDocument();
expect(screen.getByText('Rejected card')).toBeInTheDocument();
// ready card gets a "Ready to start" badge
expect(screen.getByText('Ready to start')).toBeInTheDocument();
// rejected card gets a "Rejected" badge
expect(screen.getByText('Rejected')).toBeInTheDocument();
});
it('renders awaiting_approval card in its own Awaiting Approval column', () => {
render(
<TaskKanbanBoard
board={{
...board,
cards: [
{
id: 'ap',
title: 'Awaiting approval task',
status: 'awaiting_approval',
order: 0,
updatedAt: '',
},
],
}}
/>
);
expect(screen.getByText('Awaiting approval task')).toBeInTheDocument();
// The column header should be rendered
expect(screen.getByText('Awaiting Approval')).toBeInTheDocument();
});
it('shows "Needs your input" banner in Blocked column when it has cards', () => {
render(<TaskKanbanBoard board={board} />);
// board has a blocked card ("Wait for token") — banner should show
expect(screen.getByText('These tasks need your input')).toBeInTheDocument();
});
it('renders empty column placeholder text when a column has no cards', () => {
render(
<TaskKanbanBoard
board={{
threadId: 'thread-empty',
updatedAt: '',
cards: [{ id: 't1', title: 'Only pending', status: 'todo', order: 0, updatedAt: '' }],
}}
/>
);
// Done column should show "No tasks" placeholder
const emptyPlaceholders = screen.getAllByText('No tasks');
expect(emptyPlaceholders.length).toBeGreaterThan(0);
});
it('shows evidence badge on card when evidence array is non-empty', () => {
render(
<TaskKanbanBoard
board={{
...board,
cards: [
{
id: 'ev',
title: 'Evidence task',
status: 'todo',
order: 0,
updatedAt: '',
evidence: ['test result A', 'test result B'],
},
],
}}
/>
);
// Evidence badge shows count
expect(screen.getByText('Evidence (2)')).toBeInTheDocument();
});
it('empty board still renders all five columns', () => {
render(<TaskKanbanBoard board={{ threadId: 'empty-board', updatedAt: '', cards: [] }} />);
expect(screen.getByText('Pending')).toBeInTheDocument();
expect(screen.getByText('Awaiting Approval')).toBeInTheDocument();
expect(screen.getByText('Working')).toBeInTheDocument();
expect(screen.getByText('Blocked')).toBeInTheDocument();
expect(screen.getByText('Done')).toBeInTheDocument();
});
it('drag-and-drop: dragStart on card then drop on a column calls onMove', () => {
const onMove = vi.fn();
render(
<TaskKanbanBoard
board={{
threadId: 'dnd-board',
updatedAt: '',
cards: [
{ id: 'drag-card', title: 'Draggable task', status: 'todo', order: 0, updatedAt: '' },
],
}}
onMove={onMove}
/>
);
const card = screen.getByText('Draggable task').closest('article')!;
// Simulate drag start
fireEvent.dragStart(card, { dataTransfer: { setData: vi.fn(), effectAllowed: 'move' } });
// Find the Done column section and simulate drop
const sections = document.querySelectorAll('section');
// sections[0]=Pending, [1]=AwaitingApproval, [2]=InProgress, [3]=Blocked, [4]=Done
const doneSection = sections[4];
fireEvent.dragOver(doneSection, { dataTransfer: { dropEffect: 'move' } });
fireEvent.drop(doneSection, {
dataTransfer: {
getData: (key: string) => (key === 'application/x-task-card-id' ? 'drag-card' : ''),
},
});
expect(onMove).toHaveBeenCalledWith(expect.objectContaining({ id: 'drag-card' }), 'done');
});
it('drag-and-drop: dropping a card on its own column does not call onMove', () => {
const onMove = vi.fn();
render(
<TaskKanbanBoard
board={{
threadId: 'dnd-noop-board',
updatedAt: '',
cards: [
{ id: 'same-col-card', title: 'Pending task', status: 'todo', order: 0, updatedAt: '' },
],
}}
onMove={onMove}
/>
);
const card = screen.getByText('Pending task').closest('article')!;
fireEvent.dragStart(card, { dataTransfer: { setData: vi.fn(), effectAllowed: 'move' } });
// sections[0] = Pending — the todo card's own column
const pendingSection = document.querySelectorAll('section')[0];
fireEvent.dragOver(pendingSection, { dataTransfer: { dropEffect: 'move' } });
fireEvent.drop(pendingSection, {
dataTransfer: {
getData: (key: string) => (key === 'application/x-task-card-id' ? 'same-col-card' : ''),
},
});
expect(onMove).not.toHaveBeenCalled();
});
it('drag-and-drop: a disabled board does not call onMove on drop', () => {
const onMove = vi.fn();
render(
<TaskKanbanBoard
board={{
threadId: 'dnd-disabled-board',
updatedAt: '',
cards: [
{ id: 'disabled-card', title: 'Locked task', status: 'todo', order: 0, updatedAt: '' },
],
}}
onMove={onMove}
disabled
/>
);
const card = screen.getByText('Locked task').closest('article')!;
// Disabled cards must not be draggable from the source side …
expect(card.getAttribute('draggable')).toBe('false');
// … and a drop event must be a no-op even if one is dispatched.
const doneSection = document.querySelectorAll('section')[4];
fireEvent.drop(doneSection, {
dataTransfer: {
getData: (key: string) => (key === 'application/x-task-card-id' ? 'disabled-card' : ''),
},
});
expect(onMove).not.toHaveBeenCalled();
});
it('arrow buttons still call onMove as a11y fallback', () => {
const onMove = vi.fn();
render(
<TaskKanbanBoard
board={{
threadId: 'a11y-board',
updatedAt: '',
cards: [{ id: 'a1', title: 'Arrow task', status: 'todo', order: 0, updatedAt: '' }],
}}
onMove={onMove}
/>
);
const moveRightButtons = screen.getAllByLabelText('Move right');
fireEvent.click(moveRightButtons[0]);
expect(onMove).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' }), 'awaiting_approval');
});
});