refactor(team): flatten ActivityTimeline render into atomic rows

Third step of the virtualization plan. Pure refactor — no UI change, no
virtualization yet. Prepares the timeline for row-level windowing.

- Introduces `TimelineRow`, a discriminated union of `session-separator`,
  `lead-thought-group` (pinned and non-pinned), `compaction-divider`,
  and `message-row`. Each row maps 1:1 to a single visual element.
- Adds a `renderRows` useMemo that walks `timelineItems` once and emits
  atomic rows, hoisting session separators out of the Fragment bundle
  that used to pair them with their owning item. This is the shape a
  windowing layer needs: each row measurable and addressable
  independently.
- Extracts a `renderTimelineRow(row)` helper that switches on `row.kind`
  and returns the same JSX the previous inline render produced. Logic
  per kind is identical — keys, memoization, collapse props, pinned
  thought "live" semantics — so there is no visual diff.
- The render body collapses from two blocks (pinned + `.slice().map()`)
  into a single `renderRows.map(renderTimelineRow)` call.

Follow-ups will virtualize `renderRows` with measured row heights and
tighten observer/animation wiring; pagination, collapse state, zebra
striping, and `groupTimelineItems` are untouched.
This commit is contained in:
Mike 2026-04-20 00:25:28 +05:00
parent d4f518e8c5
commit a43fedcaab
2 changed files with 246 additions and 163 deletions

View file

@ -21,9 +21,32 @@ import {
} from './LeadThoughtsGroup';
import { useNewItemKeys } from './useNewItemKeys';
import type { TimelineItem } from './LeadThoughtsGroup';
import type { LeadThoughtGroup, TimelineItem } from './LeadThoughtsGroup';
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
/**
* A single visual row in the timeline. The render phase maps 1:1 from this
* list into JSX, which is the shape a windowing library (e.g.
* `@tanstack/react-virtual`) expects. Grouping happens earlier, in
* `groupTimelineItems`; this layer flattens groups/separators/dividers into
* atomic rows so each one can be measured and rendered independently.
*
* The `itemIndex` fields point back into `timelineItems` so per-item state
* (collapse mode, zebra shading, "is new" flag, session anchor) can still be
* resolved without threading it through every row entry.
*/
type TimelineRow =
| { kind: 'session-separator'; key: string }
| {
kind: 'lead-thought-group';
key: string;
itemIndex: number;
group: LeadThoughtGroup;
isPinned: boolean;
}
| { kind: 'compaction-divider'; key: string; message: InboxMessage }
| { kind: 'message-row'; key: string; itemIndex: number; message: InboxMessage };
/**
* Viewport contract describes the scroll container that hosts the timeline
* and how ActivityTimeline should report visibility against it. When omitted,
@ -489,6 +512,66 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null;
const startIndex = pinnedThoughtGroup ? 1 : 0;
// Flatten timelineItems into atomic render rows. Each row maps to exactly
// one visual element — no Fragment bundles session separators with their
// owning item, because a windowing layer (landing in a follow-up PR) needs
// each row to be measurable and addressable independently.
const renderRows = useMemo<readonly TimelineRow[]>(() => {
const rows: TimelineRow[] = [];
if (pinnedThoughtGroup) {
rows.push({
kind: 'lead-thought-group',
key: getThoughtGroupKey(pinnedThoughtGroup.group),
itemIndex: 0,
group: pinnedThoughtGroup.group,
isPinned: true,
});
}
for (let i = startIndex; i < timelineItems.length; i += 1) {
const item = timelineItems[i];
if (i > 0) {
const currSessionId = getItemSessionAnchorId(item);
const prevSessionId = previousSessionAnchorByIndex[i];
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
// Include itemIndex in the key so a repeated transition (e.g. lead
// sessions A→B→A→B) does not collide on key `A->B` twice — React
// treats duplicate keys as the same element and reuses state
// across unrelated separators.
rows.push({
kind: 'session-separator',
key: `session-separator-${i}-${prevSessionId}->${currSessionId}`,
});
}
}
if (item.type === 'lead-thoughts') {
rows.push({
kind: 'lead-thought-group',
key: getThoughtGroupKey(item.group),
itemIndex: i,
group: item.group,
isPinned: false,
});
continue;
}
const message = item.message;
if (isCompactionMessage(message)) {
rows.push({
kind: 'compaction-divider',
key: `compaction-${toMessageKey(message)}`,
message,
});
continue;
}
rows.push({
kind: 'message-row',
key: toMessageKey(message),
itemIndex: i,
message,
});
}
return rows;
}, [pinnedThoughtGroup, previousSessionAnchorByIndex, startIndex, timelineItems]);
// Determine the index of the "newest" non-thought timeline item (for auto-expand).
const newestMessageIndex = useMemo(() => {
return findNewestMessageIndex(timelineItems);
@ -530,6 +613,113 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
[allCollapsed, newestMessageIndex, pinnedThoughtGroup, expandOverrides, onToggleExpandOverride]
);
// Render a single atomic row. Logic per kind mirrors the previous inline
// render path; separators and dividers are their own rows rather than
// being bundled into Fragments, which is the contract the virtualizer will
// consume in a follow-up PR.
const renderTimelineRow = (row: TimelineRow): React.JSX.Element | null => {
switch (row.kind) {
case 'session-separator':
return (
<div
key={row.key}
className="flex items-center gap-3"
style={{ paddingTop: 45, paddingBottom: 45 }}
>
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
<span className="whitespace-nowrap text-[11px] font-medium text-blue-600 dark:text-blue-400">
New session
</span>
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
</div>
);
case 'compaction-divider':
return <CompactionDivider key={row.key} message={row.message} />;
case 'lead-thought-group': {
const { group, itemIndex, isPinned, key } = row;
const firstThought = group.thoughts[0];
const info = memberInfo.get(firstThought.from);
const collapseProps = getItemCollapseProps(key, itemIndex);
const pinnedCanBeLive = isPinned
? currentLeadSessionId
? firstThought.leadSessionId === currentLeadSessionId
: true
: false;
return (
<LeadThoughtsGroupRow
key={key}
group={group}
memberColor={info?.color}
canBeLive={pinnedCanBeLive}
isTeamAlive={pinnedCanBeLive ? isTeamAlive : undefined}
leadActivity={pinnedCanBeLive ? leadActivity : undefined}
leadContextUpdatedAt={pinnedCanBeLive ? leadContextUpdatedAt : undefined}
isNew={newItemKeys.has(key)}
onVisible={onMessageVisible}
observerRoot={observerRoot}
zebraShade={zebraShadeSet.has(itemIndex)}
collapseMode={collapseProps.collapseMode}
isCollapsed={collapseProps.isCollapsed}
canToggleCollapse={collapseProps.canToggleCollapse}
collapseToggleKey={collapseProps.collapseToggleKey}
onToggleCollapse={onToggleExpandOverride}
onTaskIdClick={onTaskIdClick}
memberColorMap={colorMap}
onReply={onReplyToMessage}
compactHeader={compactHeader}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
onExpand={compactHeader ? onExpandItem : undefined}
expandItemKey={compactHeader ? key : undefined}
/>
);
}
case 'message-row': {
const { message, itemIndex, key } = row;
const renderProps = resolveMessageRenderProps(message, ctx);
const collapseProps = getItemCollapseProps(key, itemIndex);
const isUnread = readState
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
: !message.read;
return (
<MemoizedMessageRowWithObserver
key={key}
message={message}
teamName={teamName}
memberRole={renderProps.memberRole}
memberColor={renderProps.memberColor}
recipientColor={renderProps.recipientColor}
isUnread={isUnread}
isNew={newItemKeys.has(key)}
zebraShade={zebraShadeSet.has(itemIndex)}
memberColorMap={colorMap}
localMemberNames={localMemberNames}
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
onCreateTask={onCreateTaskFromMessage}
onReply={onReplyToMessage}
onVisible={onMessageVisible}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
collapseMode={collapseProps.collapseMode}
isCollapsed={collapseProps.isCollapsed}
canToggleCollapse={collapseProps.canToggleCollapse}
collapseToggleKey={collapseProps.collapseToggleKey}
onToggleCollapse={onToggleExpandOverride}
compactHeader={compactHeader}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
onExpand={compactHeader ? onExpandItem : undefined}
expandItemKey={compactHeader ? key : undefined}
observerRoot={observerRoot}
onExpandContent={onExpandContent}
/>
);
}
}
};
if (messages.length === 0) {
return (
<div className="rounded-md border border-[var(--color-border)] p-3 pl-5 text-xs text-[var(--color-text-muted)]">
@ -541,168 +731,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
return (
<div ref={rootRef} className="space-y-1">
{/* Pinned (newest) thought group — always at top */}
{pinnedThoughtGroup &&
(() => {
const { group } = pinnedThoughtGroup;
const firstThought = group.thoughts[0];
const pinnedCanBeLive = currentLeadSessionId
? firstThought.leadSessionId === currentLeadSessionId
: true;
const info = memberInfo.get(firstThought.from);
const itemKey = getThoughtGroupKey(group);
const stableKey = itemKey;
const collapseProps = getItemCollapseProps(stableKey, 0);
return (
<LeadThoughtsGroupRow
key={itemKey}
group={group}
memberColor={info?.color}
canBeLive={pinnedCanBeLive}
isTeamAlive={pinnedCanBeLive ? isTeamAlive : undefined}
leadActivity={pinnedCanBeLive ? leadActivity : undefined}
leadContextUpdatedAt={pinnedCanBeLive ? leadContextUpdatedAt : undefined}
isNew={newItemKeys.has(itemKey)}
onVisible={onMessageVisible}
observerRoot={observerRoot}
zebraShade={zebraShadeSet.has(0)}
collapseMode={collapseProps.collapseMode}
isCollapsed={collapseProps.isCollapsed}
canToggleCollapse={collapseProps.canToggleCollapse}
collapseToggleKey={collapseProps.collapseToggleKey}
onToggleCollapse={onToggleExpandOverride}
onTaskIdClick={onTaskIdClick}
memberColorMap={colorMap}
onReply={onReplyToMessage}
compactHeader={compactHeader}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
onExpand={compactHeader ? onExpandItem : undefined}
expandItemKey={compactHeader ? itemKey : undefined}
/>
);
})()}
{/* Remaining items */}
{timelineItems.slice(startIndex).map((item, index) => {
const realIndex = index + startIndex;
// Session boundary separator (messages sorted desc — new on top)
let sessionSeparator: React.JSX.Element | null = null;
if (realIndex > 0) {
const currSessionId = getItemSessionAnchorId(item);
const prevSessionId = previousSessionAnchorByIndex[realIndex];
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
sessionSeparator = (
<div
className="flex items-center gap-3"
style={{ paddingTop: 45, paddingBottom: 45 }}
>
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
<span className="whitespace-nowrap text-[11px] font-medium text-blue-600 dark:text-blue-400">
New session
</span>
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
</div>
);
}
}
if (item.type === 'lead-thoughts') {
const { group } = item;
const firstThought = group.thoughts[0];
const info = memberInfo.get(firstThought.from);
const itemKey = getThoughtGroupKey(group);
const stableKey = itemKey;
const collapseProps = getItemCollapseProps(stableKey, realIndex);
return (
<React.Fragment key={itemKey}>
{sessionSeparator}
<LeadThoughtsGroupRow
group={group}
memberColor={info?.color}
canBeLive={false}
isNew={newItemKeys.has(itemKey)}
onVisible={onMessageVisible}
observerRoot={observerRoot}
zebraShade={zebraShadeSet.has(realIndex)}
collapseMode={collapseProps.collapseMode}
isCollapsed={collapseProps.isCollapsed}
canToggleCollapse={collapseProps.canToggleCollapse}
collapseToggleKey={collapseProps.collapseToggleKey}
onToggleCollapse={onToggleExpandOverride}
onTaskIdClick={onTaskIdClick}
memberColorMap={colorMap}
onReply={onReplyToMessage}
compactHeader={compactHeader}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
onExpand={compactHeader ? onExpandItem : undefined}
expandItemKey={compactHeader ? itemKey : undefined}
/>
</React.Fragment>
);
}
const { message } = item;
// Compaction boundary — render as a divider instead of a regular message card
if (isCompactionMessage(message)) {
const messageKey = toMessageKey(message);
return (
<React.Fragment key={messageKey}>
{sessionSeparator}
<CompactionDivider message={message} />
</React.Fragment>
);
}
const renderProps = resolveMessageRenderProps(message, ctx);
const messageKey = toMessageKey(message);
const stableKey = messageKey;
const collapseProps = getItemCollapseProps(stableKey, realIndex);
const isUnread = readState
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
: !message.read;
return (
<React.Fragment key={messageKey}>
{sessionSeparator}
<MemoizedMessageRowWithObserver
message={message}
teamName={teamName}
memberRole={renderProps.memberRole}
memberColor={renderProps.memberColor}
recipientColor={renderProps.recipientColor}
isUnread={isUnread}
isNew={newItemKeys.has(messageKey)}
zebraShade={zebraShadeSet.has(realIndex)}
memberColorMap={colorMap}
localMemberNames={localMemberNames}
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
onCreateTask={onCreateTaskFromMessage}
onReply={onReplyToMessage}
onVisible={onMessageVisible}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
collapseMode={collapseProps.collapseMode}
isCollapsed={collapseProps.isCollapsed}
canToggleCollapse={collapseProps.canToggleCollapse}
collapseToggleKey={collapseProps.collapseToggleKey}
onToggleCollapse={onToggleExpandOverride}
compactHeader={compactHeader}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
onExpand={compactHeader ? onExpandItem : undefined}
expandItemKey={compactHeader ? messageKey : undefined}
observerRoot={observerRoot}
onExpandContent={onExpandContent}
/>
</React.Fragment>
);
})}
{renderRows.map((row) => renderTimelineRow(row))}
{hiddenCount > 0 && (
<div className="relative flex justify-center pb-3 pt-1">
{/* Bottom-up shadow gradient: darkest at bottom edge, fades upward */}

View file

@ -289,6 +289,60 @@ describe('ActivityTimeline session separators', () => {
root.unmount();
});
});
it('renders each separator distinctly when the same session transition repeats', async () => {
const warnSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const root = createRoot(container);
const messages: InboxMessage[] = [
makeMessage({
messageId: 'thought-b-2',
text: 'b second',
leadSessionId: 'lead-session-b',
from: 'team-lead',
source: 'lead_session',
}),
makeMessage({
messageId: 'thought-a-2',
text: 'a second',
leadSessionId: 'lead-session-a',
from: 'team-lead',
source: 'lead_session',
}),
makeMessage({
messageId: 'thought-b-1',
text: 'b first',
leadSessionId: 'lead-session-b',
from: 'team-lead',
source: 'lead_session',
}),
makeMessage({
messageId: 'thought-a-1',
text: 'a first',
leadSessionId: 'lead-session-a',
from: 'team-lead',
source: 'lead_session',
}),
];
await act(async () => {
root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' }));
});
// Three transitions: b→a, a→b, b→a. All three separators must render.
const matches = container.textContent?.match(/New session/g) ?? [];
expect(matches.length).toBe(3);
// React warns via `console.error` when duplicate keys are detected.
const duplicateKeyWarnings = warnSpy.mock.calls.filter((call) =>
String(call[0]).includes('unique "key"')
);
expect(duplicateKeyWarnings).toHaveLength(0);
warnSpy.mockRestore();
await act(async () => {
root.unmount();
});
});
});
describe('ActivityTimeline viewport observerRoot', () => {