agent-ecosystem/src/renderer/components/team/dialogs/TaskCommentsSection.tsx
iliya 194bd1bf1e feat: enhance task messaging and member role detection
- Introduced a new function `quoteMarkdown` to format task comments with markdown quotes for better readability.
- Updated the `buildCommentNotificationMessage` to utilize `quoteMarkdown`, ensuring comments are displayed correctly.
- Refactored member role detection across multiple services to use `isLeadMember` for consistency and clarity in identifying team leads.
- Enhanced various components to improve handling of team member roles, ensuring accurate representation in UI and logic.
- Adjusted tests to reflect changes in comment formatting and member role checks, improving overall reliability.
2026-03-15 17:45:10 +02:00

613 lines
25 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { CopyButton } from '@renderer/components/common/CopyButton';
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 { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useStore } from '@renderer/store';
import { serializeChipsWithText } from '@renderer/types/inlineChip';
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 { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
import {
extractTaskRefsFromText,
linkifyTaskIdsInMarkdown,
parseTaskLinkHref,
stripEncodedTaskReferenceMetadata,
} from '@renderer/utils/taskReferenceUtils';
import { MAX_TEXT_LENGTH } from '@shared/constants';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
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.
* Historical CLI-produced comments 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;
/** Snapshot of unread comment IDs captured when the dialog opened. Blue dot is shown for these. */
unreadCommentIds?: Set<string>;
/**
* Ref callback factory from useViewportCommentRead.
* When provided, each comment element is registered for viewport-based read tracking.
*/
registerCommentForViewport?: (commentId: string) => (el: HTMLElement | null) => void;
}
export const TaskCommentsSection = ({
teamName,
taskId,
comments,
members,
hideHeader = false,
hideInput = false,
onReply,
onTaskIdClick,
containerClassName,
unreadCommentIds,
registerCommentForViewport,
}: TaskCommentsSectionProps): React.JSX.Element => {
const addTaskComment = useStore((s) => s.addTaskComment);
const addingComment = useStore((s) => s.addingComment);
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
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 using the
// "adjust state during render" pattern (no effect needed).
// See: https://react.dev/reference/react/useState#storing-information-from-previous-renders
const resetKey = `${teamName}:${taskId}`;
const [prevResetKey, setPrevResetKey] = useState(resetKey);
if (resetKey !== prevResetKey) {
setPrevResetKey(resetKey);
setVisibleCount(INITIAL_VISIBLE_COMMENTS);
setReplyTo(null);
setPreviewImageUrl(null);
}
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
const chipDraft = useChipDraftPersistence(`taskCommentChips:${teamName}:${taskId}`);
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
const teamNamesForLinkify = useMemo(
() => teamMentionSuggestions.map((t) => t.name),
[teamMentionSuggestions]
);
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 = stripEncodedTaskReferenceMetadata(draft.value).trim();
const remaining = MAX_TEXT_LENGTH - trimmed.length;
const canSubmit =
(trimmed.length > 0 || chipDraft.chips.length > 0) &&
trimmed.length <= MAX_TEXT_LENGTH &&
!addingComment;
const handleSubmit = useCallback(async () => {
if (!canSubmit) return;
try {
const serialized = serializeChipsWithText(trimmed, chipDraft.chips);
const text = replyTo ? buildReplyBlock(replyTo.author, replyTo.text, serialized) : serialized;
const taskRefs = extractTaskRefsFromText(draft.value, taskSuggestions);
await addTaskComment(teamName, taskId, { text, taskRefs });
draft.clearDraft();
chipDraft.clearChipDraft();
setReplyTo(null);
} catch {
// Error is stored in addCommentError via store
}
}, [
canSubmit,
addTaskComment,
teamName,
taskId,
trimmed,
draft,
chipDraft,
replyTo,
taskSuggestions,
]);
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
ref={
registerCommentForViewport ? registerCommentForViewport(comment.id) : undefined
}
className={[
'group min-w-0 overflow-hidden 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)]">
{unreadCommentIds?.has(comment.id) ? (
<span className="size-2 shrink-0 rounded-full bg-blue-500" />
) : null}
<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)}
replyTaskRefs={comment.taskRefs}
bodyMaxHeight="max-h-none"
/>
) : (
<span
className="break-words"
onClickCapture={
onTaskIdClick
? (e) => {
const link = (
e.target as HTMLElement
).closest<HTMLAnchorElement>('a[href^="task://"]');
if (link) {
e.preventDefault();
e.stopPropagation();
const href = link.getAttribute('href');
const parsed = href ? parseTaskLinkHref(href) : null;
if (parsed?.taskId) onTaskIdClick(parsed.taskId);
}
}
: undefined
}
>
<MarkdownViewer
content={(() => {
let t = linkifyTaskIdsInMarkdown(displayText, comment.taskRefs);
if (colorMap.size > 0 || teamNamesForLinkify.length > 0)
t = linkifyAllMentionsInMarkdown(
t,
colorMap,
teamNamesForLinkify
);
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}
teamSuggestions={teamMentionSuggestions}
taskSuggestions={taskSuggestions}
projectPath={projectPath}
chips={chipDraft.chips}
onFileChipInsert={chipDraft.addChip}
onChipRemove={chipDraft.removeChip}
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)]">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>
);