import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { OngoingIndicator } from '@renderer/components/common/OngoingIndicator'; import { ImageLightbox, LightboxLockProvider, } 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 { TaskLogsPanel } from '@renderer/components/team/taskLogs/TaskLogsPanel'; 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 { TiptapEditor } from '@renderer/components/ui/tiptap'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet, getThemedBadge, getThemedBorder, getThemedText, } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useViewportCommentRead } from '@renderer/hooks/useViewportCommentRead'; import { getLegacyCutoff, getReadCommentIds } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; import { isImageMimeType } from '@renderer/utils/attachmentUtils'; import { agentAvatarUrl, buildMemberAvatarMap, buildMemberColorMap, displayMemberName, KANBAN_COLUMN_DISPLAY, REVIEW_STATE_DISPLAY, TASK_STATUS_LABELS, TASK_STATUS_STYLES, } from '@renderer/utils/memberHelpers'; import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence'; import { buildTaskChangeRequestOptions, buildTaskChangeSignature, deriveTaskSince, } from '@renderer/utils/taskChangeRequest'; import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { isLeadMember } from '@shared/utils/leadDetection'; import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability'; import { deriveTaskDisplayId, formatTaskDisplayLabel, taskMatchesRef, } from '@shared/utils/taskIdentity'; import { calculateTaskImplementationDuration, formatTaskImplementationDuration, shouldShowTaskImplementationDuration, } from '@shared/utils/taskWorkDuration'; import { getTeamTaskWorkflowColumn, isTeamTaskFinishedForDependency, isTeamTaskNeedsFixActionable, } from '@shared/utils/teamTaskState'; import { format, formatDistanceToNow } from 'date-fns'; import { AlertTriangle, AlignLeft, ArrowLeftFromLine, ArrowRightFromLine, Check, Clock, FileDiff, GitCompareArrows, HelpCircle, History, ImageIcon, Info, Link2, Loader2, MessageSquare, Pencil, PenLine, RefreshCw, ScrollText, SquarePen, Trash2, X, } from 'lucide-react'; import { SourceMessageAttachments } from '../attachments/SourceMessageAttachments'; import { WorkflowTimeline } from './StatusHistoryTimeline'; import { TaskAttachments } from './TaskAttachments'; import { TaskCommentAwaitingReply } from './TaskCommentAwaitingReply'; import { TaskCommentInput } from './TaskCommentInput'; import { TaskCommentsSection } from './TaskCommentsSection'; import type { FileChangeSummary, KanbanTaskState, ResolvedTeamMember, TaskAttachmentMeta, TaskChangeReviewability, TaskChangeSetV2, TeamTaskWithKanban, } from '@shared/types'; const TASK_CHANGES_AUTO_REFRESH_MS = 20_000; const TASK_CHANGES_INITIAL_LOAD_DELAY_MS = 1_500; 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; focusCommentId?: string; /** 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, focusCommentId, headerExtra, }: TaskDetailDialogProps): React.JSX.Element => { const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const avatarMap = useMemo(() => buildMemberAvatarMap(members), [members]); const { isLight } = useTheme(); const currentTask = task ? (taskMap.get(task.id) ?? task) : null; const updateTaskFields = useStore((s) => s.updateTaskFields); const recordTaskChangePresence = useStore((s) => s.recordTaskChangePresence); const setSelectedTeamTaskChangePresence = useStore((s) => s.setSelectedTeamTaskChangePresence); const [logsRefreshing, setLogsRefreshing] = useState(false); const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false); const [logsSectionOpen, setLogsSectionOpen] = useState(false); const [taskLogActivityActive, setTaskLogActivityActive] = useState(false); const [taskLogStreamCount, setTaskLogStreamCount] = useState(undefined); const [changesSectionOpen, setChangesSectionOpen] = useState(false); const [taskChangesFiles, setTaskChangesFiles] = useState(null); const [taskChangesWarnings, setTaskChangesWarnings] = useState([]); const [taskChangesReviewability, setTaskChangesReviewability] = useState(null); const [taskChangesLoading, setTaskChangesLoading] = useState(false); const [taskChangesError, setTaskChangesError] = useState(null); const loadedTaskChangeSummaryKeyRef = useRef(null); const taskChangesLoadInFlightKeysRef = useRef>(new Set()); const currentTaskChangeSummaryKeyRef = useRef(null); // 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 [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 ?? ''); 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]); useEffect(() => { setChangesSectionOpen(false); setTaskChangesFiles(null); setTaskChangesWarnings([]); setTaskChangesReviewability(null); setTaskChangesLoading(false); setTaskChangesError(null); setLogsRefreshing(false); setExecutionPreviewOnline(false); setLogsSectionOpen(false); setTaskLogActivityActive(false); setTaskLogStreamCount(undefined); }, [open, currentTask?.id]); const [replyTo, setReplyTo] = useState<{ taskId: string; author: string; text: string; } | null>(null); // Track whether a lightbox is open to block Dialog dismiss events. // Using a ref for synchronous reads (no render cycle delay) + a stable // callback so context consumers never cause re-renders. const lightboxOpenRef = useRef(false); const setLightboxOpen = useCallback((isOpen: boolean) => { lightboxOpenRef.current = isOpen; }, []); // Callback-ref + useState for the scrollable DialogContent — needed as IO root // for viewport-based read tracking. Using useState (not useRef) ensures that // useViewportObserver recreates the IntersectionObserver when the portal mounts // and the DOM element becomes available. const [dialogContentEl, setDialogContentEl] = useState(null); const handleReply = useCallback( (author: string, text: string) => { if (currentTask) setReplyTo({ taskId: currentTask.id, author, text }); }, [currentTask] ); const clearReply = useCallback(() => setReplyTo(null), []); const effectiveReplyTo = replyTo && replyTo.taskId === currentTask?.id ? { author: replyTo.author, text: replyTo.text } : null; // Snapshot unread comment IDs when dialog opens — these will show blue dots. // Dots persist for the duration of the dialog session; markAsRead happens // per-comment via IntersectionObserver inside TaskCommentsSection. const unreadSnapshotRef = useRef>(new Set()); useEffect(() => { if (!open || !currentTask) { unreadSnapshotRef.current = new Set(); return; } const comments = currentTask.comments ?? []; if (comments.length === 0) { unreadSnapshotRef.current = new Set(); return; } const readIds = getReadCommentIds(teamName, currentTask.id); const cutoff = getLegacyCutoff(teamName, currentTask.id); const unread = new Set(); for (const c of comments) { if (readIds.has(c.id)) continue; const ts = new Date(c.createdAt).getTime(); if (cutoff > 0 && ts <= cutoff) continue; unread.add(c.id); } unreadSnapshotRef.current = unread; }, [open, teamName, currentTask?.id]); // eslint-disable-line react-hooks/exhaustive-deps -- Snapshot should reset only when the dialog opens or task identity changes. // Viewport-based comment read tracking (replaces mark-all-on-mount) const { registerComment, flush: flushCommentRead } = useViewportCommentRead({ teamName, taskId: currentTask?.id ?? '', scrollContainer: dialogContentEl, }); const handleClose = useCallback(() => { flushCommentRead(); setReplyTo(null); onClose(); }, [onClose, flushCommentRead]); // 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]); const sourceAttachmentCount = currentTask?.sourceMessageId && currentTask?.sourceMessage?.attachments?.length ? currentTask.sourceMessage.attachments.length : 0; const attachmentCount = (currentTask?.attachments?.length ?? 0) + commentImageAttachments.length + sourceAttachmentCount; // Changes is the explicit lazy-load entry point. Keep it visible for all team tasks, // including old/pending tasks that may resolve to an empty result. const canShowTaskChanges = Boolean(currentTask); const taskSince = useMemo(() => deriveTaskSince(currentTask), [currentTask]); const taskChangeRequestOptions = useMemo( () => (currentTask ? buildTaskChangeRequestOptions(currentTask) : null), [currentTask] ); const taskChangeRequestSignature = useMemo( () => (taskChangeRequestOptions ? buildTaskChangeSignature(taskChangeRequestOptions) : null), [taskChangeRequestOptions] ); const currentTaskChangeSummaryKey = useMemo( () => currentTask ? `${teamName}:${currentTask.id}:${taskChangeRequestSignature ?? 'default'}` : null, [currentTask, teamName, taskChangeRequestSignature] ); const taskChangeSummaryOptions = useMemo( () => currentTask ? buildTaskChangeRequestOptions(currentTask, { since: taskSince, summaryOnly: true, }) : null, [currentTask, taskSince] ); const setTaskNeedsClarification = useStore((s) => s.setTaskNeedsClarification); useEffect(() => { currentTaskChangeSummaryKeyRef.current = currentTaskChangeSummaryKey; }, [currentTaskChangeSummaryKey]); const loadTaskChangeSummary = useCallback( async (forceFresh = false): Promise => { if (!currentTask || !taskChangeSummaryOptions || variant !== 'team' || !canShowTaskChanges) { return null; } const data = await api.review.getTaskChanges(teamName, currentTask.id, { ...taskChangeSummaryOptions, forceFresh, }); return data; }, [canShowTaskChanges, currentTask, taskChangeSummaryOptions, teamName, variant] ); const syncTaskChangeSummaryResult = useCallback( (data: TaskChangeSetV2 | null) => { setTaskChangesFiles(data?.files ?? null); const status = data ? classifyTaskChangeReviewability(data) : null; const diagnosticMessages = status && status.diagnostics.length > 0 ? status.diagnostics.map((diagnostic) => diagnostic.message) : (data?.warnings ?? []); setTaskChangesWarnings([ ...new Set(diagnosticMessages.filter((message) => message.trim().length > 0)), ]); setTaskChangesReviewability(status?.reviewability ?? null); const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null; if (currentTask && taskChangeRequestOptions) { recordTaskChangePresence(teamName, currentTask.id, taskChangeRequestOptions, nextPresence); } if (currentTask) { setSelectedTeamTaskChangePresence(teamName, currentTask.id, nextPresence ?? 'unknown'); } }, [ currentTask, recordTaskChangePresence, setSelectedTeamTaskChangePresence, taskChangeRequestOptions, teamName, ] ); const requestTaskChangeSummary = useCallback( async ({ forceFresh = false, showSpinner = false, preserveFilesOnError = false, }: { forceFresh?: boolean; showSpinner?: boolean; preserveFilesOnError?: boolean; } = {}): Promise => { const requestKey = currentTaskChangeSummaryKeyRef.current; if (!requestKey || !currentTask || variant !== 'team' || !canShowTaskChanges) return; if (taskChangesLoadInFlightKeysRef.current.has(requestKey)) return; taskChangesLoadInFlightKeysRef.current.add(requestKey); if (showSpinner) { setTaskChangesLoading(true); } setTaskChangesError(null); try { const data = await loadTaskChangeSummary(forceFresh); if (currentTaskChangeSummaryKeyRef.current !== requestKey) { return; } syncTaskChangeSummaryResult(data); } catch (error) { if (currentTaskChangeSummaryKeyRef.current !== requestKey) { return; } if (!preserveFilesOnError) { setTaskChangesFiles(null); setTaskChangesWarnings([]); setTaskChangesReviewability(null); } setTaskChangesError( error instanceof Error ? error.message : 'Failed to load task changes summary' ); } finally { taskChangesLoadInFlightKeysRef.current.delete(requestKey); if (showSpinner && currentTaskChangeSummaryKeyRef.current === requestKey) { setTaskChangesLoading(false); } } }, [canShowTaskChanges, currentTask, loadTaskChangeSummary, syncTaskChangeSummaryResult, variant] ); useEffect(() => { if (variant !== 'team') return; if (!open || !currentTask || !canShowTaskChanges || !changesSectionOpen) return; const summaryKey = currentTaskChangeSummaryKey; if (loadedTaskChangeSummaryKeyRef.current === summaryKey) { return; } if (taskChangesFiles !== null) { loadedTaskChangeSummaryKeyRef.current = summaryKey; return; } loadedTaskChangeSummaryKeyRef.current = summaryKey; // The manual open path only reaches this branch when no summary is cached yet. void requestTaskChangeSummary({ forceFresh: false, showSpinner: true, preserveFilesOnError: false, }); }, [ changesSectionOpen, open, currentTask, canShowTaskChanges, teamName, currentTaskChangeSummaryKey, taskChangeRequestSignature, variant, requestTaskChangeSummary, taskChangesFiles, ]); useEffect(() => { if (variant !== 'team') return; if (!open || !currentTask || !canShowTaskChanges || changesSectionOpen) return; if (!currentTaskChangeSummaryKey || taskChangesFiles !== null) return; const summaryKey = currentTaskChangeSummaryKey; if (loadedTaskChangeSummaryKeyRef.current === summaryKey) { return; } const timer = window.setTimeout(() => { if (currentTaskChangeSummaryKeyRef.current !== summaryKey) { return; } void requestTaskChangeSummary({ forceFresh: false, showSpinner: true, preserveFilesOnError: true, }); }, TASK_CHANGES_INITIAL_LOAD_DELAY_MS); return () => { window.clearTimeout(timer); }; }, [ changesSectionOpen, open, currentTask, canShowTaskChanges, currentTaskChangeSummaryKey, requestTaskChangeSummary, taskChangesFiles, variant, ]); useEffect(() => { if (!open || !changesSectionOpen) { loadedTaskChangeSummaryKeyRef.current = null; } }, [open, changesSectionOpen]); useEffect(() => { if (variant !== 'team') return; if (!open || !currentTask || !canShowTaskChanges || !changesSectionOpen) { return; } const timer = window.setInterval(() => { void requestTaskChangeSummary({ forceFresh: true, showSpinner: false, preserveFilesOnError: true, }); }, TASK_CHANGES_AUTO_REFRESH_MS); return () => { window.clearInterval(timer); }; }, [ changesSectionOpen, open, currentTask, canShowTaskChanges, requestTaskChangeSummary, variant, ]); const handleRefreshChanges = useCallback(() => { void requestTaskChangeSummary({ forceFresh: true, showSpinner: true, preserveFilesOnError: false, }); }, [requestTaskChangeSummary]); const handleDependencyClick = (taskId: string): void => { // Resolve short displayId (e.g. "8ce74455") to full UUID via taskMap, // since kanban cards use the full UUID in data-task-id. let resolvedId = taskId; if (!taskMap.has(taskId)) { for (const [fullId, t] of taskMap) { if (taskMatchesRef(t, taskId)) { resolvedId = fullId; break; } } } handleClose(); onScrollToTask?.(resolvedId); }; const handleChangesSectionOpenChange = useCallback((isOpen: boolean): void => { setChangesSectionOpen(isOpen); }, []); const taskChangesBadge = !taskChangesLoading ? taskChangesFiles && taskChangesFiles.length > 0 ? taskChangesFiles.length : taskChangesFiles && taskChangesWarnings.length > 0 ? taskChangesReviewability === 'attention_required' ? 'attention' : taskChangesReviewability === 'diagnostic_only' ? 'no safe diff' : undefined : undefined : undefined; const [taskDurationNowMs, setTaskDurationNowMs] = useState(() => Date.now()); const taskImplementationDuration = useMemo( () => calculateTaskImplementationDuration(currentTask, taskDurationNowMs), [currentTask, taskDurationNowMs] ); const showTaskImplementationDuration = shouldShowTaskImplementationDuration( taskImplementationDuration ); const taskImplementationDurationLabel = formatTaskImplementationDuration( taskImplementationDuration.elapsedMs ); useEffect(() => { if (!open || !taskImplementationDuration.hasRunningInterval) return; setTaskDurationNowMs(Date.now()); const intervalId = window.setInterval(() => { setTaskDurationNowMs(Date.now()); }, 1000); return () => window.clearInterval(intervalId); }, [open, taskImplementationDuration.hasRunningInterval, currentTask?.id]); if (loading) { return ( !v && onClose()}> Loading task…
Fetching team data
); } if (!currentTask) { return ( !v && handleClose()}> Task not found ); } const kanbanColumn = getTeamTaskWorkflowColumn({ ...currentTask, ...(kanbanTaskState?.column ? { kanbanColumn: kanbanTaskState.column } : {}), }); 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) => isLeadMember(m))?.name ?? 'team-lead'; const isLeadOwnedTask = (currentTask.owner ?? '').trim().toLowerCase() === leadName.trim().toLowerCase() || (currentTask.owner ?? '').trim().toLowerCase() === 'team-lead'; const allowLeadExecutionPreview = true; return ( { if (!v && lightboxOpenRef.current) return; if (!v) handleClose(); }} > { if (lightboxOpenRef.current) e.preventDefault(); }} onEscapeKeyDown={(e) => { if (lightboxOpenRef.current) e.preventDefault(); }} >
{formatTaskDisplayLabel(currentTask)} {(kanbanColumn === 'approved' || kanbanColumn === 'review') && currentTask.reviewer && currentTask.reviewer !== 'user' ? ( (() => { const reviewerColor = colorMap.get(currentTask.reviewer); const colors = kanbanColumn === 'review' ? getTeamColorSet('blue') : getTeamColorSet(reviewerColor ?? ''); const reviewerBadgeStyle = { backgroundColor: getThemedBadge(colors, isLight), color: getThemedText(colors, isLight), borderTop: `1px solid ${getThemedBorder(colors, isLight)}40`, borderRight: `1px solid ${getThemedBorder(colors, isLight)}40`, borderBottom: `1px solid ${getThemedBorder(colors, isLight)}40`, }; const lastReviewEvent = currentTask.historyEvents ?.filter((e) => kanbanColumn === 'approved' ? e.type === 'review_approved' : e.type === 'review_requested' || e.type === 'review_started' ) .at(-1); const reviewDate = lastReviewEvent ? new Date(lastReviewEvent.timestamp) : undefined; const reviewTimeLabel = reviewDate && !isNaN(reviewDate.getTime()) ? Date.now() - reviewDate.getTime() < 24 * 60 * 60 * 1000 ? formatDistanceToNow(reviewDate, { addSuffix: true }) : format(reviewDate, 'MMM d, yyyy HH:mm') : undefined; const badge = ( {statusLabel} {displayMemberName(currentTask.reviewer)} ); return reviewTimeLabel ? ( {badge} {reviewTimeLabel} ) : ( badge ); })() ) : ( {statusLabel} )} {isTeamTaskNeedsFixActionable(currentTask) ? ( {REVIEW_STATE_DISPLAY.needsFix.label} ) : null} {headerExtra ?
{headerExtra}
: null}
{editingSubject ? (
setSubjectDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); 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} {/* Related tasks & Dependencies — 2-column grid */} {(relatedIds.length > 0 || relatedByIds.length > 0 || blockedByIds.length > 0 || blocksIds.length > 0) && (
{/* "Related tasks" header — only if links exist */} {(relatedIds.length > 0 || relatedByIds.length > 0) && (
Related tasks
)}
{relatedIds.length > 0 ? (
Links {relatedIds.map((id) => { const depTask = taskMap.get(id); const label = depTask ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` : `#${deriveTaskDisplayId(id)}`; return ( {label} ); })}
) : null} {relatedByIds.length > 0 ? (
Linked from {relatedByIds.map((id) => { const depTask = taskMap.get(id); const label = depTask ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` : `#${deriveTaskDisplayId(id)}`; return ( {label} ); })}
) : null} {blockedByIds.length > 0 ? (
Blocked by {blockedByIds.map((id) => { const depTask = taskMap.get(id); const isCompleted = depTask ? isTeamTaskFinishedForDependency(depTask) : false; const label = depTask ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` : `#${deriveTaskDisplayId(id)}`; return ( {label} ); })}
) : null} {blocksIds.length > 0 ? (
Blocks {blocksIds.map((id) => { const depTask = taskMap.get(id); const isCompleted = depTask ? isTeamTaskFinishedForDependency(depTask) : false; const label = depTask ? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}` : `#${deriveTaskDisplayId(id)}`; return ( {label} ); })}
) : null}
)} {/* Sections container with uniform spacing */}
{/* Description */} } contentClassName="pl-2.5" headerClassName="-mx-6 w-[calc(100%+3rem)]" headerContentClassName="pl-6" defaultOpen > {editingDescription ? (
) : currentTask.description ? (
{ const link = (e.target as HTMLElement).closest( 'a[href^="task://"]' ); if (link) { e.preventDefault(); e.stopPropagation(); const href = link.getAttribute('href'); const parsed = href ? parseTaskLinkHref(href) : null; if (parsed?.taskId) handleDependencyClick(parsed.taskId); } } : undefined } > Edit description
) : ( )}
{/* Attachments */} } badge={attachmentCount} contentClassName="pl-2.5" headerClassName="-mx-6 w-[calc(100%+3rem)]" headerContentClassName="pl-6" defaultOpen={attachmentCount > 0} > {currentTask.sourceMessageId && currentTask.sourceMessage ? ( ) : null} {commentImageAttachments.length > 0 ? ( ) : null} {/* Changes */} {variant === 'team' && canShowTaskChanges ? ( } badge={taskChangesBadge} headerExtra={ taskChangesLoading && !changesSectionOpen ? ( ) : changesSectionOpen ? ( Refresh ) : null } contentClassName="pl-2.5" headerClassName="-mx-6 w-[calc(100%+3rem)]" headerContentClassName="pl-6" defaultOpen={false} onOpenChange={handleChangesSectionOpenChange} > {taskChangesLoading && (!taskChangesFiles || taskChangesFiles.length === 0) ? (
Loading changes...
) : taskChangesError ? (

{taskChangesError}

) : taskChangesFiles ? (
{taskChangesWarnings.length > 0 ? (
{taskChangesWarnings.slice(0, 2).map((warning) => (
{taskChangesReviewability === 'attention_required' ? ( ) : ( )} {warning}
))} {taskChangesWarnings.length > 2 ? (

{taskChangesWarnings.length - 2} more diagnostics

) : null}
) : null} {taskChangesFiles.length > 0 ? (
{taskChangesFiles.map((file) => (
{onViewChanges ? ( ) : ( {file.relativePath} )} {file.linesAdded > 0 ? ( +{file.linesAdded} ) : null} {file.linesRemoved > 0 ? ( -{file.linesRemoved} ) : null} {onViewChanges ? ( Review diff ) : null} {onOpenInEditor ? ( Open in editor ) : null}
))}
) : changesSectionOpen ? (

{taskChangesWarnings.length > 0 ? taskChangesReviewability === 'attention_required' ? 'No reviewable file changes recovered' : taskChangesReviewability === 'diagnostic_only' ? 'No safe diff available' : 'No file changes recorded yet' : 'No file changes recorded'}

) : null}
) : changesSectionOpen ? (

No file changes recorded

) : null}
) : null} {/* Execution Logs — sessions that reference this task */} {variant === 'team' ? ( } badge={taskLogStreamCount} headerExtra={ taskLogActivityActive ? ( ) : null } contentClassName="pl-2.5 overflow-visible" headerClassName="-mx-6 w-[calc(100%+3rem)]" headerContentClassName="pl-6" defaultOpen={false} onOpenChange={setLogsSectionOpen} keepMounted >
) : null} {/* Review info */} {kanbanTaskState?.reviewer || kanbanTaskState?.errorDescription ? (
{kanbanTaskState.reviewer ? ( Reviewer: {kanbanTaskState.reviewer} ) : null} {kanbanTaskState.errorDescription ? ( {kanbanTaskState.errorDescription} ) : null}
) : null} {/* Workflow History */} {currentTask.historyEvents && currentTask.historyEvents.length > 0 ? ( } badge={currentTask.historyEvents.length} contentClassName="pl-2.5" headerClassName="-mx-6 w-[calc(100%+3rem)]" headerContentClassName="pl-6" headerExtra={ showTaskImplementationDuration ? ( In progress time {taskImplementationDurationLabel} ) : undefined } defaultOpen={false} > ) : null} {/* Comments */} } badge={ (currentTask.comments?.length ?? 0) > 0 ? (currentTask.comments?.length ?? 0) : undefined } contentClassName="overflow-x-visible pl-0" headerClassName="-mx-6 w-[calc(100%+3rem)]" headerContentClassName="pl-6" defaultOpen >
handleDependencyClick(taskId) : undefined } containerClassName="-mx-6" unreadCommentIds={unreadSnapshotRef.current} focusCommentId={focusCommentId} registerCommentForViewport={registerComment} />
); }; // --------------------------------------------------------------------------- // Comment images grid — accumulated images from task comments // --------------------------------------------------------------------------- interface CommentImageItem { attachment: TaskAttachmentMeta; commentText: string; commentAuthor: string; } const CommentImagesGrid = ({ items, teamName, taskId, }: { items: CommentImageItem[]; teamName: string; taskId: string; }): React.JSX.Element => { const [previewUrl, setPreviewUrl] = useState(null); return (
From comments
{items.map((item) => ( ))}
{previewUrl ? ( setPreviewUrl(null)} src={previewUrl} alt="Comment attachment" /> ) : null}
); }; const CommentImageThumbnail = ({ item, teamName, taskId, onPreview, }: { item: CommentImageItem; teamName: string; taskId: string; onPreview: (dataUrl: string) => void; }): React.JSX.Element => { const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData); const [thumbUrl, setThumbUrl] = useState(null); useEffect(() => { let cancelled = false; void (async () => { try { const base64 = await getTaskAttachmentData( teamName, taskId, item.attachment.id, item.attachment.mimeType ); if (!cancelled && base64) { setThumbUrl(`data:${item.attachment.mimeType};base64,${base64}`); } } catch { // ignore } })(); return () => { cancelled = true; }; }, [teamName, taskId, item.attachment.id, item.attachment.mimeType, getTaskAttachmentData]); // Truncate comment text for tooltip const tooltipText = `${item.commentAuthor}: ${item.commentText.length > 200 ? item.commentText.slice(0, 200) + '...' : item.commentText}`; return ( {tooltipText} ); };