- Added requestReview method to facilitate task review requests, integrating with kanban and messaging systems. - Updated taskStore to manage review states, ensuring compatibility with kanban columns. - Enhanced message handling by introducing appendSentMessage for better tracking of sent messages. - Improved task management by normalizing review states and integrating them into task creation and retrieval processes. - Updated tests to cover new review and messaging features, ensuring robust functionality across components.
1055 lines
40 KiB
TypeScript
1055 lines
40 KiB
TypeScript
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,
|
|
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<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);
|
|
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 (
|
|
<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) => !v && onClose()}>
|
|
<DialogContent className="min-w-0 sm:max-w-4xl">
|
|
<DialogHeader>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
|
|
#{currentTask.id}
|
|
</Badge>
|
|
<span
|
|
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${statusStyle.bg} ${statusStyle.text}`}
|
|
>
|
|
{statusLabel}
|
|
</span>
|
|
{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}
|
|
|
|
{/* Status History */}
|
|
{currentTask.statusHistory && currentTask.statusHistory.length > 0 ? (
|
|
<CollapsibleTeamSection
|
|
title="Status History"
|
|
icon={<History size={14} />}
|
|
badge={currentTask.statusHistory.length}
|
|
contentClassName="pl-2.5"
|
|
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
|
headerContentClassName="pl-6"
|
|
defaultOpen={false}
|
|
>
|
|
<StatusHistoryTimeline history={currentTask.statusHistory} />
|
|
</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"
|
|
/>
|
|
</CollapsibleTeamSection>
|
|
</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>
|
|
);
|
|
};
|