import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ChevronDown, ChevronRight, ChevronUp } from 'lucide-react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { Tooltip, TooltipContent, 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 { useStore } from '@renderer/store'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { isManagedCollapseState } from './collapseState'; import type { ActivityCollapseState } from './collapseState'; import type { InboxMessage, ToolCallMeta } from '@shared/types'; export interface LeadThoughtGroup { type: 'lead-thoughts'; thoughts: InboxMessage[]; } /** * 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 (msg.source === 'lead_session') return true; if (msg.source === 'lead_process') return true; return false; } export type TimelineItem = | { type: 'message'; message: InboxMessage; originalIndex: number } | { type: 'lead-thoughts'; group: LeadThoughtGroup; originalIndices: number[] }; /** * 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[] = []; let pendingIndices: number[] = []; 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 }, originalIndices: pendingIndices, }); pendingThoughts = []; pendingIndices = []; }; for (let i = 0; i < messages.length; i++) { const msg = messages[i]; if (isLeadThought(msg)) { const previousThought = pendingThoughts[pendingThoughts.length - 1]; if (previousThought && !hasSameLeadSession(previousThought, msg)) { flushThoughts(); } pendingThoughts.push(msg); pendingIndices.push(i); } else { flushThoughts(); result.push({ type: 'message', message: msg, originalIndex: i }); } } 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 = 220; interface LeadThoughtsGroupRowProps { group: LeadThoughtGroup; memberColor?: string; isNew?: boolean; onVisible?: (message: InboxMessage) => void; /** When false, the live indicator is always off (for historical thought groups). */ canBeLive?: boolean; /** When true, apply a subtle lighter background for zebra-striped lists. */ zebraShade?: boolean; /** Explicit collapse state for timeline-controlled collapsed mode. */ collapseState?: ActivityCollapseState; } 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' }); } 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; } 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; } const LeadThoughtItem = ({ thought, showDivider, shouldAnimate, }: LeadThoughtItemProps): JSX.Element => { const wrapperRef = useRef(null); const contentRef = useRef(null); const previousHeightRef = useRef(null); const animationFrameRef = useRef(null); const cleanupTimerRef = useRef(null); 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 = ''; }, []); useLayoutEffect(() => { const wrapper = wrapperRef.current; const content = contentRef.current; if (!wrapper || !content) return; const animateHeight = ( targetHeight: number, startHeight: number, startOpacity: number ): void => { clearPendingAnimation(); wrapper.style.transition = 'none'; wrapper.style.overflow = 'hidden'; wrapper.style.height = `${Math.max(startHeight, 0)}px`; wrapper.style.opacity = `${startOpacity}`; void wrapper.offsetHeight; animationFrameRef.current = requestAnimationFrame(() => { wrapper.style.transition = `height ${THOUGHT_HEIGHT_ANIMATION_MS}ms ease, opacity ${THOUGHT_HEIGHT_ANIMATION_MS}ms ease`; wrapper.style.height = `${Math.max(targetHeight, 0)}px`; wrapper.style.opacity = '1'; }); cleanupTimerRef.current = window.setTimeout(() => { resetWrapperStyles(); cleanupTimerRef.current = null; }, THOUGHT_HEIGHT_ANIMATION_MS + 40); }; const syncHeight = (nextHeight: number, animateFromZero: boolean): void => { const previousHeight = previousHeightRef.current; previousHeightRef.current = nextHeight; if (!shouldAnimate) { resetWrapperStyles(); return; } if (previousHeight === null) { if (nextHeight > 0 && animateFromZero) { animateHeight(nextHeight, 0, 0); } else { resetWrapperStyles(); } return; } if (Math.abs(nextHeight - previousHeight) < 1) 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(); resetWrapperStyles(); }; }, [clearPendingAnimation, resetWrapperStyles, shouldAnimate]); useEffect( () => () => { clearPendingAnimation(); }, [clearPendingAnimation] ); return (
{showDivider && (

{formatTimeWithSec(thought.timestamp)}
)}
{thought.toolSummary && (
🔧 {thought.toolSummary}
)}
); }; export const LeadThoughtsGroupRow = ({ group, memberColor, isNew, onVisible, canBeLive, zebraShade, collapseState, }: 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 isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false); const leadActivity = useStore((s) => { const teamName = s.selectedTeamName; return teamName ? s.leadActivityByTeam[teamName] : undefined; }); const leadContextUpdatedAt = useStore((s) => { const teamName = s.selectedTeamName; return teamName ? s.leadContextByTeam[teamName]?.updatedAt : undefined; }); 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]); // Live = process alive AND (lead is in active turn OR context recently updated OR fresh thought) const computeIsLive = useCallback( () => canBeLive !== false && isTeamAlive && (leadActivity === 'active' || (leadContextUpdatedAt ? isRecentTimestamp(leadContextUpdatedAt) : false) || isRecentTimestamp(newest.timestamp)), [canBeLive, isTeamAlive, leadActivity, leadContextUpdatedAt, newest.timestamp] ); const [isLive, setIsLive] = useState(computeIsLive); const [expanded, setExpanded] = useState(false); const [needsTruncation, setNeedsTruncation] = useState(false); const isManaged = isManagedCollapseState(collapseState); const isBodyVisible = isManaged ? !collapseState.isCollapsed : true; const canToggleBodyVisibility = isManaged && collapseState.canToggle; const handleBodyToggle = canToggleBodyVisibility ? (): void => { collapseState.onToggle?.(); } : undefined; 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]); // 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; 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; }, { threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' } ); observer.observe(el); return () => observer.disconnect(); }, [onVisible, 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; } 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] ); useEffect(() => { 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) { scrollEl.scrollTop = scrollEl.scrollHeight; } ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }); }); }, []); 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 } > {/* Chevron for collapse mode */} {canToggleBodyVisibility ? ( ) : null} {/* Live / offline indicator */} {isLive ? ( ) : ( )} {thoughts.length} thoughts {formatTime(oldest.timestamp) === formatTime(newest.timestamp) ? formatTime(oldest.timestamp) : `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`} {totalToolSummary && ( {totalToolSummary} )}
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */} {isBodyVisible ? (
{chronologicalThoughts.map((thought, idx) => ( 0} shouldAnimate={isLive && idx === chronologicalThoughts.length - 1} /> ))}
) : null}
{isBodyVisible && !expanded && needsTruncation ? (
) : null} {isBodyVisible && expanded && needsTruncation ? (
) : null}
); };