import { type JSX, memo, type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import { CompactMarkdownPreview } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@renderer/components/ui/tooltip'; import { CARD_BG, CARD_BG_ZEBRA, CARD_BORDER_STYLE, CARD_ICON_MUTED, CARD_TEXT_LIGHT, } from '@renderer/constants/cssVariables'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; import { areStringArraysEqual, areStringMapsEqual, areThoughtMessagesEquivalentForRender, } from '@renderer/utils/messageRenderEquality'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; import { isThoughtProtocolNoise } from '@shared/utils/inboxNoise'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react'; import { buildThoughtDisplayContent } from './activityMarkdown'; import { AnimatedHeightReveal, ENTRY_REVEAL_ANIMATION_MS, ENTRY_REVEAL_EASING, } from './AnimatedHeightReveal'; import { ThoughtBodyContent } from './ThoughtBodyContent'; import type { InboxMessage, ToolCallMeta } from '@shared/types'; export interface LeadThoughtGroup { type: 'lead-thoughts'; thoughts: InboxMessage[]; } /** * Check if a message is a context compaction boundary (system event from lead process). */ export function isCompactionMessage(msg: InboxMessage): boolean { return msg.from === 'system' && !!msg.messageId?.startsWith('compact-'); } /** * Check if a message is an intermediate lead "thought" (assistant text) rather than * an official message (SendMessage, direct reply, inbox, etc.). */ export function isLeadThought(msg: InboxMessage): boolean { if (typeof msg.to === 'string' && msg.to.trim().length > 0) return false; // Compaction boundary events are system messages, not lead thoughts if (isCompactionMessage(msg)) return false; if (msg.messageKind === 'slash_command_result') return false; // Protocol noise (JSON coordination signals, raw teammate-message XML) should be hidden if (isThoughtProtocolNoise(msg.text)) return false; if (isTeamInternalControlMessageText(msg.text)) return false; if (msg.source === 'lead_session') return true; if (msg.source === 'lead_process') return true; return false; } /** * Check if a message from lead session/process is protocol noise that should be * completely excluded from the timeline (not shown as thoughts OR standalone messages). * * When `isLeadThought` returns false due to `isThoughtProtocolNoise`, the message * falls through to become a standalone ActivityItem — but ActivityItem can't parse * noise JSON wrapped in `` tags. This helper catches those cases * so `groupTimelineItems` can skip them entirely. */ function isLeadSessionNoise(msg: InboxMessage): boolean { if (msg.source !== 'lead_session' && msg.source !== 'lead_process') return false; if (typeof msg.to === 'string' && msg.to.trim().length > 0) return false; return isThoughtProtocolNoise(msg.text) || isTeamInternalControlMessageText(msg.text); } export type TimelineItem = | { type: 'message'; message: InboxMessage } | { type: 'lead-thoughts'; group: LeadThoughtGroup }; /** * Use the oldest thought as the group's stable identity so live thoughts can prepend * without remounting the whole group on every update. */ export function getThoughtGroupKey(group: LeadThoughtGroup): string { const oldestThought = group.thoughts[group.thoughts.length - 1]; return `thoughts-${toMessageKey(oldestThought)}`; } /** * Group consecutive lead thoughts into compact blocks. * Even a single thought gets its own group (rendered as LeadThoughtsGroupRow). */ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] { const result: TimelineItem[] = []; let pendingThoughts: InboxMessage[] = []; const hasSameLeadSession = (a: InboxMessage, b: InboxMessage): boolean => (a.leadSessionId ?? null) === (b.leadSessionId ?? null); const flushThoughts = (): void => { if (pendingThoughts.length === 0) return; result.push({ type: 'lead-thoughts', group: { type: 'lead-thoughts', thoughts: pendingThoughts }, }); pendingThoughts = []; }; for (const msg of messages) { if (isLeadThought(msg)) { const previousThought = pendingThoughts[pendingThoughts.length - 1]; if (previousThought && !hasSameLeadSession(previousThought, msg)) { flushThoughts(); } pendingThoughts.push(msg); } else { // Skip lead session/process messages that are protocol noise — they should // not appear in the timeline at all (neither as thoughts nor as standalone messages). // isLeadThought already rejects these from thoughts, but without this guard // they fall through as standalone ActivityItem cards that can't parse the noise JSON. // Check BEFORE flushThoughts() so noise between two thoughts doesn't split the group. if (isLeadSessionNoise(msg)) continue; flushThoughts(); result.push({ type: 'message', message: msg }); } } flushThoughts(); return result; } const VIEWPORT_THRESHOLD = 0.15; const LIVE_WINDOW_MS = 5_000; const COLLAPSED_THOUGHTS_HEIGHT = 200; const AUTO_SCROLL_THRESHOLD = 30; const THOUGHT_HEIGHT_ANIMATION_MS = ENTRY_REVEAL_ANIMATION_MS; interface LeadThoughtsGroupRowProps { group: LeadThoughtGroup; memberColor?: string; isNew?: boolean; onVisible?: (message: InboxMessage) => void; /** * Root element for IntersectionObserver-based visibility tracking. When * omitted, the observer falls back to the document viewport — correct for * top-level renders, incorrect when the row is inside a scroll container * (sidebar, bottom-sheet) that can clip the row while the document * viewport still contains it. */ observerRoot?: RefObject; /** When false, the live indicator is always off (for historical thought groups). */ canBeLive?: boolean; /** Whether the owning team is currently alive. */ isTeamAlive?: boolean; /** Current lead activity status for the owning team. */ leadActivity?: string; /** Latest lead context timestamp for the owning team. */ leadContextUpdatedAt?: string; /** When true, apply a subtle lighter background for zebra-striped lists. */ zebraShade?: boolean; /** Collapsed-mode primitives stabilized by ActivityTimeline. */ collapseMode: 'default' | 'managed'; isCollapsed: boolean; canToggleCollapse: boolean; collapseToggleKey?: string; onToggleCollapse?: (key: string) => void; /** Called when a task ID link (e.g. #10) is clicked in thought text. */ onTaskIdClick?: (taskId: string) => void; /** Map of member name → color name for @mention badge rendering. */ memberColorMap?: Map; /** Team names used for mention/team-link rendering. */ teamNames?: string[]; /** Team color mapping used by markdown viewers. */ teamColorByName?: ReadonlyMap; /** Opens a team tab from cross-team badges or team:// links. */ onTeamClick?: (teamName: string) => void; /** Called when user clicks the reply button on a thought. */ onReply?: (message: InboxMessage) => void; /** Compact header mode for narrow message lists. */ compactHeader?: boolean; /** Callback to expand this item into a fullscreen dialog. */ onExpand?: (key: string) => void; /** Stable key for expand identification. */ expandItemKey?: string; } function formatTime(timestamp: string): string { const d = new Date(timestamp); if (Number.isNaN(d.getTime())) return timestamp; return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } export function formatTimeWithSec(timestamp: string): string { const d = new Date(timestamp); if (Number.isNaN(d.getTime())) return timestamp; return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } function isRecentTimestamp(timestamp: string): boolean { const t = Date.parse(timestamp); if (Number.isNaN(t)) return false; return Date.now() - t <= LIVE_WINDOW_MS; } export const ToolSummaryTooltipContent = ({ toolCalls, toolSummary, }: Readonly<{ toolCalls?: ToolCallMeta[]; toolSummary?: string; }>): JSX.Element => { if (toolCalls && toolCalls.length > 0) { return (
{toolCalls.length} {toolCalls.length === 1 ? 'tool call' : 'tool calls'}
{toolCalls.map((tc, i) => { const isAgent = tc.name === 'Agent' || tc.name === 'TaskCreate'; return (
{isAgent ? '🤖 ' : ''} {tc.name} {tc.preview && ( {tc.preview} )}
); })}
); } if (toolSummary) { const parsed = parseToolSummary(toolSummary); if (parsed) { const sorted = Object.entries(parsed.byName).sort((a, b) => b[1] - a[1]); return (
{parsed.total} {parsed.total === 1 ? 'tool call' : 'tool calls'}
{sorted.map(([name, count]) => (
{name} ×{count}
))}
); } } return {toolSummary ?? ''}; }; interface LeadThoughtItemProps { thought: InboxMessage; showDivider: boolean; shouldAnimate: boolean; onTaskIdClick?: (taskId: string) => void; memberColorMap?: Map; teamNames?: string[]; teamColorByName?: ReadonlyMap; onTeamClick?: (teamName: string) => void; onReply?: (message: InboxMessage) => void; } function hasSelectionWithin(container: HTMLElement | null): boolean { if (!container) return false; const selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { return false; } const anchorNode = selection.anchorNode; const focusNode = selection.focusNode; return ( (!!anchorNode && container.contains(anchorNode)) || (!!focusNode && container.contains(focusNode)) ); } function areThoughtGroupsEquivalent(prev: LeadThoughtGroup, next: LeadThoughtGroup): boolean { if (prev === next) return true; if (getThoughtGroupKey(prev) !== getThoughtGroupKey(next)) return false; if (prev.thoughts.length !== next.thoughts.length) return false; for (let i = 0; i < prev.thoughts.length; i++) { if (!areThoughtMessagesEquivalentForRender(prev.thoughts[i], next.thoughts[i])) { return false; } } return true; } const LeadThoughtItem = memo( function LeadThoughtItem({ thought, showDivider, shouldAnimate, onTaskIdClick, memberColorMap, teamNames = [], teamColorByName, onTeamClick, onReply, }: LeadThoughtItemProps): JSX.Element { const wrapperRef = useRef(null); const contentRef = useRef(null); const previousHeightRef = useRef(null); const animationFrameRef = useRef(null); const cleanupTimerRef = useRef(null); const initialAnimationCompletedRef = useRef(!shouldAnimate); const [shouldAnimateOnMount] = useState(() => shouldAnimate); const clearPendingAnimation = useCallback(() => { if (animationFrameRef.current !== null) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } if (cleanupTimerRef.current !== null) { window.clearTimeout(cleanupTimerRef.current); cleanupTimerRef.current = null; } }, []); const resetWrapperStyles = useCallback(() => { const wrapper = wrapperRef.current; if (!wrapper) return; wrapper.style.height = 'auto'; wrapper.style.opacity = '1'; wrapper.style.overflow = 'visible'; wrapper.style.transition = ''; wrapper.style.willChange = ''; }, []); useLayoutEffect(() => { const wrapper = wrapperRef.current; const content = contentRef.current; if (!wrapper || !content) return; const applyTransition = (targetHeight: number): void => { wrapper.style.transition = [ `height ${THOUGHT_HEIGHT_ANIMATION_MS}ms ${ENTRY_REVEAL_EASING}`, `opacity ${THOUGHT_HEIGHT_ANIMATION_MS}ms ease`, ].join(', '); wrapper.style.height = `${Math.max(targetHeight, 0)}px`; wrapper.style.opacity = '1'; }; const scheduleTransition = (targetHeight: number): void => { animationFrameRef.current = requestAnimationFrame(() => { applyTransition(targetHeight); }); }; const animateHeight = ( targetHeight: number, startHeight: number, startOpacity: number ): void => { initialAnimationCompletedRef.current = false; clearPendingAnimation(); wrapper.style.transition = 'none'; wrapper.style.overflow = 'hidden'; wrapper.style.height = `${Math.max(startHeight, 0)}px`; wrapper.style.opacity = `${startOpacity}`; wrapper.style.willChange = 'height, opacity'; // Force layout reflow so the browser registers the starting values const _reflow = wrapper.offsetHeight; if (_reflow < -1) return; // unreachable — prevents unused-variable lint animationFrameRef.current = requestAnimationFrame(() => { scheduleTransition(targetHeight); }); cleanupTimerRef.current = window.setTimeout(() => { resetWrapperStyles(); initialAnimationCompletedRef.current = true; cleanupTimerRef.current = null; }, THOUGHT_HEIGHT_ANIMATION_MS + 40); }; const syncHeight = (nextHeight: number, animateFromZero: boolean): void => { const previousHeight = previousHeightRef.current; previousHeightRef.current = nextHeight; if (!shouldAnimateOnMount) { initialAnimationCompletedRef.current = true; resetWrapperStyles(); return; } if (previousHeight === null) { if (nextHeight > 0 && animateFromZero) { animateHeight(nextHeight, 0, 0); } else { initialAnimationCompletedRef.current = true; resetWrapperStyles(); } return; } if (Math.abs(nextHeight - previousHeight) < 1) return; // Only the first reveal should animate. Late content growth (for example when // tool summary metadata appears after the text) should resize naturally. if (initialAnimationCompletedRef.current) { resetWrapperStyles(); return; } const renderedHeight = wrapper.getBoundingClientRect().height; animateHeight(nextHeight, renderedHeight > 0 ? renderedHeight : previousHeight, 1); }; syncHeight(content.getBoundingClientRect().height, true); const observer = new ResizeObserver((entries) => { const nextHeight = entries[0]?.contentRect.height ?? content.getBoundingClientRect().height; syncHeight(nextHeight, false); }); observer.observe(content); return () => { observer.disconnect(); clearPendingAnimation(); initialAnimationCompletedRef.current = true; resetWrapperStyles(); }; }, [clearPendingAnimation, resetWrapperStyles, shouldAnimateOnMount]); useEffect( () => () => { clearPendingAnimation(); }, [clearPendingAnimation] ); return (
); }, (prev, next) => prev.showDivider === next.showDivider && prev.shouldAnimate === next.shouldAnimate && prev.onTaskIdClick === next.onTaskIdClick && prev.memberColorMap === next.memberColorMap && areStringArraysEqual(prev.teamNames, next.teamNames) && areStringMapsEqual(prev.teamColorByName, next.teamColorByName) && prev.onTeamClick === next.onTeamClick && prev.onReply === next.onReply && areThoughtMessagesEquivalentForRender(prev.thought, next.thought) ); const LiveThoughtStatusBadge = ({ canBeLive, isTeamAlive, leadActivity, leadContextUpdatedAt, newestTimestamp, }: { canBeLive?: boolean; isTeamAlive?: boolean; leadActivity?: string; leadContextUpdatedAt?: string; newestTimestamp: string; }): JSX.Element | null => { const computeIsLive = useCallback( () => canBeLive !== false && !!isTeamAlive && (leadActivity === 'active' || (leadContextUpdatedAt ? isRecentTimestamp(leadContextUpdatedAt) : false) || isRecentTimestamp(newestTimestamp)), [canBeLive, isTeamAlive, leadActivity, leadContextUpdatedAt, newestTimestamp] ); const [isLive, setIsLive] = useState(computeIsLive); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional immediate sync to avoid 1s stale gap setIsLive(computeIsLive()); const id = window.setInterval(() => setIsLive(computeIsLive()), 1000); return () => window.clearInterval(id); }, [computeIsLive]); if (!isLive) return null; return ( ); }; const LeadThoughtsGroupRowComponent = ({ group, memberColor, isNew, onVisible, observerRoot, canBeLive, isTeamAlive, leadActivity, leadContextUpdatedAt, zebraShade, collapseMode, isCollapsed, canToggleCollapse, collapseToggleKey, onToggleCollapse, onTaskIdClick, memberColorMap, teamNames = [], teamColorByName, onTeamClick, onReply, compactHeader = false, onExpand, expandItemKey, }: LeadThoughtsGroupRowProps): React.JSX.Element => { const ref = useRef(null); const scrollRef = useRef(null); const contentRef = useRef(null); const isUserScrolledUpRef = useRef(false); const distanceFromBottomRef = useRef(0); const scrollSyncFrameRef = useRef(null); const colors = getTeamColorSet(memberColor ?? ''); const { thoughts } = group; // thoughts is newest-first; first=newest, last=oldest const newest = thoughts[0]; const oldest = thoughts[thoughts.length - 1]; const leadName = newest.from; // Chronological order for rendering (oldest at top, newest at bottom) const chronologicalThoughts = useMemo(() => [...thoughts].reverse(), [thoughts]); // Aggregate tool usage across all thoughts in this group const totalToolSummary = useMemo(() => { const merged: Record = {}; let total = 0; for (const t of thoughts) { const parsed = parseToolSummary(t.toolSummary); if (!parsed) continue; total += parsed.total; for (const [name, count] of Object.entries(parsed.byName)) { merged[name] = (merged[name] ?? 0) + count; } } if (total === 0) return null; return formatToolSummary({ total, byName: merged }); }, [thoughts]); // Aggregate all toolCalls across thoughts for header tooltip const allToolCalls = useMemo(() => { const calls: ToolCallMeta[] = []; for (const t of thoughts) { if (t.toolCalls) calls.push(...t.toolCalls); } return calls.length > 0 ? calls : undefined; }, [thoughts]); // Reuse the same markdown preprocessing as the expanded thought body. const compactPreviewMarkdown = useMemo(() => { // Try newest first (most relevant), then scan for any text for (const t of thoughts) { if (t.text && t.text.trim()) { const stripped = stripAgentBlocks(t.text).trim(); if (stripped) { return buildThoughtDisplayContent(t, memberColorMap, teamNames, { preserveLineBreaks: false, stripAgentOnlyBlocks: true, }) .replace(/\n+/g, ' ') .trim(); } } } return totalToolSummary; }, [memberColorMap, teamNames, thoughts, totalToolSummary]); const compactPreviewTooltipText = useMemo(() => { const normalized = extractMarkdownPlainText(compactPreviewMarkdown ?? '') .replace(/\n+/g, ' ') .trim(); return normalized || compactPreviewMarkdown; }, [compactPreviewMarkdown]); // Detect if any thought in this group is an API error const hasApiError = useMemo(() => thoughts.some((t) => isApiErrorMessage(t.text)), [thoughts]); const [expanded, setExpanded] = useState(false); const [needsTruncation, setNeedsTruncation] = useState(false); const isManaged = collapseMode === 'managed'; const isBodyVisible = isManaged ? !isCollapsed : true; const canToggleBodyVisibility = isManaged && canToggleCollapse; const handleBodyToggle = useCallback(() => { if (canToggleBodyVisibility && collapseToggleKey) { onToggleCollapse?.(collapseToggleKey); } }, [canToggleBodyVisibility, collapseToggleKey, onToggleCollapse]); const shouldAnimateLatestThought = canBeLive !== false && isRecentTimestamp(newest.timestamp); // Track how many thoughts have been reported as visible so far. const reportedCountRef = useRef(0); useEffect(() => { if (!onVisible) return; const el = ref.current; if (!el) return; // Resolve observer root at effect-time. Falls back to the document // viewport when no root is provided — preserves pre-contract behavior. const root = observerRoot?.current ?? null; const observer = new IntersectionObserver( ([entry]) => { if (!entry?.isIntersecting) return; const alreadyReported = reportedCountRef.current; if (alreadyReported >= thoughts.length) return; for (let i = alreadyReported; i < thoughts.length; i++) { onVisible(thoughts[i]); } reportedCountRef.current = thoughts.length; }, { root, threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } ); observer.observe(el); return () => observer.disconnect(); }, [onVisible, observerRoot, thoughts]); const clearPendingScrollSync = useCallback(() => { if (scrollSyncFrameRef.current !== null) { cancelAnimationFrame(scrollSyncFrameRef.current); scrollSyncFrameRef.current = null; } }, []); const queueScrollSync = useCallback( (mode: 'bottom' | 'preserve') => { clearPendingScrollSync(); scrollSyncFrameRef.current = requestAnimationFrame(() => { scrollSyncFrameRef.current = requestAnimationFrame(() => { const scrollEl = scrollRef.current; if (!scrollEl || expanded || !isBodyVisible) { scrollSyncFrameRef.current = null; return; } if (hasSelectionWithin(scrollEl)) { scrollSyncFrameRef.current = null; return; } const nextScrollTop = mode === 'bottom' ? scrollEl.scrollHeight - scrollEl.clientHeight : scrollEl.scrollHeight - scrollEl.clientHeight - distanceFromBottomRef.current; scrollEl.scrollTop = Math.max(0, nextScrollTop); if (mode === 'bottom') { distanceFromBottomRef.current = 0; isUserScrolledUpRef.current = false; } scrollSyncFrameRef.current = null; }); }); }, [clearPendingScrollSync, expanded, isBodyVisible] ); const syncScrollableBody = useCallback( (forceScrollToBottom = false) => { const scrollEl = scrollRef.current; const contentEl = contentRef.current; if (!scrollEl || !contentEl) return; const nextNeedsTruncation = contentEl.scrollHeight > COLLAPSED_THOUGHTS_HEIGHT + 1; setNeedsTruncation((prev) => (prev === nextNeedsTruncation ? prev : nextNeedsTruncation)); if (expanded || !isBodyVisible) return; if (!nextNeedsTruncation) { clearPendingScrollSync(); distanceFromBottomRef.current = 0; isUserScrolledUpRef.current = false; return; } if (forceScrollToBottom || !isUserScrolledUpRef.current) { queueScrollSync('bottom'); return; } queueScrollSync('preserve'); }, [clearPendingScrollSync, expanded, isBodyVisible, queueScrollSync] ); useLayoutEffect(() => { if (!isBodyVisible) return; const contentEl = contentRef.current; if (!contentEl) return; syncScrollableBody(true); const observer = new ResizeObserver(() => { syncScrollableBody(); }); observer.observe(contentEl); return () => observer.disconnect(); }, [isBodyVisible, syncScrollableBody]); useEffect( () => () => { clearPendingScrollSync(); }, [clearPendingScrollSync] ); useEffect(() => { if (isBodyVisible) return; clearPendingScrollSync(); }, [clearPendingScrollSync, isBodyVisible]); const handleScroll = useCallback(() => { if (expanded) return; const el = scrollRef.current; if (!el) return; const distanceFromBottom = Math.max(0, el.scrollHeight - el.scrollTop - el.clientHeight); distanceFromBottomRef.current = distanceFromBottom; isUserScrolledUpRef.current = distanceFromBottom > AUTO_SCROLL_THRESHOLD; }, [expanded]); const handleCollapse = useCallback(() => { isUserScrolledUpRef.current = false; distanceFromBottomRef.current = 0; setExpanded(false); requestAnimationFrame(() => { requestAnimationFrame(() => { const scrollEl = scrollRef.current; if (scrollEl && !hasSelectionWithin(scrollEl)) { scrollEl.scrollTop = scrollEl.scrollHeight; } ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }); }); }, []); const timestampLabel = formatTime(oldest.timestamp) === formatTime(newest.timestamp) ? formatTime(oldest.timestamp) : `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`; const useCompactCollapsedHeader = compactHeader && !isBodyVisible; return (
{/* Header */} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below; nested tooltips prevent native button */}
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleBodyToggle?.(); } } : undefined } > {useCompactCollapsedHeader ? (
{thoughts.length} thoughts
{timestampLabel} {onExpand && expandItemKey && ( )}
{compactPreviewMarkdown ? (
{compactPreviewTooltipText}
) : null}
) : !isBodyVisible ? (
{canToggleBodyVisibility && !compactHeader ? ( ) : null} {!compactHeader ? (
) : null} {thoughts.length} thoughts
{timestampLabel} {onExpand && expandItemKey && ( )}
{compactPreviewMarkdown ? (
{compactPreviewTooltipText}
) : null}
) : ( <> {canToggleBodyVisibility && !compactHeader ? ( ) : null} {!compactHeader ? (
) : null} {thoughts.length} thoughts {totalToolSummary ? ( {totalToolSummary} ) : null}
{timestampLabel} {onExpand && expandItemKey && ( )}
)}
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */} {isBodyVisible ? (
{chronologicalThoughts.map((thought, idx) => ( 0} shouldAnimate={ shouldAnimateLatestThought && idx === chronologicalThoughts.length - 1 } onTaskIdClick={onTaskIdClick} memberColorMap={memberColorMap} teamNames={teamNames} teamColorByName={teamColorByName} onTeamClick={onTeamClick} onReply={onReply} /> ))}
) : null}
{isBodyVisible && !expanded && needsTruncation ? (
) : null} {isBodyVisible && expanded && needsTruncation ? (
) : null}
); }; export const LeadThoughtsGroupRow = memo( LeadThoughtsGroupRowComponent, (prev, next) => prev.memberColor === next.memberColor && prev.isNew === next.isNew && prev.onVisible === next.onVisible && prev.canBeLive === next.canBeLive && prev.isTeamAlive === next.isTeamAlive && prev.leadActivity === next.leadActivity && prev.leadContextUpdatedAt === next.leadContextUpdatedAt && prev.zebraShade === next.zebraShade && prev.collapseMode === next.collapseMode && prev.isCollapsed === next.isCollapsed && prev.canToggleCollapse === next.canToggleCollapse && prev.collapseToggleKey === next.collapseToggleKey && prev.onToggleCollapse === next.onToggleCollapse && prev.onTaskIdClick === next.onTaskIdClick && prev.memberColorMap === next.memberColorMap && areStringArraysEqual(prev.teamNames, next.teamNames) && areStringMapsEqual(prev.teamColorByName, next.teamColorByName) && prev.onTeamClick === next.onTeamClick && prev.onReply === next.onReply && prev.compactHeader === next.compactHeader && prev.onExpand === next.onExpand && prev.expandItemKey === next.expandItemKey && prev.observerRoot === next.observerRoot && areThoughtGroupsEquivalent(prev.group, next.group) );