From 5f0676bdb339518cd331786a0c951d732325a837 Mon Sep 17 00:00:00 2001 From: simosmik Date: Mon, 9 Feb 2026 15:26:49 +0000 Subject: [PATCH] refactor(improvement):add memo on diffviewer, cleanup messsagecomponent --- .../chat/messages/MessageComponent.tsx | 528 ++++-------------- src/components/chat/tools/ToolRenderer.tsx | 171 ++---- .../tools/components/CollapsibleDisplay.tsx | 30 +- .../chat/tools/components/DiffViewer.tsx | 9 +- src/components/chat/tools/index.ts | 1 - 5 files changed, 152 insertions(+), 587 deletions(-) diff --git a/src/components/chat/messages/MessageComponent.tsx b/src/components/chat/messages/MessageComponent.tsx index ccc432a..86cbbb6 100644 --- a/src/components/chat/messages/MessageComponent.tsx +++ b/src/components/chat/messages/MessageComponent.tsx @@ -1,17 +1,15 @@ // @ts-nocheck -import React, { memo } from 'react'; +import React, { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import TodoList from '../../TodoList'; import ClaudeLogo from '../../ClaudeLogo.jsx'; import CursorLogo from '../../CursorLogo.jsx'; import CodexLogo from '../../CodexLogo.jsx'; -import { api, authenticatedFetch } from '../../../utils/api'; import type { ChatMessage, Provider } from '../types'; import { Markdown } from '../markdown/Markdown'; import { formatUsageLimitText } from '../utils/chatFormatting'; import { getClaudePermissionSuggestion } from '../utils/chatPermissions'; import type { Project } from '../../../types/app'; -import { ToolRenderer, shouldHideToolResult, FileListContent, TaskListContent } from '../tools'; +import { ToolRenderer, shouldHideToolResult } from '../tools'; type DiffLine = { type: string; @@ -52,15 +50,15 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile }, [permissionSuggestion?.entry, message.toolId]); React.useEffect(() => { - if (!autoExpandTools || !messageRef.current || !message.isToolUse) return; - + const node = messageRef.current; + if (!autoExpandTools || !node || !message.isToolUse) return; + const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting && !isExpanded) { setIsExpanded(true); - // Find all details elements and open them - const details = messageRef.current.querySelectorAll('details'); + const details = node.querySelectorAll('details'); details.forEach(detail => { detail.open = true; }); @@ -69,16 +67,17 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile }, { threshold: 0.1 } ); - - observer.observe(messageRef.current); - + + observer.observe(node); + return () => { - if (messageRef.current) { - observer.unobserve(messageRef.current); - } + observer.unobserve(node); }; }, [autoExpandTools, isExpanded, message.isToolUse]); + const selectedProvider = useMemo(() => localStorage.getItem('selected-provider') || 'claude', []); + const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]); + return (
{message.images.map((img, idx) => ( {img.name} )}
- {new Date(message.timestamp).toLocaleTimeString()} + {formattedTime}
{!isGrouped && ( @@ -129,9 +128,9 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile ) : (
- {(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? ( + {selectedProvider === 'cursor' ? ( - ) : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? ( + ) : selectedProvider === 'codex' ? ( ) : ( @@ -139,7 +138,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
)}
- {message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? t('messageTypes.cursor') : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude'))} + {message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : (selectedProvider === 'cursor' ? t('messageTypes.cursor') : selectedProvider === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude'))}
)} @@ -172,427 +171,92 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile )} {/* Tool Result Section */} - {message.toolResult && (() => { - // Use config to determine if result should be hidden - if (shouldHideToolResult(message.toolName, message.toolResult)) { - return null; - } - - return ( -
-
- - {message.toolResult.isError ? ( + {message.toolResult && !shouldHideToolResult(message.toolName, message.toolResult) && ( + message.toolResult.isError ? ( + // Error results - red error box with content +
+
+ - ) : ( - - )} - - - {message.toolResult.isError ? 'Error' : 'Result'} - -
- -
- {(() => { - const content = String(message.toolResult.content || ''); - - // Special handling for TodoWrite/TodoRead results - if ((message.toolName === 'TodoWrite' || message.toolName === 'TodoRead') && - (content.includes('Todos have been modified successfully') || - content.includes('Todo list') || - (content.startsWith('[') && content.includes('"content"') && content.includes('"status"')))) { - try { - // Try to parse if it looks like todo JSON data - let todos = null; - if (content.startsWith('[')) { - todos = JSON.parse(content); - } else if (content.includes('Todos have been modified successfully')) { - // For TodoWrite success messages, we don't have the data in the result - return ( -
-
- Todo list has been updated successfully -
-
- ); - } - - if (todos && Array.isArray(todos)) { - return ( -
-
- Current Todo List -
- -
- ); - } - } catch (e) { - // Fall through to regular handling - } - } - - // Special handling for exit_plan_mode tool results - if (message.toolName === 'exit_plan_mode') { - try { - // Content might already be an object or a JSON string - let parsed; - if (typeof message.toolResult.content === 'object' && message.toolResult.content !== null) { - parsed = message.toolResult.content; - } else { - parsed = JSON.parse(content); - } - - if (parsed && parsed.plan) { - // Replace escaped newlines with actual newlines - const planContent = parsed.plan.replace(/\\n/g, '\n'); - return ( -
-
- Implementation Plan -
- - {planContent} - -
- ); - } - } catch (e) { - console.error('Failed to parse exit_plan_mode result:', e); - // Fall through to regular handling - } - } - - // Grep/Glob results - compact comma-separated file list - if ((message.toolName === 'Grep' || message.toolName === 'Glob') && message.toolResult?.toolUseResult) { - const toolData = message.toolResult.toolUseResult; - if (toolData.filenames && Array.isArray(toolData.filenames) && toolData.filenames.length > 0) { - const count = toolData.numFiles || toolData.filenames.length; - return ( - - ); - } - } - - // Task tool results - proper task list rendering - if (message.toolName === 'TaskList' || message.toolName === 'TaskGet') { - return ; - } - - // Special handling for interactive prompts - if (content.includes('Do you want to proceed?') && message.toolName === 'Bash') { - const lines = content.split('\n'); - const promptIndex = lines.findIndex(line => line.includes('Do you want to proceed?')); - const beforePrompt = lines.slice(0, promptIndex).join('\n'); - const promptLines = lines.slice(promptIndex); - - // Extract the question and options - const questionLine = promptLines.find(line => line.includes('Do you want to proceed?')) || ''; - const options = []; - - // Parse numbered options (1. Yes, 2. No, etc.) - promptLines.forEach(line => { - const optionMatch = line.match(/^\s*(\d+)\.\s+(.+)$/); - if (optionMatch) { - options.push({ - number: optionMatch[1], - text: optionMatch[2].trim() - }); - } - }); - - // Find which option was selected (usually indicated by "> 1" or similar) - const selectedMatch = content.match(/>\s*(\d+)/); - const selectedOption = selectedMatch ? selectedMatch[1] : null; - - return ( -
- {beforePrompt && ( -
-
{beforePrompt}
-
- )} -
-
-
- - - -
-
-

- Interactive Prompt -

-

- {questionLine} -

- - {/* Option buttons */} -
- {options.map((option) => ( - - ))} -
- - {selectedOption && ( -
-

- ✓ Claude selected option {selectedOption} -

-

- In the CLI, you would select this option interactively using arrow keys or by typing the number. -

-
- )} -
-
-
-
- ); - } - - const fileEditMatch = content.match(/The file (.+?) has been updated\./); - if (fileEditMatch) { - return ( -
-
- File updated successfully -
- -
- ); - } - - // Handle Write tool output for file creation - const fileCreateMatch = content.match(/(?:The file|File) (.+?) has been (?:created|written)(?: successfully)?\.?/); - if (fileCreateMatch) { - return ( -
-
- File created successfully -
- -
- ); - } - - // Special handling for Write tool - hide content if it's just the file content - if (message.toolName === 'Write' && !message.toolResult.isError) { - // For Write tool, the diff is already shown in the tool input section - // So we just show a success message here - return ( -
-
- - - - File written successfully -
-

- The file content is displayed in the diff view above -

-
- ); - } - - if (content.includes('cat -n') && content.includes('→')) { - return ( -
- - - - - View file content - -
-
- {content} -
-
-
- ); - } - - if (content.length > 300) { - return ( -
- - - - - View full output ({content.length} chars) - - - {content} - -
- ); - } - - return ( - - {content} - - ); - })()} - {permissionSuggestion && ( -
-
- - {onShowSettings && ( + + Error +
+
+ + {String(message.toolResult.content || '')} + + {permissionSuggestion && ( +
+
+ {onShowSettings && ( + + )} +
+
+ Adds {permissionSuggestion.entry} to Allowed Tools. +
+ {permissionGrantState === 'error' && ( +
+ Unable to update permissions. Please try again. +
+ )} + {(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && ( +
+ Permission saved. Retry the request to use the tool. +
)}
-
- Adds {permissionSuggestion.entry} to Allowed Tools. -
- {permissionGrantState === 'error' && ( -
- Unable to update permissions. Please try again. -
- )} - {(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && ( -
- Permission saved. Retry the request to use the tool. -
- )} -
- )} + )} +
-
- ); - })()} + ) : ( + // Non-error results - route through ToolRenderer (single source of truth) +
+ +
+ ) + )} ) : message.isInteractivePrompt ? ( // Special handling for interactive prompts @@ -759,7 +423,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile {!isGrouped && (
- {new Date(message.timestamp).toLocaleTimeString()} + {formattedTime}
)}
diff --git a/src/components/chat/tools/ToolRenderer.tsx b/src/components/chat/tools/ToolRenderer.tsx index 10ea393..33fbc2b 100644 --- a/src/components/chat/tools/ToolRenderer.tsx +++ b/src/components/chat/tools/ToolRenderer.tsx @@ -1,6 +1,6 @@ -import React from 'react'; +import React, { memo, useMemo, useCallback } from 'react'; import { getToolConfig } from './configs/toolConfigs'; -import { OneLineDisplay, CollapsibleDisplay, FilePathButton, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent } from './components'; +import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent } from './components'; import type { Project } from '../../../types/app'; type DiffLine = { @@ -18,7 +18,6 @@ interface ToolRendererProps { onFileOpen?: (filePath: string, diffInfo?: any) => void; createDiff?: (oldStr: string, newStr: string) => DiffLine[]; selectedProject?: Project | null; - onShowSettings?: () => void; autoExpandTools?: boolean; showRawParameters?: boolean; rawToolInput?: string; @@ -38,7 +37,7 @@ function getToolCategory(toolName: string): string { * Main tool renderer router * Routes to OneLineDisplay or CollapsibleDisplay based on tool config */ -export const ToolRenderer: React.FC = ({ +export const ToolRenderer: React.FC = memo(({ toolName, toolInput, toolResult, @@ -47,7 +46,6 @@ export const ToolRenderer: React.FC = ({ onFileOpen, createDiff, selectedProject, - onShowSettings, autoExpandTools = false, showRawParameters = false, rawToolInput @@ -57,24 +55,26 @@ export const ToolRenderer: React.FC = ({ if (!displayConfig) return null; - let parsedData: any; - try { - const rawData = mode === 'input' ? toolInput : toolResult; - parsedData = typeof rawData === 'string' ? JSON.parse(rawData) : rawData; - } catch (e) { - parsedData = mode === 'input' ? toolInput : toolResult; - } + const parsedData = useMemo(() => { + try { + const rawData = mode === 'input' ? toolInput : toolResult; + return typeof rawData === 'string' ? JSON.parse(rawData) : rawData; + } catch { + return mode === 'input' ? toolInput : toolResult; + } + }, [mode, toolInput, toolResult]); + + const handleAction = useCallback(() => { + if (displayConfig.action === 'open-file' && onFileOpen) { + const value = displayConfig.getValue?.(parsedData) || ''; + onFileOpen(value); + } + }, [displayConfig, parsedData, onFileOpen]); if (displayConfig.type === 'one-line') { const value = displayConfig.getValue?.(parsedData) || ''; const secondary = displayConfig.getSecondary?.(parsedData); - const handleAction = () => { - if (displayConfig.action === 'open-file' && onFileOpen) { - onFileOpen(value); - } - }; - return ( = ({ onFileOpen }) || {}; - let contentComponent = null; + // Build the content component based on contentType + let contentComponent: React.ReactNode = null; switch (displayConfig.contentType) { case 'diff': - if (!createDiff) { - console.error('createDiff function required for diff content type'); - break; + if (createDiff) { + contentComponent = ( + onFileOpen?.(contentProps.filePath)} + /> + ); } - contentComponent = ( - onFileOpen?.(contentProps.filePath)} - /> - ); break; case 'markdown': @@ -141,24 +140,18 @@ export const ToolRenderer: React.FC = ({ break; case 'todo-list': - if (!contentProps.todos || contentProps.todos.length === 0) { - contentComponent = null; - break; + if (contentProps.todos?.length > 0) { + contentComponent = ( + + ); } - contentComponent = ( - - ); break; case 'task': - contentComponent = ( - - ); + contentComponent = ; break; case 'text': @@ -170,83 +163,18 @@ export const ToolRenderer: React.FC = ({ ); break; - case 'success-message': - const message = displayConfig.getMessage?.(parsedData) || 'Success'; + case 'success-message': { + const msg = displayConfig.getMessage?.(parsedData) || 'Success'; contentComponent = (
- {message} + {msg}
); break; - - default: - contentComponent = ( -
Unknown content type: {displayConfig.contentType}
- ); - } - - let actionButton = null; - if (displayConfig.actionButton === 'file-button' && contentProps.filePath) { - const handleFileClick = async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (!onFileOpen) return; - - if (toolName === 'Edit' || toolName === 'ApplyPatch') { - try { - const { api } = await import('../../../utils/api'); - const response = await api.readFile(selectedProject?.name, contentProps.filePath); - const data = await response.json(); - - if (!response.ok || data.error) { - console.error('Failed to fetch file:', data.error); - onFileOpen(contentProps.filePath); - return; - } - - const currentContent = data.content || ''; - const oldContent = currentContent.replace(contentProps.newContent, contentProps.oldContent); - - onFileOpen(contentProps.filePath, { - old_string: oldContent, - new_string: currentContent - }); - } catch (error) { - console.error('Error preparing diff:', error); - onFileOpen(contentProps.filePath); - } - } - else if (toolName === 'Write') { - try { - const { api } = await import('../../../utils/api'); - const response = await api.readFile(selectedProject?.name, contentProps.filePath); - const data = await response.json(); - - const newContent = (response.ok && !data.error) ? data.content || '' : contentProps.newContent || ''; - - onFileOpen(contentProps.filePath, { - old_string: '', - new_string: newContent - }); - } catch (error) { - console.error('Error preparing diff:', error); - onFileOpen(contentProps.filePath, { - old_string: '', - new_string: contentProps.newContent || '' - }); - } - } - }; - - actionButton = ( - handleFileClick(e)} - /> - ); + } } // For edit tools, make the title (filename) clickable to open the file @@ -260,24 +188,17 @@ export const ToolRenderer: React.FC = ({ toolId={toolId} title={title} defaultOpen={defaultOpen} - action={actionButton} onTitleClick={handleTitleClick} - contentType={displayConfig.contentType || 'text'} - contentProps={{ - DiffViewer: contentComponent, - MarkdownComponent: contentComponent, - FileListComponent: contentComponent, - TodoListComponent: contentComponent, - TaskComponent: contentComponent, - TextComponent: contentComponent - }} showRawParameters={mode === 'input' && showRawParameters} rawContent={rawToolInput} - onShowSettings={onShowSettings} toolCategory={getToolCategory(toolName)} - /> + > + {contentComponent} + ); } return null; -}; +}); + +ToolRenderer.displayName = 'ToolRenderer'; diff --git a/src/components/chat/tools/components/CollapsibleDisplay.tsx b/src/components/chat/tools/components/CollapsibleDisplay.tsx index 93930bf..41556c3 100644 --- a/src/components/chat/tools/components/CollapsibleDisplay.tsx +++ b/src/components/chat/tools/components/CollapsibleDisplay.tsx @@ -1,8 +1,6 @@ import React from 'react'; import { CollapsibleSection } from './CollapsibleSection'; -type ContentType = 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text' | 'task' | 'success-message'; - interface CollapsibleDisplayProps { toolName: string; toolId?: string; @@ -10,12 +8,10 @@ interface CollapsibleDisplayProps { defaultOpen?: boolean; action?: React.ReactNode; onTitleClick?: () => void; - contentType: ContentType; - contentProps: any; + children: React.ReactNode; showRawParameters?: boolean; rawContent?: string; className?: string; - onShowSettings?: () => void; toolCategory?: string; } @@ -35,32 +31,12 @@ export const CollapsibleDisplay: React.FC = ({ defaultOpen = false, action, onTitleClick, - contentType, - contentProps, + children, showRawParameters = false, rawContent, className = '', toolCategory }) => { - const renderContent = () => { - switch (contentType) { - case 'diff': - return contentProps.DiffViewer; - case 'markdown': - return contentProps.MarkdownComponent; - case 'file-list': - return contentProps.FileListComponent; - case 'todo-list': - return contentProps.TodoListComponent; - case 'task': - return contentProps.TaskComponent; - case 'text': - return contentProps.TextComponent; - default: - return
Unknown content type: {contentType}
; - } - }; - const borderColor = borderColorMap[toolCategory || 'default']; return ( @@ -72,7 +48,7 @@ export const CollapsibleDisplay: React.FC = ({ action={action} onTitleClick={onTitleClick} > - {renderContent()} + {children} {showRawParameters && rawContent && (
diff --git a/src/components/chat/tools/components/DiffViewer.tsx b/src/components/chat/tools/components/DiffViewer.tsx index 6231603..626c784 100644 --- a/src/components/chat/tools/components/DiffViewer.tsx +++ b/src/components/chat/tools/components/DiffViewer.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; type DiffLine = { type: string; @@ -32,6 +32,11 @@ export const DiffViewer: React.FC = ({ ? 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400' : 'bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400'; + const diffLines = useMemo( + () => createDiff(oldContent, newContent), + [createDiff, oldContent, newContent] + ); + return (
{/* Header */} @@ -55,7 +60,7 @@ export const DiffViewer: React.FC = ({ {/* Diff lines */}
- {createDiff(oldContent, newContent).map((diffLine, i) => ( + {diffLines.map((diffLine, i) => (