import { useCallback, useState, useRef, useEffect } from "react"; import { Link, Clock, Layers, Pencil, ChevronDown } from "lucide-react"; import type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@kb/core"; import { fetchTaskDetail, uploadAttachment } from "../api"; import { GitHubBadge } from "./GitHubBadge"; import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket"; 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)", archived: "rgba(120,120,120,0.1)", }; 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)", archived: "var(--text-secondary)", }; const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]); const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]); function pickPreferredBadge( liveValue: T | null | undefined, liveTimestamp: string | undefined, taskValue: T | undefined, taskTimestamp: string | undefined, ): T | undefined { if (liveValue === undefined || !liveTimestamp) { return taskValue; } if (!taskTimestamp || liveTimestamp >= taskTimestamp) { return liveValue ?? undefined; } return taskValue; } interface TaskCardProps { task: Task; queued?: boolean; onOpenDetail: (task: TaskDetail) => void; addToast: (message: string, type?: ToastType) => void; globalPaused?: boolean; tasks?: Task[]; // All tasks for dependency lookup onUpdateTask?: ( id: string, updates: { title?: string; description?: string; dependencies?: string[] } ) => Promise; onArchiveTask?: (id: string) => Promise; onUnarchiveTask?: (id: string) => Promise; } export function TaskCard({ task, queued, onOpenDetail, addToast, globalPaused, tasks = [], onUpdateTask, onArchiveTask, onUnarchiveTask, }: TaskCardProps) { const [dragging, setDragging] = useState(false); const [fileDragOver, setFileDragOver] = useState(false); const [isEditing, setIsEditing] = useState(false); const [editTitle, setEditTitle] = useState(task.title || ""); const [editDescription, setEditDescription] = useState(task.description || ""); const [isSaving, setIsSaving] = useState(false); const [showSteps, setShowSteps] = useState(false); const titleInputRef = useRef(null); const descTextareaRef = useRef(null); const touchOpenHandledRef = useRef(false); const cardRef = useRef(null); const [isInViewport, setIsInViewport] = useState(false); const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket(); const isInteractiveTarget = useCallback((target: EventTarget | null): boolean => { if (!(target instanceof HTMLElement)) return false; return !!target.closest("button, a, input, textarea, select, label, [role='button']"); }, []); // Reset edit state when task changes useEffect(() => { setEditTitle(task.title || ""); setEditDescription(task.description || ""); }, [task.id, task.title, task.description]); // Auto-focus on title when entering edit mode useEffect(() => { if (isEditing) { titleInputRef.current?.focus(); titleInputRef.current?.select(); } }, [isEditing]); useEffect(() => { if (typeof IntersectionObserver === "undefined") { setIsInViewport(true); return; } const element = cardRef.current; if (!element) return; const observer = new IntersectionObserver( ([entry]) => { setIsInViewport(entry?.isIntersecting ?? true); }, { rootMargin: "200px" }, ); observer.observe(element); return () => observer.disconnect(); }, [isEditing, task.id]); 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 () => { if (isEditing) return; // Don't open detail when editing try { const detail = await fetchTaskDetail(task.id); onOpenDetail(detail); } catch (err: any) { addToast("Failed to load task details", "error"); } }, [task.id, onOpenDetail, addToast, isEditing]); const handleCardClick = useCallback((e: React.MouseEvent) => { if (touchOpenHandledRef.current) { touchOpenHandledRef.current = false; return; } if (isInteractiveTarget(e.target)) return; handleClick(); }, [handleClick, isInteractiveTarget]); const handleTouchEnd = useCallback((e: React.TouchEvent) => { if (isInteractiveTarget(e.target)) return; touchOpenHandledRef.current = true; handleClick(); }, [handleClick, isInteractiveTarget]); const handleDepClick = useCallback(async (e: React.MouseEvent, depId: string) => { e.stopPropagation(); // Prevent card click try { const detail = await fetchTaskDetail(depId); onOpenDetail(detail); } catch (err: any) { addToast(`Failed to load dependency ${depId}`, "error"); } }, [onOpenDetail, addToast]); const isFailed = task.status === "failed"; const isPaused = task.paused === true; const isArchived = task.column === "archived"; const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string)); const isDraggable = !queued && !isPaused && !isEditing && !isArchived; // Disable drag during edit or if archived // Check if this card can be edited inline const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask; const hasGitHubBadge = Boolean(task.prInfo || task.issueInfo); useEffect(() => { if (!hasGitHubBadge || !isInViewport) { unsubscribeFromBadge(task.id); return; } subscribeToBadge(task.id); return () => { unsubscribeFromBadge(task.id); }; }, [hasGitHubBadge, isInViewport, subscribeToBadge, task.id, unsubscribeFromBadge]); const liveBadgeData = badgeUpdates.get(task.id); const livePrInfo = pickPreferredBadge( liveBadgeData?.prInfo, liveBadgeData?.timestamp, task.prInfo, task.prInfo?.lastCheckedAt ?? task.updatedAt, ); const liveIssueInfo = pickPreferredBadge( liveBadgeData?.issueInfo, liveBadgeData?.timestamp, task.issueInfo, task.issueInfo?.lastCheckedAt ?? task.updatedAt, ); const enterEditMode = useCallback((e?: React.MouseEvent) => { e?.stopPropagation(); if (!canEdit || isSaving) return; setIsEditing(true); setEditTitle(task.title || ""); setEditDescription(task.description || ""); }, [canEdit, isSaving, task.title, task.description]); const exitEditMode = useCallback(() => { setIsEditing(false); setEditTitle(task.title || ""); setEditDescription(task.description || ""); }, [task.title, task.description]); const hasChanges = useCallback(() => { return editTitle !== (task.title || "") || editDescription !== (task.description || ""); }, [editTitle, editDescription, task.title, task.description]); const saveChanges = useCallback(async () => { if (!onUpdateTask || isSaving) return; if (!hasChanges()) { exitEditMode(); return; } setIsSaving(true); try { await onUpdateTask(task.id, { title: editTitle.trim() || undefined, description: editDescription.trim() || undefined, }); addToast(`Updated ${task.id}`, "success"); setIsEditing(false); } catch (err: any) { addToast(`Failed to update ${task.id}: ${err.message}`, "error"); // Stay in edit mode on error so user can retry } finally { setIsSaving(false); } }, [onUpdateTask, task.id, editTitle, editDescription, isSaving, hasChanges, exitEditMode, addToast]); const handleTitleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); // Move focus to description textarea descTextareaRef.current?.focus(); } else if (e.key === "Escape") { e.preventDefault(); exitEditMode(); } }, [exitEditMode]); const handleDescKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); saveChanges(); } else if (e.key === "Escape") { e.preventDefault(); exitEditMode(); } }, [saveChanges, exitEditMode]); const handleBlur = useCallback(() => { // Small delay to allow focus to move between title and description inputs // before checking if we should save or cancel setTimeout(() => { const activeElement = document.activeElement; const isFocusInEditArea = activeElement === titleInputRef.current || activeElement === descTextareaRef.current || activeElement?.closest(".card-editing-content"); if (!isFocusInEditArea) { if (hasChanges()) { saveChanges(); } else { exitEditMode(); } } }, 0); }, [hasChanges, saveChanges, exitEditMode]); const handleDoubleClick = useCallback((e: React.MouseEvent) => { if (canEdit) { e.stopPropagation(); enterEditMode(e); } }, [canEdit, enterEditMode]); const handleEditClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); enterEditMode(e); }, [enterEditMode]); // Auto-resize textarea (similar to InlineCreateCard) const handleDescChange = useCallback((e: React.ChangeEvent) => { setEditDescription(e.target.value); const el = e.target; el.style.height = "auto"; el.style.height = el.scrollHeight + "px"; }, []); const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`; if (isEditing) { return (
setEditTitle(e.target.value)} onKeyDown={handleTitleKeyDown} onBlur={handleBlur} disabled={isSaving} />