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, buildMemberColorMap, displayMemberName, KANBAN_COLUMN_DISPLAY, REVIEW_STATE_DISPLAY, TASK_STATUS_LABELS, TASK_STATUS_STYLES, } from '@renderer/utils/memberHelpers'; import { buildTaskChangeRequestOptions, buildTaskChangeSignature, deriveTaskSince, } from '@renderer/utils/taskChangeRequest'; import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { isLeadMember } from '@shared/utils/leadDetection'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { canDisplayTaskChanges } from '@shared/utils/taskChangeState'; import { deriveTaskDisplayId, formatTaskDisplayLabel, taskMatchesRef, } from '@shared/utils/taskIdentity'; import { format, formatDistanceToNow } from 'date-fns'; import { AlignLeft, ArrowLeftFromLine, ArrowRightFromLine, Check, Clock, FileDiff, GitCompareArrows, HelpCircle, History, ImageIcon, Link2, Loader2, MessageSquare, Pencil, PenLine, RefreshCw, ScrollText, SquarePen, Trash2, X, } from 'lucide-react'; const TASK_CHANGES_AUTO_REFRESH_MS = 20_000; 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, TaskChangeSetV2, TeamTaskWithKanban, } from '@shared/types'; function resolveTaskChangePresenceFromResult( data: Pick ): 'has_changes' | 'no_changes' | null { if (data.files.length > 0) { return 'has_changes'; } return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null; } 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 { isLight } = useTheme(); const currentTask = task ? (taskMap.get(task.id) ?? task) : null; const updateTaskFields = useStore((s) => s.updateTaskFields); const recordTaskHasChanges = useStore((s) => s.recordTaskHasChanges); 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 [changesSectionOpen, setChangesSectionOpen] = useState(false); const [taskChangesFiles, setTaskChangesFiles] = useState(null); const [taskChangesLoading, setTaskChangesLoading] = useState(false); const [taskChangesError, setTaskChangesError] = useState(null); const loadedTaskChangeSummaryKeyRef = useRef(null); const taskChangesLoadInFlightRef = useRef(false); 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); setTaskChangesLoading(false); setTaskChangesError(null); setLogsRefreshing(false); setExecutionPreviewOnline(false); setLogsSectionOpen(false); setTaskLogActivityActive(false); }, [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 // 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; // Lazy-load task changes for any displayable state (in_progress, review, approved, completed). const canShowTaskChanges = currentTask ? canDisplayTaskChanges(currentTask) : false; 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 || !onViewChanges ) { return null; } const data = await api.review.getTaskChanges(teamName, currentTask.id, { ...taskChangeSummaryOptions, forceFresh, }); return data; }, [canShowTaskChanges, currentTask, onViewChanges, taskChangeSummaryOptions, teamName, variant] ); const syncTaskChangeSummaryResult = useCallback( (data: TaskChangeSetV2 | null) => { setTaskChangesFiles(data?.files ?? null); if (currentTask && taskChangeRequestOptions) { recordTaskHasChanges( teamName, currentTask.id, taskChangeRequestOptions, !!data?.files.length ); } const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null; if (currentTask && nextPresence) { setSelectedTeamTaskChangePresence(teamName, currentTask.id, nextPresence); } }, [ currentTask, recordTaskHasChanges, 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 (taskChangesLoadInFlightRef.current) return; if ( !requestKey || !currentTask || variant !== 'team' || !canShowTaskChanges || !onViewChanges ) return; taskChangesLoadInFlightRef.current = true; 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); } setTaskChangesError( error instanceof Error ? error.message : 'Failed to load task changes summary' ); } finally { taskChangesLoadInFlightRef.current = false; if (showSpinner) { setTaskChangesLoading(false); } } }, [ canShowTaskChanges, currentTask, loadTaskChangeSummary, onViewChanges, syncTaskChangeSummaryResult, variant, ] ); useEffect(() => { if (variant !== 'team') return; if (!open || !currentTask || !canShowTaskChanges || !onViewChanges || !changesSectionOpen) return; const summaryKey = currentTaskChangeSummaryKey; if (loadedTaskChangeSummaryKeyRef.current === summaryKey) { return; } loadedTaskChangeSummaryKeyRef.current = summaryKey; // Show full loading state only when no files are cached yet; // otherwise let the refresh button spinner indicate background reload. void requestTaskChangeSummary({ forceFresh: false, showSpinner: !taskChangesFiles || taskChangesFiles.length === 0, preserveFilesOnError: false, }); }, [ changesSectionOpen, open, currentTask, canShowTaskChanges, teamName, onViewChanges, currentTaskChangeSummaryKey, taskChangeRequestSignature, variant, requestTaskChangeSummary, taskChangesFiles, ]); useEffect(() => { if (!open || !changesSectionOpen) { loadedTaskChangeSummaryKeyRef.current = null; } }, [open, changesSectionOpen]); useEffect(() => { if (variant !== 'team') return; if (!open || !currentTask || !canShowTaskChanges || !onViewChanges || !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, onViewChanges, 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); }, []); 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) => 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)} {(currentTask.reviewState === 'approved' || currentTask.reviewState === 'review') && currentTask.reviewer && currentTask.reviewer !== 'user' ? ( (() => { const reviewerColor = colorMap.get(currentTask.reviewer); const colors = currentTask.reviewState === '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) => currentTask.reviewState === '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} )} {currentTask.reviewState === 'needsFix' ? ( {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?.status === 'completed'; 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?.status === 'completed'; 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={ (currentTask.attachments?.length ?? 0) + commentImageAttachments.length + sourceAttachmentCount > 0 ? (currentTask.attachments?.length ?? 0) + commentImageAttachments.length + sourceAttachmentCount : undefined } contentClassName="pl-2.5" headerClassName="-mx-6 w-[calc(100%+3rem)]" headerContentClassName="pl-6" defaultOpen={ (currentTask.attachments?.length ?? 0) > 0 || commentImageAttachments.length > 0 || sourceAttachmentCount > 0 } > {currentTask.sourceMessageId && currentTask.sourceMessage ? ( ) : null} {commentImageAttachments.length > 0 ? ( ) : null} {/* Changes */} {variant === 'team' && canShowTaskChanges && onViewChanges ? ( } badge={taskChangesFiles ? taskChangesFiles.length : undefined} headerExtra={ 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 && taskChangesFiles.length > 0 ? (
{taskChangesFiles.map((file) => (
{file.linesAdded > 0 ? ( +{file.linesAdded} ) : null} {file.linesRemoved > 0 ? ( -{file.linesRemoved} ) : null} Review diff {onOpenInEditor ? ( Open in editor ) : null}
))}
) : changesSectionOpen ? (

No file changes detected

) : null}
) : null} {/* Execution Logs — sessions that reference this task */} {variant === 'team' ? ( } 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" 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} 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 (
thumbUrl && onPreview(thumbUrl)} > {thumbUrl ? ( {item.attachment.filename} ) : ( )}
{item.attachment.filename}
{tooltipText}
); };