fix: improve activity rendering, spacing, and path truncation (#654)

* fix: polish activity tool rows and message spacing

Show each tool call as its own row in live and activity views
Keep tool descriptions inline with truncation and cleaner read-file paths
Tune spacing, todo counts, and bash timing display for clearer chat flow

* fix: refine activity tool rows and truncation behavior

Make activity collapse behavior consistent across modes and rename default mode label to Expanded
Show tool descriptions inline with improved spacing and one-row-per-tool rendering
Fix read/edit path truncation to clip from the start while keeping edit filenames highlighted

* fix: resolve chat activity lint regressions

Remove unused activity render mode state in message body
Mark path helper animation arg as intentionally unused to satisfy lint
This commit is contained in:
Bohdan Triapitsyn
2026-03-13 14:57:30 +02:00
committed by GitHub
parent 9605fa5106
commit b7196e65c9
5 changed files with 151 additions and 304 deletions
@@ -33,7 +33,7 @@ import { toast } from '@/components/ui';
import { formatTimestampForDisplay } from './timeFormat'; import { formatTimestampForDisplay } from './timeFormat';
import { ToolRevealOnMount } from './parts/ToolRevealOnMount'; import { ToolRevealOnMount } from './parts/ToolRevealOnMount';
import { StaticToolRow } from './parts/ProgressiveGroup'; import { StaticToolRow } from './parts/ProgressiveGroup';
import { getStaticGroupToolName, isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils'; import { isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils';
import TurnActivity from '../components/TurnActivity'; import TurnActivity from '../components/TurnActivity';
type SubtaskPartLike = Part & { type SubtaskPartLike = Part & {
@@ -699,9 +699,8 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
const createSessionFromAssistantMessage = useSessionStore((state) => state.createSessionFromAssistantMessage); const createSessionFromAssistantMessage = useSessionStore((state) => state.createSessionFromAssistantMessage);
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt); const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
const chatRenderMode = useUIStore((state) => state.chatRenderMode); const chatRenderMode = useUIStore((state) => state.chatRenderMode);
const activityRenderMode = useUIStore((state) => state.activityRenderMode);
const isSortedRenderMode = chatRenderMode === 'sorted'; const isSortedRenderMode = chatRenderMode === 'sorted';
const collapsedPreviewCount = activityRenderMode === 'collapsed' ? 7 : 0; const collapsedPreviewCount = 7;
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false; const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
const hasStopFinish = messageFinish === 'stop'; const hasStopFinish = messageFinish === 'stop';
@@ -1111,24 +1110,25 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
return; return;
} }
rendered.push( rendered.push(
<TurnActivity <div key={`progressive-group-${segment.id}`} className="mb-3">
key={`progressive-group-${segment.id}`} <TurnActivity
parts={visibleSegmentParts} parts={visibleSegmentParts}
isExpanded={turnGroupingContext.isGroupExpanded === true} isExpanded={turnGroupingContext.isGroupExpanded === true}
collapsedPreviewCount={collapsedPreviewCount} collapsedPreviewCount={collapsedPreviewCount}
onToggle={toggleActivityGroup} onToggle={toggleActivityGroup}
syntaxTheme={syntaxTheme} syntaxTheme={syntaxTheme}
isMobile={isMobile} isMobile={isMobile}
expandedTools={expandedTools} expandedTools={expandedTools}
onToggleTool={onToggleTool} onToggleTool={onToggleTool}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
onContentChange={onContentChange} onContentChange={onContentChange}
streamPhase={streamPhase} streamPhase={streamPhase}
showHeader={true} showHeader={true}
animateRows={animateActivityRows} animateRows={animateActivityRows}
animatedToolIds={animatedToolIdsLookup} animatedToolIds={animatedToolIdsLookup}
diffStats={turnGroupingContext.diffStats} diffStats={turnGroupingContext.diffStats}
/> />
</div>
); );
}); });
} }
@@ -1190,7 +1190,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
if (partText && partText.trim().length > 0) { if (partText && partText.trim().length > 0) {
rendered.push( rendered.push(
<FadeInOnReveal key={`reasoning-${messageId}-${i}`}> <FadeInOnReveal key={`reasoning-${messageId}-${i}`}>
<div className="text-sm text-muted-foreground/60 italic leading-relaxed whitespace-pre-wrap"> <div className="my-0.5 text-sm text-muted-foreground/60 italic leading-relaxed whitespace-pre-wrap">
{partText} {partText}
</div> </div>
</FadeInOnReveal> </FadeInOnReveal>
@@ -1239,40 +1239,28 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
continue; continue;
} }
// Static tools: group consecutive tools of the same kind into compact rows // Static tools: one row per tool call (no grouping)
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++;
}
rendered.push( rendered.push(
<FadeInOnReveal key={`static-tools-${toolPart.id}`}> <FadeInOnReveal key={`static-tools-${toolPart.id}`}>
<ToolRevealOnMount animate={group.some((candidate) => animatedToolIdsLookup.has(candidate.id))} wipe> <ToolRevealOnMount animate={animatedToolIdsLookup.has(toolPart.id)} wipe>
<StaticToolRow <StaticToolRow
toolName={groupToolName} toolName={toolName}
activities={group.map(tp => ({ activities={[
id: tp.id, {
turnId: '', id: toolPart.id,
messageId: messageId, turnId: '',
partIndex: 0, messageId,
part: tp, partIndex: 0,
kind: 'tool' as const, part: toolPart,
}))} kind: 'tool' as const,
animateTailText={group.some((candidate) => animatedToolIdsLookup.has(candidate.id))} },
]}
animateTailText={animatedToolIdsLookup.has(toolPart.id)}
/> />
</ToolRevealOnMount> </ToolRevealOnMount>
</FadeInOnReveal> </FadeInOnReveal>
); );
i = j; i++;
continue; continue;
} }
@@ -88,7 +88,10 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
} }
return ( return (
<div className="group/assistant-text relative break-words" key={part.id || `${messageId}-text`}> <div
className={`group/assistant-text relative break-words ${chatRenderMode === 'live' ? 'my-1' : ''}`}
key={part.id || `${messageId}-text`}
>
<MarkdownRenderer <MarkdownRenderer
content={displayTextContent} content={displayTextContent}
part={part} part={part}
@@ -14,19 +14,13 @@ import { Text } from '@/components/ui/text';
import { FadeInOnReveal } from '../FadeInOnReveal'; import { FadeInOnReveal } from '../FadeInOnReveal';
import { getToolIcon } from './toolPresentation'; import { getToolIcon } from './toolPresentation';
import { getToolMetadata } from '@/lib/toolHelpers'; import { getToolMetadata } from '@/lib/toolHelpers';
import { getStaticGroupToolName, isExpandableTool, isStandaloneTool, isStaticTool } from './toolRenderUtils'; import { isExpandableTool, isStandaloneTool, isStaticTool } from './toolRenderUtils';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import ReasoningPart from './ReasoningPart'; import ReasoningPart from './ReasoningPart';
import JustificationBlock from './JustificationBlock'; import JustificationBlock from './JustificationBlock';
interface DiffStats {
additions: number;
deletions: number;
files: number;
}
interface ProgressiveGroupProps { interface ProgressiveGroupProps {
parts: TurnActivityPart[]; parts: TurnActivityPart[];
isExpanded: boolean; isExpanded: boolean;
@@ -42,121 +36,8 @@ interface ProgressiveGroupProps {
showHeader: boolean; showHeader: boolean;
animateRows?: boolean; animateRows?: boolean;
animatedToolIds?: Set<string>; animatedToolIds?: Set<string>;
diffStats?: DiffStats;
} }
const EDIT_LIKE_TOOL_NAMES = new Set<string>([
'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<string, { added: number; removed: number }>();
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<string, unknown>; input?: Record<string, unknown> } | 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 => { const isActivityRunning = (activity: TurnActivityPart): boolean => {
if (activity.kind !== 'tool') return false; if (activity.kind !== 'tool') return false;
const part = activity.part as ToolPartType; const part = activity.part as ToolPartType;
@@ -236,14 +117,11 @@ const toTodoStatusKey = (value: unknown): 'pending' | 'in_progress' | 'completed
const formatTodoSummary = (todos: unknown[]): string | null => { const formatTodoSummary = (todos: unknown[]): string | null => {
if (todos.length === 0) { if (todos.length === 0) {
return null; return '0 tasks';
} }
let pending = 0; let pending = 0;
let inProgress = 0; let inProgress = 0;
let completed = 0;
let cancelled = 0;
for (const todo of todos) { for (const todo of todos) {
if (!todo || typeof todo !== 'object') { if (!todo || typeof todo !== 'object') {
continue; continue;
@@ -254,26 +132,14 @@ const formatTodoSummary = (todos: unknown[]): string | null => {
} }
if (status === 'pending') pending += 1; if (status === 'pending') pending += 1;
if (status === 'in_progress') inProgress += 1; if (status === 'in_progress') inProgress += 1;
if (status === 'completed') completed += 1;
if (status === 'cancelled') cancelled += 1;
} }
const total = pending + inProgress + completed + cancelled; const activeCount = pending + inProgress;
if (total === 0) { if (activeCount === 0) {
return null; return '0 tasks';
} }
if (completed === total) { return `${activeCount} ${activeCount === 1 ? 'task' : 'tasks'}`;
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;
}; };
const getTodoSummaryFromActivity = (activity: TurnActivityPart): string | null => { const getTodoSummaryFromActivity = (activity: TurnActivityPart): string | null => {
@@ -343,6 +209,37 @@ const normalizePathValue = (value: string): string => {
return trimmed.replace(/\\/g, '/'); 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 resolveAbsolutePath = (currentDirectory: string, filePath: string): string => {
const normalizedPath = normalizePathValue(filePath); const normalizedPath = normalizePathValue(filePath);
if (!normalizedPath) { if (!normalizedPath) {
@@ -446,7 +343,7 @@ type AggregatedRow =
/** /**
* Aggregate sorted activity parts into display rows. * 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. * Reasoning/justification become inline text.
* Expandable tools (edit, bash, write, question) stay as individual rows. * Expandable tools (edit, bash, write, question) stay as individual rows.
* Unknown tools stay as individual expandable rows (fallback). * Unknown tools stay as individual expandable rows (fallback).
@@ -487,20 +384,8 @@ const aggregateRows = (parts: TurnActivityPart[]): AggregatedRow[] => {
} }
if (isStaticTool(toolName)) { if (isStaticTool(toolName)) {
// Aggregate consecutive static tools of the same name rows.push({ type: 'tool-static-group', toolName, activities: [activity] });
const groupedToolName = getStaticGroupToolName(toolName); i++;
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;
continue; continue;
} }
@@ -541,19 +426,20 @@ export const StaticToolRow: React.FC<{
}, [activities]); }, [activities]);
const readFileEntries = React.useMemo(() => { 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) { for (const activity of activities) {
const filePath = getToolFilePath(activity); const filePath = getToolFilePath(activity);
const fileName = getToolFileName(activity);
const offset = getToolReadOffset(activity); const offset = getToolReadOffset(activity);
if (!filePath || !fileName) continue; if (!filePath) continue;
if (entries.some((entry) => entry.path === 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; return entries;
}, [activities, isReadGroup]); }, [activities, currentDirectory, isReadGroup]);
const handleReadFileClick = React.useCallback((filePath: string, offset?: number) => { const handleReadFileClick = React.useCallback((filePath: string, offset?: number) => {
const absolutePath = resolveAbsolutePath(currentDirectory, filePath); const absolutePath = resolveAbsolutePath(currentDirectory, filePath);
@@ -575,13 +461,18 @@ export const StaticToolRow: React.FC<{
uiStore.openContextFile(contextDirectory, absolutePath); uiStore.openContextFile(contextDirectory, absolutePath);
}, [currentDirectory, runtime]); }, [currentDirectory, runtime]);
const isSearchGroup = toolName.toLowerCase() === 'grep'; const normalizedToolName = toolName.toLowerCase();
const isFetchGroup = toolName.toLowerCase() === 'webfetch' || toolName.toLowerCase() === 'fetch' || toolName.toLowerCase() === 'curl' || toolName.toLowerCase() === 'wget'; const isSearchGroup = normalizedToolName === 'grep'
|| normalizedToolName === 'search'
|| normalizedToolName === 'find'
|| normalizedToolName === 'ripgrep'
|| normalizedToolName === 'glob';
const isFetchGroup = normalizedToolName === 'webfetch' || normalizedToolName === 'fetch' || normalizedToolName === 'curl' || normalizedToolName === 'wget';
return ( return (
<div <div
className={cn( className={cn(
'flex w-full flex-wrap items-center gap-x-2 gap-y-0.5 pr-2 pl-px py-1.5 rounded-xl' '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)' }}> <div className="inline-flex h-5 items-center flex-shrink-0" style={{ color: 'var(--tools-icon)' }}>
@@ -606,28 +497,31 @@ export const StaticToolRow: React.FC<{
event.stopPropagation(); event.stopPropagation();
handleReadFileClick(entry.path, entry.offset); 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" 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-title)' }} style={{ color: 'var(--tools-description)' }}
title={entry.offset ? `${entry.path}:${entry.offset}` : entry.path} title={entry.offset ? `${entry.displayPath}:${entry.offset}` : entry.displayPath}
> >
{showToolFileIcons ? <FileTypeIcon filePath={entry.path} className="h-3.5 w-3.5" /> : null} {showToolFileIcons ? <FileTypeIcon filePath={entry.path} className="h-3.5 w-3.5" /> : null}
<Text <span
variant={animateTailText ? 'generate-effect' : undefined} className="min-w-0 flex-1 truncate whitespace-nowrap typography-meta leading-5"
className="min-w-0 max-w-full truncate typography-meta leading-5" style={{
style={{ color: 'var(--tools-title)' }} color: 'var(--tools-description)',
title={entry.path} direction: 'rtl',
textAlign: 'left',
}}
title={entry.displayPath}
> >
{entry.name} {entry.displayPath}
</Text> </span>
</button> </button>
)) ))
: null} : null}
{isSearchGroup && descriptions.length > 0 {isSearchGroup && descriptions.length > 0
? descriptions.map((desc, index) => ( ? descriptions.map((desc, index) => (
<span key={`${desc}-${index}`} className="inline-flex min-w-0 max-w-full"> <span key={`${desc}-${index}`} className="inline-flex min-w-0 flex-1">
<Text <Text
variant={animateTailText ? 'generate-effect' : undefined} variant={animateTailText ? 'generate-effect' : undefined}
className="min-w-0 max-w-full truncate typography-meta leading-5" className="min-w-0 flex-1 truncate whitespace-nowrap typography-meta leading-5"
style={{ color: 'var(--tools-description)' }} style={{ color: 'var(--tools-description)' }}
title={desc} title={desc}
> >
@@ -644,8 +538,8 @@ export const StaticToolRow: React.FC<{
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={cn( className={cn(
'min-w-0 max-w-full underline decoration-[color:var(--status-info)] underline-offset-2 hover:opacity-90', 'min-w-0 flex-1 underline decoration-[color:var(--status-info)] underline-offset-2 hover:opacity-90',
'truncate max-w-[20rem] typography-meta' 'truncate whitespace-nowrap typography-meta'
)} )}
style={{ color: 'var(--status-info)' }} style={{ color: 'var(--status-info)' }}
title={url} title={url}
@@ -657,7 +551,7 @@ export const StaticToolRow: React.FC<{
{!isReadGroup && !isSearchGroup && !isFetchGroup && descriptions.length > 0 ? ( {!isReadGroup && !isSearchGroup && !isFetchGroup && descriptions.length > 0 ? (
<Text <Text
variant={animateTailText ? 'generate-effect' : undefined} variant={animateTailText ? 'generate-effect' : undefined}
className="min-w-0 max-w-full truncate typography-meta leading-5" className="min-w-0 flex-1 truncate whitespace-nowrap typography-meta leading-5"
style={{ color: 'var(--tools-description)' }} style={{ color: 'var(--tools-description)' }}
> >
{descriptions.join(' ')} {descriptions.join(' ')}
@@ -749,16 +643,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
return rows.slice(-previewCount); return rows.slice(-previewCount);
}, [isExpanded, previewCount, rows]); }, [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) { if (shouldRenderRows && rows.length === 0) {
return null; return null;
} }
@@ -867,14 +751,14 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
if (!showHeader) { if (!showHeader) {
return ( return (
<FadeInOnReveal> <FadeInOnReveal>
<div className="my-1">{renderedRows}</div> <div className="mt-1 mb-2 space-y-1.5">{renderedRows}</div>
</FadeInOnReveal> </FadeInOnReveal>
); );
} }
return ( return (
<FadeInOnReveal> <FadeInOnReveal>
<div className="my-1"> <div className="mt-1 mb-2">
<button <button
type="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" 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"
@@ -883,30 +767,16 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
<span className="inline-flex h-5 items-center flex-shrink-0" style={{ color: 'var(--tools-icon)' }}> <span className="inline-flex h-5 items-center flex-shrink-0" style={{ color: 'var(--tools-icon)' }}>
<RiStackLine className="h-3.5 w-3.5" /> <RiStackLine className="h-3.5 w-3.5" />
</span> </span>
<span className="typography-meta leading-5 font-medium inline-flex h-5 items-center flex-shrink-0" style={{ color: 'var(--tools-title)' }}> <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 Activity
</span> </span>
{hasToolMetric ? (
<span className="typography-meta leading-5 text-muted-foreground/80 flex-shrink-0">
{toolCount} {toolCount === 1 ? 'tool' : 'tools'}
</span>
) : null}
{aggregatedFileDiffs.map((entry, index) => (
<span
key={`${entry.filePath}-${index}`}
className="inline-flex min-w-0 max-w-full items-center gap-1 typography-meta leading-5 text-muted-foreground/80"
>
{showToolFileIcons ? <FileTypeIcon filePath={entry.filePath} className="h-3.5 w-3.5" /> : null}
<span className={cn('truncate', isMobile ? 'max-w-[9rem]' : 'max-w-[12rem]')} style={{ color: 'var(--tools-description)' }} title={entry.filePath}>
{toDisplayFileName(entry.filePath)}
</span>
<span className="flex-shrink-0 inline-flex items-center gap-0 tabular-nums">
<span style={{ color: 'var(--status-success)' }}>+{entry.added}</span>
<span style={{ color: 'var(--tools-description)' }}>/</span>
<span style={{ color: 'var(--status-error)' }}>-{entry.removed}</span>
</span>
</span>
))}
</button> </button>
{shouldShowRowsContainer ? ( {shouldShowRowsContainer ? (
<div className="relative ml-2 pl-3"> <div className="relative ml-2 pl-3">
@@ -924,7 +794,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
+{previewHiddenCount} more... +{previewHiddenCount} more...
</button> </button>
) : null} ) : null}
<div>{renderedRows}</div> <div className="space-y-1.5">{renderedRows}</div>
</div> </div>
) : null} ) : null}
</div> </div>
@@ -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('/'); const lastSlash = path.lastIndexOf('/');
if (lastSlash === -1) { if (lastSlash === -1) {
return ( return (
<span className={cn('min-w-0 inline-flex items-center gap-1', grow && 'flex-1')} title={path}> <span className={cn('min-w-0 inline-flex items-center gap-1 overflow-hidden', grow && 'flex-1')} title={path}>
{showFileIcons ? <FileTypeIcon filePath={path} className="h-3.5 w-3.5 flex-shrink-0" /> : null} {showFileIcons ? <FileTypeIcon filePath={path} className="h-3.5 w-3.5 flex-shrink-0" /> : null}
<Text <span
key={animate ? `path-full-${path}` : undefined} className={cn('min-w-0 truncate whitespace-nowrap typography-meta', grow && 'flex-1')}
variant={animate ? 'generate-effect' : undefined}
className="min-w-0 truncate typography-meta"
style={{ color: 'var(--tools-title)' }} style={{ color: 'var(--tools-title)' }}
> >
{path} {path}
</Text> </span>
</span> </span>
); );
} }
@@ -1004,26 +1003,19 @@ const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, s
<span className={cn('min-w-0 inline-flex items-center gap-1 overflow-hidden', grow && 'flex-1')} title={path}> <span className={cn('min-w-0 inline-flex items-center gap-1 overflow-hidden', grow && 'flex-1')} title={path}>
{showFileIcons ? <FileTypeIcon filePath={path} className="h-3.5 w-3.5 flex-shrink-0" /> : null} {showFileIcons ? <FileTypeIcon filePath={path} className="h-3.5 w-3.5 flex-shrink-0" /> : null}
<span className={cn('min-w-0 inline-flex items-baseline overflow-hidden typography-meta', grow && 'flex-1')}> <span className={cn('min-w-0 inline-flex items-baseline overflow-hidden typography-meta', grow && 'flex-1')}>
<Text <span
key={animate ? `path-dir-${dir}` : undefined} className="min-w-0 flex-1 truncate whitespace-nowrap"
variant={animate ? 'generate-effect' : undefined} style={{
className="min-w-0 truncate typography-meta" color: 'var(--tools-description)',
style={{ color: 'var(--tools-description)', direction: 'rtl', textAlign: 'left' }} direction: 'rtl',
textAlign: 'left',
}}
> >
{dir} {dir}
</Text> </span>
<span className="flex-shrink-0 inline-flex items-baseline"> <span className="flex-shrink-0" style={{ color: 'var(--tools-description)' }}>/</span>
<Text variant={animate ? 'generate-effect' : undefined} className="typography-meta" style={{ color: 'var(--tools-description)' }}> <span className="flex-shrink-0" style={{ color: 'var(--tools-title)' }}>
/ {name}
</Text>
<Text
key={animate ? `path-name-${name}` : undefined}
variant={animate ? 'generate-effect' : undefined}
className="typography-meta"
style={{ color: 'var(--tools-title)' }}
>
{name}
</Text>
</span> </span>
</span> </span>
</span> </span>
@@ -1763,6 +1755,9 @@ const ToolPart: React.FC<ToolPartProps> = ({
// Tool title/description — shown inline as context // Tool title/description — shown inline as context
const justificationText = React.useMemo(() => { const justificationText = React.useMemo(() => {
if (normalizedPartTool === 'bash') {
return null;
}
if (normalizedPartTool === 'apply_patch') { if (normalizedPartTool === 'apply_patch') {
return null; return null;
} }
@@ -1846,15 +1841,15 @@ const ToolPart: React.FC<ToolPartProps> = ({
{} {}
<div <div
className={cn( className={cn(
'group/tool flex gap-2 pr-2 pl-px py-1.5 rounded-xl cursor-pointer', 'group/tool flex gap-1.5 pr-2 pl-px py-2 rounded-xl cursor-pointer',
isMultiFileApplyPatch ? 'flex-wrap items-start' : 'items-center' isMultiFileApplyPatch ? 'flex-wrap items-start' : 'items-center'
)} )}
onClick={handleMainClick} onClick={handleMainClick}
onKeyDown={handleMainKeyDown} onKeyDown={handleMainKeyDown}
role="button" role="button"
tabIndex={0} tabIndex={0}
> >
<div className={cn('flex gap-2', isMultiFileApplyPatch ? 'w-full min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5' : 'items-center flex-shrink-0')}> <div className={cn('flex gap-1.5', isMultiFileApplyPatch ? 'w-full min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5' : 'items-center flex-shrink-0')}>
{} {}
<div <div
className="relative h-3.5 w-3.5 flex-shrink-0 cursor-pointer" className="relative h-3.5 w-3.5 flex-shrink-0 cursor-pointer"
@@ -1893,15 +1888,6 @@ const ToolPart: React.FC<ToolPartProps> = ({
> >
{displayName} {displayName}
</MinDurationShineText> </MinDurationShineText>
{typeof effectiveTimeStart === 'number' ? (
<span className="flex-shrink-0 tabular-nums text-muted-foreground/80 typography-meta">
<LiveDuration
start={effectiveTimeStart}
end={typeof effectiveTimeEnd === 'number' ? effectiveTimeEnd : undefined}
active={Boolean(isActive && typeof effectiveTimeEnd !== 'number')}
/>
</span>
) : null}
{getMultiFileDescription(metadata, animateTailText, showToolFileIcons)} {getMultiFileDescription(metadata, animateTailText, showToolFileIcons)}
</> </>
) : ( ) : (
@@ -1917,7 +1903,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
{displayName} {displayName}
</MinDurationShineText> </MinDurationShineText>
</div> </div>
{typeof effectiveTimeStart === 'number' ? ( {normalizedPartTool === 'bash' && typeof effectiveTimeStart === 'number' ? (
<span className="flex-shrink-0 tabular-nums text-muted-foreground/80 typography-meta"> <span className="flex-shrink-0 tabular-nums text-muted-foreground/80 typography-meta">
<LiveDuration <LiveDuration
start={effectiveTimeStart} start={effectiveTimeStart}
@@ -134,7 +134,7 @@ const ACTIVITY_RENDER_MODE_OPTIONS: Option<'collapsed' | 'summary'>[] = [
}, },
{ {
id: 'summary', id: 'summary',
label: 'Summary', label: 'Expanded',
description: 'Expand Activity by default.', description: 'Expand Activity by default.',
}, },
]; ];