import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react"; import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2 } from "lucide-react"; import type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@fusion/core"; import { COLUMN_LABELS, VALID_TRANSITIONS } from "@fusion/core"; import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api"; import { GitHubBadge } from "./GitHubBadge"; import { pickPreferredBadge } from "./TaskCardBadge"; import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket"; import { getFreshBatchData } from "../hooks/useBatchBadgeFetch"; import { useTaskDiffStats } from "../hooks/useTaskDiffStats"; import { isTaskStuck } from "../utils/taskStuck"; import type { ToastType } from "../hooks/useToast"; // ── Mission title caching ─────────────────────────────────────────────────── const missionTitleCache = new Map(); /** @internal Test helper to reset the mission title cache between tests */ export function __test_clearMissionTitleCache(): void { missionTitleCache.clear(); } async function getMissionTitle(missionId: string, projectId?: string): Promise { const cached = missionTitleCache.get(missionId); if (cached) return cached; try { const mission = await fetchMission(missionId, projectId); missionTitleCache.set(missionId, mission.title); return mission.title; } catch { return missionId; } } const MAX_MISSION_TITLE_LENGTH = 12; function abbreviateMissionTitle(title: string): string { if (title.length <= MAX_MISSION_TITLE_LENGTH) return title; return title.slice(0, MAX_MISSION_TITLE_LENGTH - 3) + "..."; } // ── Assigned agent name caching ───────────────────────────────────────────── const agentNameCache = new Map(); /** @internal Test helper to reset the assigned agent cache between tests */ export function __test_clearAgentNameCache(): void { agentNameCache.clear(); } async function getAgentName(agentId: string, projectId?: string): Promise { const cached = agentNameCache.get(agentId); if (cached) return cached; try { const agent = await fetchAgent(agentId, projectId); agentNameCache.set(agentId, agent.name); return agent.name; } catch { return agentId; } } function abbreviateBadge(text: string, max: number): string { if (text.length <= max) return text; return text.slice(0, max - 3) + "..."; } // ── Constants ─────────────────────────────────────────────────────────────── 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: 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; onDeleteTask?: (id: string) => Promise; onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes") => void; /** Project-level stuck task timeout in milliseconds (undefined = disabled) */ taskStuckTimeoutMs?: number; /** Called when user clicks the mission badge on a task card. */ onOpenMission?: (missionId: string) => void; /** Called when user moves a task to a different column from the card. */ onMoveTask?: (id: string, column: Column) => Promise; /** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */ lastFetchTimeMs?: number; } 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]); } /** * Lightweight comparison for attachment metadata (not file content). * Compares counts and top-level fields that affect card rendering. */ function areAttachmentsEqual(previous: Task["attachments"], next: Task["attachments"]): boolean { if (!previous && !next) return true; if (!previous || !next) return false; if (previous.length !== next.length) return false; // Compare attachment metadata that affects card rendering return previous.every((att, i) => { const nextAtt = next[i]; if (!nextAtt) return false; // Compare fields that affect the card's visual state return ( att.filename === nextAtt.filename && att.mimeType === nextAtt.mimeType && att.size === nextAtt.size ); }); } /** * Lightweight comparison for comments. * Compares counts and top-level fields that affect card rendering. */ function areCommentsEqual(previous: Task["comments"], next: Task["comments"]): boolean { if (!previous && !next) return true; if (!previous || !next) return false; if (previous.length !== next.length) return false; // Compare comment metadata that affects card rendering return previous.every((comment, i) => { const nextComment = next[i]; if (!nextComment) return false; return ( comment.author === nextComment.author && comment.text === nextComment.text && comment.createdAt === nextComment.createdAt ); }); } // 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.taskStuckTimeoutMs === next.taskStuckTimeoutMs && previous.onOpenDetail === next.onOpenDetail && previous.addToast === next.addToast && previous.onUpdateTask === next.onUpdateTask && previous.onArchiveTask === next.onArchiveTask && previous.onUnarchiveTask === next.onUnarchiveTask && previous.onDeleteTask === next.onDeleteTask && previous.onOpenDetailWithTab === next.onOpenDetailWithTab && previous.onOpenMission === next.onOpenMission && previous.onMoveTask === next.onMoveTask && 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.missionId === nextTask.missionId && previousTask.assignedAgentId === nextTask.assignedAgentId && previousTask.mergeRetries === nextTask.mergeRetries && areAttachmentsEqual(previousTask.attachments, nextTask.attachments) && areCommentsEqual(previousTask.comments, 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, onDeleteTask, onOpenDetailWithTab, taskStuckTimeoutMs, onOpenMission, onMoveTask, lastFetchTimeMs, }: TaskCardProps) { const [dragging, setDragging] = useState(false); const [fileDragOver, setFileDragOver] = useState(false); const [isEditing, setIsEditing] = useState(false); 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 [missionTitle, setMissionTitle] = useState(null); const [agentName, setAgentName] = useState(null); const [showSendBackMenu, setShowSendBackMenu] = useState(false); const descTextareaRef = useRef(null); const touchOpenHandledRef = useRef(false); const cardRef = useRef(null); const sendBackRef = useRef(null); const [isInViewport, setIsInViewport] = useState(false); const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket(projectId); // 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 Element)) return false; return !!target.closest("button, a, input, textarea, select, label, [role='button']"); }, []); // Reset edit state when task changes useEffect(() => { setEditDescription(task.description || ""); }, [task.id, task.description]); // Close send-back menu on outside click useEffect(() => { if (!showSendBackMenu) return; const handleClick = (e: MouseEvent) => { if (sendBackRef.current && !sendBackRef.current.contains(e.target as Node)) { setShowSendBackMenu(false); } }; document.addEventListener("click", handleClick); return () => document.removeEventListener("click", handleClick); }, [showSendBackMenu]); // Fetch mission title when missionId is set useEffect(() => { if (!task.missionId) { setMissionTitle(null); return; } // Check cache synchronously first const cached = missionTitleCache.get(task.missionId); if (cached) { setMissionTitle(cached); return; } let cancelled = false; void getMissionTitle(task.missionId, projectId).then((title) => { if (!cancelled) setMissionTitle(title); }); return () => { cancelled = true; }; }, [task.missionId, projectId]); // Fetch assigned agent name when assignedAgentId is set useEffect(() => { if (!task.assignedAgentId) { setAgentName(null); return; } // Check cache synchronously first const cached = agentNameCache.get(task.assignedAgentId); if (cached) { setAgentName(cached); return; } let cancelled = false; void getAgentName(task.assignedAgentId, projectId).then((name) => { if (!cancelled) setAgentName(name); }); return () => { cancelled = true; }; }, [task.assignedAgentId, projectId]); // Auto-focus and auto-resize description textarea when entering edit mode useEffect(() => { if (isEditing && descTextareaRef.current) { const el = descTextareaRef.current; el.focus(); // Apply the same resize logic used in handleDescChange so the textarea // opens at the correct height for existing long descriptions without // requiring the user to type first. el.style.height = "auto"; el.style.height = el.scrollHeight + "px"; } }, [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(() => { if (isEditing) return; // Don't open detail when editing onOpenDetail(task); }, [task, onOpenDetail, 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 isStuck = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs); const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval"; const isArchived = task.column === "archived"; const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && (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(`${projectId ?? "default"}:${task.id}`); // Compute step version for diff stats refresh when steps change const isActiveColumn = task.column === "in-progress" || task.column === "in-review"; const stepVersion = useMemo( () => task.steps.map((s) => `${s.name}:${s.status}`).join("|"), [task.steps], ); // Viewport-gated diff stats fetching - only fetch when card is visible const { stats: diffStats } = useTaskDiffStats( task.id, task.column, task.mergeDetails?.commitSha, projectId, { enabled: isInViewport, worktree: task.worktree, stepVersion: isActiveColumn ? stepVersion : undefined, pollIntervalMs: isActiveColumn ? 30_000 : undefined, }, ); // 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); const 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); const 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); setEditDescription(task.description || ""); }, [canEdit, isSaving, task.description]); const exitEditMode = useCallback(() => { setIsEditing(false); setEditDescription(task.description || ""); }, [task.description]); const hasChanges = useCallback(() => { return editDescription !== (task.description || ""); }, [editDescription, task.description]); const saveChanges = useCallback(async () => { if (!onUpdateTask || isSaving) return; if (!hasChanges()) { exitEditMode(); return; } setIsSaving(true); try { await onUpdateTask(task.id, { 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, editDescription, isSaving, hasChanges, exitEditMode, addToast]); 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 before checking if we should save or cancel setTimeout(() => { const activeElement = document.activeElement; const isFocusInEditArea = 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 handleDeleteClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); if (!onDeleteTask) return; if (window.confirm(`Delete ${task.id}?`)) { void onDeleteTask(task.id).then(() => { addToast(`Deleted ${task.id}`, "success"); }).catch((err: any) => { addToast(`Failed to delete ${task.id}: ${err.message}`, "error"); }); } }, [addToast, onDeleteTask, task.id]); const handleOpenFiles = useCallback((e: React.MouseEvent) => { e.stopPropagation(); onOpenDetailWithTab?.(task, "changes"); }, [task, onOpenDetailWithTab]); const handleToggleSteps = useCallback((e: React.MouseEvent) => { e.stopPropagation(); setShowSteps((current) => !current); }, []); const handleMissionClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); if (task.missionId && onOpenMission) { onOpenMission(task.missionId); } }, [task.missionId, onOpenMission]); const handleSendBackClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); setShowSendBackMenu((current) => !current); }, []); const handleSendBackOptionClick = useCallback((e: React.MouseEvent, column: Column) => { e.stopPropagation(); setShowSendBackMenu(false); if (!onMoveTask) return; void onMoveTask(task.id, column).then(() => { addToast(`Moved ${task.id} to ${COLUMN_LABELS[column]}`, "success"); }).catch((err: any) => { addToast(`Failed to move ${task.id}: ${err.message}`, "error"); }); }, [addToast, onMoveTask, task.id]); const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isStuck ? " stuck" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`; if (isEditing) { return (