import "./TaskCard.css"; import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react"; import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest } from "lucide-react"; import type { Task, TaskDetail, Column, PrInfo, IssueInfo, TaskPriority, GithubIssueAction } from "@fusion/core"; import { COLUMN_LABELS, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, TASK_PRIORITIES, VALID_TRANSITIONS, getErrorMessage, } from "@fusion/core"; import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api"; import { GitHubBadge } from "./GitHubBadge"; import { PrCreateModal } from "./PrCreateModal"; import { ProviderIcon } from "./ProviderIcon"; import { PluginSlot } from "./PluginSlot"; import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket"; import { getFreshBatchData } from "../hooks/useBatchBadgeFetch"; import { useTaskDiffStats } from "../hooks/useTaskDiffStats"; import { useAgentsMapCache } from "../hooks/useAgentsMapCache"; import { isTaskStuck } from "../utils/taskStuck"; import { getStalledReviewSignal } from "../utils/taskStalledReview"; import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inReviewStallCopy"; import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../utils/stalePausedReviewCopy"; import { getTaskAgeStalenessCopy, shouldShowTaskAgeStalenessBadge } from "../utils/taskAgeStalenessCopy"; import { getUnifiedTaskProgress } from "../utils/taskProgress"; import { getActiveRuntimeMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming"; import type { ToastType } from "../hooks/useToast"; import { useConfirm } from "../hooks/useConfirm"; import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete"; import { MAX_AUTO_MERGE_RETRIES, type BlockerFanoutEntry } from "../hooks/useBlockerFanout"; import { useRetryWarning } from "../context/RetryWarningContext"; // ── 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 normalizeTaskPriorityValue(priority: Task["priority"]): TaskPriority { return typeof priority === "string" && (TASK_PRIORITIES as readonly string[]).includes(priority) ? (priority as TaskPriority) : DEFAULT_TASK_PRIORITY; } function abbreviateBadge(text: string, max: number): string { if (text.length <= max) return text; return text.slice(0, max - 3) + "..."; } function getResolvedAgentNameFromMap( agentId: string | undefined, agentsMap: ReadonlyMap, ): string | undefined { if (typeof agentId !== "string" || agentId.trim().length === 0) { return undefined; } const cachedName = agentsMap.get(agentId)?.name; return typeof cachedName === "string" && cachedName.trim().length > 0 ? cachedName.trim() : undefined; } function getSourceAgentName( task: Task, agentsMap?: ReadonlyMap, ): string | undefined { const metadataAgentName = task.sourceMetadata?.agentName; if (typeof metadataAgentName === "string" && metadataAgentName.trim().length > 0) { return metadataAgentName.trim(); } const resolvedAgentName = getResolvedAgentNameFromMap(task.sourceAgentId, agentsMap ?? new Map()); if (resolvedAgentName) { return resolvedAgentName; } if (typeof task.sourceAgentId === "string" && task.sourceAgentId.trim().length > 0) { return task.sourceAgentId.trim(); } return undefined; } function isAgentCreatedTask(task: Task): boolean { return task.sourceType === "agent_heartbeat" || task.sourceType === "automation" || Boolean(getSourceAgentName(task)); } // ── Constants ─────────────────────────────────────────────────────────────── const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]); const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]); const ACTIVE_MERGE_STATUSES = new Set(["merging", "merging-pr", "merging-fix"]); const COLUMN_PROGRESS_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-muted)", }; const TIME_INDICATOR_COLUMNS = new Set([ "in-progress", "in-review", "done", ]); const LIVE_TIME_INDICATOR_POLL_MS = 30_000; function getTaskStatusLabel(status: string): string { if (status === "merging-fix") return "Merging fixes…"; return status; } function getDoneCompletionMs(task: Task): number | null { const completionMs = parseTimestampToMs(task.columnMovedAt ?? task.updatedAt); if (completionMs == null) return null; const now = Date.now(); if (completionMs > now) return null; return completionMs; } function getInProgressElapsedMs(task: Task, nowMs: number): number | null { const startedMs = parseTimestampToMs(task.columnMovedAt ?? task.updatedAt); if (startedMs == null) return null; return Math.max(0, nowMs - startedMs); } // Wall-clock end-to-end runtime: from when the task first entered in-progress // to when it first entered done (or `now` if not yet done). Preferred over the // instrumented `[timing]` sum on cards in in-progress / in-review / done so the // timer reflects how long the task actually took, not just the time spent // inside instrumented code paths. Returns null on legacy tasks that completed // before `executionStartedAt` was tracked, so callers can fall back. function getTaskEndToEndDurationMs(task: Task, nowMs: number): number | null { if (task.cumulativeActiveMs == null) { return getEndToEndDurationMs(task.executionStartedAt, task.executionCompletedAt, nowMs); } return getActiveRuntimeMs(task, nowMs); } function getInReviewCompletionMs(task: Task): number | null { return task.column === "done" ? getDoneCompletionMs(task) : null; } function getMergeElapsedMs(task: Task, nowMs: number): number | null { const mergeStartedMs = parseTimestampToMs(task.updatedAt); if (mergeStartedMs == null) { return null; } return Math.max(0, nowMs - mergeStartedMs); } function getActiveMergeTotalMs(task: Task, nowMs: number): number | null { const endToEndMs = getTaskEndToEndDurationMs(task, nowMs); if (endToEndMs != null) { return endToEndMs; } const mergeElapsedMs = getMergeElapsedMs(task, nowMs); const instrumentedMs = getInstrumentedDurationMs(task, nowMs); if (instrumentedMs != null) { return instrumentedMs + (mergeElapsedMs ?? 0); } return mergeElapsedMs; } function getInstrumentedDurationMs(task: Task, nowMs: number): number | null { // Prefer server aggregate when present: it is the canonical persisted runtime // and may already include workflow execution. Avoid adding workflow runtime // again in that case. if (typeof task.timedExecutionMs === "number") { return task.timedExecutionMs; } const timed = getTimedDurationMs(task.log); const workflow = getWorkflowRuntimeMs(task.workflowStepResults, nowMs); if (timed == null && workflow == null) return null; return (timed ?? 0) + (workflow ?? 0); } function formatElapsedDuration(elapsedMs: number): string { if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return ""; if (elapsedMs < 60_000) return "<1m"; const elapsedMinutes = Math.floor(elapsedMs / 60_000); if (elapsedMinutes < 60) return `${elapsedMinutes}m`; const elapsedHours = Math.floor(elapsedMinutes / 60); if (elapsedHours < 24) return `${elapsedHours}h`; const elapsedDays = Math.floor(elapsedHours / 24); return `${elapsedDays}d`; } function normalizeBranchValue(value: string | undefined): string | null { if (!value) return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function getVisibleTaskCardBranches(task: Task): { branch: string | null; baseBranch: string | null } { const branch = normalizeBranchValue(task.branch); const baseBranch = normalizeBranchValue(task.baseBranch); const defaultBranchPrefix = `fusion/${task.id.toLowerCase()}`; const visibleBranch = branch && (branch === defaultBranchPrefix || branch.startsWith(`${defaultBranchPrefix}-`)) ? null : branch; const visibleBaseBranch = baseBranch?.toLowerCase() === "main" ? null : baseBranch; return { branch: visibleBranch, baseBranch: visibleBaseBranch ?? null, }; } export function formatElapsedDurationDone(elapsedMs: number): string { if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return ""; if (elapsedMs === 0) return ""; const elapsedMinutes = Math.ceil(elapsedMs / 60_000); if (elapsedMinutes < 59) return `${elapsedMinutes}m`; const elapsedHours = Math.ceil(elapsedMs / 3_600_000); if (elapsedHours < 24) return `${elapsedHours}h`; const elapsedDays = Math.ceil(elapsedMs / 86_400_000); return `${elapsedDays}d`; } interface TaskCardProps { task: Task; projectId?: string; queued?: boolean; onOpenDetail: (task: Task | TaskDetail) => void; onOpenGroupModal?: (groupId: string) => void; addToast: (message: string, type?: ToastType) => void; globalPaused?: boolean; onUpdateTask?: ( id: string, updates: { title?: string; description?: string; dependencies?: string[]; dismissNearDuplicate?: boolean } ) => Promise; onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise; onUnarchiveTask?: (id: string) => Promise; onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; githubIssueAction?: GithubIssueAction; }) => Promise; onRetryTask?: (id: string) => Promise; onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries") => 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, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise; /** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */ lastFetchTimeMs?: number; /** Lookup of workflow step IDs to display names, fetched once at board level. */ workflowStepNameLookup?: ReadonlyMap; /** Disable card drag semantics when embedding in custom draggable containers (e.g. dependency graph). */ disableDrag?: boolean; /** Downstream fan-out entry for this task, computed at board-level. */ fanout?: BlockerFanoutEntry; /** Whether GitHub CLI auth is available for creating PRs from task cards. */ prAuthAvailable?: boolean; /** Whether project-level auto-merge is enabled (hides manual Create PR quick action when true). */ autoMergeEnabled?: boolean; } function getTaskPrimaryPrInfo(task: Pick): PrInfo | undefined { return task.prInfos?.[0] ?? task.prInfo; } 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]); } function areTaskWorkflowStepIdsEqual(previous?: string[], next?: string[]): boolean { if (!previous && !next) return true; if (!previous || !next) return false; if (previous.length !== next.length) return false; return previous.every((stepId, index) => stepId === next[index]); } function getIssueUrlFromMetadata(metadata: Task["sourceMetadata"]): string | undefined { const issueUrl = metadata?.issueUrl; return typeof issueUrl === "string" && issueUrl.length > 0 ? issueUrl : undefined; } function parseGithubIssueUrl(url?: string): { owner: string; repo: string; number: number } | null { if (!url) return null; const match = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)(?:$|[/?#])/i); if (!match) return null; const issueNumber = Number(match[3]); if (!Number.isInteger(issueNumber) || issueNumber <= 0) return null; return { owner: match[1], repo: match[2], number: issueNumber, }; } function areTaskWorkflowResultsEqual(previous?: Task["workflowStepResults"], next?: Task["workflowStepResults"]): boolean { if (!previous && !next) return true; if (!previous || !next) return false; if (previous.length !== next.length) return false; return previous.every((result, index) => { const nextResult = next[index]; if (!nextResult) return false; return ( result.workflowStepId === nextResult.workflowStepId && result.workflowStepName === nextResult.workflowStepName && result.phase === nextResult.phase && result.status === nextResult.status && result.output === nextResult.output && result.startedAt === nextResult.startedAt && result.completedAt === nextResult.completedAt ); }); } /** * 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.prAuthAvailable === next.prAuthAvailable && previous.autoMergeEnabled === next.autoMergeEnabled && previous.onOpenDetail === next.onOpenDetail && previous.onOpenGroupModal === next.onOpenGroupModal && previous.addToast === next.addToast && previous.onUpdateTask === next.onUpdateTask && previous.onArchiveTask === next.onArchiveTask && previous.onUnarchiveTask === next.onUnarchiveTask && previous.onDeleteTask === next.onDeleteTask && previous.onRetryTask === next.onRetryTask && previous.onOpenDetailWithTab === next.onOpenDetailWithTab && previous.onOpenMission === next.onOpenMission && previous.onMoveTask === next.onMoveTask && previous.workflowStepNameLookup === next.workflowStepNameLookup && previous.disableDrag === next.disableDrag && previous.fanout?.totalCount === next.fanout?.totalCount && previous.fanout?.activeTodoCount === next.fanout?.activeTodoCount && previous.fanout?.isHighFanout === next.fanout?.isHighFanout && previous.fanout?.overlapBlockedTodoCount === next.fanout?.overlapBlockedTodoCount && previous.fanout?.escalation?.blockingAgeMs === next.fanout?.escalation?.blockingAgeMs && areTaskDependenciesEqual(previous.fanout?.dependentIds ?? [], next.fanout?.dependentIds ?? []) && areTaskDependenciesEqual(previous.fanout?.staleBlockedByDependentIds ?? [], next.fanout?.staleBlockedByDependentIds ?? []) && previousTask.id === nextTask.id && previousTask.title === nextTask.title && previousTask.description === nextTask.description && previousTask.column === nextTask.column && previousTask.columnMovedAt === nextTask.columnMovedAt && previousTask.timedExecutionMs === nextTask.timedExecutionMs && previousTask.updatedAt === nextTask.updatedAt && previousTask.createdAt === nextTask.createdAt && previousTask.status === nextTask.status && previousTask.priority === nextTask.priority && previousTask.executionMode === nextTask.executionMode && previousTask.paused === nextTask.paused && previousTask.userPaused === nextTask.userPaused && previousTask.error === nextTask.error && previousTask.size === nextTask.size && previousTask.blockedBy === nextTask.blockedBy && previousTask.overlapBlockedBy === nextTask.overlapBlockedBy && previousTask.worktree === nextTask.worktree && previousTask.branch === nextTask.branch && 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.planningModelProvider === nextTask.planningModelProvider && previousTask.planningModelId === nextTask.planningModelId && previousTask.reviewLevel === nextTask.reviewLevel && previousTask.missionId === nextTask.missionId && previousTask.assignedAgentId === nextTask.assignedAgentId && previousTask.mergeRetries === nextTask.mergeRetries && previousTask.retrySummary?.total === nextTask.retrySummary?.total && previousTask.sourceType === nextTask.sourceType && previousTask.sourceAgentId === nextTask.sourceAgentId && previousTask.sourceMetadata?.issueUrl === nextTask.sourceMetadata?.issueUrl && previousTask.sourceMetadata?.agentName === nextTask.sourceMetadata?.agentName && previousTask.sourceMetadata?.nearDuplicateOf === nextTask.sourceMetadata?.nearDuplicateOf && previousTask.sourceMetadata?.nearDuplicateDismissed === nextTask.sourceMetadata?.nearDuplicateDismissed && previousTask.stalledReview?.reason === nextTask.stalledReview?.reason && previousTask.stalledReview?.heuristic === nextTask.stalledReview?.heuristic && previousTask.stalledReview?.matchCount === nextTask.stalledReview?.matchCount && previousTask.stalledReview?.firstMatchAt === nextTask.stalledReview?.firstMatchAt && previousTask.stalledReview?.lastMatchAt === nextTask.stalledReview?.lastMatchAt && previousTask.ageStaleness?.level === nextTask.ageStaleness?.level && previousTask.ageStaleness?.reason === nextTask.ageStaleness?.reason && previousTask.ageStaleness?.observedAt === nextTask.ageStaleness?.observedAt && previousTask.ageStaleness?.ageMs === nextTask.ageStaleness?.ageMs && previousTask.ageStaleness?.warningThresholdMs === nextTask.ageStaleness?.warningThresholdMs && previousTask.ageStaleness?.criticalThresholdMs === nextTask.ageStaleness?.criticalThresholdMs && previousTask.ageStaleness?.column === nextTask.ageStaleness?.column && previousTask.ageStaleness?.paused === nextTask.ageStaleness?.paused && areAttachmentsEqual(previousTask.attachments, nextTask.attachments) && areCommentsEqual(previousTask.comments, nextTask.comments) && areTaskDependenciesEqual(previousTask.dependencies, nextTask.dependencies) && areTaskStepsEqual(previousTask.steps, nextTask.steps) && areTaskWorkflowStepIdsEqual(previousTask.enabledWorkflowSteps, nextTask.enabledWorkflowSteps) && areTaskWorkflowResultsEqual(previousTask.workflowStepResults, nextTask.workflowStepResults) && areTaskBadgeInfosEqual(previousTask.prInfo, nextTask.prInfo) && ((previousTask.prInfos?.length ?? 0) === (nextTask.prInfos?.length ?? 0)) && (previousTask.prInfos ?? []).every((pr, index) => { const nextPr = nextTask.prInfos?.[index]; return nextPr?.number === pr.number && nextPr?.status === pr.status; }) && areTaskBadgeInfosEqual(previousTask.issueInfo, nextTask.issueInfo) ); } function TaskCardComponent({ task, projectId, queued, onOpenDetail, onOpenGroupModal, addToast, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onRetryTask, onOpenDetailWithTab, taskStuckTimeoutMs, onOpenMission, onMoveTask, lastFetchTimeMs, workflowStepNameLookup, disableDrag, fanout, prAuthAvailable, autoMergeEnabled = false, }: 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 [isRetrying, setIsRetrying] = useState(false); const [isPrCreateOpen, setIsPrCreateOpen] = useState(false); const [timeIndicatorNowMs, setTimeIndicatorNowMs] = useState(() => Date.now()); 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); const { agentsMap } = useAgentsMapCache(projectId); const { confirm } = useConfirm(); const retryWarningThreshold = useRetryWarning(); // 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; } const cachedFromMap = getResolvedAgentNameFromMap(task.assignedAgentId, agentsMap); if (cachedFromMap) { agentNameCache.set(task.assignedAgentId, cachedFromMap); setAgentName(cachedFromMap); return; } const cached = agentNameCache.get(task.assignedAgentId); if (cached) { setAgentName(cached); return; } setAgentName(null); let cancelled = false; void getAgentName(task.assignedAgentId, projectId).then((name) => { if (!cancelled) setAgentName(name); }); return () => { cancelled = true; }; }, [agentsMap, 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) { addToast(`Failed to attach ${file.name}: ${getErrorMessage(err)}`, "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. // Prevent default here to suppress Android compatibility mouse events // (mousedown/mouseup/click) that would otherwise hit a newly-mounted overlay. if (isQuickTap && isStationary) { e.preventDefault(); 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 { addToast(`Failed to load dependency ${depId}`, "error"); } }, [onOpenDetail, addToast]); const isDoneColumn = task.column === "done"; const visualStatus = isDoneColumn ? "done" : task.status; const isFailed = !isDoneColumn && task.status === "failed"; const isPaused = !isDoneColumn && (task.paused === true || task.userPaused === true); const pausedByAgent = Boolean(!isDoneColumn && task.paused && task.pausedByAgentId); const normalizedPriority = normalizeTaskPriorityValue(task.priority); const showPriorityBadge = normalizedPriority !== DEFAULT_TASK_PRIORITY; const isStuck = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs); const stalledReview = getStalledReviewSignal(task); const showStalledReview = Boolean(stalledReview && task.column === "in-review" && !isPaused); const hasInReviewStall = shouldShowInReviewStallBadge(task); const stallCopy = task.inReviewStall ? getInReviewStallCopy(task.inReviewStall, { mergeRetries: task.mergeRetries, maxAutoMergeRetries: MAX_AUTO_MERGE_RETRIES, }) : undefined; const hasStalePausedReview = shouldShowStalePausedReviewBadge(task); const stalePausedReviewCopy = task.stalePausedReview ? getStalePausedReviewCopy(task.stalePausedReview) : undefined; const hasTaskAgeStaleness = shouldShowTaskAgeStalenessBadge(task); const taskAgeStalenessCopy = getTaskAgeStalenessCopy(task.ageStaleness); 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(visualStatus as string)); const isDraggable = !disableDrag && !queued && !isPaused && !isEditing && !isArchived; // Disable drag during edit/archived or host embedding // Check if this card can be edited inline const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask; const githubTrackedIssue = task.githubTracking?.issue; const hasGithubTrackingLink = Boolean(githubTrackedIssue); const isGitHubImportedTask = task.sourceType === "github_import"; const sourceIssueUrl = getIssueUrlFromMetadata(task.sourceMetadata); const sourceIssueFromUrl = useMemo(() => parseGithubIssueUrl(sourceIssueUrl), [sourceIssueUrl]); const issueInfoFromUrl = useMemo(() => parseGithubIssueUrl(task.issueInfo?.url), [task.issueInfo?.url]); const issueInfoOwner = issueInfoFromUrl?.owner; const issueInfoRepo = issueInfoFromUrl?.repo; const hasMatchingIssueInfoBadge = Boolean( task.issueInfo && githubTrackedIssue && task.issueInfo.number === githubTrackedIssue.number && issueInfoOwner === githubTrackedIssue.owner && issueInfoRepo === githubTrackedIssue.repo, ); const hasMatchingSourceIssue = Boolean( sourceIssueFromUrl && githubTrackedIssue && sourceIssueFromUrl.number === githubTrackedIssue.number && sourceIssueFromUrl.owner === githubTrackedIssue.owner && sourceIssueFromUrl.repo === githubTrackedIssue.repo, ); const showLinkedIssueChipForImport = isGitHubImportedTask && hasGithubTrackingLink && (hasMatchingIssueInfoBadge || hasMatchingSourceIssue); const showTrackingIndicator = hasGithubTrackingLink && !hasMatchingIssueInfoBadge && !hasMatchingSourceIssue; const showNearDuplicateChip = Boolean(task.sourceMetadata?.nearDuplicateOf) && task.sourceMetadata?.nearDuplicateDismissed !== true && task.column !== "archived" && task.column !== "done"; const branchMetadata = useMemo(() => getVisibleTaskCardBranches(task), [task.id, task.branch, task.baseBranch]); const hasBranchMetadata = Boolean(branchMetadata.branch || branchMetadata.baseBranch); const isAgentCreated = isAgentCreatedTask(task); const sourceAgentName = getSourceAgentName(task, agentsMap); const agentCreatedVisibleLabel = sourceAgentName ? abbreviateBadge(sourceAgentName, 15) : "Agent"; const agentCreatedTitle = sourceAgentName ? `Created by agent: ${sourceAgentName}` : "Created by agent"; const assignedAgentNameFromMap = getResolvedAgentNameFromMap(task.assignedAgentId, agentsMap); const assignedAgentNameFromCache = task.assignedAgentId ? agentNameCache.get(task.assignedAgentId) ?? null : null; const resolvedAssignedAgentName = assignedAgentNameFromMap ?? assignedAgentNameFromCache ?? agentName; const assignedAgentBadgeLabel = resolvedAssignedAgentName ?? task.assignedAgentId ?? ""; const isAgentNameLoading = Boolean(task.assignedAgentId && !resolvedAssignedAgentName); const taskProviders = useMemo(() => { const providers: string[] = []; if (task.modelProvider) providers.push(task.modelProvider); if (task.validatorModelProvider && !providers.includes(task.validatorModelProvider)) { providers.push(task.validatorModelProvider); } if (task.planningModelProvider && !providers.includes(task.planningModelProvider)) { providers.push(task.planningModelProvider); } return providers; }, [task.modelProvider, task.validatorModelProvider, task.planningModelProvider]); const unifiedProgress = useMemo( () => getUnifiedTaskProgress(task, workflowStepNameLookup), [task.steps, task.enabledWorkflowSteps, task.workflowStepResults, workflowStepNameLookup], ); const showProgressSection = unifiedProgress.total > 0 && (task.status === "executing" || task.column === "in-progress"); useEffect(() => { if (task.column !== "in-progress" && task.column !== "in-review") { return; } const merging = task.status != null && ACTIVE_MERGE_STATUSES.has(task.status); if (task.column === "in-progress") { const endToEndMs = getTaskEndToEndDurationMs(task, Date.now()); const elapsedMs = getInProgressElapsedMs(task, Date.now()); const instrumentedMs = getInstrumentedDurationMs(task, Date.now()); if (endToEndMs == null && elapsedMs == null && instrumentedMs == null) { return; } } if (!merging && task.column === "in-review") { const endToEndMs = getTaskEndToEndDurationMs(task, Date.now()); const instrumentedMs = getInstrumentedDurationMs(task, Date.now()); if (endToEndMs == null && instrumentedMs == null) { return; } } setTimeIndicatorNowMs(Date.now()); const interval = window.setInterval(() => { setTimeIndicatorNowMs(Date.now()); }, LIVE_TIME_INDICATOR_POLL_MS); return () => window.clearInterval(interval); }, [task.column, task.status, task.columnMovedAt, task.updatedAt, task.workflowStepResults, task.timedExecutionMs, task.firstExecutionAt, task.cumulativeActiveMs, task.executionStartedAt, task.executionCompletedAt]); const timeIndicator = useMemo(() => { if (!TIME_INDICATOR_COLUMNS.has(task.column)) { return null; } // While a merge is actively running, continue showing live end-to-end // execution time. For legacy tasks without executionStartedAt, fall back // to instrumented runtime plus live merge-phase elapsed since `updatedAt`. if (task.status != null && ACTIVE_MERGE_STATUSES.has(task.status)) { const totalMs = getActiveMergeTotalMs(task, timeIndicatorNowMs); if (totalMs != null) { const elapsedLabel = formatElapsedDurationDone(totalMs); if (elapsedLabel) { const mergeElapsedMs = getMergeElapsedMs(task, timeIndicatorNowMs); const mergeLabel = mergeElapsedMs == null ? null : formatElapsedDuration(mergeElapsedMs); const title = mergeLabel ? `Execution time ${elapsedLabel}. Merge phase ${mergeLabel}` : `Execution time ${elapsedLabel}. Merging`; return { label: elapsedLabel, title, ariaLabel: title, }; } } } if (task.column === "in-progress") { // Prefer the persistent execution start (set on first transition to // in-progress, never reset on retry-loop bounces). Fall back to the // columnMovedAt heuristic for legacy tasks predating the new field. const elapsedMs = getTaskEndToEndDurationMs(task, timeIndicatorNowMs) ?? getInProgressElapsedMs(task, timeIndicatorNowMs) ?? getInstrumentedDurationMs(task, timeIndicatorNowMs); if (elapsedMs == null) { return null; } const elapsedLabel = formatElapsedDuration(elapsedMs); if (!elapsedLabel) { return null; } return { label: elapsedLabel, title: `In progress ${elapsedLabel}`, ariaLabel: `In progress ${elapsedLabel}`, }; } // in-review and done: show wall-clock end-to-end runtime. Falls back to // the instrumented `[timing]` aggregate for tasks completed before // `executionStartedAt`/`executionCompletedAt` were tracked. const endToEndMs = getTaskEndToEndDurationMs(task, timeIndicatorNowMs); const totalMs = endToEndMs ?? getInstrumentedDurationMs(task, timeIndicatorNowMs); if (totalMs == null) { return null; } const elapsedLabel = formatElapsedDurationDone(totalMs); if (!elapsedLabel) { return null; } const completionMs = getInReviewCompletionMs(task); if (completionMs == null) { return { label: elapsedLabel, title: `Execution time ${elapsedLabel}`, ariaLabel: `Execution time ${elapsedLabel}`, }; } const completedAt = new Date(completionMs).toLocaleString(); return { label: elapsedLabel, title: `Execution time ${elapsedLabel}. Completed ${completedAt}`, ariaLabel: `Execution time ${elapsedLabel}. Completed ${completedAt}`, }; }, [task.column, task.status, task.columnMovedAt, task.timedExecutionMs, task.updatedAt, task.workflowStepResults, task.log, task.firstExecutionAt, task.cumulativeActiveMs, task.executionStartedAt, task.executionCompletedAt, timeIndicatorNowMs]); const liveBadgeData = badgeUpdates.get(`${projectId ?? "default"}:${task.id}`); // Get fresh batch data if available const batchData = useMemo(() => getFreshBatchData(task.id, projectId), [task.id, projectId]); const hasEverHadGitHubBadgeSourceRef = useRef(false); const hasCurrentGitHubBadgeSource = Boolean( getTaskPrimaryPrInfo(task) || task.issueInfo || liveBadgeData?.prInfo || liveBadgeData?.issueInfo || batchData?.result?.prInfo || batchData?.result?.issueInfo, ); if (hasCurrentGitHubBadgeSource) { hasEverHadGitHubBadgeSourceRef.current = true; } const hasGitHubBadgeSource = hasCurrentGitHubBadgeSource || hasEverHadGitHubBadgeSourceRef.current; useEffect(() => { if (!hasGitHubBadgeSource || !isInViewport) { unsubscribeFromBadge(task.id); return; } subscribeToBadge(task.id); return () => { unsubscribeFromBadge(task.id); }; }, [hasGitHubBadgeSource, isInViewport, subscribeToBadge, task.id, unsubscribeFromBadge]); // 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], ); const mergeSignature = useMemo(() => { if (task.column !== "done") { return undefined; } const landedFilesCount = task.mergeDetails?.landedFiles?.length ?? ""; const filesChanged = task.mergeDetails?.filesChanged ?? ""; return `${landedFilesCount}:${filesChanged}`; }, [task.column, task.mergeDetails?.landedFiles?.length, task.mergeDetails?.filesChanged]); // Viewport-gated diff stats fetching - only fetch when card is visible const { stats: diffStats, loading: diffLoading } = useTaskDiffStats( task.id, task.column, task.mergeDetails?.commitSha, projectId, { enabled: isInViewport, worktree: task.worktree, stepVersion: isActiveColumn ? stepVersion : undefined, mergeSignature, pollIntervalMs: isActiveColumn ? 30_000 : undefined, }, ); // 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 = getTaskPrimaryPrInfo(task); const taskTimestamp = taskInfo?.lastCheckedAt ?? task.updatedAt; let bestData = taskInfo; let bestTimestamp = taskTimestamp; if (wsData && (!bestTimestamp || (wsTimestamp != null && wsTimestamp >= bestTimestamp))) { bestData = wsData; bestTimestamp = wsTimestamp ?? bestTimestamp; } if (batchInfo && (!bestTimestamp || (batchTimestamp != null && batchTimestamp >= bestTimestamp))) { bestData = batchInfo; } return bestData; }, [liveBadgeData, batchData, task, 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; let bestData = taskInfo; let bestTimestamp = taskTimestamp; if (wsData && (!bestTimestamp || (wsTimestamp != null && wsTimestamp >= bestTimestamp))) { bestData = wsData; bestTimestamp = wsTimestamp ?? bestTimestamp; } if (batchInfo && (!bestTimestamp || (batchTimestamp != null && batchTimestamp >= bestTimestamp))) { bestData = batchInfo; } return bestData; }, [liveBadgeData, batchData, task.issueInfo, task.updatedAt]); const showInReviewMoveControl = task.column === "in-review" && Boolean(onMoveTask); const showCreatePrQuickAction = task.column === "in-review" && autoMergeEnabled !== true && !livePrInfo && prAuthAvailable === true && !isPaused && !isFailed && !queued; const metaRowVisible = (task.dependencies?.length ?? 0) > 0 || queued || task.status === "queued" || Boolean(task.blockedBy) || Boolean(task.overlapBlockedBy) || Boolean(fanout && fanout.totalCount > 0); const shouldRenderActionRow = showCreatePrQuickAction || (showInReviewMoveControl && !metaRowVisible); const renderInReviewMoveControl = () => (
{showSendBackMenu && (
{VALID_TRANSITIONS["in-review"].map((col) => ( ))}
)}
); 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) { addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "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 handleDismissNearDuplicate = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); if (!onUpdateTask) return; try { await onUpdateTask(task.id, { dismissNearDuplicate: true }); addToast(`Kept ${task.id}; duplicate warning dismissed`, "success"); } catch (err) { addToast(`Failed to keep ${task.id}: ${getErrorMessage(err)}`, "error"); } }, [addToast, onUpdateTask, task.id]); const handleArchiveClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); if (!onArchiveTask) return; void onArchiveTask(task.id).then(() => { addToast(`Archived ${task.id}`, "success"); }).catch(async (err) => { const lineageConflict = extractLineageDeleteConflict(err); if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) { addToast(`Failed to archive ${task.id}: ${getErrorMessage(err)}`, "error"); return; } const confirmed = await confirm({ title: "Force Delete Task", message: `${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` + "Archive anyway by unlinking these references first?", danger: true, }); if (!confirmed) { return; } try { await onArchiveTask(task.id, { removeLineageReferences: true }); addToast(`Archived ${task.id} after unlinking lineage references`, "success"); } catch (retryErr) { addToast(`Failed to archive ${task.id}: ${getErrorMessage(retryErr)}`, "error"); } }); }, [addToast, confirm, 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) => { addToast(`Failed to unarchive ${task.id}: ${getErrorMessage(err)}`, "error"); }); }, [addToast, onUnarchiveTask, task.id]); const handleDeleteClick = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); if (!onDeleteTask) return; const shouldDelete = await confirm({ title: "Delete Task", message: `Delete ${task.id}?`, danger: true, }); if (!shouldDelete) { return; } const trackedIssue = task.githubTracking?.enabled === true ? task.githubTracking.issue : undefined; const sourceIssueRef = (() => { if (trackedIssue) { return null; } const sourceIssue = task.sourceIssue; if (sourceIssue?.provider === "github") { const [owner, repo, extra] = sourceIssue.repository.split("/"); if (owner && repo && !extra && Number.isInteger(sourceIssue.issueNumber) && sourceIssue.issueNumber > 0) { return { owner, repo, number: sourceIssue.issueNumber }; } } return parseGithubIssueUrl(getIssueUrlFromMetadata(task.sourceMetadata) ?? task.issueInfo?.url); })(); const issueRef = trackedIssue?.owner && trackedIssue.repo && trackedIssue.number ? { owner: trackedIssue.owner, repo: trackedIssue.repo, number: trackedIssue.number } : sourceIssueRef; let githubIssueAction: GithubIssueAction | undefined; if (issueRef?.owner && issueRef.repo && issueRef.number) { const issueLabel = `${issueRef.owner}/${issueRef.repo}#${issueRef.number}`; const shouldCloseIssue = await confirm({ title: "Linked GitHub Issue", message: `Choose what to do with ${issueLabel} when deleting ${task.id}.\n\nClose the issue?`, confirmLabel: "Close Issue", cancelLabel: "More Options", }); if (shouldCloseIssue) { githubIssueAction = "close"; } else { const shouldDeleteIssue = await confirm({ title: "Delete Linked GitHub Issue", message: `Delete ${issueLabel} on GitHub, or leave it unchanged?`, confirmLabel: "Delete Issue", cancelLabel: "Leave Unchanged", danger: true, }); githubIssueAction = shouldDeleteIssue ? "delete" : "leave"; } } try { if (githubIssueAction) { await onDeleteTask(task.id, { githubIssueAction }); } else { await onDeleteTask(task.id); } const issueSuffix = issueRef?.owner && issueRef.repo && issueRef.number && githubIssueAction ? ` and ${githubIssueAction === "close" ? "closed" : githubIssueAction === "delete" ? "deleted" : "left"} issue ${issueRef.owner}/${issueRef.repo}#${issueRef.number}` : ""; addToast(`Deleted ${task.id}${issueSuffix}`, "success"); } catch (err) { const dependencyConflict = extractDependencyDeleteConflict(err); if (dependencyConflict && dependencyConflict.dependentIds.length > 0) { const dependentList = dependencyConflict.dependentIds.join(", "); const confirmed = await confirm({ title: "Force Delete Task", message: `${task.id} is a dependency of ${dependentList}.\n\n` + "Delete anyway by removing these dependency references first?", danger: true, }); if (!confirmed) { return; } try { await onDeleteTask(task.id, { removeDependencyReferences: true, removeLineageReferences: true, githubIssueAction, }); addToast(`Deleted ${task.id} after removing dependency references`, "success"); } catch (retryErr) { const lineageConflict = extractLineageDeleteConflict(retryErr); if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) { addToast(`Failed to delete ${task.id}: ${getErrorMessage(retryErr)}`, "error"); return; } const confirmedLineage = await confirm({ title: "Force Delete Task", message: `${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` + "Delete anyway by unlinking these references first?", danger: true, }); if (!confirmedLineage) { return; } try { await onDeleteTask(task.id, { removeDependencyReferences: true, removeLineageReferences: true, githubIssueAction, }); addToast(`Deleted ${task.id} after unlinking lineage references`, "success"); } catch (lineageRetryErr) { addToast(`Failed to delete ${task.id}: ${getErrorMessage(lineageRetryErr)}`, "error"); } } return; } const lineageConflict = extractLineageDeleteConflict(err); if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) { addToast(`Failed to delete ${task.id}: ${getErrorMessage(err)}`, "error"); return; } const confirmed = await confirm({ title: "Force Delete Task", message: `${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` + "Delete anyway by unlinking these references first?", danger: true, }); if (!confirmed) { return; } try { await onDeleteTask(task.id, { removeDependencyReferences: true, removeLineageReferences: true, githubIssueAction, }); addToast(`Deleted ${task.id} after unlinking lineage references`, "success"); } catch (retryErr) { addToast(`Failed to delete ${task.id}: ${getErrorMessage(retryErr)}`, "error"); } } }, [addToast, confirm, onDeleteTask, task.githubTracking?.enabled, task.githubTracking?.issue, task.id, task.issueInfo?.url, task.sourceIssue, task.sourceMetadata]); const handleOpenFiles = useCallback((e: React.MouseEvent) => { e.stopPropagation(); onOpenDetailWithTab?.(task, "changes"); }, [task, onOpenDetailWithTab]); const handleOpenRetries = useCallback((e: React.MouseEvent) => { e.stopPropagation(); onOpenDetailWithTab?.(task, "retries"); }, [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(async (e: React.MouseEvent, column: Column) => { e.stopPropagation(); setShowSendBackMenu(false); if (!onMoveTask) return; try { const hasStepProgress = task.steps.some((step) => step.status !== "pending"); const shouldPrompt = (column === "todo" || column === "triage") && hasStepProgress; let moveOptions: { preserveProgress?: boolean } | undefined; if (shouldPrompt) { const keepProgress = await confirm({ title: "Preserve Progress?", message: "This task has completed steps. Keep progress before moving?", confirmLabel: "Keep Progress", cancelLabel: "Reset Progress", }); if (keepProgress) { moveOptions = { preserveProgress: true }; } else { const resetProgress = await confirm({ title: "Reset Progress?", message: "Reset all step progress before moving this task?", confirmLabel: "Reset Progress", cancelLabel: "Cancel Move", danger: true, }); if (!resetProgress) { return; } } } await onMoveTask(task.id, column, moveOptions); addToast(`Moved ${task.id} to ${COLUMN_LABELS[column]}`, "success"); } catch (err) { addToast(`Failed to move ${task.id}: ${getErrorMessage(err)}`, "error"); } }, [addToast, confirm, onMoveTask, task.id, task.steps]); const handleRetryTask = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); if (!onRetryTask || isRetrying) return; setIsRetrying(true); try { await onRetryTask(task.id); } catch (err) { addToast(`Failed to retry ${task.id}: ${getErrorMessage(err)}`, "error"); } finally { setIsRetrying(false); } }, [addToast, isRetrying, onRetryTask, 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" : ""}`; const filesChangedButton = (() => { if (task.column === "in-progress") { const activeDiffCount = diffStats?.filesChanged; const fallbackCount = activeDiffCount == null ? task.modifiedFiles?.length : undefined; const displayCount = activeDiffCount ?? fallbackCount; if (displayCount == null || displayCount === 0) { return null; } return ( ); } if (task.column === "in-review") { const reviewDiffCount = diffStats?.filesChanged; const fallbackCount = reviewDiffCount == null ? task.modifiedFiles?.length : undefined; const displayCount = reviewDiffCount ?? fallbackCount; if (displayCount == null || displayCount === 0) { return null; } return ( ); } if (task.column === "done") { // Done cards only display committed diff counts from authoritative lineage // stats or recorded landed files; transient execution-touched files are not shown. let displayCount: number | undefined; if (diffStats) { const landed = task.mergeDetails?.landedFiles; const restricted = task.mergeDetails?.landedFilesAttributionRestricted === true; displayCount = (restricted && Array.isArray(landed)) ? Math.min(diffStats.filesChanged, landed.length) : diffStats.filesChanged; } else if (diffLoading) { displayCount = task.mergeDetails?.filesChanged ?? undefined; } else { displayCount = task.mergeDetails?.landedFiles?.length; } if (displayCount != null && displayCount > 0) { return ( ); } } return null; })(); const chipFarRight = TIME_INDICATOR_COLUMNS.has(task.column) && filesChangedButton == null && showTrackingIndicator && Boolean(githubTrackedIssue); if (isEditing) { return (