fix: remove buildProjectContext usage causing build error
This commit is contained in:
parent
842f931f71
commit
c8c91736b8
9 changed files with 13403 additions and 8 deletions
12343
.beads/dolt-server.log
12343
.beads/dolt-server.log
File diff suppressed because it is too large
Load diff
430
docs/plans/2026-03-01-blocked-triage-modal.md
Normal file
430
docs/plans/2026-03-01-blocked-triage-modal.md
Normal 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 |
|
||||
141
src/app/api/beads/[id]/comments/[commentId]/route.ts
Normal file
141
src/app/api/beads/[id]/comments/[commentId]/route.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
191
tests/api/bead-comments-item-route.test.ts
Normal file
191
tests/api/bead-comments-item-route.test.ts
Normal 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);
|
||||
});
|
||||
99
tests/components/graph/graph-node-liveness.test.ts
Normal file
99
tests/components/graph/graph-node-liveness.test.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
133
tests/components/social/social-card-liveness.test.ts
Normal file
133
tests/components/social/social-card-liveness.test.ts
Normal 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.
|
||||
});
|
||||
});
|
||||
61
tests/lib/read-interactions.test.ts
Normal file
61
tests/lib/read-interactions.test.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue