2025-12-07 19:32:53 +02:00
|
|
|
import React from 'react';
|
|
|
|
|
import { RiArrowDownSLine, RiArrowRightSLine, RiStackLine } from '@remixicon/react';
|
|
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
import type { TurnActivityPart } from '../../hooks/useTurnGrouping';
|
2026-01-03 13:39:59 +02:00
|
|
|
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
|
2025-12-07 19:32:53 +02:00
|
|
|
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';
|
|
|
|
|
|
2026-01-20 03:23:39 +02:00
|
|
|
const MAX_VISIBLE_COLLAPSED = 6;
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
interface DiffStats {
|
|
|
|
|
additions: number;
|
|
|
|
|
deletions: number;
|
|
|
|
|
files: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ProgressiveGroupProps {
|
|
|
|
|
parts: TurnActivityPart[];
|
|
|
|
|
isExpanded: boolean;
|
|
|
|
|
onToggle: () => void;
|
|
|
|
|
syntaxTheme: Record<string, React.CSSProperties>;
|
|
|
|
|
isMobile: boolean;
|
|
|
|
|
expandedTools: Set<string>;
|
|
|
|
|
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<string, { hasPrev: boolean; hasNext: boolean }> => {
|
|
|
|
|
const connections: Record<string, { hasPrev: boolean; hasNext: boolean }> = {};
|
|
|
|
|
const toolParts = parts.filter((p) => p.kind === 'tool');
|
|
|
|
|
|
|
|
|
|
toolParts.forEach((activity, index) => {
|
2026-02-20 01:47:33 +02:00
|
|
|
const partId = activity.id;
|
|
|
|
|
connections[partId] = {
|
|
|
|
|
hasPrev: index > 0,
|
|
|
|
|
hasNext: index < toolParts.length - 1,
|
|
|
|
|
};
|
2025-12-07 19:32:53 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return connections;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
|
|
|
|
parts,
|
|
|
|
|
isExpanded,
|
|
|
|
|
onToggle,
|
|
|
|
|
syntaxTheme,
|
|
|
|
|
isMobile,
|
|
|
|
|
expandedTools,
|
|
|
|
|
onToggleTool,
|
|
|
|
|
onContentChange,
|
|
|
|
|
diffStats,
|
|
|
|
|
}) => {
|
|
|
|
|
const previousExpandedRef = React.useRef<boolean | undefined>(isExpanded);
|
2026-01-20 03:23:39 +02:00
|
|
|
// Track if we just expanded from collapsed state
|
|
|
|
|
const [justExpandedFromCollapsed, setJustExpandedFromCollapsed] = React.useState(false);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2025-12-17 02:38:33 +02:00
|
|
|
const [expansionKey, setExpansionKey] = React.useState(0);
|
|
|
|
|
|
2026-01-25 19:16:26 +02:00
|
|
|
// Track which parts have already been shown in collapsed view (for fade-in animation)
|
|
|
|
|
const shownInCollapsedRef = React.useRef<Set<string>>(new Set());
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (previousExpandedRef.current === isExpanded) return;
|
2025-12-17 02:38:33 +02:00
|
|
|
const wasCollapsed = previousExpandedRef.current === false;
|
2025-12-07 19:32:53 +02:00
|
|
|
previousExpandedRef.current = isExpanded;
|
|
|
|
|
onContentChange?.('structural');
|
|
|
|
|
|
2025-12-17 02:38:33 +02:00
|
|
|
if (isExpanded && wasCollapsed) {
|
|
|
|
|
setExpansionKey((k) => k + 1);
|
2026-01-20 03:23:39 +02:00
|
|
|
setJustExpandedFromCollapsed(true);
|
2026-01-25 19:16:26 +02:00
|
|
|
// Clear collapsed tracking when expanding (will restart when collapsed again)
|
|
|
|
|
shownInCollapsedRef.current.clear();
|
2026-01-20 03:23:39 +02:00
|
|
|
// Reset after a short delay (after animations would have started)
|
|
|
|
|
const timer = setTimeout(() => setJustExpandedFromCollapsed(false), 50);
|
|
|
|
|
return () => clearTimeout(timer);
|
|
|
|
|
} else {
|
|
|
|
|
setJustExpandedFromCollapsed(false);
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
2025-12-17 02:38:33 +02:00
|
|
|
}, [isExpanded, onContentChange]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2025-12-17 02:38:33 +02:00
|
|
|
const displayParts = React.useMemo(() => {
|
2026-01-20 03:23:39 +02:00
|
|
|
return sortPartsByTime(parts);
|
|
|
|
|
}, [parts]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-20 03:23:39 +02:00
|
|
|
const toolConnections = getToolConnections(displayParts);
|
2025-12-17 02:38:33 +02:00
|
|
|
|
2026-01-20 03:23:39 +02:00
|
|
|
// For collapsed state: show last N items
|
|
|
|
|
const visibleCollapsedParts = React.useMemo(() => {
|
|
|
|
|
return displayParts.slice(-MAX_VISIBLE_COLLAPSED);
|
|
|
|
|
}, [displayParts]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-20 03:23:39 +02:00
|
|
|
// Set of part IDs that were visible in collapsed state
|
|
|
|
|
const visibleInCollapsedIds = React.useMemo(() => {
|
|
|
|
|
const ids = new Set<string>();
|
|
|
|
|
visibleCollapsedParts.forEach((p) => {
|
2026-02-20 01:47:33 +02:00
|
|
|
ids.add(p.id);
|
2026-01-20 03:23:39 +02:00
|
|
|
});
|
|
|
|
|
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);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
if (displayParts.length === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 03:23:39 +02:00
|
|
|
const partsToRender = isExpanded ? displayParts : visibleCollapsedParts;
|
|
|
|
|
const connectionsToUse = isExpanded ? toolConnections : collapsedToolConnections;
|
|
|
|
|
|
|
|
|
|
// If there are no hidden items, header is not interactive
|
|
|
|
|
const isHeaderInteractive = hiddenCount > 0;
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
return (
|
|
|
|
|
<FadeInOnReveal>
|
|
|
|
|
<div className="my-1">
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
2026-01-20 03:23:39 +02:00
|
|
|
'group/tool flex items-center gap-2 pr-2 pl-px pt-0 pb-1.5 rounded-xl',
|
|
|
|
|
isHeaderInteractive && 'cursor-pointer'
|
2025-12-07 19:32:53 +02:00
|
|
|
)}
|
2026-01-20 03:23:39 +02:00
|
|
|
onClick={isHeaderInteractive ? onToggle : undefined}
|
2025-12-07 19:32:53 +02:00
|
|
|
>
|
2026-01-20 03:23:39 +02:00
|
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
|
|
|
<div className="relative h-3.5 w-3.5 flex-shrink-0">
|
|
|
|
|
{isHeaderInteractive ? (
|
|
|
|
|
<>
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'absolute inset-0 transition-opacity',
|
|
|
|
|
isExpanded && 'opacity-0',
|
|
|
|
|
!isExpanded && !isMobile && 'group-hover/tool:opacity-0'
|
|
|
|
|
)}
|
2026-02-01 18:29:34 +02:00
|
|
|
style={{ color: 'var(--tools-icon)' }}
|
2026-01-20 03:23:39 +02:00
|
|
|
>
|
|
|
|
|
<RiStackLine className="h-3.5 w-3.5" />
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'absolute inset-0 transition-opacity flex items-center justify-center',
|
|
|
|
|
isExpanded && 'opacity-100',
|
|
|
|
|
!isExpanded && isMobile && 'opacity-0',
|
|
|
|
|
!isExpanded && !isMobile && 'opacity-0 group-hover/tool:opacity-100'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{isExpanded ? (
|
|
|
|
|
<RiArrowDownSLine className="h-3.5 w-3.5" />
|
|
|
|
|
) : (
|
|
|
|
|
<RiArrowRightSLine className="h-3.5 w-3.5" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
2025-12-07 19:32:53 +02:00
|
|
|
) : (
|
2026-02-01 18:29:34 +02:00
|
|
|
<RiStackLine className="h-3.5 w-3.5" style={{ color: 'var(--tools-icon)' }} />
|
2025-12-07 19:32:53 +02:00
|
|
|
)}
|
|
|
|
|
</div>
|
2026-02-01 18:29:34 +02:00
|
|
|
<span className="typography-meta font-medium" style={{ color: 'var(--tools-title)' }}>Activity</span>
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
|
|
|
|
|
2026-01-20 03:23:39 +02:00
|
|
|
{diffStats && (diffStats.additions > 0 || diffStats.deletions > 0) && (
|
|
|
|
|
<div className="flex-1 min-w-0 typography-meta text-muted-foreground/70 flex items-center gap-2">
|
2025-12-07 19:32:53 +02:00
|
|
|
<span className="flex-shrink-0 leading-none">
|
|
|
|
|
<span className="text-[color:var(--status-success)]">
|
|
|
|
|
+{Math.max(0, diffStats.additions)}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-muted-foreground/50">/</span>
|
|
|
|
|
<span className="text-destructive">
|
|
|
|
|
-{Math.max(0, diffStats.deletions)}
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
2026-01-20 03:23:39 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
2026-02-01 18:29:34 +02:00
|
|
|
'relative pr-2 pb-1 pt-1 pl-[1.4375rem]'
|
2025-12-07 19:32:53 +02:00
|
|
|
)}
|
|
|
|
|
>
|
2026-02-01 18:29:34 +02:00
|
|
|
<div
|
|
|
|
|
className="absolute left-[0.4375rem] w-px top-[-0.25rem] bottom-0"
|
|
|
|
|
style={{ backgroundColor: 'var(--tools-border)', borderWidth: '0', width: '1px' }}
|
|
|
|
|
></div>
|
2026-01-20 03:23:39 +02:00
|
|
|
{!isExpanded && hiddenCount > 0 && (
|
|
|
|
|
<div
|
|
|
|
|
className="typography-micro text-muted-foreground/70 mb-1 cursor-pointer hover:text-muted-foreground"
|
|
|
|
|
onClick={onToggle}
|
|
|
|
|
>
|
|
|
|
|
+{hiddenCount} more...
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-20 01:47:33 +02:00
|
|
|
{partsToRender.map((activity) => {
|
|
|
|
|
const partId = activity.id;
|
2026-01-20 03:23:39 +02:00
|
|
|
const connection = connectionsToUse[partId];
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2025-12-17 02:38:33 +02:00
|
|
|
const animationKey = `${partId}-exp${expansionKey}`;
|
|
|
|
|
|
2026-01-25 19:16:26 +02:00
|
|
|
// Determine if animation should be skipped:
|
|
|
|
|
// 1. When expanding from collapsed: skip for items that were already visible
|
|
|
|
|
// 2. When collapsed: skip for items already shown before (track in ref)
|
2026-02-20 01:47:33 +02:00
|
|
|
const wasVisibleInCollapsed = visibleInCollapsedIds.has(activity.id);
|
2026-01-25 19:16:26 +02:00
|
|
|
|
|
|
|
|
let skipAnimation = false;
|
|
|
|
|
if (justExpandedFromCollapsed && wasVisibleInCollapsed) {
|
|
|
|
|
// Expanding: don't animate items that were already visible in collapsed state
|
|
|
|
|
skipAnimation = true;
|
2026-02-20 01:47:33 +02:00
|
|
|
} else if (!isExpanded) {
|
2026-01-25 19:16:26 +02:00
|
|
|
// Collapsed: animate only items that haven't been shown yet
|
2026-02-20 01:47:33 +02:00
|
|
|
if (shownInCollapsedRef.current.has(activity.id)) {
|
2026-01-25 19:16:26 +02:00
|
|
|
skipAnimation = true;
|
|
|
|
|
} else {
|
|
|
|
|
// Mark as shown for future renders
|
2026-02-20 01:47:33 +02:00
|
|
|
shownInCollapsedRef.current.add(activity.id);
|
2026-01-25 19:16:26 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-20 03:23:39 +02:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
switch (activity.kind) {
|
|
|
|
|
case 'tool':
|
|
|
|
|
return (
|
2026-01-20 03:23:39 +02:00
|
|
|
<FadeInOnReveal key={animationKey} skipAnimation={skipAnimation}>
|
2025-12-07 19:32:53 +02:00
|
|
|
<ToolPart
|
|
|
|
|
part={activity.part as ToolPartType}
|
|
|
|
|
isExpanded={expandedTools.has(partId)}
|
2026-02-20 01:47:33 +02:00
|
|
|
onToggle={() => onToggleTool(partId)}
|
2025-12-07 19:32:53 +02:00
|
|
|
syntaxTheme={syntaxTheme}
|
|
|
|
|
isMobile={isMobile}
|
|
|
|
|
onContentChange={onContentChange}
|
|
|
|
|
hasPrevTool={connection?.hasPrev ?? false}
|
|
|
|
|
hasNextTool={connection?.hasNext ?? false}
|
|
|
|
|
/>
|
|
|
|
|
</FadeInOnReveal>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case 'reasoning':
|
|
|
|
|
return (
|
2026-01-20 03:23:39 +02:00
|
|
|
<FadeInOnReveal key={animationKey} skipAnimation={skipAnimation}>
|
2025-12-07 19:32:53 +02:00
|
|
|
<ReasoningPart
|
|
|
|
|
part={activity.part}
|
|
|
|
|
messageId={activity.messageId}
|
|
|
|
|
onContentChange={onContentChange}
|
|
|
|
|
/>
|
|
|
|
|
</FadeInOnReveal>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case 'justification':
|
|
|
|
|
return (
|
2026-01-20 03:23:39 +02:00
|
|
|
<FadeInOnReveal key={animationKey} skipAnimation={skipAnimation}>
|
2025-12-07 19:32:53 +02:00
|
|
|
<JustificationBlock
|
|
|
|
|
part={activity.part}
|
|
|
|
|
messageId={activity.messageId}
|
|
|
|
|
onContentChange={onContentChange}
|
|
|
|
|
/>
|
|
|
|
|
</FadeInOnReveal>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</FadeInOnReveal>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default React.memo(ProgressiveGroup);
|