agent-ecosystem/src/renderer/components/team/dialogs/TaskDetailDialog.tsx
iliya 3f923c480e feat: improve task status handling and notification resilience
- Enhanced the startTask function to clear stale kanban entries when a task is reopened, ensuring accurate task tracking.
- Updated the addTaskComment function to handle notification failures gracefully, allowing comments to persist even if owner notifications fail.
- Added tests to validate the resilience of task comment notifications and ensure correct behavior under failure scenarios.
- Refactored related functions for improved clarity and maintainability in task management.
2026-03-09 17:09:15 +02:00

1107 lines
43 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
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 { 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 { getLastReadTimestamp } 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 { WorkflowTimeline } 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<string, TeamTaskWithKanban>;
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);
// 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;
}, []);
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;
// 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<Set<string>>(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 lastRead = getLastReadTimestamp(teamName, currentTask.id);
const unread = new Set<string>();
for (const c of comments) {
if (new Date(c.createdAt).getTime() > lastRead) {
unread.add(c.id);
}
}
unreadSnapshotRef.current = unread;
}, [open, teamName, currentTask?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// 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 (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Loading task</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-2 text-sm text-[var(--color-text-muted)]">
<Loader2 className="size-4 animate-spin" />
<span>Fetching team data</span>
</div>
</DialogContent>
</Dialog>
);
}
if (!currentTask) {
return (
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Task not found</DialogTitle>
</DialogHeader>
</DialogContent>
</Dialog>
);
}
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 (
<Dialog
open={open}
onOpenChange={(v) => {
if (!v && lightboxOpenRef.current) return;
if (!v) onClose();
}}
>
<DialogContent
className="sm:min-w-[500px] sm:max-w-4xl"
onInteractOutside={(e) => {
if (lightboxOpenRef.current) e.preventDefault();
}}
onEscapeKeyDown={(e) => {
if (lightboxOpenRef.current) e.preventDefault();
}}
>
<LightboxLockProvider value={setLightboxOpen}>
<DialogHeader>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
{formatTaskDisplayLabel(currentTask)}
</Badge>
<span
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${statusStyle.bg} ${statusStyle.text}`}
>
{statusLabel}
</span>
{currentTask.reviewState === 'needsFix' ? (
<span
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
>
{REVIEW_STATE_DISPLAY.needsFix.label}
</span>
) : null}
{headerExtra ? <div className="ml-auto mr-4">{headerExtra}</div> : null}
</div>
{editingSubject ? (
<div className="flex items-center gap-2">
<Input
autoFocus
value={subjectDraft}
onChange={(e) => 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 ? <Loader2 size={14} className="animate-spin" /> : null}
</div>
) : (
<DialogTitle
className="group flex cursor-pointer items-center gap-1.5 text-base hover:text-[var(--color-text)]"
onClick={startEditSubject}
>
{currentTask.subject}
<Pencil
size={12}
className="shrink-0 text-[var(--color-text-muted)] opacity-0 transition-opacity group-hover:opacity-100"
/>
</DialogTitle>
)}
{currentTask.activeForm ? (
<DialogDescription>{currentTask.activeForm}</DialogDescription>
) : null}
</DialogHeader>
{/* Metadata */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
<div className="flex min-w-0 items-center gap-2">
{canReassign ? (
<MemberSelect
members={members}
value={currentTask.owner ?? null}
onChange={(v) => onOwnerChange(currentTask.id, v)}
allowUnassigned
size="sm"
className="min-w-[160px]"
/>
) : currentTask.owner ? (
<MemberBadge
name={currentTask.owner}
color={colorMap.get(currentTask.owner)}
size="md"
/>
) : (
<span className="text-xs italic text-[var(--color-text-muted)]">Unassigned</span>
)}
</div>
{currentTask.createdBy ? (
<div className="flex items-center gap-1.5 text-[var(--color-text-muted)]">
<PenLine size={12} />
<span className="text-[var(--color-text-secondary)]">{currentTask.createdBy}</span>
</div>
) : null}
{currentTask.createdAt
? (() => {
const date = new Date(currentTask.createdAt);
return isNaN(date.getTime()) ? null : (
<div className="flex items-center gap-1.5 text-[var(--color-text-muted)]">
<Clock size={12} />
<span className="text-[var(--color-text-secondary)]">
{formatDistanceToNow(date, { addSuffix: true })}
</span>
</div>
);
})()
: null}
{onDeleteTask && currentTask ? (
<Button
variant="ghost"
size="sm"
className="ml-auto h-6 gap-1 text-xs text-[var(--color-text-muted)] hover:text-red-400"
onClick={() => {
onDeleteTask(currentTask.id);
handleClose();
}}
>
<Trash2 size={12} />
Delete
</Button>
) : null}
</div>
{/* Clarification banner */}
{currentTask.needsClarification ? (
<div
className={`flex items-center justify-between rounded-md px-3 py-2 text-xs ${
currentTask.needsClarification === 'user'
? 'border border-red-500/20 bg-red-500/10 text-red-400'
: 'border border-blue-500/20 bg-blue-500/10 text-blue-400'
}`}
>
<span className="flex items-center gap-1.5">
<HelpCircle size={14} />
{currentTask.needsClarification === 'user'
? 'Awaiting clarification from you'
: 'Awaiting clarification from team lead'}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={(e) => {
e.stopPropagation();
void setTaskNeedsClarification(teamName, currentTask.id, null);
}}
>
Mark resolved
</Button>
</div>
) : null}
{/* Description */}
<CollapsibleTeamSection
title="Description"
icon={<AlignLeft size={14} />}
contentClassName="pl-2.5"
headerClassName="-mx-6 w-[calc(100%+3rem)]"
headerContentClassName="pl-6"
defaultOpen
>
{editingDescription ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<button
type="button"
className={`flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors ${
!descriptionPreview
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => setDescriptionPreview(false)}
>
<Pencil size={12} />
Edit
</button>
<button
type="button"
className={`flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors ${
descriptionPreview
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => setDescriptionPreview(true)}
>
<Eye size={12} />
Preview
</button>
</div>
{descriptionPreview ? (
<div className="max-h-[200px] overflow-y-auto rounded border border-[var(--color-border)] p-2">
{descriptionDraft.trim() ? (
<MarkdownViewer content={descriptionDraft} maxHeight="max-h-[180px]" />
) : (
<p className="text-xs text-[var(--color-text-muted)]">Nothing to preview</p>
)}
</div>
) : (
<Textarea
autoFocus
value={descriptionDraft}
onChange={(e) => setDescriptionDraft(e.target.value)}
disabled={savingDescription}
rows={6}
className="text-xs"
placeholder="Task description (supports markdown)"
/>
)}
<div className="flex items-center gap-2">
<Button
size="sm"
className="h-7 text-xs"
disabled={savingDescription}
onClick={() => void saveDescription()}
>
{savingDescription ? (
<Loader2 size={12} className="mr-1 animate-spin" />
) : (
<Check size={12} className="mr-1" />
)}
Save
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={savingDescription}
onClick={() => setEditingDescription(false)}
>
<X size={12} className="mr-1" />
Cancel
</Button>
</div>
</div>
) : currentTask.description ? (
<div className="group relative">
<ExpandableContent collapsedHeight={200}>
<MarkdownViewer content={currentTask.description} maxHeight="max-h-none" bare />
</ExpandableContent>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="absolute right-0 top-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)] group-hover:opacity-100"
onClick={startEditDescription}
>
<Pencil size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="top">Edit description</TooltipContent>
</Tooltip>
</div>
) : (
<button
type="button"
className="text-xs text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={startEditDescription}
>
Click to add description...
</button>
)}
</CollapsibleTeamSection>
{/* Attachments */}
<CollapsibleTeamSection
title="Attachments"
icon={<ImageIcon size={14} />}
badge={
(currentTask.attachments?.length ?? 0) + commentImageAttachments.length > 0
? (currentTask.attachments?.length ?? 0) + commentImageAttachments.length
: undefined
}
contentClassName="pl-2.5"
headerClassName="-mx-6 w-[calc(100%+3rem)]"
headerContentClassName="pl-6"
defaultOpen={
(currentTask.attachments?.length ?? 0) > 0 || commentImageAttachments.length > 0
}
>
<TaskAttachments
teamName={teamName}
taskId={currentTask.id}
attachments={currentTask.attachments ?? []}
/>
{commentImageAttachments.length > 0 ? (
<CommentImagesGrid
items={commentImageAttachments}
teamName={teamName}
taskId={currentTask.id}
/>
) : null}
</CollapsibleTeamSection>
{/* Changes */}
{variant === 'team' && isTaskCompleted && onViewChanges ? (
<CollapsibleTeamSection
title="Changes"
icon={<FileDiff size={14} />}
badge={taskChangesFiles ? taskChangesFiles.length : undefined}
contentClassName="pl-2.5"
headerClassName="-mx-6 w-[calc(100%+3rem)]"
headerContentClassName="pl-6"
defaultOpen={taskKnownHasChanges}
>
{changeSetLoading || (!taskChangesFiles && taskKnownHasChanges) ? (
<div className="flex items-center gap-2 py-2 text-xs text-[var(--color-text-muted)]">
<Loader2 size={14} className="animate-spin" />
Loading changes...
</div>
) : taskChangesFiles && taskChangesFiles.length > 0 ? (
<div className="max-h-[200px] space-y-0.5 overflow-y-auto">
{taskChangesFiles.map((file) => (
<div
key={file.filePath}
className="group flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]"
>
<FileIcon
fileName={file.relativePath.split('/').pop() ?? file.relativePath}
className="size-3.5"
/>
<button
type="button"
className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text)]"
onClick={() => {
handleClose();
onViewChanges(currentTask.id, file.filePath);
}}
>
{file.relativePath}
</button>
<span className="flex shrink-0 items-center gap-1.5">
{file.linesAdded > 0 ? (
<span className="text-emerald-400">+{file.linesAdded}</span>
) : null}
{file.linesRemoved > 0 ? (
<span className="text-red-400">-{file.linesRemoved}</span>
) : null}
</span>
<span className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
onClick={() => {
handleClose();
onViewChanges(currentTask.id, file.filePath);
}}
>
<GitCompareArrows size={13} />
</button>
</TooltipTrigger>
<TooltipContent side="top">Review diff</TooltipContent>
</Tooltip>
{onOpenInEditor ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
onClick={() => onOpenInEditor(file.filePath)}
>
<SquarePen size={13} />
</button>
</TooltipTrigger>
<TooltipContent side="top">Open in editor</TooltipContent>
</Tooltip>
) : null}
</span>
</div>
))}
</div>
) : (
<p className="text-xs text-[var(--color-text-muted)]">No file changes detected</p>
)}
</CollapsibleTeamSection>
) : null}
{/* Execution Logs — sessions that reference this task */}
{variant === 'team' ? (
<CollapsibleTeamSection
title="Execution Logs"
icon={<ScrollText size={14} />}
headerExtra={
logsRefreshing || executionPreviewOnline ? (
<span className="flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
{executionPreviewOnline ? (
<span
className="pointer-events-none relative inline-flex size-2 shrink-0"
title="Online"
>
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
</span>
) : null}
{logsRefreshing ? (
<span className="flex items-center gap-1">
<Loader2 size={10} className="animate-spin" />
Updating...
</span>
) : null}
</span>
) : null
}
contentClassName="pl-2.5"
headerClassName="-mx-6 w-[calc(100%+3rem)]"
headerContentClassName="pl-6"
defaultOpen
>
<div className="min-w-0">
<MemberLogsTab
teamName={teamName}
taskId={currentTask.id}
taskOwner={currentTask.owner}
taskStatus={currentTask.status}
taskWorkIntervals={currentTask.workIntervals}
onRefreshingChange={setLogsRefreshing}
// Only show a "latest messages" preview when this task is owned by a subagent.
// For lead-owned tasks, the lead session is a mixed stream (lead + multiple agents),
// so filtering to "just the member messages" is unreliable and easy to mislead.
showSubagentPreview={Boolean(currentTask.owner) && !isLeadOwnedTask}
// Temporary debug option: for lead-owned tasks, show quick preview from lead session.
showLeadPreview={allowLeadExecutionPreview && isLeadOwnedTask}
onPreviewOnlineChange={setExecutionPreviewOnline}
/>
</div>
</CollapsibleTeamSection>
) : null}
{blockedByIds.length > 0 ||
blocksIds.length > 0 ||
relatedIds.length > 0 ||
relatedByIds.length > 0 ||
kanbanTaskState ? (
<div className="space-y-2">
{/* Dependencies */}
{blockedByIds.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="inline-flex items-center gap-0.5 text-xs text-yellow-300">
<ArrowLeftFromLine size={12} />
Blocked by
</span>
{blockedByIds.map((id) => {
const depTask = taskMap.get(id);
const isCompleted = depTask?.status === 'completed';
return (
<button
key={id}
type="button"
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
isCompleted
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
: 'bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/25'
} cursor-pointer`}
title={
depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`
}
onClick={() => handleDependencyClick(id)}
>
{depTask ? formatTaskDisplayLabel(depTask) : `#${deriveTaskDisplayId(id)}`}
</button>
);
})}
</div>
) : null}
{blocksIds.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="inline-flex items-center gap-0.5 text-xs text-blue-400">
<ArrowRightFromLine size={12} />
Blocks
</span>
{blocksIds.map((id) => {
const depTask = taskMap.get(id);
const isCompleted = depTask?.status === 'completed';
return (
<button
key={id}
type="button"
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
isCompleted
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
: 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
} cursor-pointer`}
title={
depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`
}
onClick={() => handleDependencyClick(id)}
>
{depTask ? formatTaskDisplayLabel(depTask) : `#${deriveTaskDisplayId(id)}`}
</button>
);
})}
</div>
) : null}
{/* Related tasks (explicit) */}
{relatedIds.length > 0 || relatedByIds.length > 0 ? (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)]">
<Link2 size={12} />
Related tasks
</div>
{relatedIds.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-[var(--color-text-muted)]">Links</span>
{relatedIds.map((id) => {
const depTask = taskMap.get(id);
return (
<button
key={`related:${currentTask.id}:${id}`}
type="button"
className="inline-flex items-center rounded bg-purple-500/15 px-1.5 py-0.5 text-[10px] font-medium text-purple-300 transition-colors hover:bg-purple-500/25"
title={
depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`
}
onClick={() => handleDependencyClick(id)}
>
{depTask
? formatTaskDisplayLabel(depTask)
: `#${deriveTaskDisplayId(id)}`}
</button>
);
})}
</div>
) : null}
{relatedByIds.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-[var(--color-text-muted)]">Linked from</span>
{relatedByIds.map((id) => {
const depTask = taskMap.get(id);
return (
<button
key={`related-by:${currentTask.id}:${id}`}
type="button"
className="inline-flex items-center rounded bg-fuchsia-500/15 px-1.5 py-0.5 text-[10px] font-medium text-fuchsia-300 transition-colors hover:bg-fuchsia-500/25"
title={
depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`
}
onClick={() => handleDependencyClick(id)}
>
{depTask
? formatTaskDisplayLabel(depTask)
: `#${deriveTaskDisplayId(id)}`}
</button>
);
})}
</div>
) : null}
</div>
) : null}
{/* Review info */}
{kanbanTaskState ? (
<div className="flex items-center gap-2">
{kanbanTaskState.reviewer ? (
<span className="text-xs text-[var(--color-text-secondary)]">
Reviewer: {kanbanTaskState.reviewer}
</span>
) : null}
{kanbanTaskState.errorDescription ? (
<span className="text-xs text-red-400">{kanbanTaskState.errorDescription}</span>
) : null}
</div>
) : null}
</div>
) : null}
{/* Workflow History */}
{currentTask.historyEvents && currentTask.historyEvents.length > 0 ? (
<CollapsibleTeamSection
title="Workflow History"
icon={<History size={14} />}
badge={currentTask.historyEvents.length}
contentClassName="pl-2.5"
headerClassName="-mx-6 w-[calc(100%+3rem)]"
headerContentClassName="pl-6"
defaultOpen={false}
>
<WorkflowTimeline events={currentTask.historyEvents} memberColorMap={colorMap} />
</CollapsibleTeamSection>
) : null}
{/* Comments */}
<CollapsibleTeamSection
title="Comments"
icon={<MessageSquare size={14} />}
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
>
<div className="pl-2.5">
<TaskCommentInput
teamName={teamName}
taskId={currentTask.id}
members={members}
replyTo={effectiveReplyTo}
onClearReply={clearReply}
/>
</div>
<TaskCommentsSection
teamName={teamName}
taskId={currentTask.id}
comments={currentTask.comments ?? []}
members={members}
hideHeader
hideInput
onReply={handleReply}
onTaskIdClick={onScrollToTask ? (taskId) => handleDependencyClick(taskId) : undefined}
containerClassName="-mx-6"
unreadCommentIds={unreadSnapshotRef.current}
/>
</CollapsibleTeamSection>
</LightboxLockProvider>
</DialogContent>
</Dialog>
);
};
// ---------------------------------------------------------------------------
// 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<string | null>(null);
return (
<div className="mt-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)]">
<MessageSquare size={10} />
From comments
</div>
<div className="flex flex-wrap gap-2">
{items.map((item) => (
<CommentImageThumbnail
key={item.attachment.id}
item={item}
teamName={teamName}
taskId={taskId}
onPreview={setPreviewUrl}
/>
))}
</div>
{previewUrl ? (
<ImageLightbox
open
onClose={() => setPreviewUrl(null)}
src={previewUrl}
alt="Comment attachment"
/>
) : null}
</div>
);
};
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<string | null>(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 (
<Tooltip>
<TooltipTrigger asChild>
<div
className="group relative flex size-16 cursor-pointer items-center justify-center overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] transition-colors hover:border-[var(--color-border-emphasis)]"
onClick={() => thumbUrl && onPreview(thumbUrl)}
>
{thumbUrl ? (
<img src={thumbUrl} alt={item.attachment.filename} className="size-full object-cover" />
) : (
<Loader2 size={12} className="animate-spin text-[var(--color-text-muted)]" />
)}
<div className="absolute inset-x-0 bottom-0 truncate bg-black/60 px-0.5 py-px text-center text-[7px] text-white opacity-0 transition-opacity group-hover:opacity-100">
{item.attachment.filename}
</div>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[300px] text-xs">
{tooltipText}
</TooltipContent>
</Tooltip>
);
};