fix: remove buildProjectContext usage causing build error

This commit is contained in:
zenchantlive 2026-03-01 21:22:46 -08:00
parent 842f931f71
commit c8c91736b8
9 changed files with 13403 additions and 8 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,430 @@
# Blocked Triage Modal Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a BlockedTriageModal that shows all blocked tasks with blocker chain context and inline archetype assignment, replacing the TopBar "Blocked Items" toggle.
**Architecture:** The modal will use the same blocked computation logic as Kanban (`deriveBlockedIds` in kanban.ts) to accurately identify blocked tasks - both explicitly blocked (status='blocked') and derivation-blocked (has open blockers in dependency chain). Each row shows the blocker chain using `buildBlockedByTree`.
**Tech Stack:** React, shadcn/ui Dialog, useArchetypePicker hook, Next.js
---
### Task 1: Verify deriveBlockedIds export in kanban.ts
**Files:**
- Modify: `src/lib/kanban.ts:90-107`
**Step 1: Check if deriveBlockedIds is exported**
Run: `grep -n "export function deriveBlockedIds" src/lib/kanban.ts`
Expected: Should find export on line 90
If NOT exported, edit line 90:
```typescript
// Before
function deriveBlockedIds(issues: BeadIssue[]): Set<string> {
// After
export function deriveBlockedIds(issues: BeadIssue[]): Set<string> {
```
**Step 2: Commit**
```bash
git add src/lib/kanban.ts
git commit -m "feat: export deriveBlockedIds for reuse in BlockedTriageModal"
```
---
### Task 2: Create BlockedTriageModal component
**Files:**
- Create: `src/components/shared/blocked-triage-modal.tsx`
- Test: `tests/components/blocked-triage-modal.test.tsx`
**Step 1: Write the failing test**
Create `tests/components/blocked-triage-modal.test.tsx`:
```tsx
import { render, screen } from '@testing-library/react';
import { BlockedTriageModal } from '@/components/shared/blocked-triage-modal';
import { BeadIssue } from '@/lib/types';
const mockIssues: BeadIssue[] = [
{
id: 'task-1',
title: 'Task One',
status: 'blocked',
issue_type: 'task',
priority: 0,
dependencies: [],
labels: [],
created_at: '2026-01-01',
created_by: 'test',
updated_at: '2026-01-01'
},
{
id: 'task-2',
title: 'Task Two',
status: 'open',
issue_type: 'task',
priority: 0,
dependencies: [{ target: 'task-1', type: 'blocks' }],
labels: [],
created_at: '2026-01-01',
created_by: 'test',
updated_at: '2026-01-01'
}
];
describe('BlockedTriageModal', () => {
it('renders blocked tasks from both explicit status and derived blockers', () => {
const onClose = jest.fn();
const onAssign = jest.fn();
render(
<BlockedTriageModal
isOpen={true}
onClose={onClose}
issues={mockIssues}
projectRoot="test"
/>
);
// task-1 is explicitly blocked
expect(screen.getByText('Task One')).toBeInTheDocument();
// task-2 is blocked by task-1 (derived)
expect(screen.getByText('Task Two')).toBeInTheDocument();
});
it('shows blocker chain for each blocked task', () => {
const onClose = jest.fn();
render(
<BlockedTriageModal
isOpen={true}
onClose={onClose}
issues={mockIssues}
projectRoot="test"
/>
);
// Should show blocker chain
expect(screen.getByText(/blocked by/i)).toBeInTheDocument();
});
});
```
**Step 2: Run test to verify it fails**
Run: `npm run test -- tests/components/blocked-triage-modal.test.tsx`
Expected: FAIL - file does not exist
**Step 3: Write minimal implementation**
Create `src/components/shared/blocked-triage-modal.tsx`:
```tsx
'use client';
import { useMemo, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { deriveBlockedIds, buildBlockedByTree } from '@/lib/kanban';
import { useArchetypePicker } from '@/hooks/use-archetype-picker';
import type { BeadIssue } from '@/lib/types';
interface BlockedTriageModalProps {
isOpen: boolean;
onClose: () => void;
issues: BeadIssue[];
projectRoot: string;
}
interface BlockedTaskRowProps {
issue: BeadIssue;
blockerChain: { id: string; title: string; status: string }[];
onAssign: (issueId: string) => void;
}
function BlockedTaskRow({ issue, blockerChain, onAssign }: BlockedTaskRowProps) {
const [showPicker, setShowPicker] = useState(false);
const {
selectedArchetype,
setSelectedArchetype,
isAssigning,
assignError,
assignSuccess,
handleAssign,
resetAssignState
} = useArchetypePicker();
const handleAssignClick = async () => {
await handleAssign(issue.id);
if (!assignError) {
setShowPicker(false);
resetAssignState();
onAssign(issue.id);
}
};
return (
<div className="rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-card)] p-3 mb-2">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-[var(--text-tertiary)]">{issue.id}</span>
<span className="text-sm font-medium text-[var(--text-primary)]">{issue.title}</span>
</div>
{blockerChain.length > 0 && (
<div className="mt-1 text-xs text-[var(--text-tertiary)]">
<span className="text-[var(--accent-warning)]">Blocked by: </span>
{blockerChain.map((blocker, idx) => (
<span key={blocker.id}>
{idx > 0 && ', '}
<span className={blocker.status === 'closed' ? 'line-through' : ''}>
{blocker.id}
</span>
</span>
))}
</div>
)}
</div>
<button
type="button"
onClick={() => setShowPicker(!showPicker)}
className="ml-2 rounded-md bg-[var(--accent-info)] px-2 py-1 text-xs font-medium text-[var(--text-inverse)] hover:brightness-110"
>
Assign
</button>
</div>
{showPicker && (
<div className="mt-2 pt-2 border-t border-[var(--border-subtle)]">
<p className="text-xs text-[var(--text-tertiary)] mb-2">Select an archetype:</p>
{/* Archetype picker UI - simplified for now */}
<div className="flex gap-2">
{['agent:coder', 'agent:reviewer', 'agent:writer'].map((archetype) => (
<button
key={archetype}
type="button"
onClick={() => setSelectedArchetype(archetype)}
className={`rounded px-2 py-1 text-xs ${
selectedArchetype === archetype
? 'bg-[var(--accent-success)] text-[var(--text-inverse)]'
: 'bg-[var(--surface-elevated)] text-[var(--text-secondary)] border border-[var(--border-default)]'
}`}
>
{archetype.replace('agent:', '')}
</button>
))}
</div>
{assignError && (
<p className="mt-1 text-xs text-[var(--accent-danger)]">{assignError}</p>
)}
<button
type="button"
onClick={handleAssignClick}
disabled={!selectedArchetype || isAssigning}
className="mt-2 rounded bg-[var(--accent-success)] px-3 py-1 text-xs font-medium text-[var(--text-inverse)] disabled:opacity-50"
>
{isAssigning ? 'Assigning...' : 'Confirm Assignment'}
</button>
</div>
)}
</div>
);
}
export function BlockedTriageModal({ isOpen, onClose, issues, projectRoot }: BlockedTriageModalProps) {
const blockedIds = useMemo(() => deriveBlockedIds(issues), [issues]);
const blockedTasks = useMemo(() => {
return issues.filter((issue) =>
issue.status === 'blocked' || blockedIds.has(issue.id)
);
}, [issues, blockedIds]);
const handleAssign = (issueId: string) => {
// Trigger refresh - parent will handle SSE update
console.log('Assigned agent to:', issueId);
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-lg max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<span className="text-[var(--accent-warning)]"></span>
Blocked Triage
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-2">
{blockedTasks.length === 0 ? (
<p className="text-center text-[var(--text-tertiary)] py-8">
No blocked tasks found.
</p>
) : (
blockedTasks.map((issue) => {
const blockerTree = buildBlockedByTree(issues, issue.id, { maxNodes: 5 });
const blockerChain = blockerTree.nodes.map((node) => ({
id: node.id,
title: node.title,
status: node.status
}));
return (
<BlockedTaskRow
key={issue.id}
issue={issue}
blockerChain={blockerChain}
onAssign={handleAssign}
/>
);
})
)}
</div>
</DialogContent>
</Dialog>
);
}
```
**Step 4: Run test to verify it passes**
Run: `npm run test -- tests/components/blocked-triage-modal.test.tsx`
Expected: PASS
**Step 5: Run typecheck**
Run: `npm run typecheck`
Expected: PASS with 0 errors
**Step 6: Commit**
```bash
git add src/components/shared/blocked-triage-modal.tsx tests/components/blocked-triage-modal.test.tsx
git commit -m "feat: add BlockedTriageModal with inline archetype picker"
```
---
### Task 3: Wire BlockedTriageModal into UnifiedShell and TopBar
**Files:**
- Modify: `src/components/shared/unified-shell.tsx`
- Modify: `src/components/shared/top-bar.tsx`
**Step 1: Add modal state to UnifiedShell**
Read `src/components/shared/unified-shell.tsx` to find where state is defined, then add:
```tsx
const [blockedTriageOpen, setBlockedTriageOpen] = useState(false);
```
Add handler:
```tsx
const handleOpenBlockedTriage = useCallback(() => {
setBlockedTriageOpen(true);
}, []);
const handleCloseBlockedTriage = useCallback(() => {
setBlockedTriageOpen(false);
}, []);
```
**Step 2: Pass handler to TopBar**
Find the `<TopBar` component and add:
```tsx
onOpenBlockedTriage={handleOpenBlockedTriage}
```
**Step 3: Update TopBar to accept and use the handler**
In `top-bar.tsx`, add to TopBarProps:
```tsx
onOpenBlockedTriage?: () => void;
```
In the TopBar component, change the blocked button:
```tsx
// Before
onClick={toggleBlockedOnly}
// After
onClick={onOpenBlockedTriage}
```
**Step 4: Import and render BlockedTriageModal in UnifiedShell**
Add import:
```tsx
import { BlockedTriageModal } from './blocked-triage-modal';
```
Add render (at end of return, inside main container):
```tsx
<BlockedTriageModal
isOpen={blockedTriageOpen}
onClose={handleCloseBlockedTriage}
issues={issues}
projectRoot={projectRoot}
/>
```
**Step 5: Run typecheck**
Run: `npm run typecheck`
Expected: PASS with 0 errors
**Step 6: Commit**
```bash
git add src/components/shared/unified-shell.tsx src/components/shared/top-bar.tsx
git commit -m "feat: wire BlockedTriageModal to TopBar blocked button"
```
---
### Task 4: Verify full integration
**Step 1: Run all tests**
Run: `npm run test`
Expected: All tests pass
**Step 2: Run lint**
Run: `npm run lint`
Expected: 0 errors
**Step 3: Run typecheck**
Run: `npm run typecheck`
Expected: 0 errors
**Step 4: Update bead notes**
```bash
bd update beadboard-d2x.1 --notes "BlockedTriageModal implemented with deriveBlockedIds for accurate blocked computation. Uses buildBlockedByTree for blocker chain context. Inline archetype picker per row. Typecheck, lint, test all pass."
```
---
### Summary
| Task | Description |
|------|--------------|
| 1 | Export `deriveBlockedIds` from kanban.ts |
| 2 | Create BlockedTriageModal component with tests |
| 3 | Wire modal to UnifiedShell and TopBar |
| 4 | Verify full integration |

View file

@ -0,0 +1,141 @@
import { NextResponse } from 'next/server';
import { deleteCommentViaDolt, updateCommentViaDolt } from '../../../../../../lib/read-interactions';
export const dynamic = 'force-dynamic';
interface RouteParams {
id: string;
commentId: string;
}
interface PatchBody {
projectRoot?: unknown;
text?: unknown;
}
interface CommentMutationDeps {
updateComment: (projectRoot: string, commentId: number, text: string) => Promise<boolean>;
deleteComment: (projectRoot: string, commentId: number) => Promise<boolean>;
}
const defaultDeps: CommentMutationDeps = {
updateComment: updateCommentViaDolt,
deleteComment: deleteCommentViaDolt,
};
function parseCommentId(raw: string): number {
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error('commentId must be a positive integer.');
}
return parsed;
}
function parseProjectRoot(raw: unknown): string {
if (typeof raw !== 'string' || !raw.trim()) {
throw new Error('projectRoot is required.');
}
return raw.trim();
}
function parseCommentText(raw: unknown): string {
if (typeof raw !== 'string' || !raw.trim()) {
throw new Error('text is required.');
}
return raw.trim();
}
function badRequest(message: string): NextResponse {
return NextResponse.json({ ok: false, error: { message } }, { status: 400 });
}
function notFound(message: string): NextResponse {
return NextResponse.json({ ok: false, error: { message } }, { status: 404 });
}
function serverError(message: string): NextResponse {
return NextResponse.json({ ok: false, error: { message } }, { status: 500 });
}
export async function handlePatchCommentRequest(
request: Request,
params: RouteParams,
deps: CommentMutationDeps,
): Promise<NextResponse> {
let body: PatchBody;
try {
body = (await request.json()) as PatchBody;
} catch {
return badRequest('Invalid JSON body.');
}
let projectRoot: string;
let commentId: number;
let text: string;
try {
projectRoot = parseProjectRoot(body.projectRoot);
commentId = parseCommentId(params.commentId);
text = parseCommentText(body.text);
} catch (error) {
return badRequest(error instanceof Error ? error.message : 'Invalid request.');
}
try {
const updated = await deps.updateComment(projectRoot, commentId, text);
if (!updated) {
return notFound('Comment not found.');
}
return NextResponse.json({ ok: true, id: params.id, commentId });
} catch (error) {
console.error('[API] Failed to update comment:', error);
return serverError('Failed to update comment.');
}
}
export async function handleDeleteCommentRequest(
request: Request,
params: RouteParams,
deps: CommentMutationDeps,
): Promise<NextResponse> {
let projectRoot: string;
let commentId: number;
try {
projectRoot = parseProjectRoot(new URL(request.url).searchParams.get('projectRoot'));
commentId = parseCommentId(params.commentId);
} catch (error) {
return badRequest(error instanceof Error ? error.message : 'Invalid request.');
}
try {
const deleted = await deps.deleteComment(projectRoot, commentId);
if (!deleted) {
return notFound('Comment not found.');
}
return NextResponse.json({ ok: true, id: params.id, commentId });
} catch (error) {
console.error('[API] Failed to delete comment:', error);
return serverError('Failed to delete comment.');
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<RouteParams> },
): Promise<NextResponse> {
return handlePatchCommentRequest(request, await params, defaultDeps);
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<RouteParams> },
): Promise<NextResponse> {
return handleDeleteCommentRequest(request, await params, defaultDeps);
}

View file

@ -11,7 +11,6 @@ import {
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';
@ -19,7 +18,7 @@ export interface BlockedTriageModalProps {
isOpen: boolean;
onClose: () => void;
issues: BeadIssue[];
projectRoot: ProjectContext;
projectRoot: string;
}
export function BlockedTriageModal({

View file

@ -5,7 +5,6 @@ import { X } from 'lucide-react';
import { useRouter } from 'next/navigation';
import type { BeadIssue } from '../../lib/types';
import type { ProjectScopeOption } from '../../lib/project-scope';
import { buildProjectContext } from '../../lib/project-context';
import { TopBar } from './top-bar';
import { LeftPanel, type LeftPanelFilters } from './left-panel';
import { RightPanel } from './right-panel';
@ -80,12 +79,11 @@ export function UnifiedShell({
const handleOpenBlockedTriage = useCallback(() => setBlockedTriageOpen(true), []);
const handleCloseBlockedTriage = useCallback(() => setBlockedTriageOpen(false), []);
const socialCards = useMemo(() => buildSocialCards(issues), [issues]);
const socialCards = useMemo(() => buildSocialCards(issues), [issues]);
const { swarms: swarmCards } = useSwarmList(projectRoot);
const bdHealth = useBdHealth(projectRoot);
const projectContext = useMemo(() => buildProjectContext(projectRoot), [projectRoot]);
const selectedSocialCard = taskId ? socialCards.find(c => c.id === taskId) : null;
const selectedSocialCard = taskId ? socialCards.find(c => c.id === taskId) : null;
const selectedSwarmCard = swarmId ? swarmCards.find(c => c.swarmId === swarmId) : null;
const selectedIssue = taskId ? issues.find((issue) => issue.id === taskId) ?? null : null;
@ -336,7 +334,7 @@ const socialCards = useMemo(() => buildSocialCards(issues), [issues]);
isOpen={blockedTriageOpen}
onClose={handleCloseBlockedTriage}
issues={issues}
projectRoot={projectContext}
projectRoot={projectRoot}
/>
</div>
);

View file

@ -0,0 +1,191 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
handleDeleteCommentRequest,
handlePatchCommentRequest,
} from '../../src/app/api/beads/[id]/comments/[commentId]/route';
async function readJson(response: Response): Promise<any> {
const text = await response.text();
return text ? JSON.parse(text) : {};
}
const params = { id: 'bb-123', commentId: '99' };
test('handlePatchCommentRequest returns 400 for invalid JSON', async () => {
const response = await handlePatchCommentRequest(
new Request('http://localhost/api/beads/bb-123/comments/99', {
method: 'PATCH',
body: '{',
headers: { 'content-type': 'application/json' },
}),
params,
{
updateComment: async () => true,
deleteComment: async () => true,
},
);
assert.equal(response.status, 400);
const body = await readJson(response);
assert.equal(body.ok, false);
});
test('handlePatchCommentRequest returns 400 for invalid payload', async () => {
const response = await handlePatchCommentRequest(
new Request('http://localhost/api/beads/bb-123/comments/99', {
method: 'PATCH',
body: JSON.stringify({ projectRoot: 'C:/repo', text: '' }),
headers: { 'content-type': 'application/json' },
}),
params,
{
updateComment: async () => true,
deleteComment: async () => true,
},
);
assert.equal(response.status, 400);
const body = await readJson(response);
assert.match(body.error.message, /text is required/i);
});
test('handlePatchCommentRequest returns 404 when comment is missing', async () => {
const response = await handlePatchCommentRequest(
new Request('http://localhost/api/beads/bb-123/comments/99', {
method: 'PATCH',
body: JSON.stringify({ projectRoot: 'C:/repo', text: 'Updated text' }),
headers: { 'content-type': 'application/json' },
}),
params,
{
updateComment: async () => false,
deleteComment: async () => true,
},
);
assert.equal(response.status, 404);
const body = await readJson(response);
assert.equal(body.ok, false);
});
test('handlePatchCommentRequest returns 200 on success', async () => {
let observed: { projectRoot: string; commentId: number; text: string } | null = null;
const response = await handlePatchCommentRequest(
new Request('http://localhost/api/beads/bb-123/comments/99', {
method: 'PATCH',
body: JSON.stringify({ projectRoot: 'C:/repo', text: 'Updated text' }),
headers: { 'content-type': 'application/json' },
}),
params,
{
updateComment: async (projectRoot, commentId, text) => {
observed = { projectRoot, commentId, text };
return true;
},
deleteComment: async () => true,
},
);
assert.equal(response.status, 200);
assert.deepEqual(observed, {
projectRoot: 'C:/repo',
commentId: 99,
text: 'Updated text',
});
const body = await readJson(response);
assert.equal(body.ok, true);
assert.equal(body.commentId, 99);
});
test('handlePatchCommentRequest returns 500 when update throws', async () => {
const response = await handlePatchCommentRequest(
new Request('http://localhost/api/beads/bb-123/comments/99', {
method: 'PATCH',
body: JSON.stringify({ projectRoot: 'C:/repo', text: 'Updated text' }),
headers: { 'content-type': 'application/json' },
}),
params,
{
updateComment: async () => {
throw new Error('boom');
},
deleteComment: async () => true,
},
);
assert.equal(response.status, 500);
const body = await readJson(response);
assert.equal(body.ok, false);
});
test('handleDeleteCommentRequest returns 400 for missing projectRoot query', async () => {
const response = await handleDeleteCommentRequest(
new Request('http://localhost/api/beads/bb-123/comments/99', { method: 'DELETE' }),
params,
{
updateComment: async () => true,
deleteComment: async () => true,
},
);
assert.equal(response.status, 400);
const body = await readJson(response);
assert.equal(body.ok, false);
});
test('handleDeleteCommentRequest returns 404 when comment is missing', async () => {
const response = await handleDeleteCommentRequest(
new Request('http://localhost/api/beads/bb-123/comments/99?projectRoot=C%3A%2Frepo', { method: 'DELETE' }),
params,
{
updateComment: async () => true,
deleteComment: async () => false,
},
);
assert.equal(response.status, 404);
const body = await readJson(response);
assert.equal(body.ok, false);
});
test('handleDeleteCommentRequest returns 200 on success', async () => {
let observed: { projectRoot: string; commentId: number } | null = null;
const response = await handleDeleteCommentRequest(
new Request('http://localhost/api/beads/bb-123/comments/99?projectRoot=C%3A%2Frepo', { method: 'DELETE' }),
params,
{
updateComment: async () => true,
deleteComment: async (projectRoot, commentId) => {
observed = { projectRoot, commentId };
return true;
},
},
);
assert.equal(response.status, 200);
assert.deepEqual(observed, { projectRoot: 'C:/repo', commentId: 99 });
const body = await readJson(response);
assert.equal(body.ok, true);
assert.equal(body.commentId, 99);
});
test('handleDeleteCommentRequest returns 500 when delete throws', async () => {
const response = await handleDeleteCommentRequest(
new Request('http://localhost/api/beads/bb-123/comments/99?projectRoot=C%3A%2Frepo', { method: 'DELETE' }),
params,
{
updateComment: async () => true,
deleteComment: async () => {
throw new Error('boom');
},
},
);
assert.equal(response.status, 500);
const body = await readJson(response);
assert.equal(body.ok, false);
});

View file

@ -0,0 +1,99 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'fs/promises';
import path from 'path';
// Test that GraphNodeData interface includes livenessMap field
test('GraphNodeData interface includes livenessMap field', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/graph-node-card.tsx'), 'utf-8');
assert.ok(
/livenessMap/.test(fileContent),
'GraphNodeData should include livenessMap field'
);
});
// Test that GraphNodeData includes assignee field for liveness lookup
test('GraphNodeData interface includes assignee field', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/graph-node-card.tsx'), 'utf-8');
assert.ok(
/assignee/.test(fileContent),
'GraphNodeData should include assignee field for liveness lookup'
);
});
// Test that GraphNodeCard imports AgentAvatar
test('GraphNodeCard imports AgentAvatar component', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/graph-node-card.tsx'), 'utf-8');
assert.ok(
fileContent.includes('AgentAvatar'),
'GraphNodeCard should import and use AgentAvatar'
);
});
// Test that GraphNodeCard renders AgentAvatar based on liveness
test('GraphNodeCard renders AgentAvatar with stale/evicted liveness pulse', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/graph-node-card.tsx'), 'utf-8');
// Should check for stale or evicted liveness state
assert.ok(
fileContent.includes('stale') || fileContent.includes('evicted'),
'GraphNodeCard should handle stale/evicted liveness for pulse animation'
);
});
// Test that WorkflowGraph accepts livenessMap prop
test('WorkflowGraph accepts livenessMap prop', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/workflow-graph.tsx'), 'utf-8');
assert.ok(
/livenessMap/.test(fileContent),
'WorkflowGraph should accept livenessMap prop'
);
});
// Test that WorkflowGraph passes livenessMap to node data
test('WorkflowGraph passes livenessMap to node data', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/workflow-graph.tsx'), 'utf-8');
assert.ok(
fileContent.includes('livenessMap'),
'WorkflowGraph should pass livenessMap into node data'
);
});
// Test that SmartDag passes livenessMap to WorkflowGraph
test('SmartDag passes livenessMap prop to WorkflowGraph', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/smart-dag.tsx'), 'utf-8');
assert.ok(
fileContent.includes('livenessMap'),
'SmartDag should accept and pass livenessMap to WorkflowGraph'
);
});
// Test that SmartDag destructures livenessMap from props
test('SmartDag destructures livenessMap from its props', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/smart-dag.tsx'), 'utf-8');
// Check that the function signature includes livenessMap
const funcMatch = fileContent.match(/export function SmartDag\s*\(\s*\{([^}]+)\}/s);
assert.ok(
funcMatch && funcMatch[1].includes('livenessMap'),
'SmartDag function should destructure livenessMap from props'
);
});
// Test that AgentAvatar is overlaid at bottom-right of node card
test('GraphNodeCard overlays AgentAvatar at bottom-right position', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/graph-node-card.tsx'), 'utf-8');
// Should use absolute positioning for the overlay
assert.ok(
fileContent.includes('absolute') && fileContent.includes('AgentAvatar'),
'GraphNodeCard should use absolute positioning for AgentAvatar overlay'
);
});
// Test liveness-to-status mapping covers stuck states
test('GraphNodeCard maps stale/evicted liveness to stuck/stale AgentAvatar status', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/graph/graph-node-card.tsx'), 'utf-8');
// Should map liveness values to AgentAvatar status prop
assert.ok(
(fileContent.includes('stale') || fileContent.includes('stuck')) && fileContent.includes('AgentAvatar'),
'GraphNodeCard should map liveness to AgentAvatar status for stuck/stale agents'
);
});

View file

@ -0,0 +1,133 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
/**
* TDD tests for SocialCard liveness avatar rendering.
*
* SocialCard should accept a `livenessMap` prop (Record<string, string>).
* When `data.assignee` is set and exists as a key in `livenessMap`, the card
* should render an AgentAvatar for that assignee using the mapped AgentStatus.
*
* Liveness AgentStatus mapping:
* 'active' 'active'
* 'stale' 'stale'
* 'idle' 'idle'
* 'evicted' 'dead'
*/
describe('SocialCard liveness prop contract', () => {
it('SocialCard exports a function component', async () => {
const mod = await import('../../../src/components/social/social-card');
assert.ok(mod.SocialCard, 'SocialCard should be exported');
assert.equal(typeof mod.SocialCard, 'function', 'SocialCard should be a function');
});
it('SocialCard accepts a livenessMap prop without throwing', async () => {
await import('../../../src/components/social/social-card');
// Calling a React component as a plain function should not throw when
// given valid props including livenessMap.
assert.doesNotThrow(() => {
const mockData = {
id: 'task-1',
title: 'Test Task',
status: 'in_progress' as const,
blocks: [],
unblocks: [],
agents: [],
lastActivity: new Date(),
priority: 'P1' as const,
assignee: 'agent-42',
};
// Just check the component function accepts the prop shape; we do not
// need to render it via DOM in this test environment.
const props = {
data: mockData,
livenessMap: { 'agent-42': 'active' },
};
assert.ok(props.livenessMap, 'livenessMap should be truthy');
});
});
});
describe('SocialCard liveness → AgentStatus mapping', () => {
it('mapLiveness converts active → active', async () => {
const mod = await import('../../../src/components/social/social-card');
// The mapLiveness helper is exposed for testing via named export.
// If it doesn't exist yet this test will fail (TDD red phase).
assert.ok(
typeof (mod as any).mapLiveness === 'function',
'social-card should export a mapLiveness helper',
);
assert.equal((mod as any).mapLiveness('active'), 'active');
});
it('mapLiveness converts stale → stale', async () => {
const mod = await import('../../../src/components/social/social-card');
assert.equal((mod as any).mapLiveness('stale'), 'stale');
});
it('mapLiveness converts idle → idle', async () => {
const mod = await import('../../../src/components/social/social-card');
assert.equal((mod as any).mapLiveness('idle'), 'idle');
});
it('mapLiveness converts evicted → dead', async () => {
const mod = await import('../../../src/components/social/social-card');
assert.equal((mod as any).mapLiveness('evicted'), 'dead');
});
it('mapLiveness returns idle for unknown liveness strings', async () => {
const mod = await import('../../../src/components/social/social-card');
assert.equal((mod as any).mapLiveness('unknown-value'), 'idle');
});
});
describe('SocialCard SocialCardData assignee field', () => {
it('SocialCard data type supports assignee field', async () => {
// Verify social-cards.ts exports a type that includes assignee.
// We do this by inspecting the buildSocialCards output with a mock bead.
const { buildSocialCards } = await import('../../../src/lib/social-cards');
const mockBead = {
id: 'task-99',
title: 'Assignee Test',
description: null,
status: 'in_progress' as const,
priority: 1,
issue_type: 'task' as const,
assignee: 'bot-7',
templateId: null,
owner: null,
labels: [],
dependencies: [],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
closed_at: null,
close_reason: null,
closed_by_session: null,
created_by: null,
due_at: null,
estimated_minutes: null,
external_ref: null,
metadata: {},
};
const cards = buildSocialCards([mockBead]);
assert.equal(cards.length, 1, 'should produce one card');
// agents array should contain the assignee
assert.ok(
cards[0].agents.some((a) => a.name === 'bot-7'),
'card.agents should include the assignee name',
);
});
});
describe('SocialPage passes livenessMap to SocialCard', () => {
it('SocialPage component accepts livenessMap prop', async () => {
const mod = await import('../../../src/components/social/social-page');
assert.ok(mod.SocialPage, 'SocialPage should be exported');
assert.equal(typeof mod.SocialPage, 'function', 'SocialPage should be a function');
// The livenessMap prop is part of the interface; TypeScript verifies this
// at build time. Here we just confirm the module loads cleanly.
});
});

View file

@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { deleteCommentViaDolt, updateCommentViaDolt } from '../../src/lib/read-interactions';
const validRoot = 'C:/Users/Zenchant/codex/beadboard';
test('updateCommentViaDolt validates projectRoot', async () => {
await assert.rejects(
async () => updateCommentViaDolt('', 1, 'hello'),
(error: unknown) => {
assert.equal(error instanceof Error, true);
assert.equal((error as Error).message, 'projectRoot is required.');
return true;
},
);
});
test('updateCommentViaDolt validates commentId', async () => {
await assert.rejects(
async () => updateCommentViaDolt(validRoot, 0, 'hello'),
(error: unknown) => {
assert.equal(error instanceof Error, true);
assert.equal((error as Error).message, 'commentId must be a positive integer.');
return true;
},
);
});
test('updateCommentViaDolt validates text', async () => {
await assert.rejects(
async () => updateCommentViaDolt(validRoot, 10, ' '),
(error: unknown) => {
assert.equal(error instanceof Error, true);
assert.equal((error as Error).message, 'text is required.');
return true;
},
);
});
test('deleteCommentViaDolt validates projectRoot', async () => {
await assert.rejects(
async () => deleteCommentViaDolt(' ', 10),
(error: unknown) => {
assert.equal(error instanceof Error, true);
assert.equal((error as Error).message, 'projectRoot is required.');
return true;
},
);
});
test('deleteCommentViaDolt validates commentId', async () => {
await assert.rejects(
async () => deleteCommentViaDolt(validRoot, -1),
(error: unknown) => {
assert.equal(error instanceof Error, true);
assert.equal((error as Error).message, 'commentId must be a positive integer.');
return true;
},
);
});