// ======================================== // QueueBoard // ======================================== // Kanban-style view of queue execution groups with DnD reordering/moving. import { useCallback, useEffect, useMemo, useState } from 'react'; import type { DropResult } from '@hello-pangea/dnd'; import { useIntl } from 'react-intl'; import { LayoutGrid } from 'lucide-react'; import { KanbanBoard, type KanbanColumn, type KanbanItem } from '@/components/shared/KanbanBoard'; import { Badge } from '@/components/ui/Badge'; import { cn } from '@/lib/utils'; import { useQueueMutations } from '@/hooks'; import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; import type { IssueQueue, QueueItem } from '@/lib/api'; type QueueBoardItem = QueueItem & KanbanItem; function groupSortKey(groupId: string): [number, string] { const n = parseInt(groupId.match(/\d+/)?.[0] || '999'); return [Number.isFinite(n) ? n : 999, groupId]; } function buildColumns(queue: IssueQueue): KanbanColumn[] { const entries = Object.entries(queue.grouped_items || {}); entries.sort(([a], [b]) => { const [an, aid] = groupSortKey(a); const [bn, bid] = groupSortKey(b); if (an !== bn) return an - bn; return aid.localeCompare(bid); }); return entries.map(([groupId, items]) => { const sorted = [...(items || [])].sort((a, b) => (a.execution_order || 0) - (b.execution_order || 0)); const mapped = sorted.map((it) => ({ ...it, id: it.item_id, title: `${it.issue_id} · ${it.solution_id}`, status: it.status, })); return { id: groupId, title: groupId, items: mapped, icon: , }; }); } function applyDrag(columns: KanbanColumn[], result: DropResult): KanbanColumn[] { if (!result.destination) return columns; const { source, destination, draggableId } = result; const next = columns.map((c) => ({ ...c, items: [...c.items] })); const src = next.find((c) => c.id === source.droppableId); const dst = next.find((c) => c.id === destination.droppableId); if (!src || !dst) return columns; const srcIndex = src.items.findIndex((i) => i.id === draggableId); if (srcIndex === -1) return columns; const [moved] = src.items.splice(srcIndex, 1); if (!moved) return columns; dst.items.splice(destination.index, 0, moved); return next; } export function QueueBoard({ queue, onItemClick, className, }: { queue: IssueQueue; onItemClick?: (item: QueueItem) => void; className?: string; }) { const { formatMessage } = useIntl(); const projectPath = useWorkflowStore(selectProjectPath); const { reorderQueueGroup, moveQueueItem, isReordering, isMoving } = useQueueMutations(); const baseColumns = useMemo(() => buildColumns(queue), [queue]); const [columns, setColumns] = useState[]>(baseColumns); useEffect(() => { setColumns(baseColumns); }, [baseColumns]); const handleDragEnd = useCallback( async (result: DropResult, sourceColumn: string, destColumn: string) => { if (!projectPath) return; if (!result.destination) return; if (sourceColumn === destColumn && result.source.index === result.destination.index) return; try { const nextColumns = applyDrag(columns, result); setColumns(nextColumns); const itemId = result.draggableId; if (sourceColumn === destColumn) { const column = nextColumns.find((c) => c.id === sourceColumn); const nextOrder = (column?.items ?? []).map((i) => i.item_id); await reorderQueueGroup(sourceColumn, nextOrder); } else { await moveQueueItem(itemId, destColumn, result.destination.index); } } catch (e) { // Revert by resetting to server-derived columns setColumns(baseColumns); } }, [baseColumns, columns, moveQueueItem, projectPath, reorderQueueGroup] ); return (
{formatMessage({ id: 'issues.queue.stats.executionGroups' })} {(isReordering || isMoving) && ( {formatMessage({ id: 'common.status.running' })} )}
columns={columns} onDragEnd={handleDragEnd} onItemClick={(item) => onItemClick?.(item as unknown as QueueItem)} emptyColumnMessage={formatMessage({ id: 'issues.queue.empty' })} renderItem={(item, provided) => (
onItemClick?.(item as unknown as QueueItem)} className={cn( 'p-3 bg-card border border-border rounded-lg shadow-sm cursor-pointer', 'hover:shadow-md hover:border-primary/50 transition-all', item.status === 'blocked' && 'border-destructive/50 bg-destructive/5', item.status === 'failed' && 'border-destructive/50 bg-destructive/5', item.status === 'executing' && 'border-primary/40' )} >
{item.item_id}
{item.issue_id} · {item.solution_id}
{formatMessage({ id: `issues.queue.status.${item.status}` })}
{item.depends_on?.length ? (
{formatMessage({ id: 'issues.solution.overview.dependencies' })}: {item.depends_on.length}
) : null}
)} />
); } export default QueueBoard;