perf(renderer): cache activity timeline rendering

This commit is contained in:
777genius 2026-05-31 03:31:50 +03:00
parent b8f394d9f7
commit 4799fe1a3b
4 changed files with 192 additions and 49 deletions

View file

@ -52,7 +52,6 @@ import {
parseCrossTeamPrefix,
stripCrossTeamPrefix,
} from '@shared/constants/crossTeam';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import {
buildStandaloneSlashCommandMeta,
@ -80,6 +79,14 @@ import {
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import {
encodeCacheParts,
extractMarkdownPlainTextCached,
getCachedString,
stringArrayCacheSignature,
stringMapCacheSignature,
taskRefsCacheSignature,
} from './activityRenderCache';
import { ReplyQuoteBlock } from './ReplyQuoteBlock';
import type { TeamColorSet } from '@renderer/constants/teamColors';
@ -710,6 +717,65 @@ const AUTH_ERROR_PATTERNS = [
/unauthorized/i,
];
const EMPTY_MEMBER_COLOR_MAP = new Map<string, string>();
const activityTimestampCache = new Map<string, string>();
const activityDisplayTextCache = new Map<string, string>();
function getLocalDayCacheKey(date: Date): string {
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
}
function formatActivityTimestamp(timestamp: string): string {
const now = new Date();
return getCachedString(
activityTimestampCache,
encodeCacheParts([timestamp, getLocalDayCacheKey(now)]),
() => {
const parsed = Date.parse(timestamp);
if (Number.isNaN(parsed)) return timestamp;
const date = new Date(parsed);
const isToday =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
return isToday
? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: date.toLocaleString();
}
);
}
function buildActivityDisplayText(
strippedText: string,
isSystem: boolean,
taskRefs: InboxMessage['taskRefs'],
memberColorMap: Map<string, string> | undefined,
teamNames: readonly string[]
): string {
const cacheKey = encodeCacheParts([
strippedText,
isSystem ? '1' : '0',
taskRefsCacheSignature(taskRefs),
stringMapCacheSignature(memberColorMap),
stringArrayCacheSignature(teamNames),
]);
return getCachedString(activityDisplayTextCache, cacheKey, () => {
let result = highlightSystemLabels(strippedText, isSystem);
result = linkifyTaskIdsInMarkdown(result, taskRefs);
if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) {
result = linkifyAllMentionsInMarkdown(
result,
memberColorMap ?? EMPTY_MEMBER_COLOR_MAP,
teamNames
);
}
return result;
});
}
// ---------------------------------------------------------------------------
// Full message card — left colored border, name badge, collapsible content
// ---------------------------------------------------------------------------
@ -848,18 +914,10 @@ export const ActivityItem = memo(
const formattedRole =
memberRole && memberRole !== message.from ? formatAgentRole(memberRole) : null;
const timestamp = useMemo(() => {
if (Number.isNaN(Date.parse(message.timestamp))) return message.timestamp;
const date = new Date(message.timestamp);
const now = new Date();
const isToday =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
return isToday
? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: date.toLocaleString();
}, [message.timestamp]);
const timestamp = useMemo(
() => formatActivityTimestamp(message.timestamp),
[message.timestamp]
);
const structured = parseStructuredAgentMessage(message.text);
const bootstrapDisplay = getBootstrapPromptDisplay(message);
@ -963,12 +1021,14 @@ export const ActivityItem = memo(
// Linkify task IDs (always, for TaskTooltip) + @mentions for display
const displayText = useMemo(() => {
if (!strippedText) return null;
let result = highlightSystemLabels(strippedText, !!systemLabel);
result = linkifyTaskIdsInMarkdown(result, message.taskRefs);
if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0)
result = linkifyAllMentionsInMarkdown(result, memberColorMap ?? new Map(), teamNames);
return result;
}, [strippedText, memberColorMap, teamNames, systemLabel]);
return buildActivityDisplayText(
strippedText,
!!systemLabel,
message.taskRefs,
memberColorMap,
teamNames
);
}, [strippedText, message.taskRefs, memberColorMap, teamNames, systemLabel]);
const crossTeamPreview = useMemo(() => {
if (!isCrossTeamAny || !strippedText) return '';
@ -1011,7 +1071,7 @@ export const ActivityItem = memo(
slashCommandMeta,
structured,
]);
const summaryText = extractMarkdownPlainText(rawSummary);
const summaryText = extractMarkdownPlainTextCached(rawSummary);
const compactPreviewMarkdown = useMemo(() => {
if (idleSemantic?.hasPeerSummary && idleSemantic.peerSummary) {
return idleSemantic.peerSummary;
@ -1047,7 +1107,7 @@ export const ActivityItem = memo(
summaryText,
]);
const compactPreviewTooltipText = useMemo(() => {
const normalized = extractMarkdownPlainText(compactPreviewMarkdown)
const normalized = extractMarkdownPlainTextCached(compactPreviewMarkdown)
.replace(/\n+/g, ' ')
.trim();
return normalized || compactPreviewMarkdown;

View file

@ -31,12 +31,16 @@ import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
import { isThoughtProtocolNoise } from '@shared/utils/inboxNoise';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react';
import { buildThoughtDisplayContent } from './activityMarkdown';
import {
encodeCacheParts,
extractMarkdownPlainTextCached,
getCachedString,
} from './activityRenderCache';
import {
AnimatedHeightReveal,
ENTRY_REVEAL_ANIMATION_MS,
@ -149,6 +153,7 @@ const LIVE_WINDOW_MS = 5_000;
const COLLAPSED_THOUGHTS_HEIGHT = 200;
const AUTO_SCROLL_THRESHOLD = 30;
const THOUGHT_HEIGHT_ANIMATION_MS = ENTRY_REVEAL_ANIMATION_MS;
const leadThoughtTimeCache = new Map<string, string>();
interface LeadThoughtsGroupRowProps {
group: LeadThoughtGroup;
@ -200,15 +205,19 @@ interface LeadThoughtsGroupRowProps {
}
function formatTime(timestamp: string): string {
const d = new Date(timestamp);
if (Number.isNaN(d.getTime())) return timestamp;
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return getCachedString(leadThoughtTimeCache, encodeCacheParts(['minute', timestamp]), () => {
const d = new Date(timestamp);
if (Number.isNaN(d.getTime())) return timestamp;
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
});
}
export function formatTimeWithSec(timestamp: string): string {
const d = new Date(timestamp);
if (Number.isNaN(d.getTime())) return timestamp;
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
return getCachedString(leadThoughtTimeCache, encodeCacheParts(['second', timestamp]), () => {
const d = new Date(timestamp);
if (Number.isNaN(d.getTime())) return timestamp;
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
});
}
function isRecentTimestamp(timestamp: string): boolean {
@ -619,7 +628,7 @@ const LeadThoughtsGroupRowComponent = ({
return totalToolSummary;
}, [memberColorMap, teamNames, thoughts, totalToolSummary]);
const compactPreviewTooltipText = useMemo(() => {
const normalized = extractMarkdownPlainText(compactPreviewMarkdown ?? '')
const normalized = extractMarkdownPlainTextCached(compactPreviewMarkdown ?? '')
.replace(/\n+/g, ' ')
.trim();
return normalized || compactPreviewMarkdown;
@ -784,10 +793,11 @@ const LeadThoughtsGroupRowComponent = ({
});
}, []);
const timestampLabel =
formatTime(oldest.timestamp) === formatTime(newest.timestamp)
? formatTime(oldest.timestamp)
: `${formatTime(oldest.timestamp)}${formatTime(newest.timestamp)}`;
const timestampLabel = useMemo(() => {
const oldestTime = formatTime(oldest.timestamp);
const newestTime = formatTime(newest.timestamp);
return oldestTime === newestTime ? oldestTime : `${oldestTime}${newestTime}`;
}, [newest.timestamp, oldest.timestamp]);
const useCompactCollapsedHeader = compactHeader && !isBodyVisible;
return (

View file

@ -3,6 +3,14 @@ import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { stripTeammateMessageBlocks } from '@shared/utils/inboxNoise';
import {
encodeCacheParts,
getCachedString,
stringArrayCacheSignature,
stringMapCacheSignature,
taskRefsCacheSignature,
} from './activityRenderCache';
import type { InboxMessage } from '@shared/types';
interface ThoughtDisplayContentOptions {
@ -10,6 +18,9 @@ interface ThoughtDisplayContentOptions {
stripAgentOnlyBlocks?: boolean;
}
const EMPTY_MEMBER_COLOR_MAP = new Map<string, string>();
const thoughtDisplayContentCache = new Map<string, string>();
export function buildThoughtDisplayContent(
thought: Pick<InboxMessage, 'text' | 'taskRefs'>,
memberColorMap?: ReadonlyMap<string, string>,
@ -17,20 +28,31 @@ export function buildThoughtDisplayContent(
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;
const cacheKey = encodeCacheParts([
thought.text,
taskRefsCacheSignature(thought.taskRefs),
stringMapCacheSignature(memberColorMap),
stringArrayCacheSignature(teamNames),
preserveLineBreaks ? '1' : '0',
stripAgentOnlyBlocks ? '1' : '0',
]);
return getCachedString(thoughtDisplayContentCache, cacheKey, () => {
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 ?? EMPTY_MEMBER_COLOR_MAP) as Map<string, string>,
teamNames
);
}
return text;
});
}

View file

@ -0,0 +1,51 @@
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import type { TaskRef } from '@shared/types';
const MAX_ACTIVITY_RENDER_CACHE_ENTRIES = 500;
type StringCache = Map<string, string>;
export function getCachedString(cache: StringCache, key: string, buildValue: () => string): string {
const cached = cache.get(key);
if (cached !== undefined || cache.has(key)) return cached ?? '';
const value = buildValue();
if (cache.size >= MAX_ACTIVITY_RENDER_CACHE_ENTRIES) {
const oldestKey = cache.keys().next().value;
if (oldestKey !== undefined) cache.delete(oldestKey);
}
cache.set(key, value);
return value;
}
export function encodeCacheParts(parts: readonly string[]): string {
return parts.map((part) => `${part.length}:${part}`).join('|');
}
export function taskRefsCacheSignature(taskRefs?: readonly TaskRef[]): string {
if (!taskRefs || taskRefs.length === 0) return '';
return encodeCacheParts(
taskRefs.flatMap((ref) => [ref.taskId, ref.displayId, ref.teamName ?? ''])
);
}
export function stringArrayCacheSignature(values?: readonly string[]): string {
if (!values || values.length === 0) return '';
return encodeCacheParts(values);
}
export function stringMapCacheSignature(map?: ReadonlyMap<string, string>): string {
if (!map || map.size === 0) return '';
const entries = [...map.entries()].sort(([a], [b]) => a.localeCompare(b));
return encodeCacheParts(entries.flatMap(([key, value]) => [key, value]));
}
const markdownPlainTextCache: StringCache = new Map();
export function extractMarkdownPlainTextCached(markdown: string): string {
if (!markdown) return '';
return getCachedString(markdownPlainTextCache, markdown, () =>
extractMarkdownPlainText(markdown)
);
}