import { useCallback, useState } from "react"; import { Link, Clock, Layers } from "lucide-react"; import type { Task, TaskDetail, Column } from "@kb/core"; import { fetchTaskDetail, uploadAttachment } from "../api"; import type { ToastType } from "../hooks/useToast"; const COLUMN_COLOR_MAP: Record = { triage: "rgba(210,153,34,0.15)", todo: "rgba(88,166,255,0.15)", "in-progress": "rgba(188,140,255,0.15)", "in-review": "rgba(63,185,80,0.15)", done: "rgba(139,148,158,0.15)", }; const COLUMN_TEXT_COLOR_MAP: Record = { triage: "var(--triage)", todo: "var(--todo)", "in-progress": "var(--in-progress)", "in-review": "var(--in-review)", done: "var(--done)", }; const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]); interface TaskCardProps { task: Task; queued?: boolean; onOpenDetail: (task: TaskDetail) => void; addToast: (message: string, type?: ToastType) => void; } export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps) { const [dragging, setDragging] = useState(false); const [fileDragOver, setFileDragOver] = useState(false); const handleDragStart = useCallback((e: React.DragEvent) => { e.dataTransfer.setData("text/plain", task.id); e.dataTransfer.effectAllowed = "move"; setDragging(true); }, [task.id]); const handleDragEnd = useCallback(() => { setDragging(false); }, []); const isFileDrag = useCallback((e: React.DragEvent) => { return e.dataTransfer.types.includes("Files"); }, []); const handleFileDragOver = useCallback((e: React.DragEvent) => { if (!isFileDrag(e)) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = "copy"; setFileDragOver(true); }, [isFileDrag]); const handleFileDragLeave = useCallback((e: React.DragEvent) => { if (!isFileDrag(e)) return; e.preventDefault(); e.stopPropagation(); setFileDragOver(false); }, [isFileDrag]); const handleFileDrop = useCallback(async (e: React.DragEvent) => { if (!isFileDrag(e)) return; e.preventDefault(); e.stopPropagation(); setFileDragOver(false); const files = Array.from(e.dataTransfer.files); for (const file of files) { try { await uploadAttachment(task.id, file); addToast(`Attached ${file.name} to ${task.id}`, "success"); } catch (err: any) { addToast(`Failed to attach ${file.name}: ${err.message}`, "error"); } } }, [task.id, isFileDrag, addToast]); const handleClick = useCallback(async () => { try { const detail = await fetchTaskDetail(task.id); onOpenDetail(detail); } catch (err: any) { addToast("Failed to load task details", "error"); } }, [task.id, onOpenDetail, addToast]); const isFailed = task.status === "failed"; const isPaused = task.paused === true; const isAgentActive = !queued && !isFailed && !isPaused && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string)); const isDraggable = !queued && !isPaused; const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${fileDragOver ? " file-drop-target" : ""}`; return (
{task.id} {isPaused && ( paused )} {!isPaused && task.status && task.status !== "queued" && ( {task.status} )}
{task.title || (task.description ? task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "") : task.id)}
{task.steps.length > 0 && (() => { const completedSteps = task.steps.filter(s => s.status === "done").length; const totalSteps = task.steps.length; return (
{completedSteps}/{totalSteps}
); })()} {((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy) && (
{task.dependencies && task.dependencies.length > 0 && ( {task.dependencies.length} dep{task.dependencies.length > 1 ? "s" : ""} )} {task.blockedBy && ( {task.blockedBy} )} {(queued || task.status === "queued") && Queued}
)}
); }