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.
This commit is contained in:
Mike 2026-04-19 09:00:59 +05:00
parent 6ff9a28ccc
commit 4c359d5185
2 changed files with 152 additions and 14 deletions

View file

@ -75,6 +75,13 @@ const EMPTY_TEAM_NAMES: string[] = [];
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
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<readonly (string | undefined)[]>(() => {
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 = (
<div

View file

@ -153,4 +153,135 @@ describe('ActivityTimeline session separators', () => {
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();
});
});
});