feat: enhance TaskCommentsSection with improved comment rendering and visibility management
- Added state management for visible comments, allowing users to see a limited number of comments for better performance. - Implemented logic to cap the number of rendered comments, preventing UI freezes with large comment lists. - Introduced sorting for comments based on creation date to display the most recent comments first. - Updated the UI to inform users when only a subset of comments is being displayed, enhancing user experience.
This commit is contained in:
parent
c528b07fec
commit
ede47903c1
1 changed files with 172 additions and 120 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
||||||
|
|
@ -20,6 +20,9 @@ import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { ResolvedTeamMember, TaskComment } from '@shared/types';
|
import type { ResolvedTeamMember, TaskComment } from '@shared/types';
|
||||||
|
|
||||||
const MAX_COMMENT_LENGTH = 2000;
|
const MAX_COMMENT_LENGTH = 2000;
|
||||||
|
const INITIAL_VISIBLE_COMMENTS = 30;
|
||||||
|
const VISIBLE_COMMENTS_STEP = 50;
|
||||||
|
const MAX_COMMENTS_TO_RENDER = 2000;
|
||||||
|
|
||||||
interface TaskCommentsSectionProps {
|
interface TaskCommentsSectionProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -49,6 +52,13 @@ export const TaskCommentsSection = ({
|
||||||
|
|
||||||
const [replyTo, setReplyTo] = useState<{ author: string; text: string } | null>(null);
|
const [replyTo, setReplyTo] = useState<{ author: string; text: string } | null>(null);
|
||||||
const [expandedCommentIds, setExpandedCommentIds] = useState<Set<string>>(new Set());
|
const [expandedCommentIds, setExpandedCommentIds] = useState<Set<string>>(new Set());
|
||||||
|
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisibleCount(INITIAL_VISIBLE_COMMENTS);
|
||||||
|
setExpandedCommentIds(new Set());
|
||||||
|
setReplyTo(null);
|
||||||
|
}, [teamIdKey(teamName, taskId)]);
|
||||||
|
|
||||||
const toggleCommentExpanded = useCallback((commentId: string) => {
|
const toggleCommentExpanded = useCallback((commentId: string) => {
|
||||||
setExpandedCommentIds((prev) => {
|
setExpandedCommentIds((prev) => {
|
||||||
|
|
@ -62,6 +72,24 @@ export const TaskCommentsSection = ({
|
||||||
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
|
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
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 mentionSuggestions = useMemo<MentionSuggestion[]>(
|
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||||
() =>
|
() =>
|
||||||
members.map((m) => ({
|
members.map((m) => ({
|
||||||
|
|
@ -105,130 +133,150 @@ export const TaskCommentsSection = ({
|
||||||
|
|
||||||
{comments.length > 0 ? (
|
{comments.length > 0 ? (
|
||||||
<div className="mb-3 space-y-2">
|
<div className="mb-3 space-y-2">
|
||||||
{[...comments]
|
{comments.length > MAX_COMMENTS_TO_RENDER ? (
|
||||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2 text-[11px] text-[var(--color-text-muted)]">
|
||||||
.map((comment) => (
|
Showing the most recent {MAX_COMMENTS_TO_RENDER.toLocaleString()} comments to keep the
|
||||||
<div key={comment.id} className="group p-2.5">
|
UI responsive.
|
||||||
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
</div>
|
||||||
<span
|
) : null}
|
||||||
className="font-medium"
|
|
||||||
style={{
|
{visibleComments.map((comment) => (
|
||||||
color: (() => {
|
<div key={comment.id} className="group p-2.5">
|
||||||
const rc = colorMap.get(comment.author);
|
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||||
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
|
<span
|
||||||
})(),
|
className="font-medium"
|
||||||
}}
|
style={{
|
||||||
>
|
color: (() => {
|
||||||
{comment.author}
|
const rc = colorMap.get(comment.author);
|
||||||
</span>
|
return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)';
|
||||||
<span>
|
})(),
|
||||||
{(() => {
|
}}
|
||||||
const date = new Date(comment.createdAt);
|
>
|
||||||
return isNaN(date.getTime())
|
{comment.author}
|
||||||
? 'unknown time'
|
</span>
|
||||||
: formatDistanceToNow(date, { addSuffix: true });
|
<span>
|
||||||
})()}
|
{(() => {
|
||||||
</span>
|
const date = new Date(comment.createdAt);
|
||||||
<Tooltip>
|
return isNaN(date.getTime())
|
||||||
<TooltipTrigger asChild>
|
? 'unknown time'
|
||||||
<button
|
: formatDistanceToNow(date, { addSuffix: true });
|
||||||
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"
|
</span>
|
||||||
onClick={() => {
|
<Tooltip>
|
||||||
const replyText = stripAgentBlocks(
|
<TooltipTrigger asChild>
|
||||||
parseMessageReply(comment.text)?.replyText ?? comment.text
|
<button
|
||||||
);
|
type="button"
|
||||||
if (onReply) {
|
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"
|
||||||
onReply(comment.author, replyText);
|
onClick={() => {
|
||||||
} else {
|
const replyText = stripAgentBlocks(
|
||||||
setReplyTo({ author: comment.author, text: replyText });
|
parseMessageReply(comment.text)?.replyText ?? comment.text
|
||||||
}
|
);
|
||||||
}}
|
if (onReply) {
|
||||||
>
|
onReply(comment.author, replyText);
|
||||||
<Reply size={11} />
|
} else {
|
||||||
Reply
|
setReplyTo({ author: comment.author, text: replyText });
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="left">Reply to comment</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
{(() => {
|
|
||||||
const reply = parseMessageReply(comment.text);
|
|
||||||
const rawForDisplay = reply ? reply.replyText : comment.text;
|
|
||||||
const displayText = stripAgentBlocks(rawForDisplay);
|
|
||||||
const needsExpandCollapse = displayText.includes('\n');
|
|
||||||
const expanded = expandedCommentIds.has(comment.id);
|
|
||||||
const collapsedHeight = 'max-h-[120px]';
|
|
||||||
const showCollapsed = needsExpandCollapse && !expanded;
|
|
||||||
const showExpandedButton = needsExpandCollapse && expanded;
|
|
||||||
return (
|
|
||||||
<div className="relative text-xs">
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
showCollapsed ? `relative ${collapsedHeight} overflow-hidden` : undefined
|
|
||||||
}
|
}
|
||||||
>
|
}}
|
||||||
{reply ? (
|
>
|
||||||
<ReplyQuoteBlock
|
<Reply size={11} />
|
||||||
reply={{
|
Reply
|
||||||
...reply,
|
</button>
|
||||||
originalText: stripAgentBlocks(reply.originalText),
|
</TooltipTrigger>
|
||||||
replyText: stripAgentBlocks(reply.replyText),
|
<TooltipContent side="left">Reply to comment</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const reply = parseMessageReply(comment.text);
|
||||||
|
const rawForDisplay = reply ? reply.replyText : comment.text;
|
||||||
|
const displayText = stripAgentBlocks(rawForDisplay);
|
||||||
|
const needsExpandCollapse = displayText.includes('\n');
|
||||||
|
const expanded = expandedCommentIds.has(comment.id);
|
||||||
|
const collapsedHeight = 'max-h-[120px]';
|
||||||
|
const showCollapsed = needsExpandCollapse && !expanded;
|
||||||
|
const showExpandedButton = needsExpandCollapse && expanded;
|
||||||
|
return (
|
||||||
|
<div className="relative text-xs">
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
showCollapsed ? `relative ${collapsedHeight} overflow-hidden` : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{reply ? (
|
||||||
|
<ReplyQuoteBlock
|
||||||
|
reply={{
|
||||||
|
...reply,
|
||||||
|
originalText: stripAgentBlocks(reply.originalText),
|
||||||
|
replyText: stripAgentBlocks(reply.replyText),
|
||||||
|
}}
|
||||||
|
bodyMaxHeight={
|
||||||
|
needsExpandCollapse && !expanded ? 'max-h-56' : 'max-h-none'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MarkdownViewer
|
||||||
|
content={displayText}
|
||||||
|
maxHeight={
|
||||||
|
needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none'
|
||||||
|
}
|
||||||
|
bare
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showCollapsed && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-x-0 bottom-0 h-14"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(to top, var(--color-surface) 0%, transparent 100%)',
|
||||||
}}
|
}}
|
||||||
bodyMaxHeight={
|
aria-hidden
|
||||||
needsExpandCollapse && !expanded ? 'max-h-56' : 'max-h-none'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
<div className="absolute inset-x-0 bottom-0 flex justify-center pt-1">
|
||||||
<MarkdownViewer
|
<button
|
||||||
content={displayText}
|
type="button"
|
||||||
maxHeight={
|
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||||
needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none'
|
onClick={() => toggleCommentExpanded(comment.id)}
|
||||||
}
|
title="Expand"
|
||||||
/>
|
>
|
||||||
)}
|
<ChevronDown size={12} />
|
||||||
{showCollapsed && (
|
Expand
|
||||||
<>
|
</button>
|
||||||
<div
|
</div>
|
||||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-14"
|
</>
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
'linear-gradient(to top, var(--color-surface) 0%, transparent 100%)',
|
|
||||||
}}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-x-0 bottom-0 flex justify-center pt-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
|
||||||
onClick={() => toggleCommentExpanded(comment.id)}
|
|
||||||
title="Expand"
|
|
||||||
>
|
|
||||||
<ChevronDown size={12} />
|
|
||||||
Expand
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{showExpandedButton && (
|
|
||||||
<div className="flex justify-center pt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
|
|
||||||
onClick={() => toggleCommentExpanded(comment.id)}
|
|
||||||
title="Collapse"
|
|
||||||
>
|
|
||||||
<ChevronUp size={12} />
|
|
||||||
Collapse
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
{showExpandedButton && (
|
||||||
})()}
|
<div className="flex justify-center pt-2">
|
||||||
</div>
|
<button
|
||||||
))}
|
type="button"
|
||||||
|
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
|
||||||
|
onClick={() => toggleCommentExpanded(comment.id)}
|
||||||
|
title="Collapse"
|
||||||
|
>
|
||||||
|
<ChevronUp size={12} />
|
||||||
|
Collapse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -313,3 +361,7 @@ export const TaskCommentsSection = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function teamIdKey(teamName: string, taskId: string): string {
|
||||||
|
return `${teamName}::${taskId}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue