import { useCallback, useEffect, useMemo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection'; import { FileIcon } from '@renderer/components/team/editor/FileIcon'; 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, DialogHeader, DialogTitle, } from '@renderer/components/ui/dialog'; import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; import { Input } from '@renderer/components/ui/input'; import { MemberSelect } from '@renderer/components/ui/MemberSelect'; import { Textarea } from '@renderer/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { markAsRead } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; import { isImageMimeType } from '@renderer/utils/attachmentUtils'; import { buildMemberColorMap, KANBAN_COLUMN_DISPLAY, REVIEW_STATE_DISPLAY, TASK_STATUS_LABELS, TASK_STATUS_STYLES, } from '@renderer/utils/memberHelpers'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatDistanceToNow } from 'date-fns'; import { AlignLeft, ArrowLeftFromLine, ArrowRightFromLine, Check, Clock, Eye, FileDiff, GitCompareArrows, HelpCircle, History, ImageIcon, Link2, Loader2, MessageSquare, Pencil, PenLine, ScrollText, SquarePen, Trash2, X, } from 'lucide-react'; import { StatusHistoryTimeline } from './StatusHistoryTimeline'; import { TaskAttachments } from './TaskAttachments'; import { TaskCommentInput } from './TaskCommentInput'; import { TaskCommentsSection } from './TaskCommentsSection'; import type { KanbanTaskState, ResolvedTeamMember, TaskAttachmentMeta, TeamTaskWithKanban, } from '@shared/types'; interface TaskDetailDialogProps { open: boolean; loading?: boolean; variant?: 'team' | 'global'; 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; onOpenInEditor?: (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, loading = false, variant = 'team', task, teamName, kanbanTaskState, taskMap, members, onClose, onScrollToTask, onOwnerChange, onViewChanges, onOpenInEditor, onDeleteTask, headerExtra, }: TaskDetailDialogProps): React.JSX.Element => { const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const currentTask = task ? (taskMap.get(task.id) ?? task) : null; const updateTaskFields = useStore((s) => s.updateTaskFields); const [logsRefreshing, setLogsRefreshing] = useState(false); const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false); // Inline editing: subject const [editingSubject, setEditingSubject] = useState(false); const [subjectDraft, setSubjectDraft] = useState(''); const [savingSubject, setSavingSubject] = useState(false); // Inline editing: description const [editingDescription, setEditingDescription] = useState(false); const [descriptionDraft, setDescriptionDraft] = useState(''); const [descriptionPreview, setDescriptionPreview] = useState(false); const [savingDescription, setSavingDescription] = useState(false); const startEditSubject = useCallback(() => { if (!currentTask) return; setSubjectDraft(currentTask.subject); setEditingSubject(true); }, [currentTask]); const saveSubject = useCallback(async () => { if (!currentTask || savingSubject) return; const trimmed = subjectDraft.trim(); if (!trimmed || trimmed === currentTask.subject) { setEditingSubject(false); return; } setSavingSubject(true); try { await updateTaskFields(teamName, currentTask.id, { subject: trimmed }); setEditingSubject(false); } finally { setSavingSubject(false); } }, [currentTask, subjectDraft, savingSubject, teamName, updateTaskFields]); const startEditDescription = useCallback(() => { if (!currentTask) return; setDescriptionDraft(currentTask.description ?? ''); setDescriptionPreview(false); setEditingDescription(true); }, [currentTask]); const saveDescription = useCallback(async () => { if (!currentTask || savingDescription) return; const newDesc = descriptionDraft.trim(); if (newDesc === (currentTask.description ?? '')) { setEditingDescription(false); return; } setSavingDescription(true); try { await updateTaskFields(teamName, currentTask.id, { description: newDesc }); setEditingDescription(false); } finally { setSavingDescription(false); } }, [currentTask, descriptionDraft, savingDescription, teamName, updateTaskFields]); // Reset editing state on dialog close or task change useEffect(() => { setEditingSubject(false); setEditingDescription(false); }, [open, currentTask?.id]); 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]); // Collect image attachments from comments for the Attachments section const commentImageAttachments = useMemo(() => { const comments = currentTask?.comments ?? []; const result: { attachment: TaskAttachmentMeta; commentText: string; commentAuthor: string }[] = []; for (const c of comments) { if (!c.attachments) continue; for (const att of c.attachments) { if (isImageMimeType(att.mimeType)) { result.push({ attachment: att, commentText: c.text, commentAuthor: c.author }); } } } return result; }, [currentTask?.comments]); // 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 (variant !== 'team') return; 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, variant, ]); const handleDependencyClick = (taskId: string): void => { handleClose(); onScrollToTask?.(taskId); }; if (loading) { return ( !v && onClose()}> Loading task…
Fetching team data
); } if (!currentTask) { return ( !v && handleClose()}> Task not found ); } const kanbanColumn = kanbanTaskState?.column ?? getTaskKanbanColumn({ reviewState: currentTask.reviewState, kanbanColumn: 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; const leadName = members.find((m) => m.agentType === 'team-lead' || m.name === 'team-lead')?.name ?? 'team-lead'; const isLeadOwnedTask = (currentTask.owner ?? '').trim().toLowerCase() === leadName.trim().toLowerCase() || (currentTask.owner ?? '').trim().toLowerCase() === 'team-lead'; const allowLeadExecutionPreview = true; return ( !v && onClose()}>
{formatTaskDisplayLabel(currentTask)} {statusLabel} {currentTask.reviewState === 'needsFix' ? ( {REVIEW_STATE_DISPLAY.needsFix.label} ) : null} {headerExtra ?
{headerExtra}
: null}
{editingSubject ? (
setSubjectDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') void saveSubject(); if (e.key === 'Escape') setEditingSubject(false); }} onBlur={() => void saveSubject()} disabled={savingSubject} className="h-8 text-base" /> {savingSubject ? : null}
) : ( {currentTask.subject} )} {currentTask.activeForm ? ( {currentTask.activeForm} ) : null}
{/* Metadata */}
{canReassign ? ( onOwnerChange(currentTask.id, v)} allowUnassigned size="sm" className="min-w-[160px]" /> ) : currentTask.owner ? ( ) : ( Unassigned )}
{currentTask.createdBy ? (
{currentTask.createdBy}
) : null} {currentTask.createdAt ? (() => { const date = new Date(currentTask.createdAt); return isNaN(date.getTime()) ? null : (
{formatDistanceToNow(date, { addSuffix: true })}
); })() : null} {onDeleteTask && currentTask ? ( ) : null}
{/* Clarification banner */} {currentTask.needsClarification ? (
{currentTask.needsClarification === 'user' ? 'Awaiting clarification from you' : 'Awaiting clarification from team lead'}
) : null} {/* Description */} } contentClassName="pl-2.5" headerClassName="-mx-6 w-[calc(100%+3rem)]" headerContentClassName="pl-6" defaultOpen > {editingDescription ? (
{descriptionPreview ? (
{descriptionDraft.trim() ? ( ) : (

Nothing to preview

)}
) : (