986 lines
36 KiB
TypeScript
986 lines
36 KiB
TypeScript
import React, {
|
|
type RefObject,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
|
|
import {
|
|
areInboxMessagesEquivalentForRender,
|
|
areStringArraysEqual,
|
|
areStringMapsEqual,
|
|
} from '@renderer/utils/messageRenderEquality';
|
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
import { Layers, Loader2 } from 'lucide-react';
|
|
|
|
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
|
import { buildMessageContext, resolveMessageRenderProps } from './activityMessageContext';
|
|
import { AnimatedHeightReveal } from './AnimatedHeightReveal';
|
|
import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapseState';
|
|
import {
|
|
getThoughtGroupKey,
|
|
groupTimelineItems,
|
|
isCompactionMessage,
|
|
isLeadThought,
|
|
LeadThoughtsGroupRow,
|
|
} from './LeadThoughtsGroup';
|
|
import { useNewItemKeys } from './useNewItemKeys';
|
|
|
|
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,
|
|
* ActivityTimeline falls back to the document viewport (current behavior).
|
|
*
|
|
* This contract is grouped intentionally so consumers pass a single coherent
|
|
* object rather than threading several refs and flags. Virtualizer wiring
|
|
* lands in a follow-up; for now only `observerRoot` has an observable effect.
|
|
*/
|
|
export interface TimelineViewport {
|
|
/** The element that actually scrolls. */
|
|
scrollElementRef: RefObject<HTMLElement | null>;
|
|
/**
|
|
* Root element for IntersectionObserver-based visibility tracking.
|
|
* Typically the same node as `scrollElementRef`, but left separate so
|
|
* future code can observe a more specific inner container when needed.
|
|
*/
|
|
observerRoot?: RefObject<HTMLElement | null>;
|
|
/**
|
|
* Distance from the scroll container's scroll origin to the timeline root,
|
|
* measured from the DOM. Zero in this release; used by the virtualizer in a
|
|
* follow-up change.
|
|
*/
|
|
scrollMargin?: number;
|
|
/** Enable virtualization (wired in a follow-up; ignored for now). */
|
|
virtualizationEnabled?: boolean;
|
|
}
|
|
|
|
interface ActivityTimelineProps {
|
|
messages: InboxMessage[];
|
|
teamName: string;
|
|
members?: ResolvedTeamMember[];
|
|
/**
|
|
* When provided, unread is derived from this set and getMessageKey.
|
|
* When omitted, unread is derived from message.read.
|
|
*/
|
|
readState?: { readSet: Set<string>; getMessageKey: (message: InboxMessage) => string };
|
|
onCreateTaskFromMessage?: (subject: string, description: string) => void;
|
|
onReplyToMessage?: (message: InboxMessage) => void;
|
|
onMemberClick?: (member: ResolvedTeamMember) => void;
|
|
/** Called when a message enters the viewport (for marking as read). */
|
|
onMessageVisible?: (message: InboxMessage) => void;
|
|
/** Called when a task ID link (e.g. #10) is clicked in message text. */
|
|
onTaskIdClick?: (taskId: string) => void;
|
|
/** Called when the user clicks "Restart team" on an auth error message. */
|
|
onRestartTeam?: () => void;
|
|
/** When true, collapse all message bodies — show only headers with expand chevrons. */
|
|
allCollapsed?: boolean;
|
|
/** Set of stable message keys that the user has manually expanded in collapsed mode. */
|
|
expandOverrides?: Set<string>;
|
|
/** Called when user toggles expand/collapse override on a specific message. */
|
|
onToggleExpandOverride?: (key: string) => void;
|
|
/** Current lead session ID for the active team, if known. */
|
|
currentLeadSessionId?: string;
|
|
/** Whether the current team is alive. */
|
|
isTeamAlive?: boolean;
|
|
/** Current lead activity status for the active team. */
|
|
leadActivity?: string;
|
|
/** Latest lead context timestamp for the active team. */
|
|
leadContextUpdatedAt?: string;
|
|
/** Team names used for mention/team-link rendering. */
|
|
teamNames?: string[];
|
|
/** Team color mapping used by markdown viewers. */
|
|
teamColorByName?: ReadonlyMap<string, string>;
|
|
/** Opens a team tab from cross-team badges or team:// links. */
|
|
onTeamClick?: (teamName: string) => void;
|
|
/** Callback to expand a message/thought item into a fullscreen dialog. */
|
|
onExpandItem?: (key: string) => void;
|
|
/** Called when ExpandableContent is expanded via "Show more" in any ActivityItem. */
|
|
onExpandContent?: () => void;
|
|
/** True while the initial message page is loading and no cached rows are available yet. */
|
|
loading?: boolean;
|
|
/**
|
|
* Optional viewport contract. When provided, IntersectionObserver uses the
|
|
* passed `observerRoot` instead of the document viewport, which is required
|
|
* for correctness inside scrollable layouts (sidebar, bottom-sheet) where
|
|
* the row may be clipped by its scroll parent while still intersecting the
|
|
* page viewport.
|
|
*/
|
|
viewport?: TimelineViewport;
|
|
}
|
|
|
|
const VIEWPORT_THRESHOLD = 0.15;
|
|
const MESSAGES_PAGE_SIZE = 30;
|
|
const COMPACT_MESSAGES_WIDTH_PX = 400;
|
|
const EMPTY_TEAM_NAMES: string[] = [];
|
|
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
|
|
const DEFAULT_COLLAPSE_MODE = 'default' as const;
|
|
const VIRTUALIZER_OVERSCAN = 8;
|
|
const VIRTUALIZATION_ROW_GAP_PX = 4;
|
|
|
|
/**
|
|
* Row count above which virtualization is worth its complexity cost. Below
|
|
* this, the direct render path is both simpler and faster (no wrapper div,
|
|
* no position: absolute, no measurement churn). Chosen so conversations under
|
|
* roughly one session of activity stay on the direct path and the virtualized
|
|
* path only activates when scrolling behavior actually starts to matter.
|
|
*/
|
|
const VIRTUALIZATION_ROW_THRESHOLD = 60;
|
|
|
|
/**
|
|
* Per-kind height estimates for `estimateSize`. These are rough initial guesses
|
|
* only; the virtualizer re-measures rows as they mount via `measureElement`
|
|
* (wired in a follow-up PR), so small inaccuracies here are self-correcting.
|
|
* Sizes come from visually averaged steady-state heights in production layouts.
|
|
*/
|
|
const ROW_SIZE_ESTIMATES: Record<TimelineRow['kind'], number> = {
|
|
'session-separator': 135,
|
|
'compaction-divider': 50,
|
|
'lead-thought-group': 220,
|
|
'message-row': 140,
|
|
};
|
|
|
|
const TimelineLoadingState = (): React.JSX.Element => (
|
|
<div
|
|
className="rounded-md border border-[var(--color-border)] p-3 pl-5 text-xs text-[var(--color-text-muted)]"
|
|
aria-busy="true"
|
|
aria-live="polite"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Loader2 size={13} className="animate-spin" />
|
|
<span>Loading messages...</span>
|
|
</div>
|
|
<div className="mt-3 space-y-2" aria-hidden="true">
|
|
<div className="h-3 w-3/4 animate-pulse rounded bg-[var(--color-surface-raised)]" />
|
|
<div className="h-3 w-1/2 animate-pulse rounded bg-[var(--color-surface-raised)]" />
|
|
<div className="h-3 w-2/3 animate-pulse rounded bg-[var(--color-surface-raised)]" />
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const TimelineEmptyState = (): React.JSX.Element => (
|
|
<div className="rounded-md border border-[var(--color-border)] p-3 pl-5 text-xs text-[var(--color-text-muted)]">
|
|
<p>No messages</p>
|
|
<p className="mt-1 text-[11px]">Send a message to a member to see activity.</p>
|
|
</div>
|
|
);
|
|
|
|
function collectScrollMarginObserverTargets(
|
|
rootElement: HTMLElement,
|
|
scrollElement: HTMLElement
|
|
): HTMLElement[] {
|
|
const targets = new Set<HTMLElement>([rootElement, scrollElement]);
|
|
|
|
let current: HTMLElement | null = rootElement;
|
|
while (current && current !== scrollElement) {
|
|
const parentElement: HTMLElement | null = current.parentElement;
|
|
if (!parentElement) {
|
|
break;
|
|
}
|
|
|
|
targets.add(parentElement);
|
|
|
|
let previousSibling: Element | null = current.previousElementSibling;
|
|
while (previousSibling) {
|
|
if (previousSibling instanceof HTMLElement) {
|
|
targets.add(previousSibling);
|
|
}
|
|
previousSibling = previousSibling.previousElementSibling;
|
|
}
|
|
|
|
current = parentElement;
|
|
}
|
|
|
|
return [...targets];
|
|
}
|
|
|
|
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;
|
|
canToggleCollapse: boolean;
|
|
collapseToggleKey?: string;
|
|
}
|
|
|
|
/** Inline compaction boundary divider — styled like session separators but with amber accent. */
|
|
const CompactionDivider = ({ message }: { message: InboxMessage }): React.JSX.Element => (
|
|
<div className="flex items-center gap-3" style={{ paddingTop: 16, paddingBottom: 16 }}>
|
|
<div
|
|
className="h-px flex-1"
|
|
style={{ backgroundColor: 'var(--tool-call-text)', opacity: 0.3 }}
|
|
/>
|
|
<div className="flex shrink-0 items-center gap-2 px-3">
|
|
<Layers size={12} style={{ color: 'var(--tool-call-text)' }} />
|
|
<span
|
|
className="whitespace-nowrap text-[11px] font-medium"
|
|
style={{ color: 'var(--tool-call-text)' }}
|
|
>
|
|
{message.text}
|
|
</span>
|
|
</div>
|
|
<div
|
|
className="h-px flex-1"
|
|
style={{ backgroundColor: 'var(--tool-call-text)', opacity: 0.3 }}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
const MessageRowWithObserver = ({
|
|
message,
|
|
teamName,
|
|
memberRole,
|
|
memberColor,
|
|
recipientColor,
|
|
isUnread,
|
|
isNew,
|
|
zebraShade,
|
|
memberColorMap,
|
|
localMemberNames,
|
|
onMemberNameClick,
|
|
onCreateTask,
|
|
onReply,
|
|
onVisible,
|
|
onTaskIdClick,
|
|
onRestartTeam,
|
|
collapseMode,
|
|
isCollapsed,
|
|
canToggleCollapse,
|
|
collapseToggleKey,
|
|
onToggleCollapse,
|
|
compactHeader,
|
|
teamNames,
|
|
teamColorByName,
|
|
onTeamClick,
|
|
onExpand,
|
|
expandItemKey,
|
|
onExpandContent,
|
|
observerRoot,
|
|
}: {
|
|
message: InboxMessage;
|
|
teamName: string;
|
|
memberRole?: string;
|
|
memberColor?: string;
|
|
recipientColor?: string;
|
|
isUnread?: boolean;
|
|
isNew?: boolean;
|
|
zebraShade?: boolean;
|
|
memberColorMap?: Map<string, string>;
|
|
localMemberNames?: Set<string>;
|
|
onMemberNameClick?: (name: string) => void;
|
|
onCreateTask?: (subject: string, description: string) => void;
|
|
onReply?: (message: InboxMessage) => void;
|
|
onVisible?: (message: InboxMessage) => void;
|
|
onTaskIdClick?: (taskId: string) => void;
|
|
onRestartTeam?: () => void;
|
|
collapseMode: 'default' | 'managed';
|
|
isCollapsed: boolean;
|
|
canToggleCollapse: boolean;
|
|
collapseToggleKey?: string;
|
|
onToggleCollapse?: (key: string) => void;
|
|
compactHeader?: boolean;
|
|
teamNames?: string[];
|
|
teamColorByName?: ReadonlyMap<string, string>;
|
|
onTeamClick?: (teamName: string) => void;
|
|
onExpand?: (key: string) => void;
|
|
expandItemKey?: string;
|
|
onExpandContent?: () => void;
|
|
observerRoot?: RefObject<HTMLElement | null>;
|
|
}): React.JSX.Element => {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const reportedRef = useRef(false);
|
|
const messageRef = useRef(message);
|
|
const onVisibleRef = useRef(onVisible);
|
|
|
|
useEffect(() => {
|
|
messageRef.current = message;
|
|
onVisibleRef.current = onVisible;
|
|
}, [message, onVisible]);
|
|
|
|
useEffect(() => {
|
|
if (!onVisible) return;
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
// Resolve the observer root at effect-time. Falls back to the document
|
|
// viewport (null) when no root is provided — preserves pre-contract
|
|
// behavior for layouts without a known scroll owner.
|
|
const root = observerRoot?.current ?? null;
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (!entry?.isIntersecting) return;
|
|
if (reportedRef.current) return;
|
|
const cb = onVisibleRef.current;
|
|
const msg = messageRef.current;
|
|
if (!cb) return;
|
|
reportedRef.current = true;
|
|
cb(msg);
|
|
},
|
|
{ root, threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
|
|
);
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, [onVisible, observerRoot]);
|
|
|
|
return (
|
|
<AnimatedHeightReveal animate={isNew} containerRef={ref}>
|
|
<ActivityItem
|
|
message={message}
|
|
teamName={teamName}
|
|
memberRole={memberRole}
|
|
memberColor={memberColor}
|
|
recipientColor={recipientColor}
|
|
isUnread={isUnread}
|
|
zebraShade={zebraShade}
|
|
memberColorMap={memberColorMap}
|
|
localMemberNames={localMemberNames}
|
|
onMemberNameClick={onMemberNameClick}
|
|
onCreateTask={onCreateTask}
|
|
onReply={onReply}
|
|
onTaskIdClick={onTaskIdClick}
|
|
onRestartTeam={onRestartTeam}
|
|
collapseMode={collapseMode}
|
|
isCollapsed={isCollapsed}
|
|
canToggleCollapse={canToggleCollapse}
|
|
collapseToggleKey={collapseToggleKey}
|
|
onToggleCollapse={onToggleCollapse}
|
|
compactHeader={compactHeader}
|
|
teamNames={teamNames}
|
|
teamColorByName={teamColorByName}
|
|
onTeamClick={onTeamClick}
|
|
onExpand={onExpand}
|
|
expandItemKey={expandItemKey}
|
|
onExpandContent={onExpandContent}
|
|
/>
|
|
</AnimatedHeightReveal>
|
|
);
|
|
};
|
|
|
|
const MemoizedMessageRowWithObserver = React.memo(
|
|
MessageRowWithObserver,
|
|
(prev, next) =>
|
|
prev.teamName === next.teamName &&
|
|
prev.memberRole === next.memberRole &&
|
|
prev.memberColor === next.memberColor &&
|
|
prev.recipientColor === next.recipientColor &&
|
|
prev.isUnread === next.isUnread &&
|
|
prev.isNew === next.isNew &&
|
|
prev.zebraShade === next.zebraShade &&
|
|
prev.memberColorMap === next.memberColorMap &&
|
|
prev.localMemberNames === next.localMemberNames &&
|
|
prev.onMemberNameClick === next.onMemberNameClick &&
|
|
prev.onCreateTask === next.onCreateTask &&
|
|
prev.onReply === next.onReply &&
|
|
prev.onVisible === next.onVisible &&
|
|
prev.onTaskIdClick === next.onTaskIdClick &&
|
|
prev.onRestartTeam === next.onRestartTeam &&
|
|
prev.collapseMode === next.collapseMode &&
|
|
prev.isCollapsed === next.isCollapsed &&
|
|
prev.canToggleCollapse === next.canToggleCollapse &&
|
|
prev.collapseToggleKey === next.collapseToggleKey &&
|
|
prev.onToggleCollapse === next.onToggleCollapse &&
|
|
prev.compactHeader === next.compactHeader &&
|
|
areStringArraysEqual(prev.teamNames, next.teamNames) &&
|
|
areStringMapsEqual(prev.teamColorByName, next.teamColorByName) &&
|
|
prev.onTeamClick === next.onTeamClick &&
|
|
prev.onExpand === next.onExpand &&
|
|
prev.expandItemKey === next.expandItemKey &&
|
|
prev.onExpandContent === next.onExpandContent &&
|
|
prev.observerRoot === next.observerRoot &&
|
|
areInboxMessagesEquivalentForRender(prev.message, next.message)
|
|
);
|
|
|
|
export const ActivityTimeline = React.memo(function ActivityTimeline({
|
|
messages,
|
|
teamName,
|
|
members,
|
|
readState,
|
|
onCreateTaskFromMessage,
|
|
onReplyToMessage,
|
|
onMemberClick,
|
|
onMessageVisible,
|
|
onTaskIdClick,
|
|
onRestartTeam,
|
|
allCollapsed,
|
|
expandOverrides,
|
|
onToggleExpandOverride,
|
|
currentLeadSessionId,
|
|
isTeamAlive,
|
|
leadActivity,
|
|
leadContextUpdatedAt,
|
|
teamNames = EMPTY_TEAM_NAMES,
|
|
teamColorByName = EMPTY_TEAM_COLOR_MAP,
|
|
onTeamClick,
|
|
onExpandItem,
|
|
onExpandContent,
|
|
loading = false,
|
|
viewport,
|
|
}: ActivityTimelineProps): React.JSX.Element {
|
|
const observerRoot = viewport?.observerRoot ?? viewport?.scrollElementRef;
|
|
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const [compactHeader, setCompactHeader] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const el = rootRef.current;
|
|
if (!el) return;
|
|
|
|
const updateCompactMode = (width: number): void => {
|
|
setCompactHeader((prev) => {
|
|
const next = width < COMPACT_MESSAGES_WIDTH_PX;
|
|
return prev === next ? prev : next;
|
|
});
|
|
};
|
|
|
|
updateCompactMode(el.getBoundingClientRect().width);
|
|
|
|
const observer = new ResizeObserver((entries) => {
|
|
const entry = entries[0];
|
|
if (!entry) return;
|
|
updateCompactMode(entry.contentRect.width);
|
|
});
|
|
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
const ctx = useMemo(() => buildMessageContext(members), [members]);
|
|
const { colorMap, localMemberNames, memberInfo } = ctx;
|
|
|
|
const handleMemberNameClick = useCallback(
|
|
(name: string) => {
|
|
const member = members?.find(
|
|
(candidate) => candidate.name === name || candidate.agentType === name
|
|
);
|
|
if (member) onMemberClick?.(member);
|
|
},
|
|
[members, onMemberClick]
|
|
);
|
|
|
|
// Pagination counts only significant (non-thought) messages so that lead thoughts
|
|
// don't consume the page limit — they collapse into a single visual group anyway.
|
|
const { visibleMessages, hiddenCount } = useMemo(() => {
|
|
const total = messages.length;
|
|
if (total === 0) return { visibleMessages: messages, hiddenCount: 0 };
|
|
|
|
let significantSeen = 0;
|
|
let cutoff = total;
|
|
for (let i = 0; i < total; i++) {
|
|
if (!isLeadThought(messages[i])) {
|
|
significantSeen++;
|
|
if (significantSeen > visibleCount) {
|
|
cutoff = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const significantTotal =
|
|
significantSeen +
|
|
(cutoff < total ? messages.slice(cutoff).filter((m) => !isLeadThought(m)).length : 0);
|
|
const hidden = Math.max(0, significantTotal - visibleCount);
|
|
return {
|
|
visibleMessages: cutoff < total ? messages.slice(0, cutoff) : messages,
|
|
hiddenCount: hidden,
|
|
};
|
|
}, [messages, visibleCount]);
|
|
|
|
// Group consecutive lead thoughts into collapsible blocks.
|
|
const timelineItems = useMemo(() => groupTimelineItems(visibleMessages), [visibleMessages]);
|
|
|
|
// Zebra striping is anchored from the bottom of the visible list so prepending
|
|
// new live messages at the top does not recolor every existing card.
|
|
const zebraShadeSet = useMemo(() => {
|
|
const result = new Set<number>();
|
|
let cardCount = 0;
|
|
for (let i = timelineItems.length - 1; i >= 0; i--) {
|
|
const item = timelineItems[i];
|
|
if (item.type === 'lead-thoughts') {
|
|
// Thought groups count as one card for striping
|
|
if (cardCount % 2 === 1) result.add(i);
|
|
cardCount++;
|
|
} else {
|
|
if (isNoiseMessage(item.message.text)) continue;
|
|
if (isCompactionMessage(item.message)) continue;
|
|
if (cardCount % 2 === 1) result.add(i);
|
|
cardCount++;
|
|
}
|
|
}
|
|
return result;
|
|
}, [timelineItems]);
|
|
|
|
const timelineItemKeys = useMemo(() => {
|
|
const getItemKey = (item: TimelineItem): string => {
|
|
if (item.type === 'lead-thoughts') {
|
|
return getThoughtGroupKey(item.group);
|
|
}
|
|
return toMessageKey(item.message);
|
|
};
|
|
|
|
return timelineItems.map(getItemKey);
|
|
}, [timelineItems]);
|
|
|
|
const newItemKeys = useNewItemKeys({
|
|
itemKeys: timelineItemKeys,
|
|
paginationKey: visibleCount,
|
|
resetKey: teamName,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (process.env.NODE_ENV === 'production') return;
|
|
const seen = new Set<string>();
|
|
const duplicates = new Set<string>();
|
|
for (const key of timelineItemKeys) {
|
|
if (seen.has(key)) duplicates.add(key);
|
|
seen.add(key);
|
|
}
|
|
if (duplicates.size > 0) {
|
|
console.warn('[ActivityTimeline] Duplicate timeline item keys detected', {
|
|
teamName,
|
|
duplicates: [...duplicates],
|
|
});
|
|
}
|
|
}, [teamName, timelineItemKeys]);
|
|
|
|
const handleShowMore = (): void => {
|
|
setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE);
|
|
};
|
|
|
|
const handleShowAll = (): void => {
|
|
setVisibleCount(Infinity);
|
|
};
|
|
|
|
// 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 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;
|
|
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]);
|
|
|
|
// Virtualizer gate — activates only when the parent opts in via
|
|
// `viewport.virtualizationEnabled`, the scroll element ref is present, and
|
|
// the row count is large enough for virtualization to pay for itself. Below
|
|
// the threshold the direct render path is both simpler and faster, so we
|
|
// keep it for short lists.
|
|
const shouldVirtualize =
|
|
viewport?.virtualizationEnabled === true &&
|
|
viewport.scrollElementRef != null &&
|
|
renderRows.length >= VIRTUALIZATION_ROW_THRESHOLD;
|
|
|
|
// DOM-measured distance from the scroll container's scroll origin to the
|
|
// timeline root. We avoid re-measuring on every scroll: the offset only
|
|
// changes when layout above the timeline changes, so observe the timeline,
|
|
// its ancestor chain, and all previous siblings that can push it down.
|
|
const [measuredScrollMargin, setMeasuredScrollMargin] = useState(0);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!shouldVirtualize) return;
|
|
const scrollEl = viewport?.scrollElementRef?.current ?? null;
|
|
const rootEl = rootRef.current;
|
|
if (!scrollEl || !rootEl) return;
|
|
|
|
let pending = false;
|
|
let rafId: number | null = null;
|
|
const measure = (): void => {
|
|
if (pending) return;
|
|
pending = true;
|
|
rafId = requestAnimationFrame(() => {
|
|
rafId = null;
|
|
pending = false;
|
|
const scrollRect = scrollEl.getBoundingClientRect();
|
|
const rootRect = rootEl.getBoundingClientRect();
|
|
// Distance from top of scroll content to top of timeline root. Adding
|
|
// `scrollTop` compensates for the fact that both rects are relative
|
|
// to the viewport at measurement time, not the scrollable content.
|
|
const next = Math.max(0, rootRect.top - scrollRect.top + scrollEl.scrollTop);
|
|
setMeasuredScrollMargin((prev) => (Math.abs(prev - next) < 0.5 ? prev : next));
|
|
});
|
|
};
|
|
|
|
measure();
|
|
const resizeObserver = new ResizeObserver(measure);
|
|
const observedTargets = collectScrollMarginObserverTargets(rootEl, scrollEl);
|
|
observedTargets.forEach((target) => resizeObserver.observe(target));
|
|
window.addEventListener('resize', measure);
|
|
|
|
return () => {
|
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
resizeObserver.disconnect();
|
|
window.removeEventListener('resize', measure);
|
|
};
|
|
}, [shouldVirtualize, viewport?.scrollElementRef]);
|
|
|
|
const rowVirtualizer = useVirtualizer({
|
|
count: shouldVirtualize ? renderRows.length : 0,
|
|
getScrollElement: () => viewport?.scrollElementRef?.current ?? null,
|
|
estimateSize: (index) => ROW_SIZE_ESTIMATES[renderRows[index]?.kind ?? 'message-row'],
|
|
getItemKey: (index) => renderRows[index]?.key ?? `row-${index}`,
|
|
overscan: VIRTUALIZER_OVERSCAN,
|
|
gap: VIRTUALIZATION_ROW_GAP_PX,
|
|
scrollMargin: measuredScrollMargin,
|
|
});
|
|
|
|
// Determine the index of the "newest" non-thought timeline item (for auto-expand).
|
|
const newestMessageIndex = useMemo(() => {
|
|
return findNewestMessageIndex(timelineItems);
|
|
}, [timelineItems]);
|
|
|
|
/**
|
|
* Compute the externally managed collapse state for an item in the timeline.
|
|
* In collapsed mode we always keep the newest real message open, keep the pinned
|
|
* thought group open, and let localStorage overrides reopen older items.
|
|
*/
|
|
const getItemCollapseProps = useCallback(
|
|
(stableKey: string, itemIndex: number): ItemCollapseProps => {
|
|
const collapseState = resolveTimelineCollapseState({
|
|
allCollapsed,
|
|
itemIndex,
|
|
newestMessageIndex,
|
|
isPinnedThoughtGroup: itemIndex === 0 && pinnedThoughtGroup != null,
|
|
isExpandedOverride: expandOverrides?.has(stableKey) ?? false,
|
|
onToggleOverride: onToggleExpandOverride
|
|
? () => onToggleExpandOverride(stableKey)
|
|
: undefined,
|
|
});
|
|
|
|
if (collapseState.mode !== DEFAULT_COLLAPSE_MODE) {
|
|
return {
|
|
collapseMode: collapseState.mode,
|
|
isCollapsed: collapseState.isCollapsed,
|
|
canToggleCollapse: collapseState.canToggle,
|
|
collapseToggleKey: collapseState.canToggle ? stableKey : undefined,
|
|
};
|
|
}
|
|
|
|
return {
|
|
collapseMode: DEFAULT_COLLAPSE_MODE,
|
|
isCollapsed: false,
|
|
canToggleCollapse: false,
|
|
};
|
|
},
|
|
[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.
|
|
//
|
|
// `suppressEntryAnimation` is set when the caller is the virtualized path:
|
|
// the virtualizer mounts and unmounts rows as they enter and leave the
|
|
// viewport, so relying on mount as a signal of "this item is new" would
|
|
// replay the entry animation every time the user scrolls back to an old
|
|
// row. In the direct render path the flag stays false and animation still
|
|
// runs on real data-set additions.
|
|
const renderTimelineRow = (
|
|
row: TimelineRow,
|
|
options?: { suppressEntryAnimation?: boolean }
|
|
): React.JSX.Element | null => {
|
|
const suppressEntry = options?.suppressEntryAnimation === true;
|
|
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={!suppressEntry && 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={!suppressEntry && 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 ref={rootRef} className="space-y-1">
|
|
{loading ? <TimelineLoadingState /> : <TimelineEmptyState />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div ref={rootRef} className="space-y-1">
|
|
{shouldVirtualize ? (
|
|
<div
|
|
style={{
|
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
|
width: '100%',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
|
const row = renderRows[virtualRow.index];
|
|
if (!row) return null;
|
|
return (
|
|
<div
|
|
key={virtualRow.key}
|
|
// `measureElement` swaps each row's estimated height for its
|
|
// real rendered height as it mounts, so the virtualizer can
|
|
// correct totalSize and downstream row positions. The wrapper
|
|
// div carries no padding/margin, so its bounding box matches
|
|
// the inner row's bounding box — this is why a merged ref
|
|
// callback between the observer and `measureElement` isn't
|
|
// needed here.
|
|
ref={rowVirtualizer.measureElement}
|
|
data-index={virtualRow.index}
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
// `translateY` is offset by scrollMargin so the virtualizer
|
|
// positions rows relative to the timeline's own origin,
|
|
// not the scroll container's top — otherwise rows would
|
|
// overlap the composer / status block at the top.
|
|
transform: `translateY(${virtualRow.start - rowVirtualizer.options.scrollMargin}px)`,
|
|
}}
|
|
>
|
|
{renderTimelineRow(row, { suppressEntryAnimation: true })}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
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 */}
|
|
<div
|
|
className="pointer-events-none absolute inset-x-0 -top-24"
|
|
style={{
|
|
bottom: '-1.6rem',
|
|
background:
|
|
'linear-gradient(to top, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.25) 25%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.03) 75%, transparent 100%)',
|
|
}}
|
|
/>
|
|
<div
|
|
className="relative z-[1] flex items-center gap-3 rounded-full px-4 py-1.5"
|
|
style={{
|
|
backgroundColor: 'var(--color-surface-raised)',
|
|
boxShadow:
|
|
'0 0 12px 4px rgba(0, 0, 0, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.04)',
|
|
border: '1px solid var(--color-border-emphasis)',
|
|
}}
|
|
>
|
|
<span className="text-[11px] tabular-nums text-[var(--color-text-muted)]">
|
|
+{hiddenCount} older
|
|
</span>
|
|
<span className="h-3 w-px bg-blue-600/30 dark:bg-blue-400/30" />
|
|
<button
|
|
onClick={handleShowMore}
|
|
className="rounded-full px-2.5 py-0.5 text-[11px] font-medium text-[var(--color-text-secondary)] transition-all hover:bg-[rgba(255,255,255,0.08)] hover:text-[var(--color-text)]"
|
|
>
|
|
Show {Math.min(MESSAGES_PAGE_SIZE, hiddenCount)} more
|
|
</button>
|
|
{hiddenCount > MESSAGES_PAGE_SIZE && (
|
|
<>
|
|
<span className="h-3 w-px bg-blue-600/30 dark:bg-blue-400/30" />
|
|
<button
|
|
onClick={handleShowAll}
|
|
className="rounded-full px-2.5 py-0.5 text-[11px] text-[var(--color-text-muted)] transition-all hover:bg-[rgba(255,255,255,0.08)] hover:text-[var(--color-text-secondary)]"
|
|
>
|
|
Show all
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|