From 93a6ae74b06c7372cdb5d8abb0e0a95fb646e101 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 21:57:59 +0300 Subject: [PATCH] refactor(activity): reuse markdown rendering in compact previews --- .../chat/viewers/MarkdownViewer.tsx | 388 ++++++++++++------ .../components/team/activity/ActivityItem.tsx | 48 ++- .../team/activity/LeadThoughtsGroup.tsx | 61 +-- .../team/activity/ThoughtBodyContent.tsx | 20 +- .../team/activity/activityMarkdown.ts | 36 ++ .../team/activity/ActivityItem.test.ts | 49 ++- .../team/activity/LeadThoughtsGroup.test.ts | 41 ++ 7 files changed, 470 insertions(+), 173 deletions(-) create mode 100644 src/renderer/components/team/activity/activityMarkdown.ts diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index a9b9454c..1e671096 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -71,10 +71,21 @@ interface MarkdownViewerProps { onTeamClick?: (teamName: string) => void; } +interface CompactMarkdownPreviewProps { + content: string; + className?: string; + /** Optional precomputed team color map to avoid subscribing to the full team list. */ + teamColorByName?: ReadonlyMap; + /** Optional team click handler to avoid subscribing to store in leaf renderers. */ + onTeamClick?: (teamName: string) => void; +} + const EMPTY_TEAMS: { teamName?: string; displayName?: string; color?: string }[] = []; const EMPTY_TEAM_COLOR_MAP = new Map(); const NOOP_TEAM_CLICK = (): void => undefined; +type ViewerMarkdownMode = 'default' | 'compact-preview'; + // ============================================================================= // Helpers // ============================================================================= @@ -322,53 +333,89 @@ function createViewerMarkdownComponents( isLight = false, teamColorByName: ReadonlyMap = new Map(), onTeamClick?: (teamName: string) => void, - copyCodeBlocks: boolean = false + copyCodeBlocks: boolean = false, + mode: ViewerMarkdownMode = 'default' ): Components { const hl = (children: React.ReactNode): React.ReactNode => searchCtx ? highlightSearchInChildren(children, searchCtx) : children; + const isCompactPreview = mode === 'compact-preview'; + + const renderCompactInline = ( + children: React.ReactNode, + className: string, + style: React.CSSProperties + ): React.ReactElement => ( + + {hl(children)}{' '} + + ); return { // Headings - h1: ({ children }) => ( -

- {hl(children)} -

- ), - h2: ({ children }) => ( -

- {hl(children)} -

- ), - h3: ({ children }) => ( -

- {hl(children)} -

- ), - h4: ({ children }) => ( -

- {hl(children)} -

- ), - h5: ({ children }) => ( -
- {hl(children)} -
- ), - h6: ({ children }) => ( -
- {hl(children)} -
- ), + h1: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) + ) : ( +

+ {hl(children)} +

+ ), + h2: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) + ) : ( +

+ {hl(children)} +

+ ), + h3: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) + ) : ( +

+ {hl(children)} +

+ ), + h4: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) + ) : ( +

+ {hl(children)} +

+ ), + h5: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-medium', { color: PROSE_HEADING }) + ) : ( +
+ {hl(children)} +
+ ), + h6: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-medium', { color: PROSE_HEADING }) + ) : ( +
+ {hl(children)} +
+ ), // Paragraphs - p: ({ children }) => ( -

- {hl(children)} -

- ), + p: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, '', { color: PROSE_BODY }) + ) : ( +

+ {hl(children)} +

+ ), // Links — inline element, no hl(); parent block element's hl() descends here // task:// links render with TaskTooltip + are clickable via ancestor onClickCapture @@ -570,6 +617,20 @@ function createViewerMarkdownComponents( // Code blocks — intercept mermaid diagrams at the pre level pre: ({ children, node }) => { + if (isCompactPreview) { + const compactText = extractTextFromReactNode(children).trim(); + return ( + + {compactText} + + ); + } // Check if this pre contains a mermaid code block const codeEl = node?.children?.[0]; if (codeEl && 'tagName' in codeEl && codeEl.tagName === 'code' && 'properties' in codeEl) { @@ -596,74 +657,107 @@ function createViewerMarkdownComponents( }, // Blockquotes - blockquote: ({ children }) => ( -
- {hl(children)} -
- ), + blockquote: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'italic', { color: PROSE_MUTED }) + ) : ( +
+ {hl(children)} +
+ ), // Lists - ul: ({ children }) => ( -
    - {children} -
- ), - ol: ({ children }) => ( -
    - {children} -
- ), - li: ({ children }) => ( -
  • - {hl(children)} -
  • - ), + ul: ({ children }) => + isCompactPreview ? ( + {children} + ) : ( +
      + {children} +
    + ), + ol: ({ children }) => + isCompactPreview ? ( + {children} + ) : ( +
      + {children} +
    + ), + li: ({ children }) => + isCompactPreview ? ( + + • {hl(children)}{' '} + + ) : ( +
  • + {hl(children)} +
  • + ), // Tables - table: ({ children }) => ( -
    - + isCompactPreview ? ( + {children} + ) : ( +
    +
    + {children} +
    +
    + ), + thead: ({ children }) => + isCompactPreview ? ( + {children} + ) : ( + {children} + ), + th: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) + ) : ( + - {children} - - - ), - thead: ({ children }) => ( - {children} - ), - th: ({ children }) => ( - - {hl(children)} - - ), - td: ({ children }) => ( - - {hl(children)} - - ), + {hl(children)} + + ), + td: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, '', { color: PROSE_BODY }) + ) : ( + + {hl(children)} + + ), // Horizontal rule - hr: () =>
    , + hr: () => + isCompactPreview ? ( + + · + + ) : ( +
    + ), }; } @@ -679,6 +773,78 @@ const LARGE_PREVIEW_CHARS = 30_000; // Component // ============================================================================= +function useResolvedViewerTeamContext( + providedTeamColorByName?: ReadonlyMap, + providedOnTeamClick?: (teamName: string) => void +): { + teamColorByName: ReadonlyMap; + onTeamClick?: (teamName: string) => void; +} { + const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams))); + const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab)); + + const fallbackTeamColorByName = React.useMemo(() => { + const result = new Map(); + for (const team of teams) { + if (team.teamName) { + result.set(team.teamName, team.color ?? ''); + } + if (team.displayName) { + result.set(team.displayName, team.color ?? ''); + } + } + return result; + }, [teams]); + + return { + teamColorByName: providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP, + onTeamClick: providedOnTeamClick ?? openTeamTab, + }; +} + +export const CompactMarkdownPreview: React.FC = React.memo( + function CompactMarkdownPreview({ + content, + className = '', + teamColorByName: providedTeamColorByName, + onTeamClick: providedOnTeamClick, + }) { + const { isLight } = useTheme(); + const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext( + providedTeamColorByName, + providedOnTeamClick + ); + + const components = React.useMemo( + () => + createViewerMarkdownComponents( + null, + isLight, + teamColorByName, + onTeamClick, + false, + 'compact-preview' + ), + [isLight, onTeamClick, teamColorByName] + ); + + return ( +
    + + {content} + +
    + ); + } +); + export const MarkdownViewer: React.FC = ({ content, maxHeight = 'max-h-96', @@ -695,24 +861,10 @@ export const MarkdownViewer: React.FC = ({ const [showRaw, setShowRaw] = React.useState(false); const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); const { isLight } = useTheme(); - const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams))); - const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab)); - - const fallbackTeamColorByName = React.useMemo(() => { - const result = new Map(); - for (const team of teams) { - if (team.teamName) { - result.set(team.teamName, team.color ?? ''); - } - if (team.displayName) { - result.set(team.displayName, team.color ?? ''); - } - } - return result; - }, [teams]); - const teamColorByName = - providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP; - const onTeamClick = providedOnTeamClick ?? openTeamTab; + const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext( + providedTeamColorByName, + providedOnTeamClick + ); const isTooLarge = content.length > MAX_MARKDOWN_CHARS; const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 86d75b5b..0ad6473d 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -1,6 +1,9 @@ import { Fragment, memo, useCallback, useMemo } from 'react'; -import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; +import { + CompactMarkdownPreview, + MarkdownViewer, +} from '@renderer/components/chat/viewers/MarkdownViewer'; import { CopyButton } from '@renderer/components/common/CopyButton'; import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; @@ -823,7 +826,7 @@ export const ActivityItem = memo( structured, ]); const summaryText = extractMarkdownPlainText(rawSummary); - const compactPreviewText = useMemo(() => { + const compactPreviewMarkdown = useMemo(() => { if (idleSemantic?.hasPeerSummary && idleSemantic.peerSummary) { return idleSemantic.peerSummary; } @@ -839,14 +842,15 @@ export const ActivityItem = memo( } if (crossTeamPreview) return crossTeamPreview; - const fullText = strippedText?.trim() ?? ''; - if (fullText) { - return extractMarkdownPlainText(fullText).replace(/\n+/g, ' ').trim(); + const formattedDisplayText = displayText?.trim() ?? ''; + if (formattedDisplayText) { + return formattedDisplayText; } return summaryText || rawSummary; }, [ crossTeamPreview, + displayText, idleSemantic, isSlashCommandMessage, isSlashCommandResult, @@ -856,6 +860,12 @@ export const ActivityItem = memo( slashCommandMeta, summaryText, ]); + const compactPreviewTooltipText = useMemo(() => { + const normalized = extractMarkdownPlainText(compactPreviewMarkdown) + .replace(/\n+/g, ' ') + .trim(); + return normalized || compactPreviewMarkdown; + }, [compactPreviewMarkdown]); const commentTaskRef = message.messageKind === 'task_comment_notification' ? (message.taskRefs?.[0] ?? null) : null; const commentTaskDisplayId = @@ -1218,11 +1228,13 @@ export const ActivityItem = memo( -
    - {compactPreviewText} +
    +
    - {compactPreviewText} + {compactPreviewTooltipText} @@ -1298,11 +1310,13 @@ export const ActivityItem = memo( -
    - {compactPreviewText} +
    +
    - {compactPreviewText} + {compactPreviewTooltipText} diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 31295a3b..0b8df55c 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -9,6 +9,7 @@ import { useState, } from 'react'; +import { CompactMarkdownPreview } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { Tooltip, @@ -42,6 +43,7 @@ import { ENTRY_REVEAL_ANIMATION_MS, ENTRY_REVEAL_EASING, } from './AnimatedHeightReveal'; +import { buildThoughtDisplayContent } from './activityMarkdown'; import { ThoughtBodyContent } from './ThoughtBodyContent'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; @@ -588,20 +590,30 @@ const LeadThoughtsGroupRowComponent = ({ return calls.length > 0 ? calls : undefined; }, [thoughts]); - // Extract text preview for header: use newest thought's text, fallback through group - const headerTextPreview = useMemo(() => { + // Reuse the same markdown preprocessing as the expanded thought body. + const compactPreviewMarkdown = useMemo(() => { // Try newest first (most relevant), then scan for any text for (const t of thoughts) { if (t.text && t.text.trim()) { - const plain = extractMarkdownPlainText(stripAgentBlocks(t.text)); - const normalized = plain.replace(/\n+/g, ' ').trim(); - if (normalized) { - return normalized; + const stripped = stripAgentBlocks(t.text).trim(); + if (stripped) { + return buildThoughtDisplayContent(t, memberColorMap, teamNames, { + preserveLineBreaks: false, + stripAgentOnlyBlocks: true, + }) + .replace(/\n+/g, ' ') + .trim(); } } } - return null; - }, [thoughts]); + return totalToolSummary; + }, [memberColorMap, teamNames, thoughts, totalToolSummary]); + const compactPreviewTooltipText = useMemo(() => { + const normalized = extractMarkdownPlainText(compactPreviewMarkdown ?? '') + .replace(/\n+/g, ' ') + .trim(); + return normalized || compactPreviewMarkdown; + }, [compactPreviewMarkdown]); // Detect if any thought in this group is an API error const hasApiError = useMemo(() => thoughts.some((t) => isApiErrorMessage(t.text)), [thoughts]); @@ -764,7 +776,6 @@ const LeadThoughtsGroupRowComponent = ({ ? formatTime(oldest.timestamp) : `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`; const useCompactCollapsedHeader = compactHeader && !isBodyVisible; - const compactPreviewText = headerTextPreview ?? totalToolSummary; return ( @@ -837,15 +848,17 @@ const LeadThoughtsGroupRowComponent = ({ )}
    - {compactPreviewText ? ( + {compactPreviewMarkdown ? ( -
    - {compactPreviewText} +
    +
    - {compactPreviewText} + {compactPreviewTooltipText} @@ -920,15 +933,17 @@ const LeadThoughtsGroupRowComponent = ({ )}
    - {compactPreviewText ? ( + {compactPreviewMarkdown ? ( -
    - {compactPreviewText} +
    +
    - {compactPreviewText} + {compactPreviewTooltipText} diff --git a/src/renderer/components/team/activity/ThoughtBodyContent.tsx b/src/renderer/components/team/activity/ThoughtBodyContent.tsx index d4b7c212..75985812 100644 --- a/src/renderer/components/team/activity/ThoughtBodyContent.tsx +++ b/src/renderer/components/team/activity/ThoughtBodyContent.tsx @@ -4,17 +4,16 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer import { CopyButton } from '@renderer/components/common/CopyButton'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { CARD_ICON_MUTED, CARD_TEXT_LIGHT } from '@renderer/constants/cssVariables'; -import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { areStringArraysEqual, areStringMapsEqual, areThoughtMessagesEquivalentForRender, } from '@renderer/utils/messageRenderEquality'; -import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; +import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; -import { stripTeammateMessageBlocks } from '@shared/utils/inboxNoise'; import { Reply } from 'lucide-react'; +import { buildThoughtDisplayContent } from './activityMarkdown'; import { formatTimeWithSec, ToolSummaryTooltipContent } from './LeadThoughtsGroup'; import type { InboxMessage } from '@shared/types'; @@ -42,17 +41,10 @@ export const ThoughtBodyContent = memo( onTeamClick, }: ThoughtBodyContentProps): JSX.Element { const displayContent = useMemo(() => { - // Strip leaked protocol XML ( blocks) before rendering - let text = stripTeammateMessageBlocks(thought.text).replace(/\n/g, ' \n'); - text = linkifyTaskIdsInMarkdown(text, thought.taskRefs); - if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) { - text = linkifyAllMentionsInMarkdown( - text, - (memberColorMap ?? new Map()) as Map, - teamNames - ); - } - return text; + return buildThoughtDisplayContent(thought, memberColorMap, teamNames, { + preserveLineBreaks: true, + stripAgentOnlyBlocks: true, + }); }, [thought.text, thought.taskRefs, memberColorMap, teamNames]); const handleTaskLinkClick = useCallback( diff --git a/src/renderer/components/team/activity/activityMarkdown.ts b/src/renderer/components/team/activity/activityMarkdown.ts new file mode 100644 index 00000000..02776c70 --- /dev/null +++ b/src/renderer/components/team/activity/activityMarkdown.ts @@ -0,0 +1,36 @@ +import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; +import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; +import { stripTeammateMessageBlocks } from '@shared/utils/inboxNoise'; + +import type { InboxMessage } from '@shared/types'; + +interface ThoughtDisplayContentOptions { + preserveLineBreaks?: boolean; + stripAgentOnlyBlocks?: boolean; +} + +export function buildThoughtDisplayContent( + thought: Pick, + memberColorMap?: ReadonlyMap, + teamNames: string[] = [], + options: ThoughtDisplayContentOptions = {} +): string { + const { preserveLineBreaks = true, stripAgentOnlyBlocks = false } = options; + let text = stripTeammateMessageBlocks(thought.text); + if (stripAgentOnlyBlocks) { + text = stripAgentBlocks(text); + } + if (preserveLineBreaks) { + text = text.replace(/\n/g, ' \n'); + } + text = linkifyTaskIdsInMarkdown(text, thought.taskRefs); + if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) { + text = linkifyAllMentionsInMarkdown( + text, + (memberColorMap ?? new Map()) as Map, + teamNames + ); + } + return text; +} diff --git a/test/renderer/components/team/activity/ActivityItem.test.ts b/test/renderer/components/team/activity/ActivityItem.test.ts index 5bce9077..64205bcc 100644 --- a/test/renderer/components/team/activity/ActivityItem.test.ts +++ b/test/renderer/components/team/activity/ActivityItem.test.ts @@ -7,6 +7,8 @@ vi.mock('@renderer/hooks/useTheme', () => ({ })); vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({ MarkdownViewer: ({ content }: { content: string }) => React.createElement('div', null, content), + CompactMarkdownPreview: ({ content, className }: { content: string; className?: string }) => + React.createElement('div', { className }, content), })); vi.mock('@renderer/components/common/CopyButton', () => ({ CopyButton: () => null, @@ -175,7 +177,9 @@ describe('ActivityItem compact header preview', () => { const preview = host.querySelector('.line-clamp-2'); expect(preview).not.toBeNull(); - expect(preview?.textContent).toBe(visibleText); + expect(preview?.textContent).toContain('**New task assigned to you:**'); + expect(preview?.textContent).toContain('[#3fd70e2](task://3fd70e2)'); + expect(preview?.textContent).toContain('Собрать fix-batch'); expect(preview?.textContent).not.toContain('info_for_agent'); expect(preview?.textContent).not.toContain('internal only'); @@ -185,6 +189,49 @@ describe('ActivityItem compact header preview', () => { }); }); + it('reuses markdown display content for compact preview formatting', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const markdownText = '**Важно** проверить `CurrentTaskIndicator` и #abc123'; + + const message: InboxMessage = { + from: 'team-lead', + text: markdownText, + timestamp: new Date('2026-04-18T16:31:00.000Z').toISOString(), + read: true, + source: 'lead_process', + taskRefs: [{ taskId: 'abc123', displayId: '#abc123', teamName: 'my-team' }], + }; + + await act(async () => { + root.render( + React.createElement(ActivityItem, { + message, + teamName: 'my-team', + compactHeader: true, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + collapseToggleKey: 'message-key-markdown-preview', + }) + ); + await Promise.resolve(); + }); + + const preview = host.querySelector('.line-clamp-2'); + expect(preview).not.toBeNull(); + expect(preview?.textContent).toContain('**Важно**'); + expect(preview?.textContent).toContain('task://abc123'); + expect(preview?.textContent).toContain('`CurrentTaskIndicator`'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('uses a two-line preview in collapsed wide mode, not inline one-line summary', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); diff --git a/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts b/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts index 1dd46a44..0960a38b 100644 --- a/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts +++ b/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts @@ -6,6 +6,10 @@ import { afterEach, beforeEach, vi } from 'vitest'; vi.mock('@renderer/components/team/MemberBadge', () => ({ MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name), })); +vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({ + CompactMarkdownPreview: ({ content, className }: { content: string; className?: string }) => + React.createElement('div', { className }, content), +})); vi.mock('@renderer/components/ui/tooltip', () => ({ TooltipProvider: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), @@ -320,4 +324,41 @@ describe('LeadThoughtsGroup', () => { await Promise.resolve(); }); }); + + it('reuses the expanded thought markdown preprocessing for compact preview', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const thought = makeLeadSessionMsg('**Важно** проверить #task123 и ping @alice', { + messageId: 'thought-4', + leadSessionId: 'lead-session-4', + taskRefs: [{ taskId: 'task123', displayId: '#task123', teamName: 'my-team' }], + }); + + await act(async () => { + root.render( + React.createElement(LeadThoughtsGroupRow, { + group: { type: 'lead-thoughts', thoughts: [thought] }, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + compactHeader: true, + memberColorMap: new Map([['alice', 'blue']]), + teamNames: ['my-team'], + }) + ); + await Promise.resolve(); + }); + + const previewNode = host.querySelector('.line-clamp-2'); + expect(previewNode).not.toBeNull(); + expect(previewNode?.textContent).toContain('**Важно**'); + expect(previewNode?.textContent).toContain('[#task123](task://task123)'); + expect(previewNode?.textContent).toContain('mention://blue/alice'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); });