diff --git a/.claude/memory.md b/.claude/memory.md index 87b348449..7fa1a1477 100644 --- a/.claude/memory.md +++ b/.claude/memory.md @@ -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` 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::` (`memory_sources/sync.rs`). The `` can contain colons (URLs), so extract `` 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 3–4s 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: ") 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:] 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 ` fails with "document is not defined"** — Must use `--config test/vitest.config.ts` (or `pnpm debug unit `) 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) diff --git a/app/src/components/intelligence/IntelligenceTasksTab.tsx b/app/src/components/intelligence/IntelligenceTasksTab.tsx index babc3a372..93ceaee89 100644 --- a/app/src/components/intelligence/IntelligenceTasksTab.tsx +++ b/app/src/components/intelligence/IntelligenceTasksTab.tsx @@ -142,6 +142,7 @@ export default function IntelligenceTasksTab() { const [composerOpen, setComposerOpen] = useState(false); const [refiningCard, setRefiningCard] = useState(null); const [workingCardId, setWorkingCardId] = useState(null); + const [mutatingCardId, setMutatingCardId] = useState(null); const [runningAgentId, setRunningAgentId] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -264,8 +265,14 @@ export default function IntelligenceTasksTab() { // ── personal-board mutations (optimistic, with rollback) ───────────── const mutatePersonal = useCallback( - async (optimistic: TaskBoard, call: () => Promise, previous: TaskBoard) => { + async ( + optimistic: TaskBoard, + call: () => Promise, + 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 (
@@ -616,46 +630,34 @@ export default function IntelligenceTasksTab() { {t('intelligence.tasks.personalBoardTitle')}
- {personalCards.length > 0 ? ( - { - 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} - /> - ) : ( -
-

{t('intelligence.tasks.personalEmpty')}

- -
- )} + { + 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} + /> {taskSourcesBoard && ( diff --git a/app/src/components/intelligence/UserTaskComposer.tsx b/app/src/components/intelligence/UserTaskComposer.tsx index b8247205e..8fbcf2370 100644 --- a/app/src/components/intelligence/UserTaskComposer.tsx +++ b/app/src/components/intelligence/UserTaskComposer.tsx @@ -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 { diff --git a/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx b/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx index f1cf7bba9..37deb8ed9 100644 --- a/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx +++ b/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx @@ -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); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 355d16fe2..8c0846999 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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': diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 5b45f4b71..afae9c167 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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': 'আপনার বাহ্যিক রানটাইমে এই মডেলটি পরিচালনা করুন।', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 123272468..fcc7b551c 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 6ab0e6b06..fa7d15d56 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 60ae57da3..2506c6d9f 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index afeba903a..9e7c5d719 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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.', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 94572fc8f..7934a8436 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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': 'इस मॉडल को अपने बाहरी रनटाइम में प्रबंधित करें।', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 002a9e2b6..5f7ed0d5d 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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.', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 4332aafcf..dfbc51566 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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.', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 1a14dc62a..cb52fba9d 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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': '외부 런타임에서 이 모델을 관리하세요.', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 5b09601d7..13d1a5388 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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': diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index f3abbc56b..17fb8c678 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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.', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 471aa5730..c53425cda 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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': diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 498d7ae1c..d42e323ce 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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': '在外部运行时中管理此模型。', diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index 0d1168b82..52324cf84 100644 --- a/app/src/pages/__tests__/Conversations.render.test.tsx +++ b/app/src/pages/__tests__/Conversations.render.test.tsx @@ -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' }), + ]) ); }); diff --git a/app/src/pages/conversations/components/TaskKanbanBoard.test.tsx b/app/src/pages/conversations/components/TaskKanbanBoard.test.tsx index c2061294a..6cdfebc04 100644 --- a/app/src/pages/conversations/components/TaskKanbanBoard.test.tsx +++ b/app/src/pages/conversations/components/TaskKanbanBoard.test.tsx @@ -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( 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 `. */ const STATUS_LABEL_KEYS: Record = { todo: 'conversations.taskKanban.pending', awaiting_approval: 'conversations.taskKanban.awaitingApproval', @@ -62,21 +72,18 @@ const STATUS_LABEL_KEYS: Record = { 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(null); const [sourceControlsOpen, setSourceControlsOpen] = useState(false); + const [dragOverColumn, setDragOverColumn] = useState(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, columnStatus: TaskBoardCardStatus) => { + if (!canMoveCards) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOverColumn(columnStatus); + }; + + const handleDragLeave = (e: React.DragEvent) => { + if (!canMoveCards) return; + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setDragOverColumn(null); + } + }; + + const handleDrop = (e: React.DragEvent, 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 (
{!hideHeader && ( @@ -176,38 +219,62 @@ export function TaskKanbanBoard({ {showSourceControls && sourceControlsOpen && ( )} -
- {COLUMN_DEFS.map(column => ( -
-
-
- {t(column.labelKey)} -
- - {cardsByStatus[column.status].length} - -
-
- {cardsByStatus[column.status].map(card => ( - setSelectedCardId(card.id)} - /> - ))} -
-
- ))} +
+ {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 ( +
handleDragOver(e, column.status) : undefined} + onDragLeave={canMoveCards ? handleDragLeave : undefined} + onDrop={canMoveCards ? e => handleDrop(e, column.status) : undefined}> +
+
+ {t(column.labelKey)} +
+ + {cards.length} + +
+ {/* "Needs your input" banner at top of Blocked column */} + {isBlockedColumn && cards.length > 0 && ( +
+ {t('conversations.taskKanban.needsInput')} +
+ )} +
+ {cards.length === 0 ? ( +

+ {t('conversations.taskKanban.emptyColumn')} +

+ ) : ( + cards.map(card => ( + setSelectedCardId(card.id)} + /> + )) + )} +
+
+ ); + })}
{selectedCard && ( 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) => { + e.dataTransfer.setData('application/x-task-card-id', card.id); + e.dataTransfer.effectAllowed = 'move'; + }; return ( -
+

{card.title} @@ -294,7 +374,7 @@ function TaskBoardArticle({ ? t('conversations.taskKanban.startingTask') : t('conversations.taskKanban.workTask')} - ) : onMove && isColumnStatus(card.status) ? ( + ) : onMove && isColumnStatus(columnFor(card.status)) ? (

) : null} @@ -361,6 +441,26 @@ function TaskBoardArticle({ {card.acceptanceCriteria.length} )} + {/* Status badge: ready → "Ready to start" (sage); rejected → "Rejected" (coral) */} + {card.status === 'ready' && ( + + {t('conversations.taskKanban.statusBadge.ready')} + + )} + {card.status === 'rejected' && ( + + {t('conversations.taskKanban.statusBadge.rejected')} + + )} + {/* Evidence badge: shown on the card when evidence is present */} + {card.evidence && card.evidence.length > 0 && ( + + {t('conversations.taskKanban.evidenceBadge').replace( + '{count}', + String(card.evidence.length) + )} + + )}
{card.objective && (

@@ -372,7 +472,8 @@ function TaskBoardArticle({ {card.notes}

)} - {card.status === 'blocked' && card.blocker && ( + {/* Blocker text: always shown for blocked cards (column or status) */} + {card.blocker && (card.status === 'blocked' || columnStatus === 'blocked') && (

{card.blocker}

)} {(hasBriefActions || diff --git a/app/src/pages/conversations/components/__tests__/TaskKanbanBoard.test.tsx b/app/src/pages/conversations/components/__tests__/TaskKanbanBoard.test.tsx index 225f8f1cb..2ddfd19b2 100644 --- a/app/src/pages/conversations/components/__tests__/TaskKanbanBoard.test.tsx +++ b/app/src/pages/conversations/components/__tests__/TaskKanbanBoard.test.tsx @@ -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(); - // 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(); - 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(); 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( + + ); + + 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( + + ); + + 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(); + + // 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( + + ); + + // 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( + + ); + + // Evidence badge shows count + expect(screen.getByText('Evidence (2)')).toBeInTheDocument(); + }); + + it('empty board still renders all five columns', () => { + render(); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + const moveRightButtons = screen.getAllByLabelText('Move right'); + fireEvent.click(moveRightButtons[0]); + + expect(onMove).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' }), 'awaiting_approval'); + }); });