diff --git a/src/components/chat/utils/chatMessageParser.ts b/src/components/chat/utils/chatMessageParser.ts new file mode 100644 index 000000000..340e72bfd --- /dev/null +++ b/src/components/chat/utils/chatMessageParser.ts @@ -0,0 +1,456 @@ +import type { ChatMessage } from '../types/types'; + +export type UiMessageKind = + | 'thinking' + | 'bash' + | 'file-read' + | 'code-diff' + | 'file-write' + | 'web-search' + | 'file-tree' + | 'error-warning' + | 'tool-invocation' + | 'image-generation' + | 'streaming-prose' + | 'summary-completion'; + +export interface SearchResultItem { + title: string; + url?: string; + snippet?: string; +} + +export interface TreeItem { + name: string; + type: 'file' | 'folder'; + size?: string; +} + +export interface ParsedUiMessage { + kind: UiMessageKind; + collapsible: boolean; + defaultOpen: boolean; + title?: string; + details?: string; + path?: string; + filename?: string; + language?: string; + lineCount?: number; + command?: string; + output?: string; + exitCode?: number | null; + query?: string; + resultCount?: number; + searchResults?: SearchResultItem[]; + treeItems?: TreeItem[]; + content?: string; + status?: 'running' | 'done' | 'error' | 'created' | 'saved' | 'generating'; + isStreaming?: boolean; + additions?: number; + deletions?: number; + toolName?: string; + toolId?: string; + toolInputRaw?: string; + listItems?: string[]; + outputs?: string[]; + generated?: unknown; + permissionRequest?: boolean; +} + +const LANGUAGE_BY_EXT: Record = { + js: 'JS', + jsx: 'JSX', + ts: 'TS', + tsx: 'TSX', + py: 'PY', + md: 'MD', + json: 'JSON', + yml: 'YAML', + yaml: 'YAML', + sh: 'SH', + css: 'CSS', + html: 'HTML', + go: 'GO', + rs: 'RS', + java: 'JAVA', + c: 'C', + cpp: 'CPP', + cs: 'CS', + rb: 'RB', + php: 'PHP', + sql: 'SQL', +}; + +const toObject = (value: unknown): Record | null => { + if (!value) return null; + if (typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + return null; + } + } + return null; +}; + +const toArray = (value: unknown): unknown[] => { + if (Array.isArray(value)) return value; + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + return []; +}; + +const toText = (value: unknown): string => { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +}; + +const getFileExtension = (filePath?: string): string => { + if (!filePath) return ''; + const last = filePath.split('/').pop() || ''; + const dot = last.lastIndexOf('.'); + if (dot < 0) return ''; + return last.slice(dot + 1).toLowerCase(); +}; + +const getLanguageTag = (filePath?: string): string => { + const ext = getFileExtension(filePath); + return LANGUAGE_BY_EXT[ext] || (ext ? ext.toUpperCase() : 'TXT'); +}; + +const extractExitCode = (message: ChatMessage): number | null => { + const topLevel = Number(message.exitCode); + if (Number.isFinite(topLevel)) return topLevel; + const resultObject = toObject(message.toolResult); + if (!resultObject) return null; + const fields = ['exitCode', 'exit_code', 'code', 'statusCode']; + for (const field of fields) { + const value = Number(resultObject[field]); + if (Number.isFinite(value)) return value; + } + return null; +}; + +const extractResultText = (toolResult: unknown): string => { + const obj = toObject(toolResult); + if (!obj) return toText(toolResult); + + const candidate = + obj.content ?? + obj.output ?? + obj.stdout ?? + obj.text ?? + obj.message ?? + obj.result; + + return toText(candidate); +}; + +const extractSearchResults = (toolResult: unknown): SearchResultItem[] => { + const obj = toObject(toolResult); + if (!obj) return []; + + const list = [obj.results, obj.items, obj.data, obj.content] + .map((source) => toArray(source)) + .find((source) => source.length > 0) || []; + + if (!Array.isArray(list) || list.length === 0) return []; + + return list + .map((entry): SearchResultItem | null => { + const row = toObject(entry); + if (!row) return null; + return { + title: toText(row.title || row.name || row.heading).trim(), + url: toText(row.url || row.link || row.href).trim() || undefined, + snippet: toText(row.snippet || row.description || row.summary).trim() || undefined, + }; + }) + .filter((entry): entry is SearchResultItem => Boolean(entry && entry.title)); +}; + +const extractTreeItems = (toolInput: unknown, toolResult: unknown): TreeItem[] => { + const fromResult = toArray(toObject(toolResult)?.items); + const fromFilenames = toArray(toObject(toolResult)?.filenames); + const inputObj = toObject(toolInput); + const fromInput = toArray(inputObj?.items); + const candidate = fromResult.length ? fromResult : fromFilenames.length ? fromFilenames : fromInput; + + if (!candidate.length) { + const text = extractResultText(toolResult); + return text + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .slice(0, 200) + .map((line) => ({ + name: line.replace(/^[\-*]\s*/, ''), + type: line.endsWith('/') ? 'folder' : 'file', + })); + } + + return candidate + .map((entry) => { + if (typeof entry === 'string') { + return { name: entry, type: entry.endsWith('/') ? 'folder' : 'file' } as TreeItem; + } + const row = toObject(entry); + if (!row) return null; + const normalizedType = toText(row.type || row.kind).trim().toLowerCase(); + const isFolder = ['dir', 'directory', 'folder'].includes(normalizedType); + return { + name: toText(row.name || row.path || row.file || ''), + type: isFolder ? 'folder' : 'file', + size: toText(row.size || '').trim() || undefined, + } as TreeItem; + }) + .filter((item): item is TreeItem => Boolean(item?.name)); +}; + +const extractSummaryItems = (content: string): string[] => { + return content + .split('\n') + .map((line) => line.trim()) + .filter((line) => /^[-*•]\s+/.test(line)) + .map((line) => line.replace(/^[-*•]\s+/, '')) + .filter(Boolean); +}; + +export function parseChatMessageForUi(message: ChatMessage): ParsedUiMessage { + const content = toText(message.content || ''); + const toolName = String(message.toolName || 'UnknownTool'); + + if (message.isThinking) { + return { + kind: 'thinking', + collapsible: true, + defaultOpen: false, + title: 'Thinking', + isStreaming: Boolean(message.isStreaming), + content, + }; + } + + if (message.type === 'error' || message.toolResult?.isError) { + return { + kind: 'error-warning', + collapsible: false, + defaultOpen: true, + title: 'Error', + content: content || extractResultText(message.toolResult), + details: message.toolResult?.isError ? extractResultText(message.toolResult) : undefined, + status: 'error', + toolName, + toolId: toText(message.toolId || message.toolCallId).trim() || undefined, + toolInputRaw: toText(message.toolInput), + permissionRequest: Boolean(message.toolResult?.isError && message.toolName), + }; + } + + if (!message.isToolUse) { + const summaryItems = extractSummaryItems(content); + const firstLine = (content.split('\n')[0] || '').trim(); + const explicitSummary = Boolean((message as any).isSummary); + const summaryHeading = /^summary\s*[:\-]\s*/i.test(firstLine); + const completionHeading = /\b(refactoring\s+)?complete(?:d)?[.!]?$/i.test(firstLine); + const looksLikeSummary = + explicitSummary || + summaryItems.length > 1 || + summaryHeading || + completionHeading; + + if (looksLikeSummary && content.length < 2500) { + const title = (firstLine || 'Summary').replace(/^[-*•]\s+/, '').trim(); + return { + kind: 'summary-completion', + collapsible: false, + defaultOpen: true, + title, + listItems: summaryItems.length > 0 ? summaryItems : content.split('\n').slice(1).filter(Boolean), + }; + } + + return { + kind: 'streaming-prose', + collapsible: false, + defaultOpen: true, + isStreaming: Boolean(message.isStreaming), + content, + }; + } + + const toolNameLower = toolName.toLowerCase(); + const toolInputObj = toObject(message.toolInput); + const toolResultText = extractResultText(message.toolResult); + + if (toolNameLower === 'bash' || toolNameLower.includes('command')) { + const command = toText( + toolInputObj?.command ?? + toolInputObj?.cmd ?? + toolInputObj?.script ?? + message.toolInput, + ).trim(); + const exitCode = extractExitCode(message); + const isRunning = !message.toolResult; + return { + kind: 'bash', + collapsible: true, + defaultOpen: true, + title: 'Command', + command, + output: toolResultText, + exitCode, + status: isRunning ? 'running' : exitCode && exitCode > 0 ? 'error' : 'done', + toolName, + }; + } + + if (toolNameLower === 'read') { + const path = toText(toolInputObj?.file_path ?? toolInputObj?.path).trim(); + const fileBody = toolResultText || content; + return { + kind: 'file-read', + collapsible: true, + defaultOpen: false, + path, + filename: path.split('/').pop() || path, + language: getLanguageTag(path), + lineCount: fileBody ? fileBody.split('\n').length : 0, + content: fileBody, + toolName, + }; + } + + if (toolNameLower === 'edit') { + const path = toText(toolInputObj?.file_path ?? toolInputObj?.path).trim(); + const oldContent = toText(toolInputObj?.old_string || ''); + const newContent = toText(toolInputObj?.new_string || ''); + return { + kind: 'code-diff', + collapsible: true, + defaultOpen: true, + path, + filename: path.split('/').pop() || path, + content: JSON.stringify({ oldContent, newContent }), + toolName, + }; + } + + if (toolNameLower === 'write' || toolNameLower.includes('create_file') || toolNameLower.includes('createfile')) { + const path = toText(toolInputObj?.file_path ?? toolInputObj?.path).trim(); + const fileContent = toText(toolInputObj?.content || ''); + const status = fileContent ? 'created' : 'saved'; + return { + kind: 'file-write', + collapsible: false, + defaultOpen: true, + path, + filename: path.split('/').pop() || path, + status, + toolName, + }; + } + + const isImageTool = toolNameLower.includes('image') || toolNameLower.includes('dall') || toolNameLower.includes('vision'); + if (isImageTool) { + const status = message.toolResult ? 'done' : 'generating'; + const resultObj = toObject(message.toolResult); + const candidateOutputs = [ + resultObj?.outputs, + resultObj?.images, + resultObj?.generated, + resultObj?.result, + resultObj?.output, + resultObj?.content, + resultObj?.data, + ]; + const outputs = candidateOutputs + .flatMap((candidate) => { + if (Array.isArray(candidate)) return candidate.map((item) => toText(item).trim()).filter(Boolean); + const single = toText(candidate).trim(); + return single ? [single] : []; + }); + return { + kind: 'image-generation', + collapsible: true, + defaultOpen: false, + title: 'Image Generation', + status, + content: toolResultText, + output: outputs[0] || undefined, + outputs: outputs.length ? outputs : undefined, + generated: resultObj?.generated ?? resultObj?.output ?? resultObj?.outputs, + toolName, + toolId: toText(message.toolId || message.toolCallId).trim() || undefined, + }; + } + + const isSearchTool = + toolNameLower.includes('search') || + toolNameLower.includes('web_fetch') || + toolNameLower.includes('websearch') || + toolNameLower.includes('web-search'); + if (isSearchTool) { + const results = extractSearchResults(message.toolResult); + const query = toText(toolInputObj?.query || toolInputObj?.q || toolInputObj?.search_query || message.toolInput).trim(); + return { + kind: 'web-search', + collapsible: true, + defaultOpen: false, + query, + resultCount: results.length, + searchResults: results, + status: message.toolResult ? 'done' : 'running', + toolName, + }; + } + + const isTreeTool = + toolNameLower === 'ls' || + toolNameLower.includes('listdir') || + toolNameLower.includes('readdir') || + toolNameLower.includes('directory') || + toolNameLower === 'glob'; + if (isTreeTool) { + const path = toText(toolInputObj?.path || toolInputObj?.directory || toolInputObj?.cwd || '').trim(); + const treeItems = extractTreeItems(message.toolInput, message.toolResult); + return { + kind: 'file-tree', + collapsible: true, + defaultOpen: false, + path, + treeItems, + toolName, + }; + } + + // Fallback for any remaining tool-use message that does not match a known tool kind. + return { + kind: 'tool-invocation', + collapsible: true, + defaultOpen: false, + toolName, + toolId: toText(message.toolId || message.toolCallId).trim() || undefined, + status: message.toolResult ? 'done' : 'running', + toolInputRaw: toText(message.toolInput), + content: toolResultText, + }; +} diff --git a/src/components/chat/utils/messageTransforms.ts b/src/components/chat/utils/messageTransforms.ts index b4a52508e..02e97636e 100644 --- a/src/components/chat/utils/messageTransforms.ts +++ b/src/components/chat/utils/messageTransforms.ts @@ -1,5 +1,6 @@ import type { ChatMessage } from '../types/types'; import { decodeHtmlEntities, unescapeWithMathProtection } from './chatFormatting'; +import { parseUiMessageContent } from './uiParser'; export interface DiffLine { type: 'added' | 'removed'; @@ -391,38 +392,22 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => { content = decodeHtmlEntities(String(message.message.content)); } - const shouldSkip = - !content || - content.startsWith('') || - content.startsWith('') || - content.startsWith('') || - content.startsWith('') || - content.startsWith('') || - content.startsWith('Caveat:') || - content.startsWith('This session is being continued from a previous') || - content.startsWith('[Request interrupted'); - - if (!shouldSkip) { - // Parse blocks into compact system messages - const taskNotifRegex = /\s*[^<]*<\/task-id>\s*[^<]*<\/output-file>\s*([^<]*)<\/status>\s*([^<]*)<\/summary>\s*<\/task-notification>/g; - const taskNotifMatch = taskNotifRegex.exec(content); - if (taskNotifMatch) { - const status = taskNotifMatch[1]?.trim() || 'completed'; - const summary = taskNotifMatch[2]?.trim() || 'Background task finished'; - converted.push({ - type: 'assistant', - content: summary, - timestamp: message.timestamp || new Date().toISOString(), - isTaskNotification: true, - taskStatus: status, - }); - } else { - converted.push({ - type: 'user', - content: unescapeWithMathProtection(content), - timestamp: message.timestamp || new Date().toISOString(), - }); - } + const parsedContent = parseUiMessageContent(content); + + if (parsedContent.kind === 'taskNotification') { + converted.push({ + type: 'assistant', + content: parsedContent.summary, + timestamp: message.timestamp || new Date().toISOString(), + isTaskNotification: true, + taskStatus: parsedContent.status, + }); + } else if (parsedContent.kind === 'text') { + converted.push({ + type: 'user', + content: unescapeWithMathProtection(parsedContent.content), + timestamp: message.timestamp || new Date().toISOString(), + }); } return; } diff --git a/src/components/chat/utils/uiParser.ts b/src/components/chat/utils/uiParser.ts new file mode 100644 index 000000000..42d6b2d9e --- /dev/null +++ b/src/components/chat/utils/uiParser.ts @@ -0,0 +1,58 @@ +export type UiParsedMessage = + | { kind: 'skip' } + | { kind: 'taskNotification'; summary: string; status: string } + | { kind: 'text'; content: string }; + +const SKIP_PREFIXES = [ + '', + '', + '', + '', + '', + 'Caveat:', + 'This session is being continued from a previous', + '[Request interrupted', +]; + +const TASK_NOTIFICATION_REGEX = + /\s*[^<]*<\/task-id>\s*[^<]*<\/output-file>\s*([^<]*)<\/status>\s*([^<]*)<\/summary>\s*<\/task-notification>/; + +const normalizeTaskStatus = (rawStatus: string | undefined): string => { + const status = (rawStatus || '').trim().toLowerCase(); + if (!status) return 'completed'; + + if (['done', 'success', 'ok', 'completed', 'complete'].includes(status)) { + return 'completed'; + } + if (['failed', 'failure', 'error', 'errored'].includes(status)) { + return 'failed'; + } + if (['running', 'in_progress', 'in progress', 'processing'].includes(status)) { + return 'running'; + } + return status; +}; + +export function parseUiMessageContent(rawContent: string): UiParsedMessage { + const content = rawContent.trim(); + if (!content) { + return { kind: 'skip' }; + } + + for (const prefix of SKIP_PREFIXES) { + if (content.startsWith(prefix)) { + return { kind: 'skip' }; + } + } + + const taskMatch = content.match(TASK_NOTIFICATION_REGEX); + if (taskMatch) { + return { + kind: 'taskNotification', + status: normalizeTaskStatus(taskMatch[1]), + summary: taskMatch[2]?.trim() || 'Background task finished', + }; + } + + return { kind: 'text', content }; +} diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx index fe12f4273..ade794355 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.tsx @@ -1,5 +1,20 @@ import React, { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { + AlertTriangle, + Brain, + Check, + CheckCircle2, + Download, + FileCode2, + FileText, + Folder, + Globe, + Image as ImageIcon, + Loader2, + Terminal, + Wrench, +} from 'lucide-react'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; import type { ChatMessage, @@ -9,10 +24,10 @@ import type { } from '../../types/types'; import { Markdown } from './Markdown'; import { formatUsageLimitText } from '../../utils/chatFormatting'; -import { getClaudePermissionSuggestion } from '../../utils/chatPermissions'; import { copyTextToClipboard } from '../../../../utils/clipboard'; import type { Project } from '../../../../types/app'; -import { ToolRenderer, shouldHideToolResult } from '../../tools'; +import { parseChatMessageForUi } from '../../utils/chatMessageParser'; +import { getClaudePermissionSuggestion } from '../../utils/chatPermissions'; type DiffLine = { type: string; @@ -41,8 +56,6 @@ type InteractiveOption = { isSelected: boolean; }; -type PermissionGrantState = 'idle' | 'granted' | 'error'; - const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => { const { t } = useTranslation('chat'); const isGrouped = prevMessage && prevMessage.type === message.type && @@ -51,15 +64,11 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile (prevMessage.type === 'tool') || (prevMessage.type === 'error')); const messageRef = React.useRef(null); + const previousToolIdRef = React.useRef(undefined); + const previousPermissionEntryRef = React.useRef(undefined); const [isExpanded, setIsExpanded] = React.useState(false); - const permissionSuggestion = getClaudePermissionSuggestion(message, provider); - const [permissionGrantState, setPermissionGrantState] = React.useState('idle'); const [messageCopied, setMessageCopied] = React.useState(false); - - - React.useEffect(() => { - setPermissionGrantState('idle'); - }, [permissionSuggestion?.entry, message.toolId]); + const [permissionGrantState, setPermissionGrantState] = React.useState<'idle' | 'granted' | 'error'>('idle'); React.useEffect(() => { const node = messageRef.current; @@ -89,6 +98,25 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]); const shouldHideThinkingMessage = Boolean(message.isThinking && !showThinking); + const parsedUiMessage = useMemo(() => parseChatMessageForUi(message), [message]); + const permissionSuggestion = useMemo( + () => getClaudePermissionSuggestion(message, String(provider)), + [message, provider], + ); + + React.useEffect(() => { + const currentToolId = parsedUiMessage.toolId; + const currentEntry = permissionSuggestion?.entry; + if ( + previousToolIdRef.current === currentToolId && + previousPermissionEntryRef.current === currentEntry + ) { + return; + } + setPermissionGrantState('idle'); + previousToolIdRef.current = currentToolId; + previousPermissionEntryRef.current = currentEntry; + }, [parsedUiMessage.toolId, permissionSuggestion?.entry]); if (shouldHideThinkingMessage) { return null; @@ -97,12 +125,12 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile return (
{message.type === 'user' ? ( /* User message bubble on the right */
-
+
{message.content}
@@ -119,7 +147,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile ))}
)} -
+
{!isGrouped && ( -
+
U
)} @@ -198,124 +226,8 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
)} -
- - {message.isToolUse ? ( - <> -
-
- - {String(message.displayText || '')} - -
-
- - {message.toolInput && ( - - )} - - {/* Tool Result Section */} - {message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && ( - message.toolResult.isError ? ( - // Error results - red error box with content -
-
- - - - {t('messageTypes.error')} -
-
- - {String(message.toolResult.content || '')} - - {permissionSuggestion && ( -
-
- - {onShowSettings && ( - - )} -
-
- {t('permissions.addTo', { entry: permissionSuggestion.entry })} -
- {permissionGrantState === 'error' && ( -
- {t('permissions.error')} -
- )} - {(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && ( -
- {t('permissions.retry')} -
- )} -
- )} -
-
- ) : ( - // Non-error results - route through ToolRenderer (single source of truth) -
- -
- ) - )} - - ) : message.isInteractivePrompt ? ( +
+ {message.isInteractivePrompt ? ( // Special handling for interactive prompts
@@ -396,88 +308,322 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
- ) : message.isThinking ? ( - /* Thinking messages - collapsible by default */ -
-
- - - - - {t('thinking.emoji')} - -
- - {message.content} - -
-
-
) : ( -
- {/* Thinking accordion for reasoning */} - {showThinking && message.reasoning && ( -
- - {t('thinking.emoji')} + <> + {parsedUiMessage.kind === 'thinking' && ( +
+ + + Thinking + {parsedUiMessage.isStreaming && ( + + . + . + . + + )} -
-
- {message.reasoning} -
+
+ {parsedUiMessage.content}
)} - {(() => { - const content = formatUsageLimitText(String(message.content || '')); - - // Detect if content is pure JSON (starts with { or [) - const trimmedContent = content.trim(); - if ((trimmedContent.startsWith('{') || trimmedContent.startsWith('[')) && - (trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) { - try { - const parsed = JSON.parse(trimmedContent); - const formatted = JSON.stringify(parsed, null, 2); + {parsedUiMessage.kind === 'bash' && ( +
+ + {parsedUiMessage.status === 'running' ? ( + + ) : ( + + )} + $ + {parsedUiMessage.command} + {Number(parsedUiMessage.exitCode) > 0 && ( + + exit {parsedUiMessage.exitCode} + + )} + + {parsedUiMessage.output && ( +
+                        {parsedUiMessage.output}
+                      
+ )} +
+ )} - return ( -
-
- - - - {t('json.response')} -
-
-
-                              
-                                {formatted}
-                              
-                            
-
+ {parsedUiMessage.kind === 'file-read' && ( +
+ + + {parsedUiMessage.filename || parsedUiMessage.path} + {parsedUiMessage.lineCount || 0} lines + + {parsedUiMessage.language} + + +
+ {(parsedUiMessage.content || '').split('\n').map((line, lineIndex) => ( +
+
{lineIndex + 1}
+
{line}
- ); - } catch { - // Not valid JSON, fall through to normal rendering - } + ))} +
+
+ )} + + {parsedUiMessage.kind === 'code-diff' && (() => { + let oldText = ''; + let newText = ''; + try { + const payload = JSON.parse(parsedUiMessage.content || '{}'); + oldText = String(payload.oldContent || ''); + newText = String(payload.newContent || ''); + } catch { + oldText = ''; + newText = ''; } + const diffLines = createDiff(oldText, newText); + const additions = diffLines.filter((line) => line.type === 'added').length; + const deletions = diffLines.filter((line) => line.type === 'removed').length; - // Normal rendering for non-JSON content - return message.type === 'assistant' ? ( - - {content} - - ) : ( -
- {content} -
+ return ( +
+ + + {parsedUiMessage.filename || parsedUiMessage.path} + + +{additions} + -{deletions} + + +
+
+ @@ diff @@ +
+ {diffLines.map((line, diffIndex) => ( +
+ {line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '} + {line.content} +
+ ))} +
+
); })()} -
+ + {parsedUiMessage.kind === 'file-write' && ( +
+ + Wrote + {parsedUiMessage.path} + + + {parsedUiMessage.status === 'created' ? 'created' : 'saved'} + +
+ )} + + {parsedUiMessage.kind === 'web-search' && ( +
+ + {parsedUiMessage.status === 'running' ? ( + + ) : ( + + )} + Search: + "{parsedUiMessage.query}" + {parsedUiMessage.resultCount || 0} results + +
+ {(parsedUiMessage.searchResults || []).map((result, resultIndex) => ( +
+
{result.title}
+ {result.url &&
{result.url}
} + {result.snippet &&
{result.snippet}
} +
+ ))} +
+
+ )} + + {parsedUiMessage.kind === 'file-tree' && ( +
+ + + {parsedUiMessage.path || 'Directory'} + {parsedUiMessage.treeItems?.length || 0} items + +
+ {(parsedUiMessage.treeItems || []).map((item, itemIndex) => ( +
+ {item.type === 'folder' ? ( + + ) : ( + + )} + {item.name} + {item.size && {item.size}} +
+ ))} +
+
+ )} + + {parsedUiMessage.kind === 'error-warning' && ( +
+
+ + Error +
+
{parsedUiMessage.content}
+ {parsedUiMessage.details && ( +
{parsedUiMessage.details}
+ )} + {onGrantToolPermission && permissionSuggestion && parsedUiMessage.permissionRequest && ( +
+ + {permissionGrantState === 'error' && ( + Could not update permission rule + )} +
+ )} +
+ )} + + {parsedUiMessage.kind === 'tool-invocation' && ( +
+ + {parsedUiMessage.status === 'running' ? ( + + ) : ( + + )} + {parsedUiMessage.toolName} + +
+                      {parsedUiMessage.toolInputRaw}
+                    
+ {parsedUiMessage.content && ( +
+                        {parsedUiMessage.content}
+                      
+ )} +
+ )} + + {parsedUiMessage.kind === 'image-generation' && ( +
+
+ {parsedUiMessage.status === 'generating' ? ( + + ) : ( + + )} + Image Generation + + {parsedUiMessage.status === 'generating' ? 'Generating...' : 'Complete'} + +
+ {parsedUiMessage.status === 'done' && ( +
+ {(() => { + const outputs = parsedUiMessage.outputs || []; + const imageUrls = outputs.filter((value) => /^(https?:\/\/|data:image\/)/i.test(value)); + if (imageUrls.length > 0) { + return ( +
+ {imageUrls.map((imageUrl, imageIndex) => ( + {`Generated + ))} +
+ ); + } + + const textOutput = outputs.find((value) => value.trim().length > 0) || String(parsedUiMessage.content || ''); + if (textOutput.trim()) { + return ( +
+                                {textOutput}
+                              
+ ); + } + + return ( +
+ No generated output available +
+ ); + })()} +
+ )} +
+ )} + + {parsedUiMessage.kind === 'summary-completion' && ( +
+
+ + {parsedUiMessage.title || 'Complete'} +
+
+ {(parsedUiMessage.listItems || []).map((item, itemIndex) => ( +
• {item}
+ ))} +
+
+ )} + + {parsedUiMessage.kind === 'streaming-prose' && ( +
+ + {formatUsageLimitText(String(parsedUiMessage.content || ''))} + + {parsedUiMessage.isStreaming && ( + + )} +
+ )} + )} {!isGrouped && ( -
+
{formattedTime}
)} @@ -489,4 +635,3 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile }); export default MessageComponent; -