From dd761e2335ce15e29e74cda4cab452afbdf34782 Mon Sep 17 00:00:00 2001 From: Roo Date: Tue, 15 Jul 2025 13:26:45 +0000 Subject: [PATCH 1/5] feat: add undo functionality for enhance prompt feature - Add state to store original prompt before enhancement - Implement Cmd+Z/Ctrl+Z keyboard shortcut to undo enhancement - Clear original prompt when user manually edits input - Fixes #5741: Users can now undo enhanced prompts to restore original text --- .../src/components/chat/ChatTextArea.tsx | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index a38b4538d05..82d8ac3c26a 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -157,6 +157,7 @@ const ChatTextArea = forwardRef( const contextMenuContainerRef = useRef(null) const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false) const [isFocused, setIsFocused] = useState(false) + const [originalPromptBeforeEnhancement, setOriginalPromptBeforeEnhancement] = useState(null) // Use custom hook for prompt history navigation const { handleHistoryNavigation, resetHistoryNavigation, resetOnInputChange } = usePromptHistory({ @@ -186,6 +187,8 @@ const ChatTextArea = forwardRef( const trimmedInput = inputValue.trim() if (trimmedInput) { + // Store the original prompt before enhancement + setOriginalPromptBeforeEnhancement(inputValue) setIsEnhancingPrompt(true) vscode.postMessage({ type: "enhancePrompt" as const, text: trimmedInput }) } else { @@ -376,6 +379,19 @@ const ChatTextArea = forwardRef( const isComposing = event.nativeEvent?.isComposing ?? false + // Handle undo for enhanced prompt (Cmd+Z/Ctrl+Z) + if ( + (event.metaKey || event.ctrlKey) && + event.key === "z" && + !event.shiftKey && + originalPromptBeforeEnhancement + ) { + event.preventDefault() + setInputValue(originalPromptBeforeEnhancement) + setOriginalPromptBeforeEnhancement(null) + return + } + // Handle prompt history navigation using custom hook if (handleHistoryNavigation(event, showContextMenu, isComposing)) { return @@ -452,6 +468,7 @@ const ChatTextArea = forwardRef( fileSearchResults, handleHistoryNavigation, resetHistoryNavigation, + originalPromptBeforeEnhancement, ], ) @@ -470,6 +487,11 @@ const ChatTextArea = forwardRef( const newValue = e.target.value setInputValue(newValue) + // Clear original prompt when user manually changes input + if (originalPromptBeforeEnhancement) { + setOriginalPromptBeforeEnhancement(null) + } + // Reset history navigation when user types resetOnInputChange() @@ -527,7 +549,14 @@ const ChatTextArea = forwardRef( setFileSearchResults([]) // Clear file search results. } }, - [setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading, resetOnInputChange], + [ + setInputValue, + setSearchRequestId, + setFileSearchResults, + setSearchLoading, + resetOnInputChange, + originalPromptBeforeEnhancement, + ], ) useEffect(() => { From e757fd9d6ba2d1f2515fc1ba128559ed3be33b5c Mon Sep 17 00:00:00 2001 From: Roo Date: Tue, 15 Jul 2025 13:37:43 +0000 Subject: [PATCH 2/5] feat: integrate native browser undo stack for enhance prompt feature - Replace custom undo state management with native browser methods - Use document.execCommand("insertText") for text replacement to preserve undo history - Add setRangeText fallback for browsers without execCommand support - Remove originalPromptBeforeEnhancement state and custom Cmd+Z handling - Add comprehensive tests for native browser method usage - Fix React development mode in test environment for act() support This provides users with the expected VSCode textarea undo experience when using the enhance prompt feature, as requested in PR feedback. --- .../src/components/chat/ChatTextArea.tsx | 55 +++++------- .../chat/__tests__/ChatTextArea.spec.tsx | 87 ++++++++++++++++++- webview-ui/vitest.setup.ts | 6 ++ 3 files changed, 113 insertions(+), 35 deletions(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 82d8ac3c26a..5ea213f2acf 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -115,8 +115,28 @@ const ChatTextArea = forwardRef( const message = event.data if (message.type === "enhancedPrompt") { - if (message.text) { - setInputValue(message.text) + if (message.text && textAreaRef.current) { + // Use native browser methods to preserve undo stack + const textarea = textAreaRef.current + + // Focus the textarea to ensure it's the active element + textarea.focus() + + // Select all text first + textarea.select() + + // Use execCommand to replace text while preserving undo history + if (document.execCommand) { + document.execCommand("insertText", false, message.text) + } else { + // Fallback for browsers that don't support execCommand + // This approach also preserves undo history in modern browsers + textarea.setRangeText(message.text, 0, textarea.value.length, "select") + + // Trigger input event to notify React of the change + const inputEvent = new Event("input", { bubbles: true }) + textarea.dispatchEvent(inputEvent) + } } setIsEnhancingPrompt(false) @@ -157,7 +177,6 @@ const ChatTextArea = forwardRef( const contextMenuContainerRef = useRef(null) const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false) const [isFocused, setIsFocused] = useState(false) - const [originalPromptBeforeEnhancement, setOriginalPromptBeforeEnhancement] = useState(null) // Use custom hook for prompt history navigation const { handleHistoryNavigation, resetHistoryNavigation, resetOnInputChange } = usePromptHistory({ @@ -187,8 +206,6 @@ const ChatTextArea = forwardRef( const trimmedInput = inputValue.trim() if (trimmedInput) { - // Store the original prompt before enhancement - setOriginalPromptBeforeEnhancement(inputValue) setIsEnhancingPrompt(true) vscode.postMessage({ type: "enhancePrompt" as const, text: trimmedInput }) } else { @@ -379,19 +396,6 @@ const ChatTextArea = forwardRef( const isComposing = event.nativeEvent?.isComposing ?? false - // Handle undo for enhanced prompt (Cmd+Z/Ctrl+Z) - if ( - (event.metaKey || event.ctrlKey) && - event.key === "z" && - !event.shiftKey && - originalPromptBeforeEnhancement - ) { - event.preventDefault() - setInputValue(originalPromptBeforeEnhancement) - setOriginalPromptBeforeEnhancement(null) - return - } - // Handle prompt history navigation using custom hook if (handleHistoryNavigation(event, showContextMenu, isComposing)) { return @@ -468,7 +472,6 @@ const ChatTextArea = forwardRef( fileSearchResults, handleHistoryNavigation, resetHistoryNavigation, - originalPromptBeforeEnhancement, ], ) @@ -487,11 +490,6 @@ const ChatTextArea = forwardRef( const newValue = e.target.value setInputValue(newValue) - // Clear original prompt when user manually changes input - if (originalPromptBeforeEnhancement) { - setOriginalPromptBeforeEnhancement(null) - } - // Reset history navigation when user types resetOnInputChange() @@ -549,14 +547,7 @@ const ChatTextArea = forwardRef( setFileSearchResults([]) // Clear file search results. } }, - [ - setInputValue, - setSearchRequestId, - setFileSearchResults, - setSearchLoading, - resetOnInputChange, - originalPromptBeforeEnhancement, - ], + [setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading, resetOnInputChange], ) useEffect(() => { diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx index 75324c97f4b..5c9f762f2eb 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx @@ -184,10 +184,70 @@ describe("ChatTextArea", () => { }) describe("enhanced prompt response", () => { - it("should update input value when receiving enhanced prompt", () => { + it("should update input value using native browser methods when receiving enhanced prompt", () => { const setInputValue = vi.fn() - render() + // Mock document.execCommand + const mockExecCommand = vi.fn().mockReturnValue(true) + Object.defineProperty(document, "execCommand", { + value: mockExecCommand, + writable: true, + }) + + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + // Mock textarea methods + const mockSelect = vi.fn() + const mockFocus = vi.fn() + textarea.select = mockSelect + textarea.focus = mockFocus + + // Simulate receiving enhanced prompt message + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "enhancedPrompt", + text: "Enhanced test prompt", + }, + }), + ) + + // Verify native browser methods were used + expect(mockFocus).toHaveBeenCalled() + expect(mockSelect).toHaveBeenCalled() + expect(mockExecCommand).toHaveBeenCalledWith("insertText", false, "Enhanced test prompt") + }) + + it("should fallback to setRangeText when execCommand is not available", () => { + const setInputValue = vi.fn() + + // Mock document.execCommand to be undefined (not available) + Object.defineProperty(document, "execCommand", { + value: undefined, + writable: true, + }) + + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + // Mock textarea methods + const mockSelect = vi.fn() + const mockFocus = vi.fn() + const mockSetRangeText = vi.fn() + const mockDispatchEvent = vi.fn() + + textarea.select = mockSelect + textarea.focus = mockFocus + textarea.setRangeText = mockSetRangeText + textarea.dispatchEvent = mockDispatchEvent + textarea.value = "Original prompt" // Simulate receiving enhanced prompt message window.dispatchEvent( @@ -199,7 +259,28 @@ describe("ChatTextArea", () => { }), ) - expect(setInputValue).toHaveBeenCalledWith("Enhanced test prompt") + // Verify fallback methods were used + expect(mockFocus).toHaveBeenCalled() + expect(mockSetRangeText).toHaveBeenCalledWith("Enhanced test prompt", 0, 15, "select") // 15 is length of "Original prompt" + expect(mockDispatchEvent).toHaveBeenCalledWith(expect.any(Event)) + }) + + it("should not crash when textarea ref is not available", () => { + const setInputValue = vi.fn() + + render() + + // Simulate receiving enhanced prompt message when textarea ref might not be ready + expect(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "enhancedPrompt", + text: "Enhanced test prompt", + }, + }), + ) + }).not.toThrow() }) }) diff --git a/webview-ui/vitest.setup.ts b/webview-ui/vitest.setup.ts index afa37bd96d4..12210f0ec20 100644 --- a/webview-ui/vitest.setup.ts +++ b/webview-ui/vitest.setup.ts @@ -1,6 +1,12 @@ import "@testing-library/jest-dom" import "@testing-library/jest-dom/vitest" +// Force React into development mode for tests +// This is needed to enable act(...) function in React Testing Library +globalThis.process = globalThis.process || {} +globalThis.process.env = globalThis.process.env || {} +globalThis.process.env.NODE_ENV = "development" + class MockResizeObserver { observe() {} unobserve() {} From 51b4c2b02d9bf93a0ca3886dbe2de9de2e9eec31 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 15 Jul 2025 09:48:42 -0400 Subject: [PATCH 3/5] PR feedback --- .../src/components/chat/ChatTextArea.tsx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 5ea213f2acf..51608d04dc7 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -125,17 +125,23 @@ const ChatTextArea = forwardRef( // Select all text first textarea.select() - // Use execCommand to replace text while preserving undo history - if (document.execCommand) { - document.execCommand("insertText", false, message.text) - } else { - // Fallback for browsers that don't support execCommand - // This approach also preserves undo history in modern browsers - textarea.setRangeText(message.text, 0, textarea.value.length, "select") - - // Trigger input event to notify React of the change - const inputEvent = new Event("input", { bubbles: true }) - textarea.dispatchEvent(inputEvent) + try { + // Use execCommand to replace text while preserving undo history + if (document.execCommand) { + document.execCommand("insertText", false, message.text) + } else { + // Fallback for browsers that don't support execCommand + // This approach also preserves undo history in modern browsers + textarea.setRangeText(message.text, 0, textarea.value.length, "select") + + // Trigger input event to notify React of the change + const inputEvent = new Event("input", { bubbles: true }) + textarea.dispatchEvent(inputEvent) + } + } catch (error) { + // Fallback to direct value assignment if native methods fail + console.warn("Native text replacement failed, falling back to direct assignment:", error) + setInputValue(message.text) } } From 1a8f579c06e14c177843e74e148708395a912f31 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 15 Jul 2025 10:07:27 -0400 Subject: [PATCH 4/5] Simplify --- .../src/components/chat/ChatTextArea.tsx | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 51608d04dc7..7b404ddccb6 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -116,31 +116,22 @@ const ChatTextArea = forwardRef( if (message.type === "enhancedPrompt") { if (message.text && textAreaRef.current) { - // Use native browser methods to preserve undo stack - const textarea = textAreaRef.current - - // Focus the textarea to ensure it's the active element - textarea.focus() - - // Select all text first - textarea.select() - try { // Use execCommand to replace text while preserving undo history if (document.execCommand) { + // Use native browser methods to preserve undo stack + const textarea = textAreaRef.current + + // Focus the textarea to ensure it's the active element + textarea.focus() + + // Select all text first + textarea.select() document.execCommand("insertText", false, message.text) } else { - // Fallback for browsers that don't support execCommand - // This approach also preserves undo history in modern browsers - textarea.setRangeText(message.text, 0, textarea.value.length, "select") - - // Trigger input event to notify React of the change - const inputEvent = new Event("input", { bubbles: true }) - textarea.dispatchEvent(inputEvent) + setInputValue(message.text) } - } catch (error) { - // Fallback to direct value assignment if native methods fail - console.warn("Native text replacement failed, falling back to direct assignment:", error) + } catch { setInputValue(message.text) } } From ad2d05e2b0376b537833778e7b15cdbf40974031 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 15 Jul 2025 10:18:18 -0400 Subject: [PATCH 5/5] Fix test --- .../chat/__tests__/ChatTextArea.spec.tsx | 26 +++---------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx index 5c9f762f2eb..973420207c7 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx @@ -222,7 +222,7 @@ describe("ChatTextArea", () => { expect(mockExecCommand).toHaveBeenCalledWith("insertText", false, "Enhanced test prompt") }) - it("should fallback to setRangeText when execCommand is not available", () => { + it("should fallback to setInputValue when execCommand is not available", () => { const setInputValue = vi.fn() // Mock document.execCommand to be undefined (not available) @@ -231,23 +231,7 @@ describe("ChatTextArea", () => { writable: true, }) - const { container } = render( - , - ) - - const textarea = container.querySelector("textarea")! - - // Mock textarea methods - const mockSelect = vi.fn() - const mockFocus = vi.fn() - const mockSetRangeText = vi.fn() - const mockDispatchEvent = vi.fn() - - textarea.select = mockSelect - textarea.focus = mockFocus - textarea.setRangeText = mockSetRangeText - textarea.dispatchEvent = mockDispatchEvent - textarea.value = "Original prompt" + render() // Simulate receiving enhanced prompt message window.dispatchEvent( @@ -259,10 +243,8 @@ describe("ChatTextArea", () => { }), ) - // Verify fallback methods were used - expect(mockFocus).toHaveBeenCalled() - expect(mockSetRangeText).toHaveBeenCalledWith("Enhanced test prompt", 0, 15, "select") // 15 is length of "Original prompt" - expect(mockDispatchEvent).toHaveBeenCalledWith(expect.any(Event)) + // Verify fallback to setInputValue was used + expect(setInputValue).toHaveBeenCalledWith("Enhanced test prompt") }) it("should not crash when textarea ref is not available", () => {