perf(renderer): cache activity timeline rendering
This commit is contained in:
parent
b8f394d9f7
commit
4799fe1a3b
4 changed files with 192 additions and 49 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
51
src/renderer/components/team/activity/activityRenderCache.ts
Normal file
51
src/renderer/components/team/activity/activityRenderCache.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue