Files
openchamber/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx
T
Bohdan Triapitsyn 88fc8bff22 fix: animate tool paths in sorted chat mode
Adds animated reveals for file path tool descriptions
Keeps sorted mode tool animations working without extra chat gaps
Preserves file icons, truncation, and click behavior
2026-05-15 22:58:46 +03:00

975 lines
35 KiB
TypeScript

import React from 'react';
import { cn } from '@/lib/utils';
import type { TurnActivityRecord as TurnActivityPart } from '../../lib/turns/types';
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
import type { StreamPhase } from '../types';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import type { ToolPopupContent } from '../types';
import ToolPart from './ToolPart';
import { MinDurationShineText } from './MinDurationShineText';
import { ToolRevealOnMount } from './ToolRevealOnMount';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { Text } from '@/components/ui/text';
import { Icon } from "@/components/icon/Icon";
import { FadeInOnReveal } from '../FadeInOnReveal';
import { getToolIcon } from './toolPresentation';
import { getToolMetadata } from '@/lib/toolHelpers';
import { isExpandableTool, isStandaloneTool, isStaticTool } from './toolRenderUtils';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useUIStore } from '@/stores/useUIStore';
import ReasoningPart from './ReasoningPart';
import JustificationBlock from './JustificationBlock';
import { areRenderRelevantPartsEqual } from '../renderCompare';
interface ProgressiveGroupProps {
parts: TurnActivityPart[];
isExpanded: boolean;
collapsedPreviewCount?: number;
onToggle: () => void;
syntaxTheme: Record<string, React.CSSProperties>;
isMobile: boolean;
expandedTools: Set<string>;
onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void;
streamPhase: StreamPhase;
showHeader: boolean;
animateRows?: boolean;
animatedToolIds?: Set<string>;
renderJustificationActions?: (activity: TurnActivityPart) => React.ReactNode;
}
const isActivityRunning = (activity: TurnActivityPart): boolean => {
if (activity.kind !== 'tool') return false;
const part = activity.part as ToolPartType;
const status = (part.state?.status as string) || undefined;
const isFinalized = status === 'completed' || status === 'error' || status === 'aborted' || status === 'failed' || status === 'timeout' || status === 'cancelled';
if (isFinalized) {
return false;
}
if (status === 'running' || status === 'pending' || status === 'started') {
return true;
}
return typeof activity.endedAt !== 'number';
};
/**
* Parts arrive in correct chronological order:
* messages in sequence, parts within each message in their natural LLM
* production order. No re-sorting needed — time-based sorting breaks this
* because text parts get time.end = message completion time (later than
* tools), pushing text after tools within the same message.
*/
const sortPartsByTime = (parts: TurnActivityPart[]): TurnActivityPart[] => parts;
/**
* Extract a short filename from a tool part's input (for aggregation display).
*/
const getToolFileName = (activity: TurnActivityPart): string | null => {
const part = activity.part as ToolPartType;
const state = part.state as { input?: Record<string, unknown>; metadata?: Record<string, unknown> } | undefined;
const input = state?.input;
const metadata = state?.metadata;
const filePath =
(input?.filePath as string) ||
(input?.file_path as string) ||
(input?.path as string) ||
(metadata?.filePath as string) ||
(metadata?.file_path as string) ||
(metadata?.path as string);
if (typeof filePath === 'string' && filePath.trim().length > 0) {
const lastSlash = filePath.lastIndexOf('/');
return lastSlash >= 0 ? filePath.slice(lastSlash + 1) : filePath;
}
return null;
};
const getToolFilePath = (activity: TurnActivityPart): string | null => {
const part = activity.part as ToolPartType;
const state = part.state as { input?: Record<string, unknown>; metadata?: Record<string, unknown> } | undefined;
const input = state?.input;
const metadata = state?.metadata;
const filePath =
(input?.filePath as string) ||
(input?.file_path as string) ||
(input?.path as string) ||
(metadata?.filePath as string) ||
(metadata?.file_path as string) ||
(metadata?.path as string);
return typeof filePath === 'string' && filePath.trim().length > 0 ? filePath : null;
};
const toTodoStatusKey = (value: unknown): 'pending' | 'in_progress' | 'completed' | 'cancelled' | null => {
if (typeof value !== 'string') {
return null;
}
const normalized = value.trim().toLowerCase();
if (normalized === 'pending') return 'pending';
if (normalized === 'in_progress' || normalized === 'in progress' || normalized === 'inprogress') return 'in_progress';
if (normalized === 'completed' || normalized === 'done') return 'completed';
if (normalized === 'cancelled' || normalized === 'canceled') return 'cancelled';
return null;
};
const formatTodoSummary = (todos: unknown[]): string | null => {
if (todos.length === 0) {
return '0 tasks';
}
let pending = 0;
let inProgress = 0;
for (const todo of todos) {
if (!todo || typeof todo !== 'object') {
continue;
}
const status = toTodoStatusKey((todo as { status?: unknown }).status);
if (!status) {
continue;
}
if (status === 'pending') pending += 1;
if (status === 'in_progress') inProgress += 1;
}
const activeCount = pending + inProgress;
if (activeCount === 0) {
return '0 tasks';
}
return `${activeCount} ${activeCount === 1 ? 'task' : 'tasks'}`;
};
const getTodoSummaryFromActivity = (activity: TurnActivityPart): string | null => {
const part = activity.part as ToolPartType;
const state = part.state as { input?: Record<string, unknown>; output?: unknown } | undefined;
const input = state?.input;
const output = state?.output;
if (Array.isArray(input?.todos)) {
const summary = formatTodoSummary(input.todos);
if (summary) return summary;
}
if (Array.isArray(output)) {
const summary = formatTodoSummary(output);
if (summary) return summary;
}
if (output && typeof output === 'object' && Array.isArray((output as { todos?: unknown }).todos)) {
const summary = formatTodoSummary((output as { todos: unknown[] }).todos);
if (summary) return summary;
}
if (typeof output === 'string' && output.trim().length > 0) {
try {
const parsed = JSON.parse(output) as unknown;
if (Array.isArray(parsed)) {
const summary = formatTodoSummary(parsed);
if (summary) return summary;
}
if (parsed && typeof parsed === 'object' && Array.isArray((parsed as { todos?: unknown }).todos)) {
const summary = formatTodoSummary((parsed as { todos: unknown[] }).todos);
if (summary) return summary;
}
} catch {
// Ignore non-JSON output.
}
}
return null;
};
const getToolReadOffset = (activity: TurnActivityPart): number | undefined => {
const part = activity.part as ToolPartType;
const state = part.state as { input?: Record<string, unknown>; metadata?: Record<string, unknown> } | undefined;
const input = state?.input;
const metadata = state?.metadata;
const rawOffset =
(typeof input?.offset === 'number' && Number.isFinite(input.offset) ? input.offset : undefined)
?? (typeof input?.line === 'number' && Number.isFinite(input.line) ? input.line : undefined)
?? (typeof metadata?.offset === 'number' && Number.isFinite(metadata.offset) ? metadata.offset : undefined)
?? (typeof metadata?.line === 'number' && Number.isFinite(metadata.line) ? metadata.line : undefined);
if (typeof rawOffset !== 'number' || rawOffset <= 0) {
return undefined;
}
return Math.floor(rawOffset);
};
const normalizePathValue = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) {
return '';
}
return trimmed.replace(/\\/g, '/').replace(/\/{2,}/g, '/');
};
const trimTrailingSlashes = (value: string): string => {
if (value === '/') {
return value;
}
return value.replace(/\/+$/, '');
};
const getRelativePathFromDirectory = (filePath: string, currentDirectory: string): string => {
const normalizedPath = trimTrailingSlashes(normalizePathValue(filePath));
const normalizedDirectory = trimTrailingSlashes(normalizePathValue(currentDirectory));
if (!normalizedPath) {
return '';
}
if (!normalizedDirectory) {
return normalizedPath;
}
if (normalizedPath === normalizedDirectory) {
return '.';
}
const prefix = `${normalizedDirectory}/`;
if (normalizedPath.startsWith(prefix)) {
return normalizedPath.slice(prefix.length);
}
return normalizedPath;
};
const renderReadFilePath = (displayPath: string, animate = true) => {
const lastSlash = displayPath.lastIndexOf('/');
if (lastSlash === -1) {
return (
<Text
variant={animate ? 'generate-effect' : 'static'}
className="min-w-0 flex-1 truncate whitespace-nowrap typography-meta leading-5"
style={{ color: 'var(--tools-title)' }}
title={displayPath}
>
{displayPath}
</Text>
);
}
const dir = displayPath.slice(0, lastSlash);
const name = displayPath.slice(lastSlash + 1);
const hasAbsoluteRoot = dir.startsWith('/');
const displayDir = hasAbsoluteRoot ? dir.slice(1) : dir;
return (
<span className="min-w-0 inline-flex max-w-full flex-1 items-baseline overflow-hidden typography-meta leading-5" title={displayPath}>
{hasAbsoluteRoot ? <span className="flex-shrink-0" style={{ color: 'var(--tools-description)' }}>/</span> : null}
<span
className="min-w-0 shrink truncate whitespace-nowrap"
style={{
color: 'var(--tools-description)',
direction: 'rtl',
textAlign: 'left',
unicodeBidi: 'plaintext',
}}
>
{displayDir}
</span>
<span className="flex-shrink-0" style={{ color: 'var(--tools-description)' }}>/</span>
<Text
variant={animate ? 'generate-effect' : 'static'}
className="flex-shrink-0"
style={{ color: 'var(--tools-title)' }}
>
{name}
</Text>
</span>
);
};
const resolveAbsolutePath = (currentDirectory: string, filePath: string): string => {
const normalizedPath = normalizePathValue(filePath);
if (!normalizedPath) {
return '';
}
if (normalizedPath.startsWith('/')) {
return normalizedPath;
}
const normalizedDirectory = normalizePathValue(currentDirectory);
if (!normalizedDirectory) {
return normalizedPath;
}
return normalizedDirectory.endsWith('/') ? `${normalizedDirectory}${normalizedPath}` : `${normalizedDirectory}/${normalizedPath}`;
};
const getContextDirectoryForPath = (currentDirectory: string, absolutePath: string): string => {
const normalizedDirectory = normalizePathValue(currentDirectory);
if (normalizedDirectory) {
return normalizedDirectory;
}
const normalizedPath = normalizePathValue(absolutePath);
if (!normalizedPath) {
return '';
}
const parent = normalizedPath.replace(/\/[^/]*$/, '');
return parent || normalizedPath;
};
/**
* Get a short description for a static tool (for aggregation display).
*/
const getToolShortDescription = (activity: TurnActivityPart): string | null => {
const part = activity.part as ToolPartType;
const toolName = part.tool?.toLowerCase() ?? '';
const state = part.state as { input?: Record<string, unknown>; metadata?: Record<string, unknown> } | undefined;
const input = state?.input;
const metadata = state?.metadata;
// For search tools, show pattern
if (toolName === 'grep' || toolName === 'search' || toolName === 'find' || toolName === 'ripgrep') {
const pattern = input?.pattern;
if (typeof pattern === 'string' && pattern.trim().length > 0) {
return pattern.length > 40 ? pattern.slice(0, 40) + '...' : pattern;
}
}
// For glob, show pattern
if (toolName === 'glob') {
const pattern = input?.pattern;
if (typeof pattern === 'string' && pattern.trim().length > 0) {
return pattern.length > 40 ? pattern.slice(0, 40) + '...' : pattern;
}
}
// For web search tools, show query
if (toolName === 'websearch' || toolName === 'web-search' || toolName === 'search_web' || toolName === 'codesearch' || toolName === 'perplexity') {
const query = input?.query;
if (typeof query === 'string' && query.trim().length > 0) {
return query.length > 50 ? query.slice(0, 50) + '...' : query;
}
}
// For skill, show name
if (toolName === 'skill') {
const name = input?.name;
if (typeof name === 'string' && name.trim().length > 0) {
return name;
}
}
// For fetch-url tools, show URL
if (toolName === 'webfetch' || toolName === 'fetch' || toolName === 'curl' || toolName === 'wget') {
const url =
(typeof input?.url === 'string' && input.url) ||
(typeof input?.URL === 'string' && input.URL) ||
(typeof metadata?.url === 'string' && metadata.url) ||
(typeof metadata?.URL === 'string' && metadata.URL) ||
'';
if (typeof url === 'string' && url.trim().length > 0) {
return url.trim();
}
}
// For todo tools, show status summary without task names
if (toolName === 'todowrite' || toolName === 'todoread') {
return getTodoSummaryFromActivity(activity);
}
// Fallback: try filename
return getToolFileName(activity);
};
type AggregatedRow =
| { type: 'tool-expandable'; activity: TurnActivityPart }
| { type: 'tool-static-group'; toolName: string; activities: TurnActivityPart[] }
| { type: 'reasoning'; activity: TurnActivityPart }
| { type: 'justification'; activity: TurnActivityPart }
| { type: 'tool-fallback'; activity: TurnActivityPart };
interface ExpandableToolRowProps {
activity: TurnActivityPart;
isExpanded: boolean;
syntaxTheme: Record<string, React.CSSProperties>;
isMobile: boolean;
onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void;
animateTailText: boolean;
animateRows: boolean;
}
const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
activity,
isExpanded,
syntaxTheme,
isMobile,
onToggleTool,
onShowPopup,
onContentChange,
animateTailText,
animateRows,
}) => {
const handleToggle = React.useCallback(() => {
onToggleTool(activity.id);
}, [activity.id, onToggleTool]);
const content = (
<ToolPart
part={activity.part as ToolPartType}
isExpanded={isExpanded}
onToggle={handleToggle}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
onContentChange={onContentChange}
onShowPopup={onShowPopup}
animateTailText={animateTailText}
/>
);
const maybeWrapped = animateTailText ? (
<ToolRevealOnMount animate={true} wipe>
{content}
</ToolRevealOnMount>
) : content;
if (!animateRows) {
return maybeWrapped;
}
return <FadeInOnReveal>{maybeWrapped}</FadeInOnReveal>;
};
const MemoExpandableToolRow = React.memo(ExpandableToolRow, (prev, next) => {
return prev.isExpanded === next.isExpanded
&& prev.syntaxTheme === next.syntaxTheme
&& prev.isMobile === next.isMobile
&& prev.onToggleTool === next.onToggleTool
&& prev.onShowPopup === next.onShowPopup
&& prev.onContentChange === next.onContentChange
&& prev.animateTailText === next.animateTailText
&& prev.animateRows === next.animateRows
&& prev.activity.id === next.activity.id
&& prev.activity.kind === next.activity.kind
&& prev.activity.endedAt === next.activity.endedAt
&& areRenderRelevantPartsEqual([prev.activity.part], [next.activity.part]);
});
interface StaticGroupedToolRowProps {
toolName: string;
activities: TurnActivityPart[];
animateTailText: boolean;
animateRows: boolean;
}
const StaticGroupedToolRow: React.FC<StaticGroupedToolRowProps> = ({
toolName,
activities,
animateTailText,
animateRows,
}) => {
const content = (
<StaticToolRow
toolName={toolName}
activities={activities}
animateTailText={animateTailText}
/>
);
const maybeWrapped = animateTailText ? (
<ToolRevealOnMount animate={true} wipe>
{content}
</ToolRevealOnMount>
) : content;
if (!animateRows) {
return maybeWrapped;
}
return <FadeInOnReveal>{maybeWrapped}</FadeInOnReveal>;
};
const MemoStaticGroupedToolRow = React.memo(StaticGroupedToolRow, (prev, next) => {
return prev.toolName === next.toolName
&& prev.animateTailText === next.animateTailText
&& prev.animateRows === next.animateRows
&& areActivityListsEqual(prev.activities, next.activities);
});
/**
* Aggregate sorted activity parts into display rows.
* Static tools are rendered as one row per call.
* Reasoning/justification become inline text.
* Expandable tools (edit, bash, write, question) stay as individual rows.
* Unknown tools stay as individual expandable rows (fallback).
*/
const aggregateRows = (parts: TurnActivityPart[]): AggregatedRow[] => {
const rows: AggregatedRow[] = [];
let i = 0;
while (i < parts.length) {
const activity = parts[i];
if (activity.kind === 'reasoning') {
rows.push({ type: 'reasoning', activity });
i++;
continue;
}
if (activity.kind === 'justification') {
rows.push({ type: 'justification', activity });
i++;
continue;
}
// Tool part
const toolPart = activity.part as ToolPartType;
const toolName = toolPart.tool?.toLowerCase() ?? '';
if (isStandaloneTool(toolName)) {
// Standalone tools are rendered separately, skip
i++;
continue;
}
if (isExpandableTool(toolName)) {
rows.push({ type: 'tool-expandable', activity });
i++;
continue;
}
if (isStaticTool(toolName)) {
rows.push({ type: 'tool-static-group', toolName, activities: [activity] });
i++;
continue;
}
// Unknown/fallback tool — keep as expandable
rows.push({ type: 'tool-fallback', activity });
i++;
}
return rows;
};
/**
* Render a static aggregated tool row.
* Shows: [icon] DisplayName file1.tsx file2.tsx ...
*/
const areActivityListsEqual = (left: TurnActivityPart[], right: TurnActivityPart[]): boolean => {
if (left === right) {
return true;
}
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
const leftActivity = left[index];
const rightActivity = right[index];
if (leftActivity.id !== rightActivity.id) {
return false;
}
if (leftActivity.kind !== rightActivity.kind || leftActivity.endedAt !== rightActivity.endedAt) {
return false;
}
if (!areRenderRelevantPartsEqual([leftActivity.part], [rightActivity.part])) {
return false;
}
}
return true;
};
const StaticToolRowInner: React.FC<{
toolName: string;
activities: TurnActivityPart[];
animateTailText: boolean;
}> = ({ toolName, activities, animateTailText }) => {
const showToolFileIcons = useUIStore((state) => state.showToolFileIcons);
const displayName = getToolMetadata(toolName).displayName;
const icon = getToolIcon(toolName);
const isReadGroup = toolName.toLowerCase() === 'read';
const runtime = React.useContext(RuntimeAPIContext);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const hasRunningActivity = React.useMemo(() => activities.some((activity) => isActivityRunning(activity)), [activities]);
const descriptions = React.useMemo(() => {
const descs: string[] = [];
for (const activity of activities) {
const desc = getToolShortDescription(activity);
if (desc && !descs.includes(desc)) {
descs.push(desc);
}
}
return descs;
}, [activities]);
const readFileEntries = React.useMemo(() => {
if (!isReadGroup) return [] as Array<{ path: string; displayPath: string; offset?: number }>;
const entries: Array<{ path: string; displayPath: string; offset?: number }> = [];
for (const activity of activities) {
const filePath = getToolFilePath(activity);
const offset = getToolReadOffset(activity);
if (!filePath) continue;
if (entries.some((entry) => entry.path === filePath)) continue;
const displayPath = getRelativePathFromDirectory(filePath, currentDirectory);
if (!displayPath) continue;
entries.push({ path: filePath, displayPath, offset });
}
return entries;
}, [activities, currentDirectory, isReadGroup]);
const handleReadFileClick = React.useCallback((filePath: string, offset?: number) => {
const absolutePath = resolveAbsolutePath(currentDirectory, filePath);
if (!absolutePath) {
return;
}
if (runtime?.editor) {
void runtime.editor.openFile(absolutePath, offset);
return;
}
const uiStore = useUIStore.getState();
const contextDirectory = getContextDirectoryForPath(currentDirectory, absolutePath);
if (offset && Number.isFinite(offset)) {
uiStore.openContextFileAtLine(contextDirectory, absolutePath, Math.max(1, Math.trunc(offset)), 1);
return;
}
uiStore.openContextFile(contextDirectory, absolutePath);
}, [currentDirectory, runtime]);
const normalizedToolName = toolName.toLowerCase();
const isSearchGroup = normalizedToolName === 'grep'
|| normalizedToolName === 'search'
|| normalizedToolName === 'find'
|| normalizedToolName === 'ripgrep'
|| normalizedToolName === 'glob';
const isFetchGroup = normalizedToolName === 'webfetch' || normalizedToolName === 'fetch' || normalizedToolName === 'curl' || normalizedToolName === 'wget';
return (
<div
className={cn(
'flex w-full items-center gap-x-1.5 pr-2 pl-px py-1.5 rounded-xl min-w-0'
)}
>
<div className="inline-flex h-5 items-center flex-shrink-0" style={{ color: 'var(--tools-icon)' }}>
{icon}
</div>
<MinDurationShineText
active={hasRunningActivity}
minDurationMs={1000}
className="typography-meta leading-5 font-medium inline-flex h-5 items-center flex-shrink-0 opacity-85"
style={{ color: 'var(--tools-title)' }}
title={displayName}
>
{displayName}
</MinDurationShineText>
{isReadGroup && readFileEntries.length > 0
? readFileEntries.map((entry) => (
<button
key={entry.path}
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleReadFileClick(entry.path, entry.offset);
}}
className="inline-flex items-center justify-start gap-1 min-w-0 flex-1 text-left typography-meta leading-5 hover:opacity-90"
style={{ color: 'var(--tools-description)' }}
title={entry.offset ? `${entry.displayPath}:${entry.offset}` : entry.displayPath}
>
{showToolFileIcons ? <FileTypeIcon filePath={entry.path} className="h-3.5 w-3.5" /> : null}
{renderReadFilePath(entry.displayPath, animateTailText)}
</button>
))
: null}
{isSearchGroup && descriptions.length > 0
? descriptions.map((desc, index) => (
<span key={`${desc}-${index}`} className="inline-flex min-w-0 flex-1">
<Text
variant={animateTailText ? 'generate-effect' : 'static'}
className="min-w-0 flex-1 truncate whitespace-nowrap typography-meta leading-5"
style={{ color: 'var(--tools-description)' }}
title={desc}
>
"{desc}"
</Text>
</span>
))
: null}
{isFetchGroup && descriptions.length > 0
? descriptions.map((url, index) => (
<a
key={`${url}-${index}`}
href={url}
target="_blank"
rel="noopener noreferrer"
className={cn(
'min-w-0 flex-1 underline decoration-[color:var(--status-info)] underline-offset-2 hover:opacity-90',
'truncate whitespace-nowrap typography-meta'
)}
style={{ color: 'var(--status-info)' }}
title={url}
>
{url}
</a>
))
: null}
{!isReadGroup && !isSearchGroup && !isFetchGroup && descriptions.length > 0 ? (
<Text
variant={animateTailText ? 'generate-effect' : 'static'}
className="min-w-0 flex-1 truncate whitespace-nowrap typography-meta leading-5"
style={{ color: 'var(--tools-description)' }}
>
{descriptions.join(' ')}
</Text>
) : null}
</div>
);
};
export const StaticToolRow = React.memo(StaticToolRowInner, (prev, next) => {
return prev.toolName === next.toolName
&& prev.animateTailText === next.animateTailText
&& areActivityListsEqual(prev.activities, next.activities);
});
/**
* Inline reasoning text block — rendered as dimmed italic markdown.
*/
const InlineReasoningBlock = React.memo(({ activity, onContentChange }: {
activity: TurnActivityPart;
onContentChange?: (reason?: ContentChangeReason) => void;
}) => {
return (
<ReasoningPart
part={activity.part}
messageId={activity.messageId}
onContentChange={onContentChange}
/>
);
});
/**
* Inline justification text block — rendered as normal assistant text between tools.
*/
const InlineJustificationBlock = React.memo(({ activity, onContentChange, actions }: {
activity: TurnActivityPart;
onContentChange?: (reason?: ContentChangeReason) => void;
actions?: React.ReactNode;
}) => {
return (
<JustificationBlock
part={activity.part}
messageId={activity.messageId}
onContentChange={onContentChange}
actions={actions}
/>
);
});
const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
parts,
isExpanded,
collapsedPreviewCount = 0,
onToggle,
syntaxTheme,
isMobile,
expandedTools,
onToggleTool,
onShowPopup,
onContentChange,
streamPhase: _streamPhase,
showHeader,
animateRows = true,
animatedToolIds,
renderJustificationActions,
}) => {
void _streamPhase;
const previewCount = showHeader && !isExpanded
? Math.max(0, Math.floor(collapsedPreviewCount))
: 0;
const shouldRenderRows = !showHeader || isExpanded || previewCount > 0;
const sortedParts = React.useMemo(() => {
if (!shouldRenderRows) {
return [] as TurnActivityPart[];
}
return sortPartsByTime(parts);
}, [parts, shouldRenderRows]);
const rows = React.useMemo(() => {
if (!shouldRenderRows) {
return [] as AggregatedRow[];
}
return aggregateRows(sortedParts);
}, [shouldRenderRows, sortedParts]);
const previewHiddenCount = React.useMemo(() => {
if (isExpanded || previewCount === 0) {
return 0;
}
return Math.max(0, rows.length - previewCount);
}, [isExpanded, previewCount, rows.length]);
const visibleRows = React.useMemo(() => {
if (isExpanded || previewCount === 0) {
return rows;
}
return rows.slice(-previewCount);
}, [isExpanded, previewCount, rows]);
if (shouldRenderRows && rows.length === 0) {
return null;
}
const wrapRow = (key: string, content: React.ReactNode) => {
if (!animateRows) {
return <React.Fragment key={key}>{content}</React.Fragment>;
}
return <FadeInOnReveal key={key}>{content}</FadeInOnReveal>;
};
const renderedRows = shouldRenderRows
? visibleRows.map((row, index) => {
switch (row.type) {
case 'reasoning':
return wrapRow(
row.activity.id,
<>
<InlineReasoningBlock
activity={row.activity}
onContentChange={onContentChange}
/>
</>
);
case 'justification':
return wrapRow(
row.activity.id,
<>
<InlineJustificationBlock
activity={row.activity}
onContentChange={onContentChange}
actions={renderJustificationActions?.(row.activity)}
/>
</>
);
case 'tool-expandable':
return (
<MemoExpandableToolRow
key={row.activity.id}
activity={row.activity}
isExpanded={expandedTools.has(row.activity.id)}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
onToggleTool={onToggleTool}
onShowPopup={onShowPopup}
onContentChange={onContentChange}
animateTailText={Boolean(animatedToolIds?.has(row.activity.id))}
animateRows={animateRows}
/>
);
case 'tool-static-group':
return (
<MemoStaticGroupedToolRow
key={`static-${row.toolName}-${row.activities[0]?.id ?? index}`}
toolName={row.toolName}
activities={row.activities}
animateTailText={row.activities.some((activity) => animatedToolIds?.has(activity.id))}
animateRows={animateRows}
/>
);
case 'tool-fallback':
return (
<MemoExpandableToolRow
key={row.activity.id}
activity={row.activity}
isExpanded={expandedTools.has(row.activity.id)}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
onToggleTool={onToggleTool}
onShowPopup={onShowPopup}
onContentChange={onContentChange}
animateTailText={Boolean(animatedToolIds?.has(row.activity.id))}
animateRows={animateRows}
/>
);
default:
return null;
}
})
: null;
const shouldShowRowsContainer = isExpanded || visibleRows.length > 0;
if (!showHeader) {
return (
<FadeInOnReveal>
<div className="mt-1 mb-2 space-y-1.5">{renderedRows}</div>
</FadeInOnReveal>
);
}
return (
<FadeInOnReveal>
<div className="mt-1 mb-2">
<button
type="button"
className="group/tool flex w-full flex-wrap items-center gap-x-2 gap-y-0.5 pr-2 pl-px py-1.5 rounded-xl text-left"
onClick={onToggle}
>
<span className="inline-flex h-5 items-center flex-shrink-0" style={{ color: 'var(--tools-icon)' }}>
<Icon name="stack" className="h-3.5 w-3.5" />
</span>
<span
className="leading-5 font-semibold inline-flex h-5 items-center flex-shrink-0"
style={{
color: 'var(--tools-title)',
fontSize: '0.9rem',
letterSpacing: '0.005em',
}}
>
Activity
</span>
</button>
{shouldShowRowsContainer ? (
<div className="relative ml-2 pl-3">
<span
aria-hidden="true"
className="pointer-events-none absolute left-0 top-px bottom-0 w-px"
style={{ backgroundColor: 'var(--tools-border)' }}
/>
{previewHiddenCount > 0 ? (
<button
type="button"
onClick={onToggle}
className="typography-meta leading-5 px-2 py-1 text-muted-foreground/45 hover:text-muted-foreground/65 text-left"
>
+{previewHiddenCount} more...
</button>
) : null}
<div className="space-y-1.5">{renderedRows}</div>
</div>
) : null}
</div>
</FadeInOnReveal>
);
};
export default React.memo(ProgressiveGroup);