Files
openchamber/packages/ui/src/components/chat/message/parts/ToolPart.tsx
T
Nelson PiresandBohdan Triapitsyn c9bcf79ca7 fix(chat): show in-flight tools immediately and keep collapsed activity live (#563)
* fix(chat): render active tools before completion

* fix(chat): preserve running activity in collapsed view

* fix(chat): live-update duration for active tools

* fix: show live tool activity without timer resets

Show active tool rows before completion
Keep live duration stable during streaming updates

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-03-03 04:00:46 +02:00

1812 lines
69 KiB
TypeScript

import React from 'react';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { RiAiAgentLine, RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiExternalLinkLine, RiFileEditLine, RiFileList2Line, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck2, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiSurveyLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
import { File as PierreFile, PatchDiff } from '@pierre/diffs/react';
import { cn } from '@/lib/utils';
import { formatTimestampForDisplay } from '../timeFormat';
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers';
import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk/v2';
import { toolDisplayStyles } from '@/lib/typography';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { opencodeClient } from '@/lib/opencode/client';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import type { ToolPopupContent } from '../types';
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
import { getDefaultTheme } from '@/lib/theme/themes';
import {
renderListOutput,
renderGrepOutput,
renderGlobOutput,
renderTodoOutput,
renderWebSearchOutput,
formatEditOutput,
detectLanguageFromOutput,
formatInputForDisplay,
parseReadToolOutput,
} from '../toolRenderers';
import { DiffViewToggle, type DiffViewMode } from '../DiffViewToggle';
import { VirtualizedCodeBlock, type CodeLine } from './VirtualizedCodeBlock';
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } };
interface ToolPartProps {
part: ToolPartType;
isExpanded: boolean;
onToggle: (toolId: string) => void;
syntaxTheme: { [key: string]: React.CSSProperties };
isMobile: boolean;
onContentChange?: (reason?: ContentChangeReason) => void;
onShowPopup?: (content: ToolPopupContent) => void;
hasPrevTool?: boolean;
hasNextTool?: boolean;
}
// eslint-disable-next-line react-refresh/only-export-components
export const getToolIcon = (toolName: string) => {
const iconClass = 'h-3.5 w-3.5 flex-shrink-0';
const tool = toolName.toLowerCase();
if (tool === 'edit' || tool === 'multiedit' || tool === 'apply_patch' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
return <RiPencilLine className={iconClass} />;
}
if (tool === 'write' || tool === 'create' || tool === 'file_write') {
return <RiFileEditLine className={iconClass} />;
}
if (tool === 'read' || tool === 'view' || tool === 'file_read' || tool === 'cat') {
return <RiFileTextLine className={iconClass} />;
}
if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal') {
return <RiTerminalBoxLine className={iconClass} />;
}
if (tool === 'list' || tool === 'ls' || tool === 'dir' || tool === 'list_files') {
return <RiFolder6Line className={iconClass} />;
}
if (tool === 'search' || tool === 'grep' || tool === 'find' || tool === 'ripgrep') {
return <RiMenuSearchLine className={iconClass} />;
}
if (tool === 'glob') {
return <RiFileSearchLine className={iconClass} />;
}
if (tool === 'fetch' || tool === 'curl' || tool === 'wget' || tool === 'webfetch') {
return <RiGlobalLine className={iconClass} />;
}
if (
tool === 'web-search' ||
tool === 'websearch' ||
tool === 'search_web' ||
tool === 'codesearch' ||
tool === 'google' ||
tool === 'bing' ||
tool === 'duckduckgo' ||
tool === 'perplexity'
) {
return <RiGlobalLine className={iconClass} />;
}
if (tool === 'todowrite' || tool === 'todoread') {
return <RiListCheck3 className={iconClass} />;
}
if (tool === 'structuredoutput' || tool === 'structured_output') {
return <RiListCheck2 className={iconClass} />;
}
if (tool === 'skill') {
return <RiBookLine className={iconClass} />;
}
if (tool === 'task') {
return <RiAiAgentLine className={iconClass} />;
}
if (tool === 'question') {
return <RiSurveyLine className={iconClass} />;
}
if (tool === 'plan_enter') {
return <RiFileList2Line className={iconClass} />;
}
if (tool === 'plan_exit') {
return <RiTaskLine className={iconClass} />;
}
if (tool.startsWith('git')) {
return <RiGitBranchLine className={iconClass} />;
}
return <RiToolsLine className={iconClass} />;
};
const formatDuration = (start: number, end?: number, now: number = Date.now()) => {
const duration = end ? end - start : now - start;
const seconds = duration / 1000;
const displaySeconds = seconds < 0.05 && end !== undefined ? 0.1 : seconds;
return `${displaySeconds.toFixed(1)}s`;
};
const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> = ({ start, end, active }) => {
const [now, setNow] = React.useState(() => Date.now());
React.useEffect(() => {
if (!active) {
return;
}
const timer = window.setInterval(() => {
setNow(Date.now());
}, 100);
return () => window.clearInterval(timer);
}, [active]);
return <>{formatDuration(start, end, now)}</>;
};
const parseDiffStats = (metadata?: Record<string, unknown>): { added: number; removed: number } | null => {
if (!metadata?.diff || typeof metadata.diff !== 'string') return null;
const lines = metadata.diff.split('\n');
let added = 0;
let removed = 0;
for (const line of lines) {
if (line.startsWith('+') && !line.startsWith('+++')) added++;
if (line.startsWith('-') && !line.startsWith('---')) removed++;
}
if (added === 0 && removed === 0) return null;
return { added, removed };
};
const getRelativePath = (absolutePath: string, currentDirectory: string): string => {
if (absolutePath.startsWith(currentDirectory)) {
const relativePath = absolutePath.substring(currentDirectory.length);
return relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
}
return absolutePath;
};
const usePierreThemeConfig = () => {
const themeSystem = useOptionalThemeSystem();
const fallbackLightTheme = React.useMemo(() => getDefaultTheme(false), []);
const fallbackDarkTheme = React.useMemo(() => getDefaultTheme(true), []);
const availableThemes = React.useMemo(
() => themeSystem?.availableThemes ?? [fallbackLightTheme, fallbackDarkTheme],
[fallbackDarkTheme, fallbackLightTheme, themeSystem?.availableThemes],
);
const lightThemeId = themeSystem?.lightThemeId ?? fallbackLightTheme.metadata.id;
const darkThemeId = themeSystem?.darkThemeId ?? fallbackDarkTheme.metadata.id;
const lightTheme = React.useMemo(
() => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? fallbackLightTheme,
[availableThemes, fallbackLightTheme, lightThemeId],
);
const darkTheme = React.useMemo(
() => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? fallbackDarkTheme,
[availableThemes, darkThemeId, fallbackDarkTheme],
);
React.useEffect(() => {
ensurePierreThemeRegistered(lightTheme);
ensurePierreThemeRegistered(darkTheme);
}, [darkTheme, lightTheme]);
const currentVariant = themeSystem?.currentTheme.metadata.variant ?? 'light';
return {
pierreTheme: { light: lightTheme.metadata.id, dark: darkTheme.metadata.id },
pierreThemeType: currentVariant === 'dark' ? ('dark' as const) : ('light' as const),
};
};
// Parse question tool output: "User has answered your questions: "Q1"="A1", "Q2"="A2". You can now..."
const parseQuestionOutput = (output: string): Array<{ question: string; answer: string }> | null => {
const match = output.match(/^User has answered your questions:\s*(.+?)\.\s*You can now/s);
if (!match) return null;
const pairs: Array<{ question: string; answer: string }> = [];
const content = match[1];
// Match "question"="answer" pairs, handling multiline answers
const pairRegex = /"([^"]+)"="([^"]*(?:[^"\\]|\\.)*)"/g;
let pairMatch;
while ((pairMatch = pairRegex.exec(content)) !== null) {
pairs.push({
question: pairMatch[1],
answer: pairMatch[2],
});
}
return pairs.length > 0 ? pairs : null;
};
const formatStructuredOutputDescription = (input: Record<string, unknown> | undefined, output: unknown): string => {
if (typeof output === 'string' && output.trim().length > 0) {
const maxLength = 100;
const text = output.trim();
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
}
if (!input || typeof input !== 'object') {
return 'Result';
}
const rawValue = Object.prototype.hasOwnProperty.call(input, 'result') ? input.result : input;
const toPreview = (value: unknown): string => {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (Array.isArray(value)) {
const joined = value
.map((item) => (typeof item === 'string' ? item : JSON.stringify(item)))
.join(', ');
return joined;
}
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
if (typeof record.subject === 'string' && record.subject.trim().length > 0) {
return record.subject;
}
if (typeof record.title === 'string' && record.title.trim().length > 0) {
return record.title;
}
return JSON.stringify(value);
}
return '';
};
const preview = toPreview(rawValue).trim();
if (!preview) {
return 'Result';
}
const maxLength = 100;
const truncated = preview.length > maxLength ? `${preview.substring(0, maxLength)}...` : preview;
return truncated;
};
const getToolDescriptionPath = (part: ToolPartType, state: ToolStateUnion, currentDirectory: string): string | null => {
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const input = stateWithData.input;
if (part.tool === 'apply_patch') {
const files = Array.isArray(metadata?.files) ? metadata?.files : [];
const firstFile = files[0] as { relativePath?: string; filePath?: string } | undefined;
const filePath = firstFile?.relativePath || firstFile?.filePath;
if (files.length > 1) return null;
if (typeof filePath === 'string') {
return getRelativePath(filePath, currentDirectory);
}
return null;
}
if ((part.tool === 'edit' || part.tool === 'multiedit') && input) {
const filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
if (typeof filePath === 'string') {
return getRelativePath(filePath, currentDirectory);
}
}
if (['write', 'create', 'file_write', 'read', 'view', 'file_read', 'cat'].includes(part.tool) && input) {
const filePath = input?.filePath || input?.file_path || input?.path;
if (typeof filePath === 'string') {
return getRelativePath(filePath, currentDirectory);
}
}
return null;
};
const getToolDescription = (part: ToolPartType, state: ToolStateUnion, currentDirectory: string): string => {
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const input = stateWithData.input;
const tool = part.tool.toLowerCase();
if (tool === 'structuredoutput' || tool === 'structured_output') {
return formatStructuredOutputDescription(input, stateWithData.output);
}
const filePathLabel = getToolDescriptionPath(part, state, currentDirectory);
if (filePathLabel) {
return filePathLabel;
}
if (part.tool === 'apply_patch') {
const files = Array.isArray(metadata?.files) ? metadata?.files : [];
if (files.length > 1) {
return `${files.length} files`;
}
return 'Patch';
}
// Question tool: show "Asked N question(s)"
if (part.tool === 'question' && input?.questions && Array.isArray(input.questions)) {
const count = input.questions.length;
return `Asked ${count} question${count !== 1 ? 's' : ''}`;
}
if (part.tool === 'bash' && input?.command && typeof input.command === 'string') {
const firstLine = input.command.split('\n')[0];
return firstLine.substring(0, 100);
}
if (part.tool === 'task' && input?.description && typeof input.description === 'string') {
return input.description.substring(0, 80);
}
if (part.tool === 'skill' && input?.name && typeof input.name === 'string') {
return input.name;
}
if (part.tool === 'plan_enter') {
return 'Switching to planning';
}
if (part.tool === 'plan_exit') {
return 'Switching to building';
}
const desc = input?.description || metadata?.description || ('title' in state && state.title) || '';
return typeof desc === 'string' ? desc : '';
};
interface ToolScrollableSectionProps {
children: React.ReactNode;
maxHeightClass?: string;
className?: string;
outerClassName?: string;
disableHorizontal?: boolean;
}
const ToolScrollableSection: React.FC<ToolScrollableSectionProps> = ({
children,
maxHeightClass = 'max-h-[60vh]',
className,
outerClassName,
disableHorizontal = false,
}) => (
<ScrollableOverlay
outerClassName={cn('w-full min-w-0 flex-none overflow-hidden', maxHeightClass, outerClassName)}
className={cn('tool-output-surface p-2 rounded-xl w-full min-w-0 bg-transparent', className)}
style={{ borderWidth: '1px', borderColor: 'var(--tools-border)' }}
disableHorizontal={disableHorizontal}
>
<div className="w-full min-w-0">
{children}
</div>
</ScrollableOverlay>
);
type TaskToolSummaryEntry = {
id?: string;
tool?: string;
state?: {
status?: string;
title?: string;
};
};
type SessionMessageWithParts = {
info?: {
role?: string;
};
parts?: Array<{
id?: string;
type?: string;
tool?: string;
state?: {
status?: string;
title?: string;
};
}>;
};
const EMPTY_SESSION_MESSAGES: SessionMessageWithParts[] = [];
const readTaskSessionIdFromOutput = (output: string | undefined): string | undefined => {
if (typeof output !== 'string' || output.trim().length === 0) {
return undefined;
}
const parsedMetadata = parseTaskMetadataBlock(output);
if (parsedMetadata.sessionId) {
return parsedMetadata.sessionId;
}
const match = output.match(/task_id:\s*([a-zA-Z0-9_]+)/);
const candidate = match?.[1];
return typeof candidate === 'string' && candidate.trim().length > 0 ? candidate : undefined;
};
const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[]): TaskToolSummaryEntry[] => {
const entries: TaskToolSummaryEntry[] = [];
for (const message of messages) {
if (message?.info?.role !== 'assistant') {
continue;
}
const parts = Array.isArray(message.parts) ? message.parts : [];
for (const part of parts) {
if (part?.type !== 'tool') {
continue;
}
const toolName = typeof part.tool === 'string' ? part.tool.toLowerCase() : '';
if (!toolName || toolName === 'task' || toolName === 'todowrite' || toolName === 'todoread') {
continue;
}
entries.push({
id: part.id,
tool: part.tool,
state: {
status: part.state?.status,
title: part.state?.title,
},
});
}
}
return entries;
};
const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => {
const title = entry.state?.title;
if (typeof title === 'string' && title.trim().length > 0) {
return title;
}
if (typeof entry.tool === 'string' && entry.tool.trim().length > 0) {
return entry.tool;
}
return 'tool';
};
const FILE_PATH_LABEL_TOOLS = new Set([
'read',
'view',
'file_read',
'cat',
'write',
'create',
'file_write',
'edit',
'multiedit',
'apply_patch',
]);
const shouldRenderGitPathLabel = (toolName: string, label: string): boolean => {
if (!FILE_PATH_LABEL_TOOLS.has(toolName.toLowerCase())) {
return false;
}
const trimmed = label.trim();
if (!trimmed || trimmed === 'Patch' || /^\d+\s+files$/.test(trimmed)) {
return false;
}
return trimmed.includes('/') || trimmed.includes('\\');
};
const stripTaskMetadataFromOutput = (output: string): string => {
// Strip only a trailing <task_metadata>...</task_metadata> block.
return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
};
const normalizeTaskSummaryEntries = (value: unknown): TaskToolSummaryEntry[] => {
if (!Array.isArray(value)) {
return [];
}
const normalized: TaskToolSummaryEntry[] = [];
for (const entry of value) {
if (typeof entry === 'string') {
normalized.push({
tool: 'tool',
state: { status: 'completed', title: entry },
});
continue;
}
if (!entry || typeof entry !== 'object') {
continue;
}
const record = entry as {
id?: unknown;
tool?: unknown;
title?: unknown;
status?: unknown;
state?: { status?: unknown; title?: unknown };
};
const stateStatus = typeof record.state?.status === 'string' ? record.state.status : undefined;
const stateTitle = typeof record.state?.title === 'string' ? record.state.title : undefined;
const status = stateStatus ?? (typeof record.status === 'string' ? record.status : undefined);
const title = stateTitle ?? (typeof record.title === 'string' ? record.title : undefined);
normalized.push({
id: typeof record.id === 'string' ? record.id : undefined,
tool: typeof record.tool === 'string' ? record.tool : 'tool',
state: {
status,
title,
},
});
}
return normalized;
};
const parseTaskMetadataBlock = (output: string | undefined): {
sessionId?: string;
summaryEntries: TaskToolSummaryEntry[];
} => {
if (typeof output !== 'string' || output.trim().length === 0) {
return { summaryEntries: [] };
}
const blockMatch = output.match(/<task_metadata>\s*([\s\S]*?)\s*<\/task_metadata>/i);
if (!blockMatch?.[1]) {
return { summaryEntries: [] };
}
const raw = blockMatch[1].trim();
if (!raw) {
return { summaryEntries: [] };
}
try {
const parsed = JSON.parse(raw) as {
sessionId?: unknown;
sessionID?: unknown;
summary?: unknown;
entries?: unknown;
tools?: unknown;
calls?: unknown;
};
const summaryEntries = normalizeTaskSummaryEntries(
parsed.summary ?? parsed.entries ?? parsed.tools ?? parsed.calls
);
const sessionId =
(typeof parsed.sessionId === 'string' && parsed.sessionId.trim().length > 0
? parsed.sessionId.trim()
: undefined) ??
(typeof parsed.sessionID === 'string' && parsed.sessionID.trim().length > 0
? parsed.sessionID.trim()
: undefined);
return { sessionId, summaryEntries };
} catch {
return { summaryEntries: [] };
}
};
const TaskToolSummary: React.FC<{
entries: TaskToolSummaryEntry[];
isExpanded: boolean;
isMobile: boolean;
hasPrevTool: boolean;
hasNextTool: boolean;
output?: string;
sessionId?: string;
onShowPopup?: (content: ToolPopupContent) => void;
input?: Record<string, unknown>;
}> = ({ entries, isExpanded, isMobile, hasPrevTool, hasNextTool, output, sessionId, onShowPopup, input }) => {
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
const displayEntries = React.useMemo(() => {
const nonPending = entries.filter((entry) => entry.state?.status !== 'pending');
return nonPending.length > 0 ? nonPending : entries;
}, [entries]);
const trimmedOutput = typeof output === 'string'
? stripTaskMetadataFromOutput(output)
: '';
const hasOutput = trimmedOutput.length > 0;
const [isOutputExpanded, setIsOutputExpanded] = React.useState(false);
const handleOpenSession = (event: React.MouseEvent) => {
event.stopPropagation();
if (sessionId) {
setCurrentSession(sessionId);
}
};
const agentType = typeof input?.subagent_type === 'string'
? input.subagent_type
: 'subagent';
if (displayEntries.length === 0 && !hasOutput && !sessionId) {
return null;
}
const visibleEntries = isExpanded ? displayEntries : displayEntries.slice(-6);
const hiddenCount = Math.max(0, displayEntries.length - visibleEntries.length);
return (
<div
className={cn(
'relative pr-2 pb-2 pt-2 space-y-2 pl-[1.4375rem]',
'before:absolute before:left-[0.4375rem] before:w-px before:bg-border/80 before:content-[""]',
hasPrevTool ? 'before:top-[-0.45rem]' : 'before:top-[-0.25rem]',
hasNextTool ? 'before:bottom-[-0.6rem]' : 'before:bottom-0'
)}
>
{displayEntries.length > 0 ? (
<ToolScrollableSection maxHeightClass={isExpanded ? 'max-h-[40vh]' : 'max-h-56'} disableHorizontal>
<div className="w-full min-w-0 space-y-1">
{hiddenCount > 0 ? (
<div className="typography-micro text-muted-foreground/70">+{hiddenCount} more</div>
) : null}
{visibleEntries.map((entry, idx) => {
const toolName = typeof entry.tool === 'string' && entry.tool.trim().length > 0 ? entry.tool : 'tool';
const label = getTaskSummaryLabel(entry);
const status = entry.state?.status;
const displayName = getToolMetadata(toolName).displayName;
return (
<div key={entry.id ?? `${toolName}-${idx}`} className={cn("flex gap-2 min-w-0 w-full", isMobile ? 'items-start' : 'items-center')}>
<span className="flex-shrink-0 text-foreground/80">{getToolIcon(toolName)}</span>
<span className="typography-meta text-foreground/80 flex-shrink-0">{displayName}</span>
{status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? (
renderPathLikeGitChanges(label)
) : (
<span className={cn(
'typography-meta flex-1 min-w-0',
isMobile ? 'whitespace-normal break-words' : 'truncate',
status === 'error' ? 'text-[var(--status-error)]' : 'text-muted-foreground/70'
)}>{label}</span>
)}
</div>
);
})}
</div>
</ToolScrollableSection>
) : null}
{sessionId && (
<button
type="button"
className="flex items-center gap-2 typography-meta text-primary hover:text-primary/80 w-full"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleOpenSession}
>
<RiExternalLinkLine className="h-3.5 w-3.5 flex-shrink-0" />
<span className="typography-meta text-primary font-medium">Open {agentType.charAt(0).toUpperCase() + agentType.slice(1)} subtask</span>
</button>
)}
{hasOutput ? (
<div className={cn('space-y-1', (displayEntries.length > 0 || sessionId) && 'pt-1')}
>
<button
type="button"
className="flex items-center gap-2 typography-meta text-foreground/80 hover:text-foreground w-full"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
setIsOutputExpanded((prev) => !prev);
}}
>
{isOutputExpanded ? (
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0" />
) : (
<RiArrowRightSLine className="h-3.5 w-3.5 flex-shrink-0" />
)}
<span className="typography-meta text-foreground/80 font-medium">Output</span>
</button>
{isOutputExpanded ? (
<ToolScrollableSection maxHeightClass="max-h-[50vh]">
<div className="w-full min-w-0">
<SimpleMarkdownRenderer content={trimmedOutput} variant="tool" onShowPopup={onShowPopup} />
</div>
</ToolScrollableSection>
) : null}
</div>
) : null}
</div>
);
};
interface DiffPreviewProps {
diff: string;
pierreTheme: { light: string; dark: string };
pierreThemeType: 'light' | 'dark';
diffViewMode: DiffViewMode;
}
const TOOL_DIFF_UNSAFE_CSS = `
[data-diff-header],
[data-diff] {
[data-separator] {
height: 24px !important;
}
}
`;
const TOOL_DIFF_METRICS = {
hunkLineCount: 50,
lineHeight: 24,
diffHeaderHeight: 44,
hunkSeparatorHeight: 24,
fileGap: 0,
};
type DiffPatchEntry = {
id: string;
title: string;
patch: string;
};
const renderPathLikeGitChanges = (path: string, grow = true) => {
const lastSlash = path.lastIndexOf('/');
if (lastSlash === -1) {
return (
<span
className={cn('min-w-0 truncate typography-ui-label text-foreground', grow && 'flex-1')}
style={{ direction: 'rtl', textAlign: 'left' }}
title={path}
>
{path}
</span>
);
}
const dir = path.slice(0, lastSlash);
const name = path.slice(lastSlash + 1);
return (
<span className={cn('min-w-0 flex items-baseline overflow-hidden typography-ui-label', grow && 'flex-1')} title={path}>
<span className="min-w-0 truncate text-muted-foreground" style={{ direction: 'rtl', textAlign: 'left' }}>
{dir}
</span>
<span className="flex-shrink-0">
<span className="text-muted-foreground">/</span>
<span className="text-foreground">{name}</span>
</span>
</span>
);
};
const getDiffPatchEntries = (
metadata: Record<string, unknown> | undefined,
fallbackDiff: string,
currentDirectory: string,
): DiffPatchEntry[] => {
const files = Array.isArray(metadata?.files) ? metadata.files : [];
const entries = files
.map((file, index) => {
if (!file || typeof file !== 'object') {
return null;
}
const record = file as { relativePath?: unknown; filePath?: unknown; diff?: unknown };
const patch = typeof record.diff === 'string' ? record.diff.trim() : '';
if (!patch) {
return null;
}
const rawPath = typeof record.relativePath === 'string'
? record.relativePath
: typeof record.filePath === 'string'
? record.filePath
: `File ${index + 1}`;
const title = typeof rawPath === 'string'
? getRelativePath(rawPath, currentDirectory)
: `File ${index + 1}`;
return {
id: `${title}-${index}`,
title,
patch,
} satisfies DiffPatchEntry;
})
.filter((entry): entry is DiffPatchEntry => entry !== null);
if (entries.length > 0) {
return entries;
}
return [
{
id: 'diff-0',
title: 'Diff',
patch: fallbackDiff,
},
];
};
const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, pierreTheme, pierreThemeType, diffViewMode }) => {
return (
<div className="typography-code px-1 pb-1 pt-0">
<PatchDiff
patch={diff}
metrics={TOOL_DIFF_METRICS}
options={{
diffStyle: diffViewMode === 'side-by-side' ? 'split' : 'unified',
diffIndicators: 'none',
hunkSeparators: 'line-info-basic',
lineDiffType: 'none',
disableFileHeader: true,
maxLineDiffLength: 1000,
expansionLineCount: 20,
overflow: 'wrap',
theme: pierreTheme,
themeType: pierreThemeType,
unsafeCSS: TOOL_DIFF_UNSAFE_CSS,
}}
className="block w-full"
/>
</div>
);
});
DiffPreview.displayName = 'DiffPreview';
interface WriteInputPreviewProps {
content: string;
filePath?: string;
displayPath: string;
pierreTheme: { light: string; dark: string };
pierreThemeType: 'light' | 'dark';
}
const WriteInputPreview: React.FC<WriteInputPreviewProps> = React.memo(({
content,
filePath,
displayPath,
pierreTheme,
pierreThemeType,
}) => {
const language = React.useMemo(
() => getLanguageFromExtension(filePath ?? '') || detectLanguageFromOutput(content, 'write', filePath ? { filePath } : undefined),
[content, filePath]
);
const lineCount = Math.max(content.split('\n').length, 1);
const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`;
return (
<div className="w-full min-w-0">
<div className="bg-muted/20 px-2 py-1 rounded-lg mb-1 flex items-center gap-2 min-w-0" style={{ borderWidth: '1px', borderColor: 'var(--tools-border)' }}>
{renderPathLikeGitChanges(displayPath)}
<span className="typography-meta text-muted-foreground/80 flex-shrink-0">({headerLineLabel})</span>
</div>
<PierreFile
file={{
name: displayPath,
contents: content,
lang: language || undefined,
}}
options={{
disableFileHeader: true,
overflow: 'wrap',
theme: pierreTheme,
themeType: pierreThemeType,
}}
className="block w-full"
/>
</div>
);
});
WriteInputPreview.displayName = 'WriteInputPreview';
// ── PERF-007: Read tool output with virtualised highlighting ─────────
interface ReadToolVirtualizedProps {
outputString: string;
input?: Record<string, unknown>;
syntaxTheme: { [key: string]: React.CSSProperties };
toolName: string;
currentDirectory: string;
pierreTheme: { light: string; dark: string };
pierreThemeType: 'light' | 'dark';
renderScrollableBlock: (
content: React.ReactNode,
options?: { maxHeightClass?: string; className?: string; disableHorizontal?: boolean; outerClassName?: string }
) => React.ReactNode;
}
const ReadToolVirtualized: React.FC<ReadToolVirtualizedProps> = React.memo(({
outputString,
input,
syntaxTheme,
toolName,
currentDirectory,
pierreTheme,
pierreThemeType,
renderScrollableBlock,
}) => {
const parsedReadOutput = React.useMemo(() => parseReadToolOutput(outputString), [outputString]);
const language = React.useMemo(() => {
const contentForLanguage = parsedReadOutput.lines.map((l) => l.text).join('\n');
return detectLanguageFromOutput(contentForLanguage, toolName, input as Record<string, unknown>);
}, [parsedReadOutput, toolName, input]);
const rawFilePath =
typeof input?.filePath === 'string'
? input.filePath
: typeof input?.file_path === 'string'
? input.file_path
: typeof input?.path === 'string'
? input.path
: 'read-output';
const displayPath = getRelativePath(rawFilePath, currentDirectory);
const codeLines: CodeLine[] = React.useMemo(() => parsedReadOutput.lines.map((line) => ({
text: line.text,
lineNumber: line.lineNumber,
isInfo: line.isInfo,
})), [parsedReadOutput]);
if (parsedReadOutput.type === 'file') {
const fileContent = parsedReadOutput.lines.map((line) => line.text).join('\n');
const lineCount = Math.max(parsedReadOutput.lines.length, 1);
const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`;
return renderScrollableBlock(
<div className="w-full min-w-0">
<div className="bg-muted/20 px-2 py-1 rounded-lg mb-1 flex items-center gap-2 min-w-0" style={{ borderWidth: '1px', borderColor: 'var(--tools-border)' }}>
{renderPathLikeGitChanges(displayPath)}
<span className="typography-meta text-muted-foreground/80 flex-shrink-0">({headerLineLabel})</span>
</div>
<PierreFile
file={{
name: displayPath,
contents: fileContent,
lang: language || undefined,
}}
options={{
disableFileHeader: true,
overflow: 'wrap',
theme: pierreTheme,
themeType: pierreThemeType,
}}
className="block w-full"
/>
</div>,
{ className: 'p-1' }
) as React.ReactElement;
}
return renderScrollableBlock(
<VirtualizedCodeBlock
lines={codeLines}
language={language}
syntaxTheme={syntaxTheme}
maxHeight="55vh"
/>,
{ className: 'p-1' }
) as React.ReactElement;
});
ReadToolVirtualized.displayName = 'ReadToolVirtualized';
interface ImagePreviewProps {
content: string;
filePath: string;
displayPath: string;
}
const ImagePreview: React.FC<ImagePreviewProps> = React.memo(({ content, filePath, displayPath }) => {
const mimeType = getImageMimeType(filePath);
const isSvg = filePath.toLowerCase().endsWith('.svg');
// For SVG, content might be raw XML, otherwise assume base64
const imageSrc = React.useMemo(() => {
if (isSvg && !content.startsWith('data:')) {
// Raw SVG content
return `data:image/svg+xml;base64,${btoa(content)}`;
}
if (content.startsWith('data:')) {
return content;
}
// Assume base64 encoded
return `data:${mimeType};base64,${content}`;
}, [content, mimeType, isSvg]);
return (
<div className="w-full min-w-0">
<div className="bg-muted/20 px-2 py-1 rounded-lg mb-2 flex items-center min-w-0" style={{ borderWidth: '1px', borderColor: 'var(--tools-border)' }}>
{renderPathLikeGitChanges(displayPath)}
</div>
<div className="flex justify-center p-4 bg-muted/10 rounded-lg" style={{ borderWidth: '1px', borderColor: 'var(--tools-border)' }}>
<img
src={imageSrc}
alt={displayPath}
className="max-w-full max-h-96 object-contain rounded"
style={{ imageRendering: 'auto' }}
/>
</div>
</div>
);
});
ImagePreview.displayName = 'ImagePreview';
interface ToolExpandedContentProps {
part: ToolPartType;
state: ToolStateUnion;
syntaxTheme: { [key: string]: React.CSSProperties };
isMobile: boolean;
currentDirectory: string;
onShowPopup?: (content: ToolPopupContent) => void;
hasPrevTool: boolean;
hasNextTool: boolean;
}
const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
part,
state,
syntaxTheme,
isMobile,
currentDirectory,
onShowPopup,
hasPrevTool,
hasNextTool,
}) => {
const { pierreTheme, pierreThemeType } = usePierreThemeConfig();
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>('unified');
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const input = stateWithData.input;
const rawOutput = stateWithData.output;
const hasStringOutput = typeof rawOutput === 'string' && rawOutput.length > 0;
const outputString = typeof rawOutput === 'string' ? rawOutput : '';
const diffContent = typeof metadata?.diff === 'string' ? (metadata.diff as string) : null;
const diffEntries = React.useMemo(
() => (diffContent ? getDiffPatchEntries(metadata, diffContent, currentDirectory) : []),
[currentDirectory, diffContent, metadata]
);
const writeFilePath = part.tool === 'write'
? typeof input?.filePath === 'string'
? input.filePath
: typeof input?.file_path === 'string'
? input.file_path
: typeof input?.path === 'string'
? input.path
: undefined
: undefined;
const writeInputContent = part.tool === 'write'
? typeof (input as { content?: unknown })?.content === 'string'
? (input as { content?: string }).content
: typeof (input as { text?: unknown })?.text === 'string'
? (input as { text?: string }).text
: null
: null;
const shouldShowWriteInputPreview = part.tool === 'write' && !!writeInputContent;
const isWriteImageFile = writeFilePath ? isImageFile(writeFilePath) : false;
const writeDisplayPath = shouldShowWriteInputPreview
? (writeFilePath ? getRelativePath(writeFilePath, currentDirectory) : 'New file')
: null;
const inputTextContent = React.useMemo(() => {
if (!input || typeof input !== 'object' || Object.keys(input).length === 0) {
return '';
}
if ('command' in input && typeof input.command === 'string' && part.tool === 'bash') {
return formatInputForDisplay(input, part.tool);
}
if (typeof (input as { content?: unknown }).content === 'string') {
return (input as { content?: string }).content ?? '';
}
return formatInputForDisplay(input, part.tool);
}, [input, part.tool]);
const hasInputText = part.tool !== 'apply_patch' && inputTextContent.trim().length > 0;
React.useEffect(() => {
setDiffViewMode('unified');
}, [part.id]);
const renderScrollableBlock = (
content: React.ReactNode,
options?: { maxHeightClass?: string; className?: string; disableHorizontal?: boolean; outerClassName?: string }
) => (
<ToolScrollableSection
maxHeightClass={options?.maxHeightClass}
className={options?.className}
disableHorizontal={options?.disableHorizontal}
outerClassName={options?.outerClassName}
>
{content}
</ToolScrollableSection>
);
const renderResultContent = () => {
// Question tool: show parsed Q&A summary
if (part.tool === 'question') {
if (state.status === 'completed' && hasStringOutput) {
const parsedQA = parseQuestionOutput(outputString);
if (parsedQA && parsedQA.length > 0) {
return renderScrollableBlock(
<div className="space-y-2">
{parsedQA.map((qa, index) => (
<div key={index} className="space-y-0.5">
<div className="typography-micro text-muted-foreground">{qa.question}</div>
<div className="typography-meta text-foreground whitespace-pre-wrap">{qa.answer}</div>
</div>
))}
</div>,
{ maxHeightClass: 'max-h-[40vh]' }
);
}
}
if (state.status === 'error' && 'error' in state) {
return (
<div>
<div className="typography-meta font-medium text-muted-foreground mb-1">Error:</div>
<div className="typography-meta p-2 rounded-xl border" style={{
backgroundColor: 'var(--status-error-background)',
color: 'var(--status-error)',
borderColor: 'var(--status-error-border)',
}}>
{state.error}
</div>
</div>
);
}
return <div className="typography-meta text-muted-foreground">Awaiting response...</div>;
}
if (part.tool === 'todowrite' || part.tool === 'todoread') {
if (state.status === 'completed' && hasStringOutput) {
const todoContent = renderTodoOutput(outputString, { unstyled: true });
return renderScrollableBlock(
todoContent ?? (
<div className="typography-meta text-muted-foreground">Unable to parse todo list</div>
)
);
}
if (state.status === 'error' && 'error' in state) {
return (
<div>
<div className="typography-meta font-medium text-muted-foreground mb-1">Error:</div>
<div className="typography-meta p-2 rounded-xl border" style={{
backgroundColor: 'var(--status-error-background)',
color: 'var(--status-error)',
borderColor: 'var(--status-error-border)',
}}>
{state.error}
</div>
</div>
);
}
return <div className="typography-meta text-muted-foreground">Processing todo list...</div>;
}
if (part.tool === 'list' && hasStringOutput) {
const listOutput = renderListOutput(outputString, { unstyled: true });
return renderScrollableBlock(
listOutput ?? (
<pre className="typography-code font-mono whitespace-pre-wrap break-words w-full min-w-0">
{outputString}
</pre>
)
);
}
if (part.tool === 'grep' && hasStringOutput) {
const grepOutput = renderGrepOutput(outputString, isMobile, { unstyled: true });
return renderScrollableBlock(
grepOutput ?? (
<pre className="typography-code font-mono whitespace-pre-wrap break-words w-full min-w-0">
{outputString}
</pre>
)
);
}
if (part.tool === 'glob' && hasStringOutput) {
const globOutput = renderGlobOutput(outputString, isMobile, { unstyled: true });
return renderScrollableBlock(
globOutput ?? (
<pre className="typography-code font-mono whitespace-pre-wrap break-words w-full min-w-0">
{outputString}
</pre>
)
);
}
if (part.tool === 'task' && hasStringOutput) {
return renderScrollableBlock(
<div className="w-full min-w-0">
<SimpleMarkdownRenderer content={outputString} variant="tool" onShowPopup={onShowPopup} />
</div>
);
}
if ((part.tool === 'web-search' || part.tool === 'websearch' || part.tool === 'search_web') && hasStringOutput) {
const webSearchContent = renderWebSearchOutput(outputString, syntaxTheme, { unstyled: true });
return renderScrollableBlock(
webSearchContent ?? (
<pre className="typography-code font-mono whitespace-pre-wrap break-words w-full min-w-0">
{outputString}
</pre>
)
);
}
if (part.tool === 'codesearch' && hasStringOutput) {
return renderScrollableBlock(
<div className="w-full min-w-0">
<SimpleMarkdownRenderer content={outputString} variant="tool" onShowPopup={onShowPopup} />
</div>
);
}
if (part.tool === 'skill' && hasStringOutput) {
return renderScrollableBlock(
<div className="w-full min-w-0">
<SimpleMarkdownRenderer content={outputString} variant="tool" onShowPopup={onShowPopup} />
</div>
);
}
if ((part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') && diffEntries.length > 0) {
return renderScrollableBlock(
<div className="space-y-3">
{diffEntries.map((entry) => (
<div key={entry.id} className="w-full min-w-0">
{diffEntries.length > 1 ? (
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground rounded-lg mb-1" style={{ borderWidth: '1px', borderColor: 'var(--tools-border)' }}>
{renderPathLikeGitChanges(entry.title)}
</div>
) : null}
<DiffPreview
diff={entry.patch}
pierreTheme={pierreTheme}
pierreThemeType={pierreThemeType}
diffViewMode={diffViewMode}
/>
</div>
))}
</div>,
{ className: 'p-1' }
);
}
if (hasStringOutput && outputString.trim()) {
if (part.tool === 'read') {
return <ReadToolVirtualized
outputString={outputString}
input={input}
syntaxTheme={syntaxTheme}
toolName={part.tool}
currentDirectory={currentDirectory}
pierreTheme={pierreTheme}
pierreThemeType={pierreThemeType}
renderScrollableBlock={renderScrollableBlock}
/>;
}
return renderScrollableBlock(
<SyntaxHighlighter
style={syntaxTheme}
language={detectLanguageFromOutput(formatEditOutput(outputString, part.tool, metadata), part.tool, input)}
PreTag="div"
customStyle={{
...toolDisplayStyles.getCollapsedStyles(),
padding: 0,
overflow: 'visible',
}}
codeTagProps={{
style: {
background: 'transparent',
backgroundColor: 'transparent',
},
}}
wrapLongLines
>
{formatEditOutput(outputString, part.tool, metadata)}
</SyntaxHighlighter>,
{ className: 'p-1' }
);
}
return renderScrollableBlock(
<div className="typography-meta text-muted-foreground/70">No output produced</div>,
{ maxHeightClass: 'max-h-60' }
);
};
return (
<div
className={cn(
'relative pr-2 pb-2 pt-2 space-y-2 pl-[1.4375rem]'
)}
>
<div
className="absolute left-[0.4375rem] w-px"
style={{
backgroundColor: 'var(--tools-border)',
top: hasPrevTool ? '-0.45rem' : '-0.25rem',
bottom: hasNextTool ? '-0.6rem' : '0',
width: '1px'
}}
></div>
{(part.tool === 'todowrite' || part.tool === 'todoread' || part.tool === 'question') ? (
renderResultContent()
) : (
<>
{shouldShowWriteInputPreview && isWriteImageFile ? (
<div className="my-1">
{renderScrollableBlock(
<ImagePreview
content={writeInputContent as string}
filePath={writeFilePath as string}
displayPath={writeDisplayPath ?? 'New file'}
/>
)}
</div>
) : shouldShowWriteInputPreview ? (
<div className="my-1">
{renderScrollableBlock(
<WriteInputPreview
content={writeInputContent as string}
filePath={writeFilePath}
displayPath={writeDisplayPath ?? 'New file'}
pierreTheme={pierreTheme}
pierreThemeType={pierreThemeType}
/>
)}
</div>
) : hasInputText ? (
<div className="my-1">
{renderScrollableBlock(
<blockquote className="tool-input-text whitespace-pre-wrap break-words typography-meta italic text-muted-foreground/70">
{inputTextContent}
</blockquote>,
{ maxHeightClass: 'max-h-60', className: 'tool-input-surface' }
)}
</div>
) : null}
{part.tool !== 'write' && state.status === 'completed' && 'output' in state && (
<div>
<div className="mb-1 flex items-center justify-between gap-2">
<div className="typography-meta font-medium text-muted-foreground/80">
Result:
</div>
{(part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') && diffContent ? (
<DiffViewToggle
mode={diffViewMode}
onModeChange={setDiffViewMode}
className="h-5 w-5 p-0"
/>
) : null}
</div>
{renderResultContent()}
</div>
)}
{state.status === 'error' && 'error' in state && (
<div>
<div className="typography-meta font-medium text-muted-foreground/80 mb-1">Error:</div>
<div className="typography-meta p-2 rounded-xl border" style={{
backgroundColor: 'var(--status-error-background)',
color: 'var(--status-error)',
borderColor: 'var(--status-error-border)',
}}>
{state.error}
</div>
</div>
)}
</>
)}
</div>
);
});
ToolExpandedContent.displayName = 'ToolExpandedContent';
const ToolPart: React.FC<ToolPartProps> = ({
part,
isExpanded,
onToggle,
syntaxTheme,
isMobile,
onContentChange,
onShowPopup,
hasPrevTool = false,
hasNextTool = false,
}) => {
const state = part.state;
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const isTaskTool = part.tool.toLowerCase() === 'task';
const status = state.status as string | undefined;
const isFinalized = status === 'completed' || status === 'error';
const isActive = status === 'running' || status === 'pending' || status === 'started';
const isError = state.status === 'error';
const shouldNotifyStructuralChange = isFinalized || isTaskTool;
React.useEffect(() => {
if (!shouldNotifyStructuralChange) {
return;
}
if (typeof isExpanded === 'boolean') {
onContentChange?.('structural');
}
}, [isExpanded, onContentChange, shouldNotifyStructuralChange]);
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const input = stateWithData.input;
const time = stateWithData.time;
const [pinnedTime, setPinnedTime] = React.useState<{ start?: number; end?: number }>({});
React.useEffect(() => {
setPinnedTime({});
}, [part.id]);
React.useEffect(() => {
setPinnedTime((prev) => {
const next = { ...prev };
let changed = false;
if (typeof time?.start === 'number' && (typeof prev.start !== 'number' || time.start < prev.start)) {
next.start = time.start;
changed = true;
}
if (typeof time?.end === 'number' && prev.end !== time.end) {
next.end = time.end;
changed = true;
}
return changed ? next : prev;
});
}, [time?.end, time?.start]);
const effectiveTimeStart = pinnedTime.start ?? time?.start;
const effectiveTimeEnd = pinnedTime.end ?? time?.end;
const endedTimestampText = React.useMemo(() => {
if (typeof effectiveTimeEnd !== 'number' || !Number.isFinite(effectiveTimeEnd)) {
return null;
}
const formatted = formatTimestampForDisplay(effectiveTimeEnd);
return formatted.length > 0 ? formatted : null;
}, [effectiveTimeEnd]);
const taskOutputString = React.useMemo(() => {
return typeof stateWithData.output === 'string' ? stateWithData.output : undefined;
}, [stateWithData.output]);
const parsedTaskMetadata = React.useMemo(() => {
return parseTaskMetadataBlock(taskOutputString);
}, [taskOutputString]);
const taskSessionId = React.useMemo<string | undefined>(() => {
if (!isTaskTool) {
return undefined;
}
const candidate = metadata as { sessionId?: string } | undefined;
if (typeof candidate?.sessionId === 'string' && candidate.sessionId.trim().length > 0) {
return candidate.sessionId;
}
if (parsedTaskMetadata.sessionId) {
return parsedTaskMetadata.sessionId;
}
return readTaskSessionIdFromOutput(taskOutputString);
}, [isTaskTool, metadata, parsedTaskMetadata.sessionId, taskOutputString]);
const childSessionMessages = useSessionStore(
React.useCallback((store) => {
if (!taskSessionId) {
return EMPTY_SESSION_MESSAGES;
}
return (store.messages.get(taskSessionId) as SessionMessageWithParts[] | undefined) ?? EMPTY_SESSION_MESSAGES;
}, [taskSessionId])
);
const metadataTaskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
if (!isTaskTool) {
return [];
}
const candidateSummary = (metadata as { summary?: unknown; entries?: unknown; tools?: unknown; calls?: unknown } | undefined);
const normalized = normalizeTaskSummaryEntries(
candidateSummary?.summary ?? candidateSummary?.entries ?? candidateSummary?.tools ?? candidateSummary?.calls
);
if (normalized.length > 0) {
return normalized;
}
return parsedTaskMetadata.summaryEntries;
}, [isTaskTool, metadata, parsedTaskMetadata.summaryEntries]);
const childSessionTaskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
if (!isTaskTool || !taskSessionId) {
return [];
}
if (!Array.isArray(childSessionMessages) || childSessionMessages.length === 0) {
return [];
}
return buildTaskSummaryEntriesFromSession(childSessionMessages);
}, [childSessionMessages, isTaskTool, taskSessionId]);
const taskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
if (childSessionTaskSummaryEntries.length > 0) {
return childSessionTaskSummaryEntries;
}
return metadataTaskSummaryEntries;
}, [childSessionTaskSummaryEntries, metadataTaskSummaryEntries]);
const fetchedTaskSessionsRef = React.useRef<Set<string>>(new Set());
React.useEffect(() => {
if (!isTaskTool || !taskSessionId) {
return;
}
if (childSessionTaskSummaryEntries.length > 0) {
return;
}
if (fetchedTaskSessionsRef.current.has(taskSessionId)) {
return;
}
fetchedTaskSessionsRef.current.add(taskSessionId);
let cancelled = false;
void opencodeClient
.getSessionMessages(taskSessionId, 500)
.then((messages) => {
if (cancelled || !Array.isArray(messages)) {
return;
}
if (messages.length === 0) {
fetchedTaskSessionsRef.current.delete(taskSessionId);
return;
}
useSessionStore.getState().syncMessages(taskSessionId, messages);
})
.catch(() => {
fetchedTaskSessionsRef.current.delete(taskSessionId);
});
return () => {
cancelled = true;
};
}, [childSessionTaskSummaryEntries.length, isTaskTool, taskSessionId]);
const taskSummaryLenRef = React.useRef<number>(taskSummaryEntries.length);
React.useEffect(() => {
if (!isTaskTool) {
return;
}
if (taskSummaryLenRef.current === taskSummaryEntries.length) {
return;
}
taskSummaryLenRef.current = taskSummaryEntries.length;
onContentChange?.('structural');
}, [isTaskTool, onContentChange, taskSummaryEntries.length]);
const diffStats = (part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') ? parseDiffStats(metadata) : null;
const descriptionPath = getToolDescriptionPath(part, state, currentDirectory);
const description = getToolDescription(part, state, currentDirectory);
const displayName = getToolMetadata(part.tool).displayName;
// Get justification text (tool title/description) when setting is enabled
const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity);
const justificationText = React.useMemo(() => {
if (!showTextJustificationActivity) return null;
if (part.tool === 'apply_patch') return null;
if (part.tool.toLowerCase() === 'structuredoutput' || part.tool.toLowerCase() === 'structured_output') return null;
// Get title or description from state - this is the "yapping" text like "Shows system information"
const title = (stateWithData as { title?: string }).title;
if (typeof title === 'string' && title.trim().length > 0) {
return title;
}
const inputDesc = input?.description;
if (typeof inputDesc === 'string' && inputDesc.trim().length > 0) {
return inputDesc;
}
return null;
}, [showTextJustificationActivity, part.tool, stateWithData, input]);
const runtime = React.useContext(RuntimeAPIContext);
const handleMainClick = (e: { stopPropagation: () => void }) => {
if (isTaskTool || !runtime?.editor) {
onToggle(part.id);
return;
}
let filePath: unknown;
if (part.tool === 'edit' || part.tool === 'multiedit') {
filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
} else if (part.tool === 'apply_patch') {
const files = Array.isArray(metadata?.files) ? metadata?.files : [];
const firstFile = files[0] as { relativePath?: string; filePath?: string } | undefined;
filePath = firstFile?.relativePath || firstFile?.filePath;
} else if (['write', 'create', 'file_write', 'read', 'view', 'file_read', 'cat'].includes(part.tool)) {
filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
}
if (typeof filePath === 'string') {
e.stopPropagation();
let absolutePath = filePath;
if (!filePath.startsWith('/')) {
absolutePath = currentDirectory.endsWith('/') ? currentDirectory + filePath : currentDirectory + '/' + filePath;
}
runtime.editor.openFile(absolutePath);
} else {
onToggle(part.id);
}
};
const handleMainKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
handleMainClick(event);
};
if (!isFinalized && !isActive && !isTaskTool) {
return null;
}
return (
<div className="my-1">
{}
<div
className={cn(
'group/tool flex items-center gap-2 pr-2 pl-px py-1.5 rounded-xl cursor-pointer'
)}
onClick={handleMainClick}
onKeyDown={handleMainKeyDown}
role="button"
tabIndex={0}
>
<div className="flex items-center gap-2 flex-shrink-0">
{}
<div
className="relative h-3.5 w-3.5 flex-shrink-0"
onClick={(event) => { event.stopPropagation(); onToggle(part.id); }}
>
{}
<div
className={cn(
'absolute inset-0 transition-opacity',
isExpanded && 'opacity-0',
!isExpanded && 'group-hover/tool:opacity-0'
)}
style={!isTaskTool && isError ? { color: 'var(--status-error)' } : { color: 'var(--tools-icon)' }}
>
{getToolIcon(part.tool)}
</div>
{}
<div
className={cn(
'absolute inset-0 transition-opacity flex items-center justify-center',
isExpanded && 'opacity-100',
!isExpanded && 'opacity-0 group-hover/tool:opacity-100'
)}
>
{isExpanded ? <RiArrowDownSLine className="h-3.5 w-3.5" /> : <RiArrowRightSLine className="h-3.5 w-3.5" />}
</div>
</div>
<span
className="typography-meta font-medium"
style={!isTaskTool && isError ? { color: 'var(--status-error)' } : { color: 'var(--tools-title)' }}
>
{displayName}
</span>
</div>
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta" style={{ color: 'var(--tools-description)' }}>
<div className="flex items-center gap-1 flex-1 min-w-0">
{justificationText && (
<span className="min-w-0 truncate" style={{ color: 'var(--tools-description)', opacity: 0.8 }}>
{justificationText}
</span>
)}
{!justificationText && description && (
descriptionPath && description === descriptionPath ? (
renderPathLikeGitChanges(descriptionPath, false)
) : (
<span className="min-w-0 truncate">
{description}
</span>
)
)}
{diffStats && (
<span className="text-muted-foreground/60 flex-shrink-0">
<span style={{ color: 'var(--status-success)' }}>+{diffStats.added}</span>
{' '}
<span style={{ color: 'var(--status-error)' }}>-{diffStats.removed}</span>
</span>
)}
</div>
{typeof effectiveTimeStart === 'number' ? (
<span className="ml-auto relative flex-shrink-0 tabular-nums text-right">
<span
className={cn(
'text-muted-foreground/80 transition-opacity duration-150',
!isMobile && endedTimestampText && 'group-hover/tool:opacity-0'
)}
>
<LiveDuration
start={effectiveTimeStart}
end={typeof effectiveTimeEnd === 'number' ? effectiveTimeEnd : undefined}
active={Boolean(isActive && typeof effectiveTimeEnd !== 'number')}
/>
</span>
{!isMobile && endedTimestampText ? (
<span
className={cn(
'pointer-events-none absolute right-0 top-0 whitespace-nowrap text-muted-foreground/70 transition-opacity duration-150',
'opacity-0 group-hover/tool:opacity-100'
)}
>
{endedTimestampText}
</span>
) : null}
</span>
) : null}
{typeof effectiveTimeStart !== 'number' && !isMobile && endedTimestampText ? (
<span className="ml-auto text-muted-foreground/70 flex-shrink-0 tabular-nums">
{endedTimestampText}
</span>
) : null}
</div>
</div>
{}
{isTaskTool && (taskSummaryEntries.length > 0 || isActive || isFinalized || taskSessionId) ? (
<TaskToolSummary
entries={taskSummaryEntries}
isExpanded={isExpanded}
isMobile={isMobile}
hasPrevTool={hasPrevTool}
hasNextTool={hasNextTool}
output={taskOutputString}
sessionId={taskSessionId}
onShowPopup={onShowPopup}
input={input}
/>
) : null}
{!isTaskTool && isExpanded ? (
<ToolExpandedContent
part={part}
state={state}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
currentDirectory={currentDirectory}
onShowPopup={onShowPopup}
hasPrevTool={hasPrevTool}
hasNextTool={hasNextTool}
/>
) : null}
</div>
);
};
export default ToolPart;