From e5075ea180c213ee29ad2117f3ff983a1bd42729 Mon Sep 17 00:00:00 2001 From: Bright Zheng Date: Tue, 19 Aug 2025 11:22:57 -0400 Subject: [PATCH] feat(ui): Implement in-place message editing with re-response (#3798) Co-authored-by: Zane Staggs --- ui/desktop/src/components/BaseChat.tsx | 3 + .../src/components/MCPUIResourceRenderer.tsx | 11 +- .../src/components/ProgressiveMessageList.tsx | 7 +- ui/desktop/src/components/UserMessage.tsx | 250 ++++++++++++++++-- ui/desktop/src/hooks/useChatEngine.test.ts | 155 +++++++++++ ui/desktop/src/hooks/useChatEngine.ts | 34 +++ ui/desktop/src/test/setup.ts | 9 +- 7 files changed, 432 insertions(+), 37 deletions(-) create mode 100644 ui/desktop/src/hooks/useChatEngine.test.ts diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 5e86d50dc5..d7ebf70593 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -158,6 +158,7 @@ function BaseChatContent({ sessionMetadata, isUserMessage, clearError, + onMessageUpdate, } = useChatEngine({ chat, setChat, @@ -418,6 +419,7 @@ function BaseChatContent({ isUserMessage={isUserMessage} onScrollToBottom={handleScrollToBottom} isStreamingMessage={chatState !== ChatState.Idle} + onMessageUpdate={onMessageUpdate} /> ) : ( // Render messages with SearchView wrapper when search is enabled @@ -434,6 +436,7 @@ function BaseChatContent({ isUserMessage={isUserMessage} onScrollToBottom={handleScrollToBottom} isStreamingMessage={chatState !== ChatState.Idle} + onMessageUpdate={onMessageUpdate} /> )} diff --git a/ui/desktop/src/components/MCPUIResourceRenderer.tsx b/ui/desktop/src/components/MCPUIResourceRenderer.tsx index 8800c93e63..50cc4522cd 100644 --- a/ui/desktop/src/components/MCPUIResourceRenderer.tsx +++ b/ui/desktop/src/components/MCPUIResourceRenderer.tsx @@ -64,12 +64,11 @@ export default function MCPUIResourceRenderer({ content }: MCPUIResourceRenderer diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index ed940dd806..e6b0d0321c 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -37,6 +37,7 @@ interface ProgressiveMessageListProps { // Custom render function for messages renderMessage?: (message: Message, index: number) => React.ReactNode | null; isStreamingMessage?: boolean; // Whether messages are currently being streamed + onMessageUpdate?: (messageId: string, newContent: string) => void; } export default function ProgressiveMessageList({ @@ -52,6 +53,7 @@ export default function ProgressiveMessageList({ showLoadingThreshold = 50, renderMessage, // Custom render function isStreamingMessage = false, // Whether messages are currently being streamed + onMessageUpdate, }: ProgressiveMessageListProps) { const [renderedCount, setRenderedCount] = useState(() => { // Initialize with either all messages (if small) or first batch (if large) @@ -206,7 +208,9 @@ export default function ProgressiveMessageList({ }} /> ) : ( - !hasOnlyToolResponses(message) && + !hasOnlyToolResponses(message) && ( + + ) )} ) : ( @@ -258,6 +262,7 @@ export default function ProgressiveMessageList({ appendMessage, toolCallNotifications, isStreamingMessage, + onMessageUpdate, hasContextHandlerContent, getContextHandlerType, onScrollToBottom, diff --git a/ui/desktop/src/components/UserMessage.tsx b/ui/desktop/src/components/UserMessage.tsx index 4eecb8016d..036eb91f8d 100644 --- a/ui/desktop/src/components/UserMessage.tsx +++ b/ui/desktop/src/components/UserMessage.tsx @@ -1,4 +1,4 @@ -import { useRef, useMemo } from 'react'; +import { useRef, useMemo, useState, useEffect, useCallback } from 'react'; import LinkPreview from './LinkPreview'; import ImagePreview from './ImagePreview'; import { extractUrls } from '../utils/urlUtils'; @@ -7,13 +7,21 @@ import MarkdownContent from './MarkdownContent'; import { Message, getTextContent } from '../types/message'; import MessageCopyLink from './MessageCopyLink'; import { formatMessageTimestamp } from '../utils/timeUtils'; +import Edit from './icons/Edit'; +import { Button } from './ui/button'; interface UserMessageProps { message: Message; + onMessageUpdate?: (messageId: string, newContent: string) => void; } -export default function UserMessage({ message }: UserMessageProps) { +export default function UserMessage({ message, onMessageUpdate }: UserMessageProps) { const contentRef = useRef(null); + const textareaRef = useRef(null); + const [isEditing, setIsEditing] = useState(false); + const [editContent, setEditContent] = useState(''); + const [hasBeenEdited, setHasBeenEdited] = useState(false); + const [error, setError] = useState(null); // Extract text content from the message const textContent = getTextContent(message); @@ -21,46 +29,230 @@ export default function UserMessage({ message }: UserMessageProps) { // Extract image paths from the message const imagePaths = extractImagePaths(textContent); - // Remove image paths from text for display - const displayText = removeImagePathsFromText(textContent, imagePaths); + // Remove image paths from text for display - memoized for performance + const displayText = useMemo( + () => removeImagePathsFromText(textContent, imagePaths), + [textContent, imagePaths] + ); // Memoize the timestamp const timestamp = useMemo(() => formatMessageTimestamp(message.created), [message.created]); // Extract URLs which explicitly contain the http:// or https:// protocol - const urls = extractUrls(displayText, []); + const urls = useMemo(() => extractUrls(displayText, []), [displayText]); + + // Effect to handle message content changes and ensure persistence + useEffect(() => { + // Log content display for debugging + window.electron.logInfo( + `Displaying content for message: ${message.id} content: ${displayText}` + ); + + // If we're not editing, update the edit content to match the current message + if (!isEditing) { + setEditContent(displayText); + } + }, [message.content, displayText, message.id, isEditing]); + + // Initialize edit mode with current message content + const initializeEditMode = useCallback(() => { + setEditContent(displayText); + setError(null); + window.electron.logInfo(`Entering edit mode with content: ${displayText}`); + }, [displayText]); + + // Handle edit button click + const handleEditClick = useCallback(() => { + const newEditingState = !isEditing; + setIsEditing(newEditingState); + + // Initialize edit content when entering edit mode + if (newEditingState) { + initializeEditMode(); + window.electron.logInfo(`Edit interface shown for message: ${message.id}`); + + // Focus the textarea after a brief delay to ensure it's rendered + setTimeout(() => { + if (textareaRef.current) { + textareaRef.current.focus(); + textareaRef.current.setSelectionRange( + textareaRef.current.value.length, + textareaRef.current.value.length + ); + } + }, 50); + } + + window.electron.logInfo(`Edit state toggled: ${newEditingState} for message: ${message.id}`); + }, [isEditing, initializeEditMode, message.id]); + + // Handle content changes in edit mode + const handleContentChange = useCallback((e: React.ChangeEvent) => { + const newContent = e.target.value; + setEditContent(newContent); + setError(null); // Clear any previous errors + window.electron.logInfo(`Content changed: ${newContent}`); + }, []); + + // Handle save action + const handleSave = useCallback(() => { + // Exit edit mode immediately + setIsEditing(false); + + // Check if content has actually changed + if (editContent !== displayText) { + // Validate content + if (editContent.trim().length === 0) { + setError('Message cannot be empty'); + return; + } + + // Update the message content through the callback + if (onMessageUpdate && message.id) { + onMessageUpdate(message.id, editContent); + setHasBeenEdited(true); + } + } + }, [editContent, displayText, onMessageUpdate, message.id]); + + // Handle cancel action + const handleCancel = useCallback(() => { + window.electron.logInfo('Cancel clicked - reverting to original content'); + setIsEditing(false); + setEditContent(displayText); // Reset to original content + setError(null); + }, [displayText]); + + // Handle keyboard events for accessibility + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + window.electron.logInfo( + `Key pressed: ${e.key}, metaKey: ${e.metaKey}, ctrlKey: ${e.ctrlKey}` + ); + + if (e.key === 'Escape') { + e.preventDefault(); + handleCancel(); + } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + window.electron.logInfo('Cmd+Enter detected, calling handleSave'); + handleSave(); + } + }, + [handleCancel, handleSave] + ); + + // Auto-resize textarea based on content + useEffect(() => { + if (textareaRef.current && isEditing) { + textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`; + } + }, [editContent, isEditing]); return ( -
-
-
-
-
- +
+
+ {isEditing ? ( + // Truly wide, centered, in-place edit box replacing the bubble +
+