diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 5d8e0a2112..5e51edadce 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -27,6 +27,7 @@ import ContextMenu from "./ContextMenu" import { VolumeX, Pin, Check } from "lucide-react" import { IconButton } from "./IconButton" import { cn } from "@/lib/utils" +import { usePromptHistory } from "./hooks/usePromptHistory" interface ChatTextAreaProps { inputValue: string @@ -75,6 +76,8 @@ const ChatTextArea = forwardRef( cwd, pinnedApiConfigs, togglePinnedApiConfig, + taskHistory, + clineMessages, } = useExtensionState() // Find the ID and display text for the currently selected API configuration @@ -153,6 +156,21 @@ const ChatTextArea = forwardRef( const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false) const [isFocused, setIsFocused] = useState(false) + // Use custom hook for prompt history navigation + const { + inputValueWithCursor, + setInputValueWithCursor, + handleHistoryNavigation, + resetHistoryNavigation, + resetOnInputChange, + } = usePromptHistory({ + clineMessages, + taskHistory, + cwd, + inputValue, + setInputValue, + }) + // Fetch git commits when Git is selected or when typing a hash. useEffect(() => { if (selectedType === ContextMenuOptionType.Git || /^[a-f0-9]+$/i.test(searchQuery)) { @@ -360,10 +378,17 @@ const ChatTextArea = forwardRef( const isComposing = event.nativeEvent?.isComposing ?? false + // Handle prompt history navigation using custom hook + if (handleHistoryNavigation(event, showContextMenu, isComposing)) { + return + } + if (event.key === "Enter" && !event.shiftKey && !isComposing) { event.preventDefault() if (!sendingDisabled) { + // Reset history navigation state when sending + resetHistoryNavigation() onSend() } } @@ -427,6 +452,8 @@ const ChatTextArea = forwardRef( queryItems, customModes, fileSearchResults, + handleHistoryNavigation, + resetHistoryNavigation, ], ) @@ -437,6 +464,27 @@ const ChatTextArea = forwardRef( } }, [inputValue, intendedCursorPosition]) + // Handle cursor positioning after history navigation + useLayoutEffect(() => { + if (!inputValueWithCursor.afterRender || !textAreaRef.current) return + + if (inputValueWithCursor.afterRender === "SET_CURSOR_FIRST_LINE") { + const firstLineEnd = + inputValueWithCursor.value.indexOf("\n") === -1 + ? inputValueWithCursor.value.length + : inputValueWithCursor.value.indexOf("\n") + textAreaRef.current.setSelectionRange(firstLineEnd, firstLineEnd) + } else if (inputValueWithCursor.afterRender === "SET_CURSOR_LAST_LINE") { + const lines = inputValueWithCursor.value.split("\n") + const lastLineStart = inputValueWithCursor.value.length - lines[lines.length - 1].length + textAreaRef.current.setSelectionRange(lastLineStart, lastLineStart) + } else if (inputValueWithCursor.afterRender === "SET_CURSOR_START") { + textAreaRef.current.setSelectionRange(0, 0) + } + + setInputValueWithCursor({ value: inputValueWithCursor.value }) + }, [inputValueWithCursor, setInputValueWithCursor]) + // Ref to store the search timeout. const searchTimeoutRef = useRef(null) @@ -445,6 +493,9 @@ const ChatTextArea = forwardRef( const newValue = e.target.value setInputValue(newValue) + // Reset history navigation when user types + resetOnInputChange() + const newCursorPosition = e.target.selectionStart setCursorPosition(newCursorPosition) @@ -499,7 +550,7 @@ const ChatTextArea = forwardRef( setFileSearchResults([]) // Clear file search results. } }, - [setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading], + [setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading, resetOnInputChange], ) useEffect(() => { diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx index 8b09a5eb87..7f01245144 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx @@ -68,6 +68,8 @@ describe("ChatTextArea", () => { apiConfiguration: { apiProvider: "anthropic", }, + taskHistory: [], + cwd: "/test/workspace", }) }) @@ -76,6 +78,8 @@ describe("ChatTextArea", () => { ;(useExtensionState as jest.Mock).mockReturnValue({ filePaths: [], openedTabs: [], + taskHistory: [], + cwd: "/test/workspace", }) render() const enhanceButton = getEnhancePromptButton() @@ -94,6 +98,8 @@ describe("ChatTextArea", () => { filePaths: [], openedTabs: [], apiConfiguration, + taskHistory: [], + cwd: "/test/workspace", }) render() @@ -114,6 +120,8 @@ describe("ChatTextArea", () => { apiConfiguration: { apiProvider: "openrouter", }, + taskHistory: [], + cwd: "/test/workspace", }) render() @@ -131,6 +139,8 @@ describe("ChatTextArea", () => { apiConfiguration: { apiProvider: "openrouter", }, + taskHistory: [], + cwd: "/test/workspace", }) render() @@ -155,6 +165,8 @@ describe("ChatTextArea", () => { apiProvider: "openrouter", newSetting: "test", }, + taskHistory: [], + cwd: "/test/workspace", }) rerender() @@ -408,6 +420,338 @@ describe("ChatTextArea", () => { // Verify setInputValue was not called expect(setInputValue).not.toHaveBeenCalled() }) + + describe("prompt history navigation", () => { + const mockClineMessages = [ + { type: "say", say: "user_feedback", text: "First prompt", ts: 1000 }, + { type: "say", say: "user_feedback", text: "Second prompt", ts: 2000 }, + { type: "say", say: "user_feedback", text: "Third prompt", ts: 3000 }, + ] + + beforeEach(() => { + ;(useExtensionState as jest.Mock).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "anthropic", + }, + taskHistory: [], + clineMessages: mockClineMessages, + cwd: "/test/workspace", + }) + }) + + it("should navigate to previous prompt on arrow up", () => { + const setInputValue = jest.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + // Simulate arrow up key press + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + + // Should set the newest conversation message (first in reversed array) + expect(setInputValue).toHaveBeenCalledWith("Third prompt") + }) + + it("should navigate through history with multiple arrow up presses", () => { + const setInputValue = jest.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + // First arrow up - newest conversation message + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + expect(setInputValue).toHaveBeenCalledWith("Third prompt") + + // Update input value to simulate the state change + setInputValue.mockClear() + + // Second arrow up - previous conversation message + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + expect(setInputValue).toHaveBeenCalledWith("Second prompt") + }) + + it("should navigate forward with arrow down", () => { + const setInputValue = jest.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + // Go back in history first (index 0 -> "Third prompt", then index 1 -> "Second prompt") + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + setInputValue.mockClear() + + // Navigate forward (from index 1 back to index 0) + fireEvent.keyDown(textarea, { key: "ArrowDown" }) + expect(setInputValue).toHaveBeenCalledWith("Third prompt") + }) + + it("should preserve current input when starting navigation", () => { + const setInputValue = jest.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + // Navigate to history + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + expect(setInputValue).toHaveBeenCalledWith("Third prompt") + + setInputValue.mockClear() + + // Navigate back to current input + fireEvent.keyDown(textarea, { key: "ArrowDown" }) + expect(setInputValue).toHaveBeenCalledWith("Current input") + }) + + it("should reset history navigation when user types", () => { + const setInputValue = jest.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + // Navigate to history + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + setInputValue.mockClear() + + // Type something + fireEvent.change(textarea, { target: { value: "New input", selectionStart: 9 } }) + + // Should reset history navigation + expect(setInputValue).toHaveBeenCalledWith("New input") + }) + + it("should reset history navigation when sending message", () => { + const onSend = jest.fn() + const setInputValue = jest.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + // Navigate to history first + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + setInputValue.mockClear() + + // Send message + fireEvent.keyDown(textarea, { key: "Enter" }) + + expect(onSend).toHaveBeenCalled() + }) + + it("should navigate history when cursor is at first line", () => { + const setInputValue = jest.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + // Clear any calls from initial render + setInputValue.mockClear() + + // With empty input, cursor is at first line by default + // Arrow up should navigate history + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + expect(setInputValue).toHaveBeenCalledWith("Third prompt") + }) + + it("should filter history by current workspace", () => { + const mixedClineMessages = [ + { type: "say", say: "user_feedback", text: "Workspace 1 prompt", ts: 1000 }, + { type: "say", say: "user_feedback", text: "Other workspace prompt", ts: 2000 }, + { type: "say", say: "user_feedback", text: "Workspace 1 prompt 2", ts: 3000 }, + ] + + ;(useExtensionState as jest.Mock).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "anthropic", + }, + taskHistory: [], + clineMessages: mixedClineMessages, + cwd: "/test/workspace", + }) + + const setInputValue = jest.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + // Should show conversation messages newest first (after reverse) + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + expect(setInputValue).toHaveBeenCalledWith("Workspace 1 prompt 2") + + setInputValue.mockClear() + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + expect(setInputValue).toHaveBeenCalledWith("Other workspace prompt") + }) + + it("should handle empty conversation history gracefully", () => { + ;(useExtensionState as jest.Mock).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "anthropic", + }, + taskHistory: [], + clineMessages: [], + cwd: "/test/workspace", + }) + + const setInputValue = jest.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + // Should not crash or call setInputValue + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + expect(setInputValue).not.toHaveBeenCalled() + }) + + it("should ignore empty or whitespace-only messages", () => { + const clineMessagesWithEmpty = [ + { type: "say", say: "user_feedback", text: "Valid prompt", ts: 1000 }, + { type: "say", say: "user_feedback", text: "", ts: 2000 }, + { type: "say", say: "user_feedback", text: " ", ts: 3000 }, + { type: "say", say: "user_feedback", text: "Another valid prompt", ts: 4000 }, + ] + + ;(useExtensionState as jest.Mock).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "anthropic", + }, + taskHistory: [], + clineMessages: clineMessagesWithEmpty, + cwd: "/test/workspace", + }) + + const setInputValue = jest.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + // Should skip empty messages, newest first for conversation + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + expect(setInputValue).toHaveBeenCalledWith("Another valid prompt") + + setInputValue.mockClear() + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + expect(setInputValue).toHaveBeenCalledWith("Valid prompt") + }) + + it("should use task history (oldest first) when no conversation messages exist", () => { + const mockTaskHistory = [ + { task: "First task", workspace: "/test/workspace" }, + { task: "Second task", workspace: "/test/workspace" }, + { task: "Third task", workspace: "/test/workspace" }, + ] + + ;(useExtensionState as jest.Mock).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "anthropic", + }, + taskHistory: mockTaskHistory, + clineMessages: [], // No conversation messages + cwd: "/test/workspace", + }) + + const setInputValue = jest.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + // Should show task history oldest first (chronological order) + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + expect(setInputValue).toHaveBeenCalledWith("First task") + + setInputValue.mockClear() + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + expect(setInputValue).toHaveBeenCalledWith("Second task") + }) + + it("should reset navigation position when switching between history sources", () => { + const setInputValue = jest.fn() + const { rerender } = render( + , + ) + + // Start with task history + ;(useExtensionState as jest.Mock).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "anthropic", + }, + taskHistory: [ + { task: "Task 1", workspace: "/test/workspace" }, + { task: "Task 2", workspace: "/test/workspace" }, + ], + clineMessages: [], + cwd: "/test/workspace", + }) + + rerender() + + const textarea = document.querySelector("textarea")! + + // Navigate in task history + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + expect(setInputValue).toHaveBeenCalledWith("Task 1") + + // Switch to conversation messages + ;(useExtensionState as jest.Mock).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "anthropic", + }, + taskHistory: [], + clineMessages: [ + { type: "say", say: "user_feedback", text: "Message 1", ts: 1000 }, + { type: "say", say: "user_feedback", text: "Message 2", ts: 2000 }, + ], + cwd: "/test/workspace", + }) + + setInputValue.mockClear() + rerender() + + // Should start from beginning of conversation history (newest first) + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + expect(setInputValue).toHaveBeenCalledWith("Message 2") + }) + }) }) describe("selectApiConfig", () => { diff --git a/webview-ui/src/components/chat/hooks/usePromptHistory.ts b/webview-ui/src/components/chat/hooks/usePromptHistory.ts new file mode 100644 index 0000000000..810c4a606a --- /dev/null +++ b/webview-ui/src/components/chat/hooks/usePromptHistory.ts @@ -0,0 +1,198 @@ +import { ClineMessage, HistoryItem } from "@roo-code/types" +import { useCallback, useEffect, useMemo, useState } from "react" + +interface UsePromptHistoryProps { + clineMessages: ClineMessage[] | undefined + taskHistory: HistoryItem[] | undefined + cwd: string | undefined + inputValue: string + setInputValue: (value: string) => void +} + +interface CursorPositionState { + value: string + afterRender?: "SET_CURSOR_FIRST_LINE" | "SET_CURSOR_LAST_LINE" | "SET_CURSOR_START" +} + +export interface UsePromptHistoryReturn { + historyIndex: number + setHistoryIndex: (index: number) => void + tempInput: string + setTempInput: (input: string) => void + promptHistory: string[] + inputValueWithCursor: CursorPositionState + setInputValueWithCursor: (state: CursorPositionState) => void + handleHistoryNavigation: ( + event: React.KeyboardEvent, + showContextMenu: boolean, + isComposing: boolean, + ) => boolean + resetHistoryNavigation: () => void + resetOnInputChange: () => void +} + +export const usePromptHistory = ({ + clineMessages, + taskHistory, + cwd, + inputValue, + setInputValue, +}: UsePromptHistoryProps): UsePromptHistoryReturn => { + // Maximum number of prompts to keep in history for memory management + const MAX_PROMPT_HISTORY_SIZE = 100 + + // Prompt history navigation state + const [historyIndex, setHistoryIndex] = useState(-1) + const [tempInput, setTempInput] = useState("") + const [promptHistory, setPromptHistory] = useState([]) + const [inputValueWithCursor, setInputValueWithCursor] = useState({ value: inputValue }) + + // Initialize prompt history with hybrid approach: conversation messages if in task, otherwise task history + const filteredPromptHistory = useMemo(() => { + // First try to get conversation messages (user_feedback from clineMessages) + const conversationPrompts = clineMessages + ?.filter((message) => { + // Filter for user_feedback messages that have text content + return ( + message.type === "say" && + message.say === "user_feedback" && + message.text && + message.text.trim() !== "" + ) + }) + .map((message) => message.text!) + + // If we have conversation messages, use those (newest first when navigating up) + if (conversationPrompts && conversationPrompts.length > 0) { + return conversationPrompts.slice(-MAX_PROMPT_HISTORY_SIZE).reverse() // newest first for conversation messages + } + + // If we have clineMessages array (meaning we're in an active task), don't fall back to task history + // Only use task history when starting fresh (no active conversation) + if (clineMessages && clineMessages.length > 0) { + return [] + } + + // Fall back to task history only when starting fresh (no active conversation) + if (!taskHistory || taskHistory.length === 0 || !cwd) { + return [] + } + + // Extract user prompts from task history for the current workspace only + const taskPrompts = taskHistory + .filter((item) => { + // Filter by workspace and ensure task is not empty + return item.task && item.task.trim() !== "" && (!item.workspace || item.workspace === cwd) + }) + .map((item) => item.task) + .slice(0, MAX_PROMPT_HISTORY_SIZE) + + return taskPrompts + }, [clineMessages, taskHistory, cwd]) + + // Update prompt history when filtered history changes and reset navigation + useEffect(() => { + setPromptHistory(filteredPromptHistory) + // Reset navigation state when switching between history sources + setHistoryIndex(-1) + setTempInput("") + }, [filteredPromptHistory]) + + // Reset history navigation when user types (but not when we're setting it programmatically) + const resetOnInputChange = useCallback(() => { + if (historyIndex !== -1) { + setHistoryIndex(-1) + setTempInput("") + } + }, [historyIndex]) + + const handleHistoryNavigation = useCallback( + (event: React.KeyboardEvent, showContextMenu: boolean, isComposing: boolean): boolean => { + // Handle prompt history navigation + if (!showContextMenu && promptHistory.length > 0 && !isComposing) { + const textarea = event.currentTarget + const { selectionStart, selectionEnd, value } = textarea + const lines = value.substring(0, selectionStart).split("\n") + const currentLineIndex = lines.length - 1 + const totalLines = value.split("\n").length + const isAtFirstLine = currentLineIndex === 0 + const isAtLastLine = currentLineIndex === totalLines - 1 + const hasSelection = selectionStart !== selectionEnd + + // Only navigate history if cursor is at first/last line and no text is selected + if (!hasSelection) { + if (event.key === "ArrowUp" && isAtFirstLine) { + event.preventDefault() + + // Save current input if starting navigation + if (historyIndex === -1 && inputValue.trim() !== "") { + setTempInput(inputValue) + } + + // Navigate to previous prompt + const newIndex = historyIndex + 1 + if (newIndex < promptHistory.length) { + setHistoryIndex(newIndex) + const historicalPrompt = promptHistory[newIndex] + if (historicalPrompt) { + setInputValue(historicalPrompt) + setInputValueWithCursor({ + value: historicalPrompt, + afterRender: "SET_CURSOR_FIRST_LINE", + }) + } + } + return true + } + + if (event.key === "ArrowDown" && isAtLastLine) { + event.preventDefault() + + // Navigate to next prompt + if (historyIndex > 0) { + const newIndex = historyIndex - 1 + setHistoryIndex(newIndex) + const historicalPrompt = promptHistory[newIndex] + if (historicalPrompt) { + setInputValue(historicalPrompt) + setInputValueWithCursor({ + value: historicalPrompt, + afterRender: "SET_CURSOR_LAST_LINE", + }) + } + } else if (historyIndex === 0) { + // Return to current input + setHistoryIndex(-1) + setInputValue(tempInput) + setInputValueWithCursor({ + value: tempInput, + afterRender: "SET_CURSOR_START", + }) + } + return true + } + } + } + return false + }, + [promptHistory, historyIndex, inputValue, tempInput, setInputValue], + ) + + const resetHistoryNavigation = useCallback(() => { + setHistoryIndex(-1) + setTempInput("") + }, []) + + return { + historyIndex, + setHistoryIndex, + tempInput, + setTempInput, + promptHistory, + inputValueWithCursor, + setInputValueWithCursor, + handleHistoryNavigation, + resetHistoryNavigation, + resetOnInputChange, + } +}