import React from 'react'; import { RiArrowDownSLine, RiArrowRightSLine, RiStackLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; import type { TurnActivityPart } from '../../hooks/useTurnGrouping'; import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2'; import type { ContentChangeReason } from '@/hooks/useChatScrollManager'; import type { ToolPopupContent } from '../types'; import ToolPart from './ToolPart'; import ReasoningPart from './ReasoningPart'; import JustificationBlock from './JustificationBlock'; import { FadeInOnReveal } from '../FadeInOnReveal'; const MAX_VISIBLE_COLLAPSED = 6; interface DiffStats { additions: number; deletions: number; files: number; } interface ProgressiveGroupProps { parts: TurnActivityPart[]; isExpanded: boolean; onToggle: () => void; syntaxTheme: Record; isMobile: boolean; expandedTools: Set; onToggleTool: (toolId: string) => void; onShowPopup: (content: ToolPopupContent) => void; onContentChange?: (reason?: ContentChangeReason) => void; diffStats?: DiffStats; } const sortPartsByTime = (parts: TurnActivityPart[]): TurnActivityPart[] => { return [...parts].sort((a, b) => { const aTime = typeof a.endedAt === 'number' ? a.endedAt : undefined; const bTime = typeof b.endedAt === 'number' ? b.endedAt : undefined; if (aTime === undefined && bTime === undefined) return 0; if (aTime === undefined) return 1; if (bTime === undefined) return -1; return aTime - bTime; }); }; const getToolConnections = ( parts: TurnActivityPart[] ): Record => { const connections: Record = {}; const toolParts = parts.filter((p) => p.kind === 'tool'); toolParts.forEach((activity, index) => { const partId = activity.part.id; if (partId) { connections[partId] = { hasPrev: index > 0, hasNext: index < toolParts.length - 1, }; } }); return connections; }; const ProgressiveGroup: React.FC = ({ parts, isExpanded, onToggle, syntaxTheme, isMobile, expandedTools, onToggleTool, onContentChange, diffStats, }) => { const previousExpandedRef = React.useRef(isExpanded); // Track if we just expanded from collapsed state const [justExpandedFromCollapsed, setJustExpandedFromCollapsed] = React.useState(false); const [expansionKey, setExpansionKey] = React.useState(0); React.useEffect(() => { if (previousExpandedRef.current === isExpanded) return; const wasCollapsed = previousExpandedRef.current === false; previousExpandedRef.current = isExpanded; onContentChange?.('structural'); if (isExpanded && wasCollapsed) { setExpansionKey((k) => k + 1); setJustExpandedFromCollapsed(true); // Reset after a short delay (after animations would have started) const timer = setTimeout(() => setJustExpandedFromCollapsed(false), 50); return () => clearTimeout(timer); } else { setJustExpandedFromCollapsed(false); } }, [isExpanded, onContentChange]); const displayParts = React.useMemo(() => { return sortPartsByTime(parts); }, [parts]); const toolConnections = getToolConnections(displayParts); // For collapsed state: show last N items const visibleCollapsedParts = React.useMemo(() => { return displayParts.slice(-MAX_VISIBLE_COLLAPSED); }, [displayParts]); // Set of part IDs that were visible in collapsed state const visibleInCollapsedIds = React.useMemo(() => { const ids = new Set(); visibleCollapsedParts.forEach((p) => { if (p.part.id) ids.add(p.part.id); }); return ids; }, [visibleCollapsedParts]); // Connections for collapsed view (based on visible parts only) const collapsedToolConnections = React.useMemo(() => { return getToolConnections(visibleCollapsedParts); }, [visibleCollapsedParts]); const hiddenCount = Math.max(0, displayParts.length - MAX_VISIBLE_COLLAPSED); if (displayParts.length === 0) { return null; } const partsToRender = isExpanded ? displayParts : visibleCollapsedParts; const connectionsToUse = isExpanded ? toolConnections : collapsedToolConnections; // If there are no hidden items, header is not interactive const isHeaderInteractive = hiddenCount > 0; return (
{isHeaderInteractive ? ( <>
{isExpanded ? ( ) : ( )}
) : ( )}
Activity
{diffStats && (diffStats.additions > 0 || diffStats.deletions > 0) && (
+{Math.max(0, diffStats.additions)} / -{Math.max(0, diffStats.deletions)}
)}
{!isExpanded && hiddenCount > 0 && (
+{hiddenCount} more...
)} {partsToRender.map((activity, index) => { const partId = activity.part.id || `group-part-${index}`; const connection = connectionsToUse[partId]; const animationKey = `${partId}-exp${expansionKey}`; // Skip animation if: // - We just expanded from collapsed AND // - This part was already visible in collapsed state const wasVisibleInCollapsed = activity.part.id ? visibleInCollapsedIds.has(activity.part.id) : false; const skipAnimation = justExpandedFromCollapsed && wasVisibleInCollapsed; switch (activity.kind) { case 'tool': return ( ); case 'reasoning': return ( ); case 'justification': return ( ); default: return null; } })}
); }; export default React.memo(ProgressiveGroup);