From 4799fe1a3bd62e557cd23c25a874c06bd9c85991 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 31 May 2026 03:31:50 +0300 Subject: [PATCH] perf(renderer): cache activity timeline rendering --- .../components/team/activity/ActivityItem.tsx | 102 ++++++++++++++---- .../team/activity/LeadThoughtsGroup.tsx | 34 +++--- .../team/activity/activityMarkdown.ts | 54 +++++++--- .../team/activity/activityRenderCache.ts | 51 +++++++++ 4 files changed, 192 insertions(+), 49 deletions(-) create mode 100644 src/renderer/components/team/activity/activityRenderCache.ts diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 5253f2cd..e02f49ee 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -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(); +const activityTimestampCache = new Map(); +const activityDisplayTextCache = new Map(); + +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 | 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; diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index d0537dd1..4c17b572 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -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(); 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 ( diff --git a/src/renderer/components/team/activity/activityMarkdown.ts b/src/renderer/components/team/activity/activityMarkdown.ts index 02776c70..50e1bfde 100644 --- a/src/renderer/components/team/activity/activityMarkdown.ts +++ b/src/renderer/components/team/activity/activityMarkdown.ts @@ -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(); +const thoughtDisplayContentCache = new Map(); + export function buildThoughtDisplayContent( thought: Pick, memberColorMap?: ReadonlyMap, @@ -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, - 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, + teamNames + ); + } + return text; + }); } diff --git a/src/renderer/components/team/activity/activityRenderCache.ts b/src/renderer/components/team/activity/activityRenderCache.ts new file mode 100644 index 00000000..7eef371e --- /dev/null +++ b/src/renderer/components/team/activity/activityRenderCache.ts @@ -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; + +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 { + 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) + ); +}