diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index dcaa9c3e..5ab76ba9 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -33,7 +33,7 @@ import { toast } from '@/components/ui'; import { formatTimestampForDisplay } from './timeFormat'; import { ToolRevealOnMount } from './parts/ToolRevealOnMount'; import { StaticToolRow } from './parts/ProgressiveGroup'; -import { getStaticGroupToolName, isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils'; +import { isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils'; import TurnActivity from '../components/TurnActivity'; type SubtaskPartLike = Part & { @@ -699,9 +699,8 @@ const AssistantMessageBody: React.FC> = ({ const createSessionFromAssistantMessage = useSessionStore((state) => state.createSessionFromAssistantMessage); const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt); const chatRenderMode = useUIStore((state) => state.chatRenderMode); - const activityRenderMode = useUIStore((state) => state.activityRenderMode); const isSortedRenderMode = chatRenderMode === 'sorted'; - const collapsedPreviewCount = activityRenderMode === 'collapsed' ? 7 : 0; + const collapsedPreviewCount = 7; const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false; const hasStopFinish = messageFinish === 'stop'; @@ -1111,24 +1110,25 @@ const AssistantMessageBody: React.FC> = ({ return; } rendered.push( - +
+ +
); }); } @@ -1190,7 +1190,7 @@ const AssistantMessageBody: React.FC> = ({ if (partText && partText.trim().length > 0) { rendered.push( -
+
{partText}
@@ -1239,40 +1239,28 @@ const AssistantMessageBody: React.FC> = ({ continue; } - // Static tools: group consecutive tools of the same kind into compact rows - const groupToolName = getStaticGroupToolName(toolName); - const group: ToolPartType[] = [toolPart]; - let j = i + 1; - while (j < visibleParts.length) { - const next = visibleParts[j]; - if (next.type !== 'tool') break; - const nextTool = next as ToolPartType; - if (!shouldShowTool(nextTool)) { j++; continue; } - const nextName = nextTool.tool?.toLowerCase() ?? ''; - if (getStaticGroupToolName(nextName) !== groupToolName) break; - group.push(nextTool); - j++; - } - + // Static tools: one row per tool call (no grouping) rendered.push( - animatedToolIdsLookup.has(candidate.id))} wipe> + ({ - id: tp.id, - turnId: '', - messageId: messageId, - partIndex: 0, - part: tp, - kind: 'tool' as const, - }))} - animateTailText={group.some((candidate) => animatedToolIdsLookup.has(candidate.id))} + toolName={toolName} + activities={[ + { + id: toolPart.id, + turnId: '', + messageId, + partIndex: 0, + part: toolPart, + kind: 'tool' as const, + }, + ]} + animateTailText={animatedToolIdsLookup.has(toolPart.id)} /> ); - i = j; + i++; continue; } diff --git a/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx b/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx index db0e9334..d10fd3a3 100644 --- a/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx +++ b/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx @@ -88,7 +88,10 @@ const AssistantTextPart: React.FC = ({ } return ( -
+
; - diffStats?: DiffStats; } -const EDIT_LIKE_TOOL_NAMES = new Set([ - 'edit', - 'multiedit', - 'apply_patch', - 'str_replace', - 'str_replace_based_edit_tool', -]); - -const isEditLikeTool = (toolName: unknown): boolean => { - return typeof toolName === 'string' && EDIT_LIKE_TOOL_NAMES.has(toolName.toLowerCase()); -}; - -const parseDiffCounts = (diffText: string): { added: number; removed: number } => { - const lines = diffText.split('\n'); - let added = 0; - let removed = 0; - - for (const line of lines) { - if (line.startsWith('+') && !line.startsWith('+++')) added += 1; - if (line.startsWith('-') && !line.startsWith('---')) removed += 1; - } - - return { added, removed }; -}; - -type FileDiffAggregate = { filePath: string; added: number; removed: number }; - -const aggregateFileDiffs = (parts: TurnActivityPart[]): FileDiffAggregate[] => { - const byPath = new Map(); - - const addToPath = (filePath: string, added: number, removed: number) => { - if (!filePath) return; - const current = byPath.get(filePath) ?? { added: 0, removed: 0 }; - current.added += Math.max(0, added); - current.removed += Math.max(0, removed); - byPath.set(filePath, current); - }; - - for (const activity of parts) { - if (activity.kind !== 'tool') continue; - const toolPart = activity.part as ToolPartType; - if (!isEditLikeTool(toolPart.tool)) continue; - - const state = toolPart.state as { metadata?: Record; input?: Record } | undefined; - const metadata = state?.metadata; - const input = state?.input; - const files = Array.isArray(metadata?.files) ? metadata?.files : []; - - if (files.length > 0) { - for (const file of files) { - if (!file || typeof file !== 'object') continue; - const record = file as { - relativePath?: unknown; - filePath?: unknown; - path?: unknown; - additions?: unknown; - deletions?: unknown; - diff?: unknown; - }; - - const filePath = - (typeof record.relativePath === 'string' && record.relativePath) || - (typeof record.filePath === 'string' && record.filePath) || - (typeof record.path === 'string' && record.path) || - ''; - - const explicitAdditions = typeof record.additions === 'number' ? record.additions : null; - const explicitDeletions = typeof record.deletions === 'number' ? record.deletions : null; - - if (explicitAdditions !== null || explicitDeletions !== null) { - addToPath(filePath, explicitAdditions ?? 0, explicitDeletions ?? 0); - continue; - } - - if (typeof record.diff === 'string' && record.diff.trim().length > 0) { - const counts = parseDiffCounts(record.diff); - addToPath(filePath, counts.added, counts.removed); - } - } - continue; - } - - const fallbackPath = - (typeof input?.filePath === 'string' && input.filePath) || - (typeof input?.file_path === 'string' && input.file_path) || - (typeof input?.path === 'string' && input.path) || - ''; - - if (typeof metadata?.diff === 'string' && metadata.diff.trim().length > 0) { - const counts = parseDiffCounts(metadata.diff); - addToPath(fallbackPath || 'Diff', counts.added, counts.removed); - } - } - - return Array.from(byPath.entries()) - .map(([filePath, counts]) => ({ filePath, added: counts.added, removed: counts.removed })) - .filter((entry) => entry.added > 0 || entry.removed > 0) - .sort((a, b) => { - const aChanges = a.added + a.removed; - const bChanges = b.added + b.removed; - if (aChanges !== bChanges) return bChanges - aChanges; - return a.filePath.localeCompare(b.filePath); - }); -}; - -const toDisplayFileName = (filePath: string): string => { - const normalized = filePath.replace(/\\/g, '/'); - const segments = normalized.split('/').filter(Boolean); - if (segments.length === 0) return normalized; - return segments[segments.length - 1]; -}; - const isActivityRunning = (activity: TurnActivityPart): boolean => { if (activity.kind !== 'tool') return false; const part = activity.part as ToolPartType; @@ -236,14 +117,11 @@ const toTodoStatusKey = (value: unknown): 'pending' | 'in_progress' | 'completed const formatTodoSummary = (todos: unknown[]): string | null => { if (todos.length === 0) { - return null; + return '0 tasks'; } let pending = 0; let inProgress = 0; - let completed = 0; - let cancelled = 0; - for (const todo of todos) { if (!todo || typeof todo !== 'object') { continue; @@ -254,26 +132,14 @@ const formatTodoSummary = (todos: unknown[]): string | null => { } if (status === 'pending') pending += 1; if (status === 'in_progress') inProgress += 1; - if (status === 'completed') completed += 1; - if (status === 'cancelled') cancelled += 1; } - const total = pending + inProgress + completed + cancelled; - if (total === 0) { - return null; + const activeCount = pending + inProgress; + if (activeCount === 0) { + return '0 tasks'; } - if (completed === total) { - return `All ${total} tasks done`; - } - - const parts: string[] = []; - if (inProgress > 0) parts.push(`${inProgress} in progress`); - if (pending > 0) parts.push(`${pending} pending`); - if (completed > 0) parts.push(`${completed} done`); - if (cancelled > 0) parts.push(`${cancelled} cancelled`); - - return parts.length > 0 ? parts.join(', ') : null; + return `${activeCount} ${activeCount === 1 ? 'task' : 'tasks'}`; }; const getTodoSummaryFromActivity = (activity: TurnActivityPart): string | null => { @@ -343,6 +209,37 @@ const normalizePathValue = (value: string): string => { return trimmed.replace(/\\/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 resolveAbsolutePath = (currentDirectory: string, filePath: string): string => { const normalizedPath = normalizePathValue(filePath); if (!normalizedPath) { @@ -446,7 +343,7 @@ type AggregatedRow = /** * Aggregate sorted activity parts into display rows. - * Consecutive static tools of the same type are merged into a single row. + * 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). @@ -487,20 +384,8 @@ const aggregateRows = (parts: TurnActivityPart[]): AggregatedRow[] => { } if (isStaticTool(toolName)) { - // Aggregate consecutive static tools of the same name - const groupedToolName = getStaticGroupToolName(toolName); - const group: TurnActivityPart[] = [activity]; - let j = i + 1; - while (j < parts.length) { - const next = parts[j]; - if (next.kind !== 'tool') break; - const nextTool = (next.part as ToolPartType).tool?.toLowerCase() ?? ''; - if (getStaticGroupToolName(nextTool) !== groupedToolName) break; - group.push(next); - j++; - } - rows.push({ type: 'tool-static-group', toolName: groupedToolName, activities: group }); - i = j; + rows.push({ type: 'tool-static-group', toolName, activities: [activity] }); + i++; continue; } @@ -541,19 +426,20 @@ export const StaticToolRow: React.FC<{ }, [activities]); const readFileEntries = React.useMemo(() => { - if (!isReadGroup) return [] as Array<{ path: string; name: string; offset?: number }>; + if (!isReadGroup) return [] as Array<{ path: string; displayPath: string; offset?: number }>; - const entries: Array<{ path: string; name: string; offset?: number }> = []; + const entries: Array<{ path: string; displayPath: string; offset?: number }> = []; for (const activity of activities) { const filePath = getToolFilePath(activity); - const fileName = getToolFileName(activity); const offset = getToolReadOffset(activity); - if (!filePath || !fileName) continue; + if (!filePath) continue; if (entries.some((entry) => entry.path === filePath)) continue; - entries.push({ path: filePath, name: fileName, offset }); + const displayPath = getRelativePathFromDirectory(filePath, currentDirectory); + if (!displayPath) continue; + entries.push({ path: filePath, displayPath, offset }); } return entries; - }, [activities, isReadGroup]); + }, [activities, currentDirectory, isReadGroup]); const handleReadFileClick = React.useCallback((filePath: string, offset?: number) => { const absolutePath = resolveAbsolutePath(currentDirectory, filePath); @@ -575,13 +461,18 @@ export const StaticToolRow: React.FC<{ uiStore.openContextFile(contextDirectory, absolutePath); }, [currentDirectory, runtime]); - const isSearchGroup = toolName.toLowerCase() === 'grep'; - const isFetchGroup = toolName.toLowerCase() === 'webfetch' || toolName.toLowerCase() === 'fetch' || toolName.toLowerCase() === 'curl' || toolName.toLowerCase() === 'wget'; + 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 (
@@ -606,28 +497,31 @@ export const StaticToolRow: React.FC<{ event.stopPropagation(); handleReadFileClick(entry.path, entry.offset); }} - className="inline-flex items-center gap-1 min-w-0 max-w-full typography-meta leading-5 hover:opacity-90" - style={{ color: 'var(--tools-title)' }} - title={entry.offset ? `${entry.path}:${entry.offset}` : entry.path} + 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 ? : null} - - {entry.name} - + {entry.displayPath} + )) : null} {isSearchGroup && descriptions.length > 0 ? descriptions.map((desc, index) => ( - + @@ -644,8 +538,8 @@ export const StaticToolRow: React.FC<{ target="_blank" rel="noopener noreferrer" className={cn( - 'min-w-0 max-w-full underline decoration-[color:var(--status-info)] underline-offset-2 hover:opacity-90', - 'truncate max-w-[20rem] typography-meta' + '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} @@ -657,7 +551,7 @@ export const StaticToolRow: React.FC<{ {!isReadGroup && !isSearchGroup && !isFetchGroup && descriptions.length > 0 ? ( {descriptions.join(' ')} @@ -749,16 +643,6 @@ const ProgressiveGroup: React.FC = ({ return rows.slice(-previewCount); }, [isExpanded, previewCount, rows]); - const toolCount = React.useMemo( - () => parts.filter((activity) => activity.kind === 'tool').length, - [parts] - ); - - const aggregatedFileDiffs = React.useMemo(() => aggregateFileDiffs(parts), [parts]); - - const hasToolMetric = toolCount > 0; - const showToolFileIcons = useUIStore((state) => state.showToolFileIcons); - if (shouldRenderRows && rows.length === 0) { return null; } @@ -867,14 +751,14 @@ const ProgressiveGroup: React.FC = ({ if (!showHeader) { return ( -
{renderedRows}
+
{renderedRows}
); } return ( -
+
{shouldShowRowsContainer ? (
@@ -924,7 +794,7 @@ const ProgressiveGroup: React.FC = ({ +{previewHiddenCount} more... ) : null} -
{renderedRows}
+
{renderedRows}
) : null}
diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 36469fa9..7e7c2dc4 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -978,21 +978,20 @@ const renderPathLikeGitChanges = (path: string, grow = true) => { ); }; -const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, showFileIcons = true) => { +const renderAnimatedPathWithIcon = (path: string, _animate = true, grow = true, showFileIcons = true) => { + void _animate; const lastSlash = path.lastIndexOf('/'); if (lastSlash === -1) { return ( - + {showFileIcons ? : null} - {path} - + ); } @@ -1004,26 +1003,19 @@ const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, s {showFileIcons ? : null} - {dir} - - - - / - - - {name} - + + / + + {name} @@ -1763,6 +1755,9 @@ const ToolPart: React.FC = ({ // Tool title/description — shown inline as context const justificationText = React.useMemo(() => { + if (normalizedPartTool === 'bash') { + return null; + } if (normalizedPartTool === 'apply_patch') { return null; } @@ -1846,15 +1841,15 @@ const ToolPart: React.FC = ({ {}
-
+
{}
= ({ > {displayName} - {typeof effectiveTimeStart === 'number' ? ( - - - - ) : null} {getMultiFileDescription(metadata, animateTailText, showToolFileIcons)} ) : ( @@ -1917,7 +1903,7 @@ const ToolPart: React.FC = ({ {displayName}
- {typeof effectiveTimeStart === 'number' ? ( + {normalizedPartTool === 'bash' && typeof effectiveTimeStart === 'number' ? ( [] = [ }, { id: 'summary', - label: 'Summary', + label: 'Expanded', description: 'Expand Activity by default.', }, ];