- Replaced manual tracking of new item keys in ActivityTimeline and TaskCommentsSection with a new custom hook, useNewItemKeys, for improved code clarity and reusability. - Simplified state management related to new items in both components, enhancing maintainability and reducing complexity. - Updated related logic to ensure consistent handling of newly visible items during pagination and resets.
563 lines
23 KiB
TypeScript
563 lines
23 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
|
import { CopyButton } from '@renderer/components/common/CopyButton';
|
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
|
import { AnimatedHeightReveal } from '@renderer/components/team/activity/AnimatedHeightReveal';
|
|
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
|
import { useNewItemKeys } from '@renderer/components/team/activity/useNewItemKeys';
|
|
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
|
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
|
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
|
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
|
|
import { useStore } from '@renderer/store';
|
|
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
|
|
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
|
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
import { CheckCircle2, Eye, File, Loader2, MessageSquare, Reply, Send, X } from 'lucide-react';
|
|
|
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
|
import type { ResolvedTeamMember, TaskAttachmentMeta, TaskComment } from '@shared/types';
|
|
|
|
/**
|
|
* Convert literal backslash-n sequences to real newlines.
|
|
* CLI tools (teamctl.js) may store `\n` as literal text when
|
|
* shell double-quotes don't interpret escape sequences.
|
|
*/
|
|
function normalizeLiteralNewlines(text: string): string {
|
|
return text.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
|
}
|
|
|
|
const INITIAL_VISIBLE_COMMENTS = 30;
|
|
const VISIBLE_COMMENTS_STEP = 50;
|
|
const MAX_COMMENTS_TO_RENDER = 2000;
|
|
|
|
interface TaskCommentsSectionProps {
|
|
teamName: string;
|
|
taskId: string;
|
|
comments: TaskComment[];
|
|
members: ResolvedTeamMember[];
|
|
/** When true, the "Comments" header is not rendered (e.g. inside a collapsible section). */
|
|
hideHeader?: boolean;
|
|
/** When true, the comment input area is not rendered (useful when input is rendered externally). */
|
|
hideInput?: boolean;
|
|
/** Called when the user clicks Reply on a comment (used when input is rendered externally). */
|
|
onReply?: (author: string, text: string) => void;
|
|
/** Called when a task ID link (e.g. #10) is clicked in comment text. */
|
|
onTaskIdClick?: (taskId: string) => void;
|
|
/** Extra className on the outer comments container (e.g. negative margins for edge-to-edge). */
|
|
containerClassName?: string;
|
|
}
|
|
|
|
/** Convert `#<digits>` in plain text to markdown links with task:// protocol. */
|
|
function linkifyTaskIdsInMarkdown(text: string): string {
|
|
return text.replace(/#(\d+)\b/g, '[#$1](task://$1)');
|
|
}
|
|
|
|
/** Convert `@memberName` to markdown links with mention:// protocol for colored badge rendering. */
|
|
function linkifyMentionsInMarkdown(text: string, memberColorMap: Map<string, string>): string {
|
|
if (memberColorMap.size === 0) return text;
|
|
const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length);
|
|
const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
const pattern = new RegExp(`(^|\\s)@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}-]|$)`, 'gi');
|
|
return text.replace(pattern, (_match, prefix: string, name: string) => {
|
|
const canonical = names.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
|
|
const color = memberColorMap.get(canonical) ?? '';
|
|
return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`;
|
|
});
|
|
}
|
|
|
|
export const TaskCommentsSection = ({
|
|
teamName,
|
|
taskId,
|
|
comments,
|
|
members,
|
|
hideHeader = false,
|
|
hideInput = false,
|
|
onReply,
|
|
onTaskIdClick,
|
|
containerClassName,
|
|
}: TaskCommentsSectionProps): React.JSX.Element => {
|
|
const addTaskComment = useStore((s) => s.addTaskComment);
|
|
const addingComment = useStore((s) => s.addingComment);
|
|
const commentsRef = useMarkCommentsRead(teamName, taskId, comments);
|
|
|
|
const [replyTo, setReplyTo] = useState<{ author: string; text: string } | null>(null);
|
|
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
|
|
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null);
|
|
|
|
// Reset local UI state when team/task changes.
|
|
useEffect(() => {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
|
setVisibleCount(INITIAL_VISIBLE_COMMENTS);
|
|
setReplyTo(null);
|
|
setPreviewImageUrl(null);
|
|
}, [teamName, taskId]);
|
|
|
|
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
|
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
|
|
|
const cappedComments = useMemo(() => {
|
|
if (comments.length <= MAX_COMMENTS_TO_RENDER) return comments;
|
|
// In extreme cases, rendering thousands of markdown blocks can freeze the renderer.
|
|
// Keep the UI responsive by showing only the most recent subset.
|
|
return comments.slice(-MAX_COMMENTS_TO_RENDER);
|
|
}, [comments]);
|
|
|
|
const sortedComments = useMemo(() => {
|
|
const list = [...cappedComments];
|
|
list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
return list;
|
|
}, [cappedComments]);
|
|
|
|
const visibleComments = useMemo(
|
|
() => sortedComments.slice(0, Math.min(visibleCount, sortedComments.length)),
|
|
[sortedComments, visibleCount]
|
|
);
|
|
|
|
const visibleCommentIds = useMemo(
|
|
() => visibleComments.map((comment) => comment.id),
|
|
[visibleComments]
|
|
);
|
|
const newCommentIds = useNewItemKeys({
|
|
itemKeys: visibleCommentIds,
|
|
paginationKey: visibleCount,
|
|
resetKey: `${teamName}:${taskId}`,
|
|
});
|
|
|
|
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
|
() =>
|
|
members.map((m) => ({
|
|
id: m.name,
|
|
name: m.name,
|
|
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
|
color: colorMap.get(m.name),
|
|
})),
|
|
[members, colorMap]
|
|
);
|
|
|
|
const trimmed = draft.value.trim();
|
|
const remaining = MAX_TEXT_LENGTH - trimmed.length;
|
|
const canSubmit = trimmed.length > 0 && trimmed.length <= MAX_TEXT_LENGTH && !addingComment;
|
|
|
|
const handleSubmit = useCallback(async () => {
|
|
if (!canSubmit) return;
|
|
try {
|
|
const text = replyTo ? buildReplyBlock(replyTo.author, replyTo.text, trimmed) : trimmed;
|
|
await addTaskComment(teamName, taskId, text);
|
|
draft.clearDraft();
|
|
setReplyTo(null);
|
|
} catch {
|
|
// Error is stored in addCommentError via store
|
|
}
|
|
}, [canSubmit, addTaskComment, teamName, taskId, trimmed, draft, replyTo]);
|
|
|
|
return (
|
|
<div ref={commentsRef}>
|
|
{!hideHeader ? (
|
|
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)]">
|
|
<MessageSquare size={12} />
|
|
Comments
|
|
{comments.length > 0 ? (
|
|
<span className="rounded-full bg-[var(--color-surface-raised)] px-1.5 py-0 text-[10px]">
|
|
{comments.length}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{comments.length > 0 ? (
|
|
<div className="mb-3">
|
|
{comments.length > MAX_COMMENTS_TO_RENDER ? (
|
|
<div className="mb-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2 text-[11px] text-[var(--color-text-muted)]">
|
|
Showing the most recent {MAX_COMMENTS_TO_RENDER.toLocaleString()} comments to keep the
|
|
UI responsive.
|
|
</div>
|
|
) : null}
|
|
|
|
<div className={containerClassName ?? ''}>
|
|
{visibleComments.map((comment, index) => (
|
|
<AnimatedHeightReveal key={comment.id} animate={newCommentIds.has(comment.id)}>
|
|
<div
|
|
className={[
|
|
'group px-4 py-2.5',
|
|
comment.type === 'review_approved'
|
|
? 'border-y border-emerald-500/20 bg-emerald-500/5'
|
|
: comment.type === 'review_request'
|
|
? 'border-y border-blue-500/20 bg-blue-500/5'
|
|
: '',
|
|
].join(' ')}
|
|
style={
|
|
!comment.type || comment.type === 'regular'
|
|
? {
|
|
backgroundColor:
|
|
index % 2 === 1 ? 'var(--card-bg-zebra)' : 'var(--card-bg)',
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
|
<MemberBadge
|
|
name={comment.author}
|
|
color={colorMap.get(comment.author)}
|
|
hideAvatar={comment.author === 'user'}
|
|
/>
|
|
{comment.type === 'review_approved' ? (
|
|
<span className="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
|
|
<CheckCircle2 size={10} />
|
|
Approved
|
|
</span>
|
|
) : comment.type === 'review_request' ? (
|
|
<span className="inline-flex items-center gap-0.5 rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
|
|
<Eye size={10} />
|
|
Review requested
|
|
</span>
|
|
) : null}
|
|
<span>
|
|
{(() => {
|
|
const date = new Date(comment.createdAt);
|
|
return isNaN(date.getTime())
|
|
? 'unknown time'
|
|
: formatDistanceToNow(date, { addSuffix: true });
|
|
})()}
|
|
</span>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
|
|
onClick={() => {
|
|
const replyText = stripAgentBlocks(
|
|
parseMessageReply(comment.text)?.replyText ?? comment.text
|
|
);
|
|
if (onReply) {
|
|
onReply(comment.author, replyText);
|
|
} else {
|
|
setReplyTo({ author: comment.author, text: replyText });
|
|
}
|
|
}}
|
|
>
|
|
<Reply size={11} />
|
|
Reply
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="left">Reply to comment</TooltipContent>
|
|
</Tooltip>
|
|
<span className="opacity-0 transition-opacity group-hover:opacity-100">
|
|
<CopyButton text={comment.text} inline />
|
|
</span>
|
|
</div>
|
|
{(() => {
|
|
const reply = parseMessageReply(comment.text);
|
|
const rawForDisplay = reply ? reply.replyText : comment.text;
|
|
const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay));
|
|
return (
|
|
<ExpandableContent collapsedHeight={120} className="text-xs">
|
|
{reply ? (
|
|
<ReplyQuoteBlock
|
|
reply={{
|
|
...reply,
|
|
originalText: stripAgentBlocks(reply.originalText),
|
|
replyText: stripAgentBlocks(reply.replyText),
|
|
}}
|
|
memberColor={colorMap.get(reply.agentName)}
|
|
bodyMaxHeight="max-h-none"
|
|
/>
|
|
) : (
|
|
<span
|
|
onClickCapture={
|
|
onTaskIdClick
|
|
? (e) => {
|
|
const link = (
|
|
e.target as HTMLElement
|
|
).closest<HTMLAnchorElement>('a[href^="task://"]');
|
|
if (link) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const id = link.getAttribute('href')?.replace('task://', '');
|
|
if (id) onTaskIdClick(id);
|
|
}
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
<MarkdownViewer
|
|
content={(() => {
|
|
let t = linkifyTaskIdsInMarkdown(displayText);
|
|
if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap);
|
|
return t;
|
|
})()}
|
|
maxHeight="max-h-none"
|
|
bare
|
|
/>
|
|
</span>
|
|
)}
|
|
</ExpandableContent>
|
|
);
|
|
})()}
|
|
{comment.attachments && comment.attachments.length > 0 ? (
|
|
<CommentAttachments
|
|
attachments={comment.attachments}
|
|
teamName={teamName}
|
|
taskId={taskId}
|
|
onPreview={setPreviewImageUrl}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</AnimatedHeightReveal>
|
|
))}
|
|
</div>
|
|
|
|
{sortedComments.length > visibleComments.length ? (
|
|
<div className="flex items-center justify-center pt-2">
|
|
<button
|
|
type="button"
|
|
className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
|
onClick={() =>
|
|
setVisibleCount((v) => Math.min(sortedComments.length, v + VISIBLE_COMMENTS_STEP))
|
|
}
|
|
>
|
|
Show more comments ({visibleComments.length}/{sortedComments.length})
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Image lightbox */}
|
|
{previewImageUrl ? (
|
|
<ImageLightbox
|
|
open
|
|
onClose={() => setPreviewImageUrl(null)}
|
|
src={previewImageUrl}
|
|
alt="Attachment preview"
|
|
/>
|
|
) : null}
|
|
|
|
{!hideInput && (
|
|
<>
|
|
{replyTo ? (
|
|
<div className="mb-2 flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="mb-0.5 flex items-center gap-1 text-[10px] font-medium text-[var(--color-text-muted)]">
|
|
Replying to
|
|
<MemberBadge name={replyTo.author} color={colorMap.get(replyTo.author)} />
|
|
</div>
|
|
<div className="line-clamp-3 text-[11px] text-[var(--color-text-muted)]">
|
|
{replyTo.text}
|
|
</div>
|
|
</div>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
|
|
onClick={() => setReplyTo(null)}
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="left">Cancel reply</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="relative">
|
|
<MentionableTextarea
|
|
id={`task-comment-${taskId}`}
|
|
placeholder="Add a comment... (Enter to send)"
|
|
value={draft.value}
|
|
onValueChange={draft.setValue}
|
|
suggestions={mentionSuggestions}
|
|
onModEnter={() => void handleSubmit()}
|
|
minRows={2}
|
|
maxRows={8}
|
|
maxLength={MAX_TEXT_LENGTH}
|
|
disabled={addingComment}
|
|
cornerAction={
|
|
<button
|
|
type="button"
|
|
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
disabled={!canSubmit}
|
|
onClick={() => void handleSubmit()}
|
|
>
|
|
<Send size={12} />
|
|
Comment
|
|
</button>
|
|
}
|
|
footerRight={
|
|
<div className="flex items-center gap-2">
|
|
{remaining < 200 ? (
|
|
<span
|
|
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
|
|
>
|
|
{remaining} chars left
|
|
</span>
|
|
) : null}
|
|
{draft.isSaved ? (
|
|
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
|
) : null}
|
|
</div>
|
|
}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Comment attachment thumbnail (read-only, no delete)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface CommentAttachmentThumbnailProps {
|
|
attachment: TaskAttachmentMeta;
|
|
teamName: string;
|
|
taskId: string;
|
|
onPreview: (dataUrl: string) => void;
|
|
}
|
|
|
|
const CommentAttachmentThumbnail = ({
|
|
attachment,
|
|
teamName,
|
|
taskId,
|
|
onPreview,
|
|
}: CommentAttachmentThumbnailProps): React.JSX.Element => {
|
|
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
|
|
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
|
const [downloading, setDownloading] = useState(false);
|
|
const [downloadError, setDownloadError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void (async () => {
|
|
try {
|
|
if (!isImageMimeType(attachment.mimeType)) return;
|
|
const base64 = await getTaskAttachmentData(
|
|
teamName,
|
|
taskId,
|
|
attachment.id,
|
|
attachment.mimeType
|
|
);
|
|
if (!cancelled && base64) {
|
|
setThumbUrl(`data:${attachment.mimeType};base64,${base64}`);
|
|
}
|
|
} catch {
|
|
// ignore — thumbnail simply won't render
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]);
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div
|
|
className={`group relative flex size-14 cursor-pointer items-center justify-center overflow-hidden rounded border bg-[var(--color-surface)] transition-colors ${
|
|
downloadError
|
|
? 'border-red-500/60'
|
|
: 'border-[var(--color-border)] hover:border-[var(--color-border-emphasis)]'
|
|
}`}
|
|
onClick={() => {
|
|
if (isImageMimeType(attachment.mimeType)) {
|
|
if (thumbUrl) onPreview(thumbUrl);
|
|
return;
|
|
}
|
|
void (async () => {
|
|
setDownloading(true);
|
|
setDownloadError(null);
|
|
try {
|
|
const base64 = await getTaskAttachmentData(
|
|
teamName,
|
|
taskId,
|
|
attachment.id,
|
|
attachment.mimeType
|
|
);
|
|
if (!base64) return;
|
|
const mime =
|
|
attachment.mimeType && typeof attachment.mimeType === 'string'
|
|
? attachment.mimeType
|
|
: 'application/octet-stream';
|
|
const dataUrl = `data:${mime};base64,${base64}`;
|
|
const blob = await fetch(dataUrl).then((r) => r.blob());
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = attachment.filename || 'attachment';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
} catch (err) {
|
|
setDownloadError(err instanceof Error ? err.message : 'Download failed');
|
|
} finally {
|
|
setDownloading(false);
|
|
}
|
|
})();
|
|
}}
|
|
>
|
|
{isImageMimeType(attachment.mimeType) ? (
|
|
thumbUrl ? (
|
|
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" />
|
|
) : (
|
|
<Loader2 size={12} className="animate-spin text-[var(--color-text-muted)]" />
|
|
)
|
|
) : downloading ? (
|
|
<Loader2 size={12} className="animate-spin text-[var(--color-text-muted)]" />
|
|
) : (
|
|
<File size={14} className="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">
|
|
{attachment.filename}
|
|
</div>
|
|
</div>
|
|
</TooltipTrigger>
|
|
{downloadError ? (
|
|
<TooltipContent side="top" className="text-red-400">
|
|
{downloadError}
|
|
</TooltipContent>
|
|
) : (
|
|
<TooltipContent side="top">{attachment.filename}</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Comment attachments grid
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface CommentAttachmentsProps {
|
|
attachments: TaskAttachmentMeta[];
|
|
teamName: string;
|
|
taskId: string;
|
|
onPreview: (dataUrl: string) => void;
|
|
}
|
|
|
|
const CommentAttachments = ({
|
|
attachments,
|
|
teamName,
|
|
taskId,
|
|
onPreview,
|
|
}: CommentAttachmentsProps): React.JSX.Element => (
|
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
|
{attachments.map((att) => (
|
|
<CommentAttachmentThumbnail
|
|
key={att.id}
|
|
attachment={att}
|
|
teamName={teamName}
|
|
taskId={taskId}
|
|
onPreview={onPreview}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|