From 4c359d5185d1bd74b4fc2291d89feb8213717f09 Mon Sep 17 00:00:00 2001 From: Mike Date: Sun, 19 Apr 2026 09:00:59 +0500 Subject: [PATCH] perf(team): precompute ActivityTimeline session anchors once per render Replace the per-item backward scan that located the most recent session anchor with a single forward pass via useMemo. Before: for every timeline item the render loop walked backward until it found a lead-thought anchor, so N items produced up to N * N anchor lookups on every render pass. After: a single O(n) sweep builds previousSessionAnchorByIndex; render time lookup is O(1). getItemSessionAnchorId is hoisted to module scope so it is not recreated per render. Behavior is unchanged. The three existing separator tests still pass, and four new cases cover three-session transitions, long runs of non-anchor items between thought groups, consecutive same-session thoughts, and single-item lists. --- .../team/activity/ActivityTimeline.tsx | 35 +++-- .../team/activity/ActivityTimeline.test.ts | 131 ++++++++++++++++++ 2 files changed, 152 insertions(+), 14 deletions(-) diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index d803c2de..abc3313d 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -75,6 +75,13 @@ const EMPTY_TEAM_NAMES: string[] = []; const EMPTY_TEAM_COLOR_MAP = new Map(); const DEFAULT_COLLAPSE_MODE = 'default' as const; +function getItemSessionAnchorId(item: TimelineItem): string | undefined { + if (item.type === 'lead-thoughts') { + return item.group.thoughts[0]?.leadSessionId; + } + return undefined; +} + interface ItemCollapseProps { collapseMode: 'default' | 'managed'; isCollapsed: boolean; @@ -418,13 +425,20 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ setVisibleCount(Infinity); }; - const getItemSessionAnchorId = (item: TimelineItem): string | undefined => { - if (item.type === 'lead-thoughts') { - return item.group.thoughts[0]?.leadSessionId; + // Precompute, per timeline index, the most recent session anchor that appears + // strictly earlier in the list. Replaces an O(n) backward scan during render + // with an O(1) lookup; total work drops from O(n^2) to O(n) per timelineItems + // change. + const previousSessionAnchorByIndex = useMemo(() => { + const anchors: (string | undefined)[] = []; + let lastSeen: string | undefined; + for (const item of timelineItems) { + anchors.push(lastSeen); + const anchor = getItemSessionAnchorId(item); + if (anchor) lastSeen = anchor; } - - return undefined; - }; + return anchors; + }, [timelineItems]); // Pin the newest thought group (if first) so it stays at the top and doesn't jump. const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null; @@ -532,14 +546,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({ let sessionSeparator: React.JSX.Element | null = null; if (realIndex > 0) { const currSessionId = getItemSessionAnchorId(item); - let prevSessionId: string | undefined; - for (let searchIndex = realIndex - 1; searchIndex >= 0; searchIndex -= 1) { - const candidateSessionId = getItemSessionAnchorId(timelineItems[searchIndex]); - if (candidateSessionId) { - prevSessionId = candidateSessionId; - break; - } - } + const prevSessionId = previousSessionAnchorByIndex[realIndex]; if (prevSessionId && currSessionId && prevSessionId !== currSessionId) { sessionSeparator = (
{ root.unmount(); }); }); + + it('renders a separator for every session transition across three lead sessions', async () => { + const root = createRoot(container); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'thought-s3', + text: 'thought session 3', + leadSessionId: 'lead-session-3', + from: 'team-lead', + source: 'lead_session', + }), + makeMessage({ + messageId: 'thought-s2', + text: 'thought session 2', + leadSessionId: 'lead-session-2', + from: 'team-lead', + source: 'lead_session', + }), + makeMessage({ + messageId: 'thought-s1', + text: 'thought session 1', + leadSessionId: 'lead-session-1', + from: 'team-lead', + source: 'lead_session', + }), + ]; + + await act(async () => { + root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' })); + }); + + const matches = container.textContent?.match(/New session/g) ?? []; + expect(matches.length).toBe(2); + + await act(async () => { + root.unmount(); + }); + }); + + it('finds the previous anchor even when many non-anchor items sit between lead thought groups', async () => { + const root = createRoot(container); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'thought-newest', + text: 'newest thought', + leadSessionId: 'lead-session-newest', + from: 'team-lead', + source: 'lead_session', + }), + ...Array.from({ length: 8 }, (_, i) => + makeMessage({ + messageId: `filler-${i}`, + text: `filler message ${i}`, + leadSessionId: `member-session-${i}`, + from: 'alice', + source: 'inbox', + }) + ), + makeMessage({ + messageId: 'thought-oldest', + text: 'oldest thought', + leadSessionId: 'lead-session-oldest', + from: 'team-lead', + source: 'lead_session', + }), + ]; + + await act(async () => { + root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' })); + }); + + expect(container.textContent).toContain('New session'); + + await act(async () => { + root.unmount(); + }); + }); + + it('does not render a separator when two consecutive lead thoughts share the same session', async () => { + const root = createRoot(container); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'thought-a', + text: 'thought a', + leadSessionId: 'lead-session-shared', + from: 'team-lead', + source: 'lead_session', + }), + makeMessage({ + messageId: 'thought-b', + text: 'thought b', + leadSessionId: 'lead-session-shared', + from: 'team-lead', + source: 'lead_session', + }), + ]; + + await act(async () => { + root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' })); + }); + + expect(container.textContent).not.toContain('New session'); + + await act(async () => { + root.unmount(); + }); + }); + + it('handles a single message list without errors or separators', async () => { + const root = createRoot(container); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'only', + text: 'only message', + leadSessionId: 'lead-session-1', + from: 'team-lead', + source: 'lead_session', + }), + ]; + + await act(async () => { + root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' })); + }); + + expect(container.textContent).not.toContain('New session'); + expect(container.textContent).toContain('only message'); + + await act(async () => { + root.unmount(); + }); + }); });