import React from 'react'; import { useMobileAppActions } from '@/apps/mobileAppContext'; 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 { 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 { useSkillsStore } from '@/stores/useSkillsStore'; import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants'; import ReasoningPart from './ReasoningPart'; import JustificationBlock from './JustificationBlock'; import { areRenderRelevantPartsEqual } from '../renderCompare'; import { getExternalFaviconUrl } from '@/lib/url'; import { getDirectoryForFilePath, getRelativeFilePath, isFilePathWithinDirectory, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils'; const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal'; const TOOL_ROW_TITLE_CLASS = cn('typography-meta font-medium', TOOL_ROW_TEXT_CLASS); const TOOL_ROW_DESCRIPTION_CLASS = cn('typography-meta', TOOL_ROW_TEXT_CLASS); interface ProgressiveGroupProps { parts: TurnActivityPart[]; isExpanded: boolean; collapsedPreviewCount?: number; onToggle: () => void; isMobile: boolean; expandedTools: Set; onToggleTool: (toolId: string) => void; onShowPopup: (content: ToolPopupContent) => void; streamPhase: StreamPhase; showHeader: boolean; animateRows?: boolean; animatedToolIds?: Set; renderJustificationActions?: (activity: TurnActivityPart) => React.ReactNode; } const ExternalLinkFavicon: React.FC<{ href: string }> = ({ href }) => { const [failed, setFailed] = React.useState(false); const faviconUrl = React.useMemo(() => getExternalFaviconUrl(href), [href]); if (!faviconUrl || failed) { return null; } return ( setFailed(true)} /> ); }; 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; metadata?: Record } | 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; metadata?: Record } | 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 getToolSkillDirectory = (activity: TurnActivityPart): string | null => { const part = activity.part as ToolPartType; const state = part.state as { metadata?: Record } | undefined; const dir = state?.metadata?.dir; return typeof dir === 'string' && dir.trim().length > 0 ? dir : 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; 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; metadata?: Record } | 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 renderReadFilePath = (displayPath: string, animate = true) => { const lastSlash = displayPath.lastIndexOf('/'); if (lastSlash === -1) { return ( {displayPath} ); } const dir = displayPath.slice(0, lastSlash); const name = displayPath.slice(lastSlash + 1); const hasAbsoluteRoot = dir.startsWith('/'); const displayDir = hasAbsoluteRoot ? dir.slice(1) : dir; return ( {hasAbsoluteRoot ? / : null} {displayDir} / {name} ); }; const resolveSkillFilePath = (skillPathOrDir: string): string => { const normalizedPath = normalizeFilePath(skillPathOrDir); if (!normalizedPath) { return ''; } return normalizedPath.toLowerCase().endsWith('/skill.md') ? normalizedPath : `${normalizedPath}/SKILL.md`; }; /** * 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; metadata?: Record } | 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; isMobile: boolean; onToggleTool: (toolId: string) => void; onShowPopup: (content: ToolPopupContent) => void; animateTailText: boolean; } const ExpandableToolRow: React.FC = ({ activity, isExpanded, isMobile, onToggleTool, onShowPopup, animateTailText, }) => { const handleToggle = React.useCallback(() => { onToggleTool(activity.id); }, [activity.id, onToggleTool]); const content = ( ); // Wrappers are unconditional: a conditional wrapper changes the element // type at this position when animateTailText/animateRows flip (message // completion), remounting the tool subtree and replaying the reveal wipe. // Both wrappers are inert with animation off. return ( {content} ); }; const MemoExpandableToolRow = React.memo(ExpandableToolRow, (prev, next) => { return prev.isExpanded === next.isExpanded && prev.isMobile === next.isMobile && prev.onToggleTool === next.onToggleTool && prev.onShowPopup === next.onShowPopup && prev.animateTailText === next.animateTailText && 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; } const StaticGroupedToolRow: React.FC = ({ toolName, activities, animateTailText, }) => { const content = ( ); // Wrappers are unconditional: a conditional wrapper changes the element // type at this position when animateTailText/animateRows flip (message // completion), remounting the tool subtree and replaying the reveal wipe. // Both wrappers are inert with animation off. return ( {content} ); }; const MemoStaticGroupedToolRow = React.memo(StaticGroupedToolRow, (prev, next) => { return prev.toolName === next.toolName && prev.animateTailText === next.animateTailText && 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 mobileActions = useMobileAppActions(); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const skills = useSkillsStore((state) => state.skills); const hasRunningActivity = React.useMemo(() => activities.some((activity) => isActivityRunning(activity)), [activities]); const skillByName = React.useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]); 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 skillEntries = React.useMemo(() => { if (toolName.toLowerCase() !== 'skill') return [] as Array<{ name: string; path: string }>; const entries: Array<{ name: string; path: string }> = []; for (const activity of activities) { const name = getToolShortDescription(activity); if (!name) continue; const skill = skillByName.get(name); const rawPath = skill?.path || getToolSkillDirectory(activity); const path = rawPath ? resolveSkillFilePath(rawPath) : ''; if (!path || entries.some((entry) => entry.name === name && entry.path === path)) continue; entries.push({ name, path }); } return entries; }, [activities, skillByName, toolName]); 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 = getRelativeFilePath(filePath, currentDirectory); if (!displayPath) continue; entries.push({ path: filePath, displayPath, offset }); } return entries; }, [activities, currentDirectory, isReadGroup]); const handleFileClick = React.useCallback((filePath: string, offset?: number) => { const absolutePath = toAbsoluteFilePath(currentDirectory, filePath); if (!absolutePath) { return; } if (runtime?.editor) { void runtime.editor.openFile(absolutePath, offset); return; } // Dedicated mobile app: stage the same pending file focus/navigation // desktop uses, then surface the Files pane (workspace drawer tab), // which consumes it. Desktop grant flows don't apply here. if (mobileActions) { const uiStore = useUIStore.getState(); const contextDirectory = currentDirectory || getDirectoryForFilePath(currentDirectory, absolutePath); if (offset && Number.isFinite(offset)) { uiStore.openContextFileAtLine(contextDirectory, absolutePath, Math.max(1, Math.trunc(offset)), 1); } else { uiStore.openContextFile(contextDirectory, absolutePath); } mobileActions.openFiles(); return; } if (!isFilePathWithinDirectory(absolutePath, currentDirectory)) { void ensureOutsideFileGrantForDesktop(absolutePath, currentDirectory).then(() => { const uiStore = useUIStore.getState(); const contextDirectory = currentDirectory || getDirectoryForFilePath(currentDirectory, absolutePath); if (offset && Number.isFinite(offset)) { uiStore.openContextFileAtLine(contextDirectory, absolutePath, Math.max(1, Math.trunc(offset)), 1); return; } uiStore.openContextFile(contextDirectory, absolutePath); }); return; } const uiStore = useUIStore.getState(); const contextDirectory = getDirectoryForFilePath(currentDirectory, absolutePath); if (offset && Number.isFinite(offset)) { uiStore.openContextFileAtLine(contextDirectory, absolutePath, Math.max(1, Math.trunc(offset)), 1); return; } uiStore.openContextFile(contextDirectory, absolutePath); }, [currentDirectory, mobileActions, 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'; const isSkillGroup = normalizedToolName === 'skill'; return (
{icon}
{displayName} {isReadGroup && readFileEntries.length > 0 ? readFileEntries.map((entry) => ( )) : null} {isSearchGroup && descriptions.length > 0 ? descriptions.map((desc, index) => ( "{desc}" )) : null} {isFetchGroup && descriptions.length > 0 ? descriptions.map((url, index) => ( {url} )) : null} {isSkillGroup && skillEntries.length > 0 ? skillEntries.map((entry, index) => ( )) : null} {!isReadGroup && !isSearchGroup && !isFetchGroup && !isSkillGroup && descriptions.length > 0 ? ( {descriptions.join(' ')} ) : null}
); }; 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, streamPhase }: { activity: TurnActivityPart; streamPhase: StreamPhase; }) => { return ( ); }); /** * Inline justification text block — rendered as normal assistant text between tools. */ const InlineJustificationBlock = React.memo(({ activity, actions }: { activity: TurnActivityPart; actions?: React.ReactNode; }) => { return ( ); }); const ProgressiveGroup: React.FC = ({ parts, isExpanded, collapsedPreviewCount = 0, onToggle, isMobile, expandedTools, onToggleTool, onShowPopup, streamPhase, showHeader, animateRows = true, animatedToolIds, renderJustificationActions, }) => { 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 {content}; } return {content}; }; const renderedRows = shouldRenderRows ? visibleRows.map((row, index) => { switch (row.type) { case 'reasoning': return wrapRow( row.activity.id, <> ); case 'justification': return wrapRow( row.activity.id, <> ); case 'tool-expandable': return ( ); case 'tool-static-group': return ( animatedToolIds?.has(activity.id))} /> ); case 'tool-fallback': return ( ); default: return null; } }) : null; const shouldShowRowsContainer = isExpanded || visibleRows.length > 0; if (!showHeader) { return (
{renderedRows}
); } return (
{shouldShowRowsContainer ? (
) : null}
); }; export default React.memo(ProgressiveGroup);