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
975 lines
35 KiB
TypeScript
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);
|