refactor(activity): reuse markdown rendering in compact previews

This commit is contained in:
777genius 2026-04-18 21:57:59 +03:00
parent fd0c936244
commit 93a6ae74b0
7 changed files with 470 additions and 173 deletions

View file

@ -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<string, string>;
/** 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<string, string>();
const NOOP_TEAM_CLICK = (): void => undefined;
type ViewerMarkdownMode = 'default' | 'compact-preview';
// =============================================================================
// Helpers
// =============================================================================
@ -322,53 +333,89 @@ function createViewerMarkdownComponents(
isLight = false,
teamColorByName: ReadonlyMap<string, string> = 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 => (
<span className={className} style={style}>
{hl(children)}{' '}
</span>
);
return {
// Headings
h1: ({ children }) => (
<h1 className="mb-2 mt-4 text-xl font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h1>
),
h2: ({ children }) => (
<h2 className="mb-2 mt-4 text-lg font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h2>
),
h3: ({ children }) => (
<h3 className="mb-2 mt-3 text-base font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h3>
),
h4: ({ children }) => (
<h4 className="mb-1 mt-3 text-sm font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h4>
),
h5: ({ children }) => (
<h5 className="mb-1 mt-2 text-sm font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h5>
),
h6: ({ children }) => (
<h6 className="mb-1 mt-2 text-xs font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h6>
),
h1: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
) : (
<h1 className="mb-2 mt-4 text-xl font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h1>
),
h2: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
) : (
<h2 className="mb-2 mt-4 text-lg font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h2>
),
h3: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
) : (
<h3
className="mb-2 mt-3 text-base font-semibold first:mt-0"
style={{ color: PROSE_HEADING }}
>
{hl(children)}
</h3>
),
h4: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
) : (
<h4 className="mb-1 mt-3 text-sm font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h4>
),
h5: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'font-medium', { color: PROSE_HEADING })
) : (
<h5 className="mb-1 mt-2 text-sm font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h5>
),
h6: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'font-medium', { color: PROSE_HEADING })
) : (
<h6 className="mb-1 mt-2 text-xs font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h6>
),
// Paragraphs
p: ({ children }) => (
<p
className="my-2 text-sm leading-relaxed first:mt-0 last:mb-0"
style={{ color: PROSE_BODY }}
>
{hl(children)}
</p>
),
p: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, '', { color: PROSE_BODY })
) : (
<p
className="my-2 text-sm leading-relaxed first:mt-0 last:mb-0"
style={{ color: PROSE_BODY }}
>
{hl(children)}
</p>
),
// 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 (
<code
className="break-all rounded px-1.5 py-0.5 font-mono text-xs"
style={{
backgroundColor: PROSE_CODE_BG,
color: PROSE_CODE_TEXT,
}}
>
{compactText}
</code>
);
}
// 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 }) => (
<blockquote
className="my-3 border-l-4 pl-4 italic"
style={{
borderColor: PROSE_BLOCKQUOTE_BORDER,
color: PROSE_MUTED,
}}
>
{hl(children)}
</blockquote>
),
blockquote: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'italic', { color: PROSE_MUTED })
) : (
<blockquote
className="my-3 border-l-4 pl-4 italic"
style={{
borderColor: PROSE_BLOCKQUOTE_BORDER,
color: PROSE_MUTED,
}}
>
{hl(children)}
</blockquote>
),
// Lists
ul: ({ children }) => (
<ul className="my-2 list-disc space-y-1 pl-5" style={{ color: PROSE_BODY }}>
{children}
</ul>
),
ol: ({ children }) => (
<ol className="my-2 list-decimal space-y-1 pl-5" style={{ color: PROSE_BODY }}>
{children}
</ol>
),
li: ({ children }) => (
<li className="text-sm" style={{ color: PROSE_BODY }}>
{hl(children)}
</li>
),
ul: ({ children }) =>
isCompactPreview ? (
<span>{children}</span>
) : (
<ul className="my-2 list-disc space-y-1 pl-5" style={{ color: PROSE_BODY }}>
{children}
</ul>
),
ol: ({ children }) =>
isCompactPreview ? (
<span>{children}</span>
) : (
<ol className="my-2 list-decimal space-y-1 pl-5" style={{ color: PROSE_BODY }}>
{children}
</ol>
),
li: ({ children }) =>
isCompactPreview ? (
<span className="inline" style={{ color: PROSE_BODY }}>
{hl(children)}{' '}
</span>
) : (
<li className="text-sm" style={{ color: PROSE_BODY }}>
{hl(children)}
</li>
),
// Tables
table: ({ children }) => (
<div className="my-3 overflow-x-auto">
<table
className="min-w-full border-collapse text-sm"
style={{ borderColor: PROSE_TABLE_BORDER }}
table: ({ children }) =>
isCompactPreview ? (
<span>{children}</span>
) : (
<div className="my-3 overflow-x-auto">
<table
className="min-w-full border-collapse text-sm"
style={{ borderColor: PROSE_TABLE_BORDER }}
>
{children}
</table>
</div>
),
thead: ({ children }) =>
isCompactPreview ? (
<span>{children}</span>
) : (
<thead style={{ backgroundColor: PROSE_TABLE_HEADER_BG }}>{children}</thead>
),
th: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
) : (
<th
className="px-3 py-2 text-left font-semibold"
style={{
border: `1px solid ${PROSE_TABLE_BORDER}`,
color: PROSE_HEADING,
}}
>
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead style={{ backgroundColor: PROSE_TABLE_HEADER_BG }}>{children}</thead>
),
th: ({ children }) => (
<th
className="px-3 py-2 text-left font-semibold"
style={{
border: `1px solid ${PROSE_TABLE_BORDER}`,
color: PROSE_HEADING,
}}
>
{hl(children)}
</th>
),
td: ({ children }) => (
<td
className="px-3 py-2"
style={{
border: `1px solid ${PROSE_TABLE_BORDER}`,
color: PROSE_BODY,
}}
>
{hl(children)}
</td>
),
{hl(children)}
</th>
),
td: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, '', { color: PROSE_BODY })
) : (
<td
className="px-3 py-2"
style={{
border: `1px solid ${PROSE_TABLE_BORDER}`,
color: PROSE_BODY,
}}
>
{hl(children)}
</td>
),
// Horizontal rule
hr: () => <hr className="my-4" style={{ borderColor: PROSE_TABLE_BORDER }} />,
hr: () =>
isCompactPreview ? (
<span className="mx-1" style={{ color: PROSE_TABLE_BORDER }}>
·
</span>
) : (
<hr className="my-4" style={{ borderColor: PROSE_TABLE_BORDER }} />
),
};
}
@ -679,6 +773,78 @@ const LARGE_PREVIEW_CHARS = 30_000;
// Component
// =============================================================================
function useResolvedViewerTeamContext(
providedTeamColorByName?: ReadonlyMap<string, string>,
providedOnTeamClick?: (teamName: string) => void
): {
teamColorByName: ReadonlyMap<string, string>;
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<string, string>();
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<CompactMarkdownPreviewProps> = 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 (
<div className={`min-w-0 overflow-hidden ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={REHYPE_PLUGINS_NO_HIGHLIGHT}
components={components}
urlTransform={allowCustomProtocols}
allowElement={isAllowedElement}
unwrapDisallowed
>
{content}
</ReactMarkdown>
</div>
);
}
);
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
content,
maxHeight = 'max-h-96',
@ -695,24 +861,10 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
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<string, string>();
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;

View file

@ -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(
<TooltipProvider delayDuration={1000}>
<Tooltip>
<TooltipTrigger asChild>
<div
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
style={{ color: CARD_TEXT_LIGHT }}
>
{compactPreviewText}
<div>
<CompactMarkdownPreview
content={compactPreviewMarkdown}
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
</div>
</TooltipTrigger>
<TooltipContent
@ -1230,7 +1242,7 @@ export const ActivityItem = memo(
align="start"
className="max-w-sm whitespace-normal break-words"
>
{compactPreviewText}
{compactPreviewTooltipText}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -1298,11 +1310,13 @@ export const ActivityItem = memo(
<TooltipProvider delayDuration={1000}>
<Tooltip>
<TooltipTrigger asChild>
<div
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
style={{ color: CARD_TEXT_LIGHT }}
>
{compactPreviewText}
<div>
<CompactMarkdownPreview
content={compactPreviewMarkdown}
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
</div>
</TooltipTrigger>
<TooltipContent
@ -1310,7 +1324,7 @@ export const ActivityItem = memo(
align="start"
className="max-w-sm whitespace-normal break-words"
>
{compactPreviewText}
{compactPreviewTooltipText}
</TooltipContent>
</Tooltip>
</TooltipProvider>

View file

@ -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 (
<AnimatedHeightReveal animate={isNew} containerRef={ref} style={{ overflowAnchor: 'none' }}>
@ -837,15 +848,17 @@ const LeadThoughtsGroupRowComponent = ({
)}
</div>
</div>
{compactPreviewText ? (
{compactPreviewMarkdown ? (
<TooltipProvider delayDuration={1000}>
<Tooltip>
<TooltipTrigger asChild>
<div
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
style={{ color: headerTextPreview ? CARD_TEXT_LIGHT : CARD_ICON_MUTED }}
>
{compactPreviewText}
<div>
<CompactMarkdownPreview
content={compactPreviewMarkdown}
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
</div>
</TooltipTrigger>
<TooltipContent
@ -853,7 +866,7 @@ const LeadThoughtsGroupRowComponent = ({
align="start"
className="max-w-sm whitespace-normal break-words"
>
{compactPreviewText}
{compactPreviewTooltipText}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -920,15 +933,17 @@ const LeadThoughtsGroupRowComponent = ({
)}
</div>
</div>
{compactPreviewText ? (
{compactPreviewMarkdown ? (
<TooltipProvider delayDuration={1000}>
<Tooltip>
<TooltipTrigger asChild>
<div
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
style={{ color: headerTextPreview ? CARD_TEXT_LIGHT : CARD_ICON_MUTED }}
>
{compactPreviewText}
<div>
<CompactMarkdownPreview
content={compactPreviewMarkdown}
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
</div>
</TooltipTrigger>
<TooltipContent
@ -936,7 +951,7 @@ const LeadThoughtsGroupRowComponent = ({
align="start"
className="max-w-sm whitespace-normal break-words"
>
{compactPreviewText}
{compactPreviewTooltipText}
</TooltipContent>
</Tooltip>
</TooltipProvider>

View file

@ -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 (<teammate-message> 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<string, string>,
teamNames
);
}
return text;
return buildThoughtDisplayContent(thought, memberColorMap, teamNames, {
preserveLineBreaks: true,
stripAgentOnlyBlocks: true,
});
}, [thought.text, thought.taskRefs, memberColorMap, teamNames]);
const handleTaskLinkClick = useCallback(

View file

@ -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<InboxMessage, 'text' | 'taskRefs'>,
memberColorMap?: ReadonlyMap<string, string>,
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<string, string>,
teamNames
);
}
return text;
}

View file

@ -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');

View file

@ -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();
});
});
});