import { useEffect, 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, IssueStatus } from "@paperclipai/shared"; import { AlertTriangle } from "lucide-react"; import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff"; export const KANBAN_BOARD_HIGH_VOLUME_THRESHOLD = 100; export const KANBAN_COLUMN_PAGE_SIZE_OPTIONS = [10, 25, 50] as const; export type KanbanColumnPageSize = (typeof KANBAN_COLUMN_PAGE_SIZE_OPTIONS)[number]; export const KANBAN_COLUMN_DEFAULT_PAGE_SIZE: KanbanColumnPageSize = 10; export const KANBAN_COLUMN_INITIAL_VISIBLE_LIMIT = KANBAN_COLUMN_DEFAULT_PAGE_SIZE; export const KANBAN_COLUMN_REVEAL_INCREMENT = KANBAN_COLUMN_DEFAULT_PAGE_SIZE; export const KANBAN_COLD_STATUSES = ["backlog", "done", "cancelled"] as const; export const boardStatuses = [ "backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled", ] as const satisfies readonly IssueStatus[]; function statusLabel(status: string): string { return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); } export function resolveKanbanTargetStatus(overId: string, issues: Issue[]): IssueStatus | null { if ((boardStatuses as readonly string[]).includes(overId)) { return overId as IssueStatus; } return issues.find((issue) => issue.id === overId)?.status ?? null; } interface Agent { id: string; name: string; } interface KanbanBoardProps { issues: Issue[]; agents?: Agent[]; liveIssueIds?: Set; compactCards?: boolean; collapsedStatuses?: string[]; initialVisibleCount?: number; revealIncrement?: number; onUpdateIssue: (id: string, data: Record) => void; } /* ── Droppable Column ── */ function KanbanColumn({ status, issues, agents, liveIssueIds, compactCards = false, collapsed = false, visibleCount, revealIncrement, onShowMore, }: { status: IssueStatus; issues: Issue[]; agents?: Agent[]; liveIssueIds?: Set; compactCards?: boolean; collapsed?: boolean; visibleCount: number; revealIncrement: number; onShowMore: () => void; }) { const { setNodeRef, isOver } = useDroppable({ id: status }); const isEmpty = issues.length === 0; const visibleIssues = collapsed ? [] : issues.slice(0, visibleCount); const hiddenCount = Math.max(issues.length - visibleIssues.length, 0); const nextRevealCount = Math.min(revealIncrement, hiddenCount); if (collapsed) { return (
{statusLabel(status)} {issues.length}
); } return (
{(!isEmpty || isOver) && ( <> {statusLabel(status)} {issues.length} )}
{/* Hidden cards are intentionally excluded from sort targets until revealed. */} i.id)} strategy={verticalListSortingStrategy} > {visibleIssues.map((issue) => ( ))} {hiddenCount > 0 ? ( ) : null} {issues.length > 0 && (hiddenCount > 0 || issues.length >= visibleCount) ? (

Showing {visibleIssues.length} of {issues.length}

) : null}
); } /* ── Draggable Card ── */ function KanbanCard({ issue, agents, isLive, isOverlay, compact = false, }: { issue: Issue; agents?: Agent[]; isLive?: boolean; isOverlay?: boolean; compact?: 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)} {isSuccessfulRunHandoffRequired(issue) ? ( Next step ) : null} {isLive && ( {compact ? "Live" : null} )}

{issue.title}

{issue.assigneeAgentId && (() => { const name = agentName(issue.assigneeAgentId); return name ? ( ) : ( {issue.assigneeAgentId.slice(0, 8)} ); })()}
); } /* ── Main Board ── */ export function KanbanBoard({ issues, agents, liveIssueIds, compactCards = false, collapsedStatuses = [], initialVisibleCount = KANBAN_COLUMN_INITIAL_VISIBLE_LIMIT, revealIncrement = KANBAN_COLUMN_REVEAL_INCREMENT, onUpdateIssue, }: KanbanBoardProps) { const [activeId, setActiveId] = useState(null); const [visibleCountByStatus, setVisibleCountByStatus] = useState>({}); const collapsedStatusSet = useMemo(() => new Set(collapsedStatuses), [collapsedStatuses]); useEffect(() => { setVisibleCountByStatus({}); }, [initialVisibleCount, revealIncrement]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) ); const columnIssues = useMemo(() => { const grouped: Record = {} as 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. const targetStatus = resolveKanbanTargetStatus(over.id as string, issues); 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) => ( { setVisibleCountByStatus((current) => ({ ...current, [status]: (current[status] ?? initialVisibleCount) + revealIncrement, })); }} /> ))}
{activeIssue ? ( ) : null}
); }