mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
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:
co-authored by
Steven Enamakel
parent
a7516a83bd
commit
1d2b6a7d06
+5
-9
@@ -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 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: <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
@@ -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':
|
||||
|
||||
@@ -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': 'আপনার বাহ্যিক রানটাইমে এই মডেলটি পরিচালনা করুন।',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': 'इस मॉडल को अपने बाहरी रनटाइम में प्रबंधित करें।',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': '외부 런타임에서 이 모델을 관리하세요.',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user