import { memo, useEffect, useMemo, useReducer, useState } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; import { OngoingIndicator } from '@renderer/components/common/OngoingIndicator'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge'; import { Button } from '@renderer/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useTheme } from '@renderer/hooks/useTheme'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; import { REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers'; import { buildTaskChangeRequestOptions, canDisplayTaskChangesForOptions, } from '@renderer/utils/taskChangeRequest'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { isTeamTaskFinishedForDependency, isTeamTaskNeedsFixActionable, } from '@shared/utils/teamTaskState'; import { ArrowLeftFromLine, ArrowRightFromLine, CheckCircle2, Eye, FileCode, FilePenLine, HelpCircle, Play, RotateCcw, Trash2, XCircle, } from 'lucide-react'; import type { KanbanColumnId, KanbanTaskState, TaskComment, TeamTask, TeamTaskWithKanban, } from '@shared/types'; interface KanbanTaskCardProps { task: TeamTaskWithKanban; teamName: string; columnId: KanbanColumnId; kanbanTaskState?: KanbanTaskState; hasReviewers: boolean; compact?: boolean; taskMap: Map; memberColorMap: Map; hasLiveTaskLogs?: boolean; onRequestReview: (taskId: string) => void; onApprove: (taskId: string) => void; onRequestChanges: (taskId: string) => void; onMoveBackToDone: (taskId: string) => void; onStartTask: (taskId: string) => void; onCompleteTask: (taskId: string) => void; onCancelTask: (taskId: string) => void; onScrollToTask?: (taskId: string) => void; onTaskClick?: (task: TeamTask) => void; onViewChanges?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; } interface DependencyBadgeProps { taskId: string; taskMap: Map; onScrollToTask?: (taskId: string) => void; } interface CommentPulseState { taskKey: string; commentCount: number; commentIds: Set; pulseKey: number; } interface CommentPulseSyncAction { taskKey: string; comments: readonly TaskComment[]; } const EMPTY_TASK_COMMENTS: readonly TaskComment[] = []; const taskCardSignatureCache = new WeakMap(); function getTaskCardSignature(task: TeamTaskWithKanban): string { const cached = taskCardSignatureCache.get(task); if (cached !== undefined) return cached; const signature = JSON.stringify(task); taskCardSignatureCache.set(task, signature); return signature; } function areKanbanTaskStatesEqual( prev: KanbanTaskState | undefined, next: KanbanTaskState | undefined ): boolean { if (prev === next) return true; if (!prev || !next) return !prev && !next; return ( prev.column === next.column && prev.reviewer === next.reviewer && prev.errorDescription === next.errorDescription && prev.movedAt === next.movedAt ); } function getTaskDependencyIds(task: TeamTaskWithKanban): string[] { return [...(task.blockedBy ?? []), ...(task.blocks ?? [])].filter((id) => id.length > 0); } function getDependencyTaskSignature(task: TeamTask | undefined): string { if (!task) return ''; const kanbanTask = task as Partial; return [ task.id, task.displayId ?? '', task.subject, task.status, task.reviewState ?? '', kanbanTask.kanbanColumn ?? '', ].join('\u001f'); } function areTaskMapDependenciesEqual( prevTask: TeamTaskWithKanban, nextTask: TeamTaskWithKanban, prevTaskMap: Map, nextTaskMap: Map ): boolean { const dependencyIds = new Set([ ...getTaskDependencyIds(prevTask), ...getTaskDependencyIds(nextTask), ]); for (const taskId of dependencyIds) { if ( getDependencyTaskSignature(prevTaskMap.get(taskId)) !== getDependencyTaskSignature(nextTaskMap.get(taskId)) ) { return false; } } return true; } function createCommentPulseState( taskKey: string, comments: readonly TaskComment[], pulseKey = 0 ): CommentPulseState { return { taskKey, commentCount: comments.length, commentIds: new Set(comments.map((comment) => comment.id)), pulseKey, }; } function hasSameCommentIds(state: CommentPulseState, comments: readonly TaskComment[]): boolean { return ( comments.length === state.commentCount && comments.every((comment) => state.commentIds.has(comment.id)) ); } function syncCommentPulseState( state: CommentPulseState, action: CommentPulseSyncAction ): CommentPulseState { if (state.taskKey !== action.taskKey) { return createCommentPulseState(action.taskKey, action.comments); } const hasNewIncomingComment = action.comments.length > state.commentCount && action.comments.some( (comment) => !state.commentIds.has(comment.id) && comment.author !== 'user' ); if (!hasNewIncomingComment && hasSameCommentIds(state, action.comments)) { return state; } return createCommentPulseState( action.taskKey, action.comments, hasNewIncomingComment ? state.pulseKey + 1 : state.pulseKey ); } const DependencyBadge = ({ taskId, taskMap, onScrollToTask, }: DependencyBadgeProps): React.JSX.Element => { const depTask = taskMap.get(taskId); const isCompleted = depTask ? isTeamTaskFinishedForDependency(depTask) : false; const label = depTask ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` : `#${deriveTaskDisplayId(taskId)}`; return ( {label} ); }; const TruncatedTitle = ({ text, className, }: { text: string; className?: string; }): React.JSX.Element => (
{text}
); const CancelTaskButton = ({ taskId, onConfirm, }: { taskId: string; onConfirm: (taskId: string) => void; }): React.JSX.Element => { const { t } = useAppTranslation('team'); const [open, setOpen] = useState(false); return ( {t('kanban.taskCard.cancel')} {open ? ( e.stopPropagation()} >

{t('kanban.taskCard.moveBackToTodoConfirm')}

) : null}
); }; interface TaskActionIconButtonProps { label: string; icon: React.ReactNode; onClick: (e: React.MouseEvent) => void; className: string; variant?: 'outline' | 'ghost' | 'destructive'; disabled?: boolean; } const TaskActionIconButton = ({ label, icon, onClick, className, variant = 'outline', disabled = false, }: TaskActionIconButtonProps): React.JSX.Element => ( ); interface TaskMetaActionsProps { taskId: string; unreadCount: number; commentCount: number; pulseKey: number; canOpenChanges: boolean; changesNeedAttention: boolean; onViewChanges?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; } const TaskMetaActions = memo(function TaskMetaActions({ taskId, unreadCount, commentCount, pulseKey, canOpenChanges, changesNeedAttention, onViewChanges, onDeleteTask, }: TaskMetaActionsProps): React.JSX.Element { const { t } = useAppTranslation('team'); return ( <> {canOpenChanges && onViewChanges ? ( } variant="ghost" className={ changesNeedAttention ? 'text-amber-400 hover:bg-amber-500/10 hover:text-amber-300' : 'text-sky-400 hover:bg-sky-500/10 hover:text-sky-300' } onClick={(e) => { e.stopPropagation(); onViewChanges(taskId); }} /> ) : null} {onDeleteTask ? ( } variant="ghost" className="text-red-400 hover:bg-red-500/10 hover:text-red-300" onClick={(e) => { e.stopPropagation(); onDeleteTask(taskId); }} /> ) : null} ); }); interface TaskPrimaryActionsProps { taskId: string; columnId: KanbanColumnId; isReviewManual: boolean; onRequestReview: (taskId: string) => void; onApprove: (taskId: string) => void; onRequestChanges: (taskId: string) => void; onMoveBackToDone: (taskId: string) => void; onStartTask: (taskId: string) => void; onCompleteTask: (taskId: string) => void; onCancelTask: (taskId: string) => void; } const TaskPrimaryActions = memo(function TaskPrimaryActions({ taskId, columnId, isReviewManual, onRequestReview, onApprove, onRequestChanges, onMoveBackToDone, onStartTask, onCompleteTask, onCancelTask, }: TaskPrimaryActionsProps): React.JSX.Element { const { t } = useAppTranslation('team'); return (
{columnId === 'todo' ? ( <> } className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" onClick={(e) => { e.stopPropagation(); onStartTask(taskId); }} /> } className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" onClick={(e) => { e.stopPropagation(); onCompleteTask(taskId); }} /> ) : null} {columnId === 'in_progress' ? ( <> } className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" onClick={(e) => { e.stopPropagation(); onCompleteTask(taskId); }} /> ) : null} {columnId === 'done' ? ( <> } className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" onClick={(e) => { e.stopPropagation(); onApprove(taskId); }} /> } className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300" onClick={(e) => { e.stopPropagation(); onRequestReview(taskId); }} /> ) : null} {columnId === 'review' ? (
{isReviewManual ? (
{t('kanban.taskCard.manualReview')}
) : null}
} className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" onClick={(e) => { e.stopPropagation(); onApprove(taskId); }} /> } variant="destructive" className="bg-red-500/90 text-white hover:bg-red-500" onClick={(e) => { e.stopPropagation(); onRequestChanges(taskId); }} />
) : null} {columnId === 'approved' ? ( } className="border-amber-500/40 text-amber-400 hover:bg-amber-500/10 hover:text-amber-300" onClick={(e) => { e.stopPropagation(); onMoveBackToDone(taskId); }} /> ) : null}
); }); export const KanbanTaskCard = memo( function KanbanTaskCard({ task, teamName, columnId, kanbanTaskState, hasReviewers, compact, taskMap, memberColorMap, hasLiveTaskLogs = false, onRequestReview, onApprove, onRequestChanges, onMoveBackToDone, onStartTask, onCompleteTask, onCancelTask, onScrollToTask, onTaskClick, onViewChanges, onDeleteTask, }: KanbanTaskCardProps): React.JSX.Element { const { t } = useAppTranslation('team'); const { isLight } = useTheme(); const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments); const commentPulseTaskKey = `${teamName}/${task.id}`; const comments = task.comments ?? EMPTY_TASK_COMMENTS; const commentCount = comments.length; const [commentPulse, syncCommentPulse] = useReducer( syncCommentPulseState, { taskKey: commentPulseTaskKey, comments }, ({ taskKey, comments: initialComments }) => createCommentPulseState(taskKey, initialComments) ); const visibleCommentPulseKey = commentPulse.taskKey === commentPulseTaskKey ? commentPulse.pulseKey : 0; const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? []; const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? []; const hasBlockedBy = blockedByIds.length > 0; const hasBlocks = blocksIds.length > 0; const shouldHighlightBlocked = hasBlockedBy && columnId !== 'done' && columnId !== 'approved'; const cardSurfaceClass = isLight ? 'bg-white' : 'bg-[var(--color-surface-raised)]'; const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]); const canDisplay = useMemo( () => canDisplayTaskChangesForOptions(taskChangeRequestOptions) && !!onViewChanges, [taskChangeRequestOptions, onViewChanges] ); const effectiveReviewer = (kanbanTaskState?.reviewer ?? task.reviewer ?? '').trim(); const isReviewManual = columnId === 'review' && !hasReviewers && effectiveReviewer.length === 0; const canOpenChanges = canDisplay && (task.changePresence === 'has_changes' || task.changePresence === 'needs_attention'); const changesNeedAttention = task.changePresence === 'needs_attention'; useEffect(() => { syncCommentPulse({ taskKey: commentPulseTaskKey, comments }); }, [commentCount, commentPulseTaskKey, comments]); return (
onTaskClick?.(task)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onTaskClick?.(task); } }} > {formatTaskDisplayLabel(task)} {hasLiveTaskLogs ? ( ) : null} {task.owner ? ( ) : null}
{!compact && } {task.needsClarification ? ( {task.needsClarification === 'user' ? t('kanban.taskCard.awaitingUser') : t('kanban.taskCard.awaitingLead')} ) : null} {isTeamTaskNeedsFixActionable(task) ? ( {REVIEW_STATE_DISPLAY.needsFix.label} ) : null} {compact && }
{hasBlockedBy ? (
{t('kanban.taskCard.blockedBy')} {blockedByIds.map((id) => ( ))}
) : null} {hasBlocks ? (
{t('kanban.taskCard.blocks')} {blocksIds.map((id) => ( ))}
) : null}
); }, (prev, next) => getTaskCardSignature(prev.task) === getTaskCardSignature(next.task) && prev.teamName === next.teamName && prev.columnId === next.columnId && areKanbanTaskStatesEqual(prev.kanbanTaskState, next.kanbanTaskState) && prev.hasReviewers === next.hasReviewers && prev.compact === next.compact && areTaskMapDependenciesEqual(prev.task, next.task, prev.taskMap, next.taskMap) && prev.memberColorMap === next.memberColorMap && prev.hasLiveTaskLogs === next.hasLiveTaskLogs && prev.onRequestReview === next.onRequestReview && prev.onApprove === next.onApprove && prev.onRequestChanges === next.onRequestChanges && prev.onMoveBackToDone === next.onMoveBackToDone && prev.onStartTask === next.onStartTask && prev.onCompleteTask === next.onCompleteTask && prev.onCancelTask === next.onCancelTask && prev.onScrollToTask === next.onScrollToTask && prev.onTaskClick === next.onTaskClick && prev.onViewChanges === next.onViewChanges && prev.onDeleteTask === next.onDeleteTask );