From 29eefaf7ecb3772f27a7574a79c3ebf355ff8236 Mon Sep 17 00:00:00 2001 From: zenchantlive Date: Sun, 1 Mar 2026 21:12:46 -0800 Subject: [PATCH] feat: add BlockedTriageModal component with tests - Create BlockedTriageModal component at src/components/shared/blocked-triage-modal.tsx - Implements modal with blocked task triage functionality - Uses deriveBlockedIds and buildBlockedByTree from kanban lib - Each row shows blocker chain and has inline archetype picker - Modal is scrollable and closes via Escape/close button - Add corresponding tests at tests/components/blocked-triage-modal.test.tsx - Register test in package.json test script --- package.json | 2 +- .../shared/blocked-triage-modal.tsx | 189 ++++++++++++++++++ .../components/blocked-triage-modal.test.tsx | 73 +++++++ 3 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 src/components/shared/blocked-triage-modal.tsx create mode 100644 tests/components/blocked-triage-modal.test.tsx diff --git a/package.json b/package.json index ac75fa2..47f8054 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "start": "next start", "lint": "eslint .", "typecheck": "tsc --noEmit", - "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/components/shared/base-card.test.tsx && node --import tsx --test tests/components/shared/agent-avatar.test.tsx && node --import tsx --test tests/components/sessions/sessions-header.test.ts && node --import tsx --test tests/components/sessions/agent-station-logic.test.ts && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/components/shared/left-panel.test.tsx && node --import tsx --test tests/components/shared/top-bar.test.tsx && node --import tsx --test tests/components/shared/mobile-nav.test.tsx && node --import tsx --test tests/components/swarm/swarm-card.test.tsx && node --import tsx --test tests/hooks/url-state-integration.test.ts && node --import tsx --test tests/hooks/use-graph-analysis.test.ts && node --import tsx --test tests/components/graph/smart-dag.test.tsx && node --import tsx --test tests/components/unified-shell.test.tsx && node --import tsx --test tests/components/graph/graph-node-labels.test.tsx && node --import tsx --test tests/components/graph/graph-node-assign.test.tsx && node --import tsx --test tests/components/graph/graph-node-conversation.test.tsx && node --import tsx --test tests/lib/coord-schema.test.ts && node --import tsx --test tests/lib/coord-events.test.ts && node --import tsx --test tests/api/coord-events-route.test.ts && node --import tsx --test tests/lib/coord-projections-inbox.test.ts && node --import tsx --test tests/lib/coord-projections-reservations.test.ts && node --import tsx --test tests/components/sessions/conversation-drawer-coord.test.tsx", + "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/components/shared/base-card.test.tsx && node --import tsx --test tests/components/shared/agent-avatar.test.tsx && node --import tsx --test tests/components/sessions/sessions-header.test.ts && node --import tsx --test tests/components/sessions/agent-station-logic.test.ts && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/components/shared/left-panel.test.tsx && node --import tsx --test tests/components/shared/top-bar.test.tsx && node --import tsx --test tests/components/shared/mobile-nav.test.tsx && node --import tsx --test tests/components/swarm/swarm-card.test.tsx && node --import tsx --test tests/hooks/url-state-integration.test.ts && node --import tsx --test tests/hooks/use-graph-analysis.test.ts && node --import tsx --test tests/components/graph/smart-dag.test.tsx && node --import tsx --test tests/components/unified-shell.test.tsx && node --import tsx --test tests/components/blocked-triage-modal.test.tsx && node --import tsx --test tests/components/graph/graph-node-labels.test.tsx && node --import tsx --test tests/components/graph/graph-node-assign.test.tsx && node --import tsx --test tests/components/graph/graph-node-conversation.test.tsx && node --import tsx --test tests/lib/coord-schema.test.ts && node --import tsx --test tests/lib/coord-events.test.ts && node --import tsx --test tests/api/coord-events-route.test.ts && node --import tsx --test tests/lib/coord-projections-inbox.test.ts && node --import tsx --test tests/lib/coord-projections-reservations.test.ts && node --import tsx --test tests/components/sessions/conversation-drawer-coord.test.tsx", "video": "remotion preview src/video/index.ts", "video:render": "remotion render src/video/index.ts Main out/video.mp4", "video:thumbnail": "remotion still src/video/index.ts Main out/thumbnail.png --frame=60" diff --git a/src/components/shared/blocked-triage-modal.tsx b/src/components/shared/blocked-triage-modal.tsx new file mode 100644 index 0000000..a46ac02 --- /dev/null +++ b/src/components/shared/blocked-triage-modal.tsx @@ -0,0 +1,189 @@ +"use client"; + +import React, { useMemo, useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { deriveBlockedIds, buildBlockedByTree, type BlockedTreeNode } from '../../lib/kanban'; +import { useArchetypePicker } from '../../hooks/use-archetype-picker'; +import type { BeadIssue } from '../../lib/types'; +import type { ProjectContext } from '../../lib/types'; +import { Blocks, ChevronRight, UserPlus } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export interface BlockedTriageModalProps { + isOpen: boolean; + onClose: () => void; + issues: BeadIssue[]; + projectRoot: ProjectContext; +} + +export function BlockedTriageModal({ + isOpen, + onClose, + issues, + projectRoot, +}: BlockedTriageModalProps) { + const blockedIdsSet = useMemo(() => deriveBlockedIds(issues), [issues]); + + const blockedTasks = useMemo(() => { + return issues.filter((issue) => { + const isExplicitlyBlocked = issue.status === 'blocked'; + const isDerivedBlocked = blockedIdsSet.has(issue.id); + return isExplicitlyBlocked || isDerivedBlocked; + }); + }, [issues, blockedIdsSet]); + + const [expandedRow, setExpandedRow] = useState(null); + const archetypePicker = useArchetypePicker(); + + const toggleRow = (issueId: string) => { + setExpandedRow((prev) => (prev === issueId ? null : issueId)); + }; + + const handleAssign = async (issueId: string) => { + await archetypePicker.handleAssign(issueId); + if (archetypePicker.assignSuccess) { + setExpandedRow(null); + archetypePicker.resetAssignState(); + } + }; + + return ( + !open && onClose()}> + + + + + Blocked Tasks Triage + + + {blockedTasks.length} blocked task{blockedTasks.length !== 1 ? 's' : ''} require attention. + Click on a row to see the blocker chain and assign an archetype. + + + +
+ {blockedTasks.length === 0 ? ( +
+ No blocked tasks found. +
+ ) : ( + blockedTasks.map((issue) => { + const blockerChain = buildBlockedByTree(issues, issue.id); + const isExpanded = expandedRow === issue.id; + + return ( +
+ + + {isExpanded && ( +
+ {blockerChain.nodes.length > 0 ? ( +
+

+ Blocked by: +

+
+ {blockerChain.nodes.map((node: BlockedTreeNode) => ( + + + {node.title} + + ))} + {blockerChain.total > blockerChain.nodes.length && ( + + +{blockerChain.total - blockerChain.nodes.length} more + + )} +
+
+ ) : ( +

+ No blocker chain found. +

+ )} + +
+ + +
+ {archetypePicker.assignError && ( +

+ {archetypePicker.assignError} +

+ )} + {archetypePicker.assignSuccess && ( +

+ Assigned successfully! +

+ )} +
+ )} +
+ ); + }) + )} +
+
+
+ ); +} diff --git a/tests/components/blocked-triage-modal.test.tsx b/tests/components/blocked-triage-modal.test.tsx new file mode 100644 index 0000000..fc510fd --- /dev/null +++ b/tests/components/blocked-triage-modal.test.tsx @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'fs/promises'; +import path from 'path'; + +test('BlockedTriageModal - file exists and exports', async () => { + const filePath = path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'); + const fileContent = await fs.readFile(filePath, 'utf-8'); + assert.ok(fileContent.includes('export function BlockedTriageModal'), 'Should export BlockedTriageModal function'); + assert.ok(fileContent.includes('export interface BlockedTriageModalProps'), 'Should export BlockedTriageModalProps interface'); +}); + +test('BlockedTriageModal - imports Dialog from shadcn', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8'); + assert.ok(fileContent.includes('from "@/components/ui/dialog"') || fileContent.includes("from '@/components/ui/dialog'"), 'Should import Dialog components from shadcn'); +}); + +test('BlockedTriageModal - imports deriveBlockedIds and buildBlockedByTree from kanban', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8'); + assert.ok(fileContent.includes('deriveBlockedIds'), 'Should import deriveBlockedIds'); + assert.ok(fileContent.includes('buildBlockedByTree'), 'Should import buildBlockedByTree'); +}); + +test('BlockedTriageModal - imports useArchetypePicker hook', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8'); + assert.ok(fileContent.includes('useArchetypePicker'), 'Should import useArchetypePicker hook'); +}); + +test('BlockedTriageModal - imports BeadIssue type', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8'); + assert.ok(fileContent.includes('BeadIssue'), 'Should import BeadIssue type'); +}); + +test('BlockedTriageModal - accepts isOpen, onClose, issues, projectRoot props', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8'); + assert.ok(fileContent.includes('isOpen:'), 'Should have isOpen prop'); + assert.ok(fileContent.includes('onClose:'), 'Should have onClose prop'); + assert.ok(fileContent.includes('issues:'), 'Should have issues prop'); + assert.ok(fileContent.includes('projectRoot:'), 'Should have projectRoot prop'); +}); + +test('BlockedTriageModal - uses useMemo for blockedIds computation', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8'); + assert.ok(fileContent.includes('useMemo'), 'Should use useMemo for computing blockedIds'); +}); + +test('BlockedTriageModal - computes blocked tasks using status=blocked AND deriveBlockedIds', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8'); + assert.ok(fileContent.includes("status === 'blocked'"), 'Should check explicit blocked status'); + assert.ok(fileContent.includes('deriveBlockedIds'), 'Should use deriveBlockedIds'); +}); + +test('BlockedTriageModal - uses buildBlockedByTree for blocker chain display', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8'); + assert.ok(fileContent.includes('buildBlockedByTree'), 'Should use buildBlockedByTree for blocker chains'); +}); + +test('BlockedTriageModal - has local state for showing/hiding archetype picker per row', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8'); + assert.ok(fileContent.includes('useState'), 'Should use useState'); + assert.ok(fileContent.includes('showPicker') || fileContent.includes('pickerOpen') || fileContent.includes('expandedRow'), 'Should have state for row picker visibility'); +}); + +test('BlockedTriageModal - modal content is scrollable', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8'); + assert.ok(fileContent.includes('overflow-y-auto') || fileContent.includes('overflowAuto') || fileContent.includes('scrollable'), 'Should have scrollable content'); +}); + +test('BlockedTriageModal - reuses useArchetypePicker for row assignments', async () => { + const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8'); + const useArchetypePickerCount = (fileContent.match(/useArchetypePicker/g) || []).length; + assert.ok(useArchetypePickerCount >= 2, 'Should use useArchetypePicker hook'); +});