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(); + }); + }); });