import { useCallback, useEffect, useMemo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@renderer/components/ui/dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@renderer/components/ui/select'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { markAsRead } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap, KANBAN_COLUMN_DISPLAY, TASK_STATUS_LABELS, TASK_STATUS_STYLES, } from '@renderer/utils/memberHelpers'; import { formatDistanceToNow } from 'date-fns'; import { AlignLeft, ArrowLeftFromLine, ArrowRightFromLine, Clock, FileCode, FileDiff, HelpCircle, Link2, Loader2, MessageSquare, PenLine, ScrollText, Trash2, } from 'lucide-react'; import { TaskCommentInput } from './TaskCommentInput'; import { TaskCommentsSection } from './TaskCommentsSection'; import type { KanbanTaskState, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; interface TaskDetailDialogProps { open: boolean; task: TeamTaskWithKanban | null; teamName: string; kanbanTaskState?: KanbanTaskState; taskMap: Map; members: ResolvedTeamMember[]; onClose: () => void; onScrollToTask?: (taskId: string) => void; onOwnerChange?: (taskId: string, owner: string | null) => void; onViewChanges?: (taskId: string, filePath?: string) => void; onDeleteTask?: (taskId: string) => void; /** Extra content rendered in the dialog header (e.g. "Open team" button). */ headerExtra?: React.ReactNode; } export const TaskDetailDialog = ({ open, task, teamName, kanbanTaskState, taskMap, members, onClose, onScrollToTask, onOwnerChange, onViewChanges, onDeleteTask, headerExtra, }: TaskDetailDialogProps): React.JSX.Element => { const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const currentTask = task ? (taskMap.get(task.id) ?? task) : null; const [replyTo, setReplyTo] = useState<{ taskId: string; author: string; text: string; } | null>(null); const handleReply = useCallback( (author: string, text: string) => { if (currentTask) setReplyTo({ taskId: currentTask.id, author, text }); }, [currentTask] ); const clearReply = useCallback(() => setReplyTo(null), []); const handleClose = useCallback(() => { setReplyTo(null); onClose(); }, [onClose]); const effectiveReplyTo = replyTo && replyTo.taskId === currentTask?.id ? { author: replyTo.author, text: replyTo.text } : null; useEffect(() => { if (!open || !currentTask) return; const comments = currentTask.comments ?? []; if (comments.length === 0) return; const latest = Math.max(...comments.map((c) => new Date(c.createdAt).getTime())); if (latest > 0) markAsRead(teamName, currentTask.id, latest); }, [open, teamName, currentTask]); // Lazy-load task changes when dialog is open and task is completed const isTaskCompleted = currentTask?.status === 'completed'; const setTaskNeedsClarification = useStore((s) => s.setTaskNeedsClarification); const activeChangeSet = useStore((s) => s.activeChangeSet); const changeSetLoading = useStore((s) => s.changeSetLoading); const fetchTaskChanges = useStore((s) => s.fetchTaskChanges); // Use the lightweight cache to know if changes exist before full data loads const changesCacheKey = currentTask ? `${teamName}:${currentTask.id}` : ''; const taskKnownHasChanges = useStore((s) => s.taskHasChanges[changesCacheKey]) === true; const taskChangesFiles = useMemo(() => { if (!activeChangeSet || !currentTask) return null; if ('taskId' in activeChangeSet && activeChangeSet.taskId === currentTask.id) { return activeChangeSet.files; } return null; }, [activeChangeSet, currentTask]); useEffect(() => { if (!open || !currentTask || !isTaskCompleted || !onViewChanges) return; // Only fetch if we don't already have data for this task if (taskChangesFiles !== null) return; void fetchTaskChanges(teamName, currentTask.id); }, [ open, currentTask, isTaskCompleted, teamName, fetchTaskChanges, taskChangesFiles, onViewChanges, ]); const handleDependencyClick = (taskId: string): void => { handleClose(); onScrollToTask?.(taskId); }; if (!currentTask) { return ( !v && handleClose()}> Task not found ); } const kanbanColumn = kanbanTaskState?.column ?? currentTask.kanbanColumn; const status = currentTask.status; const statusStyle = kanbanColumn && KANBAN_COLUMN_DISPLAY[kanbanColumn] ? { bg: KANBAN_COLUMN_DISPLAY[kanbanColumn].bg, text: KANBAN_COLUMN_DISPLAY[kanbanColumn].text, } : TASK_STATUS_STYLES[status]; const statusLabel = kanbanColumn && KANBAN_COLUMN_DISPLAY[kanbanColumn] ? KANBAN_COLUMN_DISPLAY[kanbanColumn].label : TASK_STATUS_LABELS[status]; const blockedByIds = currentTask.blockedBy?.filter((id) => id.length > 0) ?? []; const blocksIds = currentTask.blocks?.filter((id) => id.length > 0) ?? []; const relatedIds = (currentTask.related ?? []).filter( (id) => id.length > 0 && id !== currentTask.id ); const relatedByIds = Array.from(taskMap.values()) .filter( (t) => t.id !== currentTask.id && Array.isArray(t.related) && t.related.includes(currentTask.id) ) .map((t) => t.id); const isTodo = status === 'pending' && !kanbanColumn; const canReassign = isTodo && onOwnerChange; return ( !v && onClose()}>
#{currentTask.id} {statusLabel} {headerExtra ?
{headerExtra}
: null}
{currentTask.subject} {currentTask.activeForm ? ( {currentTask.activeForm} ) : null}
{/* Metadata */}
{canReassign ? ( ) : currentTask.owner ? ( ) : ( )}
{currentTask.createdBy ? (
{currentTask.createdBy}
) : null} {currentTask.createdAt ? (() => { const date = new Date(currentTask.createdAt); return isNaN(date.getTime()) ? null : (
{formatDistanceToNow(date, { addSuffix: true })}
); })() : null}
{/* Clarification banner */} {currentTask.needsClarification ? (
{currentTask.needsClarification === 'user' ? 'Awaiting clarification from you' : 'Awaiting clarification from team lead'}
) : null} {/* Description */} } defaultOpen> {currentTask.description ? (
) : (

No description

)}
{/* Changes */} {isTaskCompleted && onViewChanges ? ( } badge={taskChangesFiles ? taskChangesFiles.length : undefined} defaultOpen={taskKnownHasChanges} > {changeSetLoading || (!taskChangesFiles && taskKnownHasChanges) ? (
Loading changes...
) : taskChangesFiles && taskChangesFiles.length > 0 ? (
{taskChangesFiles.map((file) => ( ))}
) : (

No file changes detected

)}
) : null} {/* Execution Logs — sessions that reference this task */} } defaultOpen>
{/* Dependencies */} {blockedByIds.length > 0 ? (
Blocked by {blockedByIds.map((id) => { const depTask = taskMap.get(id); const isCompleted = depTask?.status === 'completed'; return ( ); })}
) : null} {blocksIds.length > 0 ? (
Blocks {blocksIds.map((id) => { const depTask = taskMap.get(id); const isCompleted = depTask?.status === 'completed'; return ( ); })}
) : null} {/* Related tasks (explicit) */} {relatedIds.length > 0 || relatedByIds.length > 0 ? (
Related tasks
{relatedIds.length > 0 ? (
Links {relatedIds.map((id) => { const depTask = taskMap.get(id); return ( ); })}
) : null} {relatedByIds.length > 0 ? (
Linked from {relatedByIds.map((id) => { const depTask = taskMap.get(id); return ( ); })}
) : null}
) : null} {/* Review info */} {kanbanTaskState ? (
{kanbanTaskState.reviewer ? ( Reviewer: {kanbanTaskState.reviewer} ) : null} {kanbanTaskState.errorDescription ? ( {kanbanTaskState.errorDescription} ) : null}
) : null}
{/* Comments */} } badge={ (currentTask.comments?.length ?? 0) > 0 ? (currentTask.comments?.length ?? 0) : undefined } defaultOpen > {onDeleteTask && currentTask ? ( ) : (
)}
); };