refactor(activity): reuse markdown rendering in compact previews
This commit is contained in:
parent
fd0c936244
commit
93a6ae74b0
7 changed files with 470 additions and 173 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
36
src/renderer/components/team/activity/activityMarkdown.ts
Normal file
36
src/renderer/components/team/activity/activityMarkdown.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue