import { useMemo, useState } from "react"; import { Link } from "@/lib/router"; import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, type DragStartEvent, type DragEndEvent, type DragOverEvent, } from "@dnd-kit/core"; import { useDroppable } from "@dnd-kit/core"; import { CSS } from "@dnd-kit/utilities"; import { SortableContext, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { Identity } from "./Identity"; import type { Issue } from "@paperclipai/shared"; const boardStatuses = [ "backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled", ]; function statusLabel(status: string): string { return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); } interface Agent { id: string; name: string; } interface KanbanBoardProps { issues: Issue[]; agents?: Agent[]; liveIssueIds?: Set; onUpdateIssue: (id: string, data: Record) => void; } /* ── Droppable Column ── */ function KanbanColumn({ status, issues, agents, liveIssueIds, }: { status: string; issues: Issue[]; agents?: Agent[]; liveIssueIds?: Set; }) { const { setNodeRef, isOver } = useDroppable({ id: status }); return (
{statusLabel(status)} {issues.length}
i.id)} strategy={verticalListSortingStrategy} > {issues.map((issue) => ( ))}
); } /* ── Draggable Card ── */ function KanbanCard({ issue, agents, isLive, isOverlay, }: { issue: Issue; agents?: Agent[]; isLive?: boolean; isOverlay?: boolean; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: issue.id, data: { issue } }); const style = { transform: CSS.Transform.toString(transform), transition, }; const agentName = (id: string | null) => { if (!id || !agents) return null; return agents.find((a) => a.id === id)?.name ?? null; }; return (
{ // Prevent navigation during drag if (isDragging) e.preventDefault(); }} >
{issue.identifier ?? issue.id.slice(0, 8)} {isLive && ( )}

{issue.title}

{issue.assigneeAgentId && (() => { const name = agentName(issue.assigneeAgentId); return name ? ( ) : ( {issue.assigneeAgentId.slice(0, 8)} ); })()}
); } /* ── Main Board ── */ export function KanbanBoard({ issues, agents, liveIssueIds, onUpdateIssue, }: KanbanBoardProps) { const [activeId, setActiveId] = useState(null); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) ); const columnIssues = useMemo(() => { const grouped: Record = {}; for (const status of boardStatuses) { grouped[status] = []; } for (const issue of issues) { if (grouped[issue.status]) { grouped[issue.status].push(issue); } } return grouped; }, [issues]); const activeIssue = useMemo( () => (activeId ? issues.find((i) => i.id === activeId) : null), [activeId, issues] ); function handleDragStart(event: DragStartEvent) { setActiveId(event.active.id as string); } function handleDragEnd(event: DragEndEvent) { setActiveId(null); const { active, over } = event; if (!over) return; const issueId = active.id as string; const issue = issues.find((i) => i.id === issueId); if (!issue) return; // Determine target status: the "over" could be a column id (status string) // or another card's id. Find which column the "over" belongs to. let targetStatus: string | null = null; if (boardStatuses.includes(over.id as string)) { targetStatus = over.id as string; } else { // It's a card - find which column it's in const targetIssue = issues.find((i) => i.id === over.id); if (targetIssue) { targetStatus = targetIssue.status; } } if (targetStatus && targetStatus !== issue.status) { onUpdateIssue(issueId, { status: targetStatus }); } } function handleDragOver(_event: DragOverEvent) { // Could be used for visual feedback; keeping simple for now } return (
{boardStatuses.map((status) => ( ))}
{activeIssue ? ( ) : null}
); }