import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react"; import { Link, Clock, Layers, Pencil, ChevronDown, Folder } from "lucide-react"; import type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@fusion/core"; import { fetchTaskDetail, uploadAttachment } from "../api"; import { GitHubBadge } from "./GitHubBadge"; import { pickPreferredBadge } from "./TaskCardBadge"; import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket"; import { getFreshBatchData } from "../hooks/useBatchBadgeFetch"; import { useSessionFiles } from "../hooks/useSessionFiles"; 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"]); interface TaskCardProps { task: Task; projectId?: string; queued?: boolean; onOpenDetail: (task: TaskDetail) => void; addToast: (message: string, type?: ToastType) => void; globalPaused?: boolean; onUpdateTask?: ( id: string, updates: { title?: string; description?: string; dependencies?: string[] } ) => Promise; onArchiveTask?: (id: string) => Promise; onUnarchiveTask?: (id: string) => Promise; onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void; } function areTaskBadgeInfosEqual( previous: PrInfo | IssueInfo | undefined, next: PrInfo | IssueInfo | undefined, ): boolean { if (!previous && !next) return true; if (!previous || !next) return false; const previousKeys = Object.keys(previous) as Array; const nextKeys = Object.keys(next) as Array; if (previousKeys.length !== nextKeys.length) return false; return previousKeys.every((key) => previous[key] === next[key]); } function areTaskStepsEqual(previous: Task["steps"], next: Task["steps"]): boolean { if (previous.length !== next.length) return false; return previous.every((step, index) => step.name === next[index]?.name && step.status === next[index]?.status); } function areTaskDependenciesEqual(previous: string[], next: string[]): boolean { if (previous.length !== next.length) return false; return previous.every((dependency, index) => dependency === next[index]); } // Keep this comparator aligned with the fields TaskCard renders directly and the // task metadata that influences child badge freshness/subscriptions. function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): boolean { const previousTask = previous.task; const nextTask = next.task; return ( previous.queued === next.queued && previous.projectId === next.projectId && previous.globalPaused === next.globalPaused && previous.onOpenDetail === next.onOpenDetail && previous.addToast === next.addToast && previous.onUpdateTask === next.onUpdateTask && previous.onArchiveTask === next.onArchiveTask && previous.onUnarchiveTask === next.onUnarchiveTask && previous.onOpenFilesForTask === next.onOpenFilesForTask && previousTask.id === nextTask.id && previousTask.title === nextTask.title && previousTask.description === nextTask.description && previousTask.column === nextTask.column && previousTask.columnMovedAt === nextTask.columnMovedAt && previousTask.updatedAt === nextTask.updatedAt && previousTask.createdAt === nextTask.createdAt && previousTask.status === nextTask.status && previousTask.paused === nextTask.paused && previousTask.error === nextTask.error && previousTask.size === nextTask.size && previousTask.blockedBy === nextTask.blockedBy && previousTask.worktree === nextTask.worktree && previousTask.baseBranch === nextTask.baseBranch && previousTask.breakIntoSubtasks === nextTask.breakIntoSubtasks && previousTask.currentStep === nextTask.currentStep && previousTask.modelProvider === nextTask.modelProvider && previousTask.modelId === nextTask.modelId && previousTask.validatorModelProvider === nextTask.validatorModelProvider && previousTask.validatorModelId === nextTask.validatorModelId && previousTask.reviewLevel === nextTask.reviewLevel && previousTask.mergeRetries === nextTask.mergeRetries && JSON.stringify(previousTask.attachments ?? []) === JSON.stringify(nextTask.attachments ?? []) && JSON.stringify(previousTask.comments ?? []) === JSON.stringify(nextTask.comments ?? []) && areTaskDependenciesEqual(previousTask.dependencies, nextTask.dependencies) && areTaskStepsEqual(previousTask.steps, nextTask.steps) && areTaskBadgeInfosEqual(previousTask.prInfo, nextTask.prInfo) && areTaskBadgeInfosEqual(previousTask.issueInfo, nextTask.issueInfo) ); } function TaskCardComponent({ task, projectId, queued, onOpenDetail, addToast, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onOpenFilesForTask, }: 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( task.column === "in-progress" || (task.column === "triage" && task.steps.some(s => s.status === "done" || s.status === "skipped")) ); 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(); // Touch gesture detection refs const touchStartPosRef = useRef<{ x: number; y: number; time: number } | null>(null); const hasTouchMovedRef = useRef(false); 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, projectId); 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, projectId); onOpenDetail(detail); } catch { 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; void handleClick(); }, [handleClick, isInteractiveTarget]); const handleTouchStart = useCallback((e: React.TouchEvent) => { const touch = e.touches[0]; if (!touch) return; touchStartPosRef.current = { x: touch.clientX, y: touch.clientY, time: Date.now() }; hasTouchMovedRef.current = false; }, []); const handleTouchMove = useCallback((e: React.TouchEvent) => { if (!touchStartPosRef.current) return; const touch = e.touches[0]; if (!touch) return; const dx = Math.abs(touch.clientX - touchStartPosRef.current.x); const dy = Math.abs(touch.clientY - touchStartPosRef.current.y); // If moved beyond threshold, mark as moved (scrolling/dragging) if (dx > TOUCH_MOVE_THRESHOLD || dy > TOUCH_MOVE_THRESHOLD) { hasTouchMovedRef.current = true; } }, []); const handleTouchEnd = useCallback((e: React.TouchEvent) => { if (isInteractiveTarget(e.target)) return; // Check if this was a valid tap (not a scroll) if (!touchStartPosRef.current) return; const touchDuration = Date.now() - touchStartPosRef.current.time; const isQuickTap = touchDuration < TOUCH_TAP_MAX_DURATION; const isStationary = !hasTouchMovedRef.current; // Only open modal for quick taps that didn't move significantly if (isQuickTap && isStationary) { touchOpenHandledRef.current = true; void handleClick(); } // Reset touch tracking touchStartPosRef.current = null; hasTouchMovedRef.current = false; }, [handleClick, isInteractiveTarget]); const handleDepClick = useCallback(async (e: React.MouseEvent, depId: string) => { e.stopPropagation(); // Prevent card click try { const detail = await fetchTaskDetail(depId, projectId); 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 { files: sessionFiles, loading: sessionFilesLoading } = useSessionFiles(task.id, task.worktree, task.column, projectId); // Get fresh batch data if available const batchData = useMemo(() => getFreshBatchData(task.id, projectId), [task.id, projectId]); // Pick the freshest data among WebSocket, batch, and task data const livePrInfo = useMemo(() => { const wsData = liveBadgeData?.prInfo; const wsTimestamp = liveBadgeData?.timestamp; const batchInfo = batchData?.result?.prInfo; const batchTimestamp = batchData?.timestamp ? new Date(batchData.timestamp).toISOString() : undefined; const taskInfo = task.prInfo; const taskTimestamp = task.prInfo?.lastCheckedAt ?? task.updatedAt; // Compare all three sources and pick the freshest let bestData = pickPreferredBadge(wsData, wsTimestamp, taskInfo, taskTimestamp); let bestTimestamp = wsTimestamp && wsTimestamp >= taskTimestamp ? wsTimestamp : taskTimestamp; if (batchInfo && batchTimestamp) { if (!bestTimestamp || batchTimestamp > bestTimestamp) { bestData = batchInfo; } } return bestData; }, [liveBadgeData, batchData, task.prInfo, task.updatedAt]); const liveIssueInfo = useMemo(() => { const wsData = liveBadgeData?.issueInfo; const wsTimestamp = liveBadgeData?.timestamp; const batchInfo = batchData?.result?.issueInfo; const batchTimestamp = batchData?.timestamp ? new Date(batchData.timestamp).toISOString() : undefined; const taskInfo = task.issueInfo; const taskTimestamp = task.issueInfo?.lastCheckedAt ?? task.updatedAt; // Compare all three sources and pick the freshest let bestData = pickPreferredBadge(wsData, wsTimestamp, taskInfo, taskTimestamp); let bestTimestamp = wsTimestamp && wsTimestamp >= taskTimestamp ? wsTimestamp : taskTimestamp; if (batchInfo && batchTimestamp) { if (!bestTimestamp || batchTimestamp > bestTimestamp) { bestData = batchInfo; } } return bestData; }, [liveBadgeData, batchData, task.issueInfo, 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(); void 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()) { void 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 handleArchiveClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); if (!onArchiveTask) return; void onArchiveTask(task.id).then(() => { addToast(`Archived ${task.id}`, "success"); }).catch((err: any) => { addToast(`Failed to archive ${task.id}: ${err.message}`, "error"); }); }, [addToast, onArchiveTask, task.id]); const handleUnarchiveClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); if (!onUnarchiveTask) return; void onUnarchiveTask(task.id).then(() => { addToast(`Unarchived ${task.id}`, "success"); }).catch((err: any) => { addToast(`Failed to unarchive ${task.id}: ${err.message}`, "error"); }); }, [addToast, onUnarchiveTask, task.id]); const handleToggleSteps = useCallback((e: React.MouseEvent) => { e.stopPropagation(); setShowSteps((current) => !current); }, []); 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} />