From 4000aefc536b4a65ff89e620ae9bc35fd8657702 Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Sat, 7 Jun 2025 19:33:21 -0600 Subject: [PATCH 01/11] feat: add prompt history navigation with arrow keys (#4139) - Navigate through prompt history using arrow up/down keys - Only triggers when cursor is at first line (up) or last line (down) - Preserves current input when starting navigation - Resets navigation state when typing or sending messages - Follows VSCode's standard UX patterns for history navigation --- .../src/components/chat/ChatTextArea.tsx | 109 +++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 5d8e0a2112..4c4c4c1ec8 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -75,6 +75,7 @@ const ChatTextArea = forwardRef( cwd, pinnedApiConfigs, togglePinnedApiConfig, + taskHistory, } = useExtensionState() // Find the ID and display text for the currently selected API configuration @@ -153,6 +154,23 @@ const ChatTextArea = forwardRef( const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false) const [isFocused, setIsFocused] = useState(false) + // Prompt history navigation state + const [historyIndex, setHistoryIndex] = useState(-1) + const [tempInput, setTempInput] = useState("") + const [promptHistory, setPromptHistory] = useState([]) + + // Initialize prompt history from task history + useEffect(() => { + if (taskHistory && taskHistory.length > 0) { + // Extract user prompts from task history + const prompts = taskHistory + .filter((item) => item.task && item.task.trim() !== "") + .map((item) => item.task) + .reverse() // Most recent first + setPromptHistory(prompts) + } + }, [taskHistory]) + // 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,90 @@ const ChatTextArea = forwardRef( const isComposing = event.nativeEvent?.isComposing ?? false + // 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] + setInputValue(historicalPrompt) + + // Set cursor to end of first line + setTimeout(() => { + if (textAreaRef.current) { + const firstLineEnd = + historicalPrompt.indexOf("\n") === -1 + ? historicalPrompt.length + : historicalPrompt.indexOf("\n") + textAreaRef.current.setSelectionRange(firstLineEnd, firstLineEnd) + } + }, 0) + } + return + } + + if (event.key === "ArrowDown" && isAtLastLine) { + event.preventDefault() + + // Navigate to next prompt + if (historyIndex > 0) { + const newIndex = historyIndex - 1 + setHistoryIndex(newIndex) + const historicalPrompt = promptHistory[newIndex] + setInputValue(historicalPrompt) + + // Set cursor to start of last line + setTimeout(() => { + if (textAreaRef.current) { + const lines = historicalPrompt.split("\n") + const lastLineStart = historicalPrompt.length - lines[lines.length - 1].length + textAreaRef.current.setSelectionRange(lastLineStart, lastLineStart) + } + }, 0) + } else if (historyIndex === 0) { + // Return to current input + setHistoryIndex(-1) + setInputValue(tempInput) + + // Set cursor to start + setTimeout(() => { + if (textAreaRef.current) { + textAreaRef.current.setSelectionRange(0, 0) + } + }, 0) + } + return + } + } + } + if (event.key === "Enter" && !event.shiftKey && !isComposing) { event.preventDefault() if (!sendingDisabled) { + // Reset history navigation state when sending + setHistoryIndex(-1) + setTempInput("") onSend() } } @@ -427,6 +525,9 @@ const ChatTextArea = forwardRef( queryItems, customModes, fileSearchResults, + historyIndex, + tempInput, + promptHistory, ], ) @@ -445,6 +546,12 @@ const ChatTextArea = forwardRef( const newValue = e.target.value setInputValue(newValue) + // Reset history navigation when user types + if (historyIndex !== -1) { + setHistoryIndex(-1) + setTempInput("") + } + const newCursorPosition = e.target.selectionStart setCursorPosition(newCursorPosition) @@ -499,7 +606,7 @@ const ChatTextArea = forwardRef( setFileSearchResults([]) // Clear file search results. } }, - [setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading], + [setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading, historyIndex], ) useEffect(() => { From d1f15ba9c45da0e226ba0cc420877687e7342773 Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Sat, 7 Jun 2025 19:41:42 -0600 Subject: [PATCH 02/11] fix: correct prompt history order and add workspace filtering (#4139) - Remove reverse() to maintain chronological order in history array - Add workspace filtering to only show prompts from current workspace - Ensure arrow up navigates to older prompts (as expected) - Filter history items by workspace field matching current cwd --- webview-ui/src/components/chat/ChatTextArea.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 4c4c4c1ec8..9fd1354cb4 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -161,15 +161,21 @@ const ChatTextArea = forwardRef( // Initialize prompt history from task history useEffect(() => { - if (taskHistory && taskHistory.length > 0) { - // Extract user prompts from task history + if (taskHistory && taskHistory.length > 0 && cwd) { + // Extract user prompts from task history for the current workspace only const prompts = taskHistory - .filter((item) => item.task && item.task.trim() !== "") + .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) - .reverse() // Most recent first + // taskHistory is already in chronological order (oldest first) + // We keep it as-is so that navigation works correctly: + // - Arrow up increases index to go back in history (older prompts) + // - Arrow down decreases index to go forward (newer prompts) setPromptHistory(prompts) } - }, [taskHistory]) + }, [taskHistory, cwd]) // Fetch git commits when Git is selected or when typing a hash. useEffect(() => { From c38c43bd7fc7629f094c5ddaba0f90823e8b2fae Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Mon, 9 Jun 2025 10:26:02 -0600 Subject: [PATCH 03/11] test: Fix Windows unit test failures for prompt history navigation - Add missing taskHistory and cwd properties to all useExtensionState mocks - Add comprehensive test coverage for prompt history navigation feature - Ensure all 25 tests pass including new prompt history functionality Fixes failing Windows CI test in PR #4450 --- .../chat/__tests__/ChatTextArea.test.tsx | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx index 8b09a5eb87..275ccbe5b6 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,249 @@ describe("ChatTextArea", () => { // Verify setInputValue was not called expect(setInputValue).not.toHaveBeenCalled() }) + + describe("prompt history navigation", () => { + const mockTaskHistory = [ + { task: "First prompt", workspace: "/test/workspace" }, + { task: "Second prompt", workspace: "/test/workspace" }, + { task: "Third prompt", workspace: "/test/workspace" }, + ] + + beforeEach(() => { + ;(useExtensionState as jest.Mock).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "anthropic", + }, + taskHistory: mockTaskHistory, + 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 most recent prompt (last in array) + expect(setInputValue).toHaveBeenCalledWith("First 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 - most recent prompt + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + expect(setInputValue).toHaveBeenCalledWith("First prompt") + + // Update input value to simulate the state change + setInputValue.mockClear() + + // Second arrow up - previous prompt + 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 -> "First 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("First 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("First prompt") + + setInputValue.mockClear() + + // Navigate back to current input + fireEvent.keyDown(textarea, { key: "ArrowDown" }) + 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("First prompt") + }) + + it("should filter history by current workspace", () => { + const mixedTaskHistory = [ + { task: "Workspace 1 prompt", workspace: "/test/workspace" }, + { task: "Other workspace prompt", workspace: "/other/workspace" }, + { task: "Workspace 1 prompt 2", workspace: "/test/workspace" }, + ] + + ;(useExtensionState as jest.Mock).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "anthropic", + }, + taskHistory: mixedTaskHistory, + cwd: "/test/workspace", + }) + + const setInputValue = jest.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + // Should only show prompts from current workspace + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + expect(setInputValue).toHaveBeenCalledWith("Workspace 1 prompt") + + setInputValue.mockClear() + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + expect(setInputValue).toHaveBeenCalledWith("Workspace 1 prompt 2") + }) + + it("should handle empty task history gracefully", () => { + ;(useExtensionState as jest.Mock).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "anthropic", + }, + taskHistory: [], + 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 tasks", () => { + const taskHistoryWithEmpty = [ + { task: "Valid prompt", workspace: "/test/workspace" }, + { task: "", workspace: "/test/workspace" }, + { task: " ", workspace: "/test/workspace" }, + { task: "Another valid prompt", workspace: "/test/workspace" }, + ] + + ;(useExtensionState as jest.Mock).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "anthropic", + }, + taskHistory: taskHistoryWithEmpty, + cwd: "/test/workspace", + }) + + const setInputValue = jest.fn() + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + // Should skip empty tasks + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + expect(setInputValue).toHaveBeenCalledWith("Valid prompt") + + setInputValue.mockClear() + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + expect(setInputValue).toHaveBeenCalledWith("Another valid prompt") + }) + }) }) describe("selectApiConfig", () => { From 6e4f195f369d938ddaaa165f1c6d39117a2bc7bb Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Mon, 9 Jun 2025 10:29:02 -0600 Subject: [PATCH 04/11] refactor: Improve cursor positioning with useLayoutEffect - Replace setTimeout(..., 0) with useLayoutEffect for more reliable cursor positioning - Implement state-based cursor positioning pattern suggested by @mochiya98 - Add CursorPositionState interface for better type safety - Maintain all existing functionality while improving timing reliability This addresses the technical suggestion in PR #4450 comment about using useLayoutEffect instead of setTimeout for DOM manipulation timing. --- .../src/components/chat/ChatTextArea.tsx | 66 +++++++++++-------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 9fd1354cb4..b529bfef47 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -45,6 +45,11 @@ interface ChatTextAreaProps { modeShortcutText: string } +interface CursorPositionState { + value: string + afterRender?: "SET_CURSOR_FIRST_LINE" | "SET_CURSOR_LAST_LINE" | "SET_CURSOR_START" +} + const ChatTextArea = forwardRef( ( { @@ -158,6 +163,7 @@ const ChatTextArea = forwardRef( const [historyIndex, setHistoryIndex] = useState(-1) const [tempInput, setTempInput] = useState("") const [promptHistory, setPromptHistory] = useState([]) + const [inputValueWithCursor, setInputValueWithCursor] = useState({ value: inputValue }) // Initialize prompt history from task history useEffect(() => { @@ -411,17 +417,10 @@ const ChatTextArea = forwardRef( setHistoryIndex(newIndex) const historicalPrompt = promptHistory[newIndex] setInputValue(historicalPrompt) - - // Set cursor to end of first line - setTimeout(() => { - if (textAreaRef.current) { - const firstLineEnd = - historicalPrompt.indexOf("\n") === -1 - ? historicalPrompt.length - : historicalPrompt.indexOf("\n") - textAreaRef.current.setSelectionRange(firstLineEnd, firstLineEnd) - } - }, 0) + setInputValueWithCursor({ + value: historicalPrompt, + afterRender: "SET_CURSOR_FIRST_LINE", + }) } return } @@ -435,26 +434,18 @@ const ChatTextArea = forwardRef( setHistoryIndex(newIndex) const historicalPrompt = promptHistory[newIndex] setInputValue(historicalPrompt) - - // Set cursor to start of last line - setTimeout(() => { - if (textAreaRef.current) { - const lines = historicalPrompt.split("\n") - const lastLineStart = historicalPrompt.length - lines[lines.length - 1].length - textAreaRef.current.setSelectionRange(lastLineStart, lastLineStart) - } - }, 0) + setInputValueWithCursor({ + value: historicalPrompt, + afterRender: "SET_CURSOR_LAST_LINE", + }) } else if (historyIndex === 0) { // Return to current input setHistoryIndex(-1) setInputValue(tempInput) - - // Set cursor to start - setTimeout(() => { - if (textAreaRef.current) { - textAreaRef.current.setSelectionRange(0, 0) - } - }, 0) + setInputValueWithCursor({ + value: tempInput, + afterRender: "SET_CURSOR_START", + }) } return } @@ -544,6 +535,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]) + // Ref to store the search timeout. const searchTimeoutRef = useRef(null) From 1a0bb9fc6a1ef53b5cad3c3e01dfac78dec1c98a Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Mon, 9 Jun 2025 12:34:55 -0600 Subject: [PATCH 05/11] feat: optimize prompt history with performance improvements and memory management - Add useMemo for prompt history filtering to prevent unnecessary re-computations - Implement MAX_PROMPT_HISTORY_SIZE = 100 limit for memory management - Extract logic into usePromptHistory custom hook for better code organization - Simplify ChatTextArea component by delegating history logic to custom hook Addresses review feedback on PR #4450 for issue #4139 --- .../src/components/chat/ChatTextArea.tsx | 120 +++--------- .../components/chat/hooks/usePromptHistory.ts | 176 ++++++++++++++++++ 2 files changed, 203 insertions(+), 93 deletions(-) create mode 100644 webview-ui/src/components/chat/hooks/usePromptHistory.ts diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index b529bfef47..b66ecae938 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 @@ -159,29 +160,24 @@ const ChatTextArea = forwardRef( const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false) const [isFocused, setIsFocused] = useState(false) - // 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 from task history - useEffect(() => { - if (taskHistory && taskHistory.length > 0 && cwd) { - // Extract user prompts from task history for the current workspace only - const prompts = 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) - // taskHistory is already in chronological order (oldest first) - // We keep it as-is so that navigation works correctly: - // - Arrow up increases index to go back in history (older prompts) - // - Arrow down decreases index to go forward (newer prompts) - setPromptHistory(prompts) - } - }, [taskHistory, cwd]) + // Use custom hook for prompt history navigation + const { + historyIndex, + setHistoryIndex, + tempInput, + setTempInput, + promptHistory, + inputValueWithCursor, + setInputValueWithCursor, + handleHistoryNavigation, + resetHistoryNavigation, + resetOnInputChange, + } = usePromptHistory({ + taskHistory, + cwd, + inputValue, + setInputValue, + }) // Fetch git commits when Git is selected or when typing a hash. useEffect(() => { @@ -390,66 +386,9 @@ const ChatTextArea = forwardRef( const isComposing = event.nativeEvent?.isComposing ?? false - // 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] - setInputValue(historicalPrompt) - setInputValueWithCursor({ - value: historicalPrompt, - afterRender: "SET_CURSOR_FIRST_LINE", - }) - } - return - } - - if (event.key === "ArrowDown" && isAtLastLine) { - event.preventDefault() - - // Navigate to next prompt - if (historyIndex > 0) { - const newIndex = historyIndex - 1 - setHistoryIndex(newIndex) - const historicalPrompt = promptHistory[newIndex] - 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 - } - } + // Handle prompt history navigation using custom hook + if (handleHistoryNavigation(event, showContextMenu, isComposing)) { + return } if (event.key === "Enter" && !event.shiftKey && !isComposing) { @@ -457,8 +396,7 @@ const ChatTextArea = forwardRef( if (!sendingDisabled) { // Reset history navigation state when sending - setHistoryIndex(-1) - setTempInput("") + resetHistoryNavigation() onSend() } } @@ -522,9 +460,8 @@ const ChatTextArea = forwardRef( queryItems, customModes, fileSearchResults, - historyIndex, - tempInput, - promptHistory, + handleHistoryNavigation, + resetHistoryNavigation, ], ) @@ -565,10 +502,7 @@ const ChatTextArea = forwardRef( setInputValue(newValue) // Reset history navigation when user types - if (historyIndex !== -1) { - setHistoryIndex(-1) - setTempInput("") - } + resetOnInputChange(newValue) const newCursorPosition = e.target.selectionStart setCursorPosition(newCursorPosition) @@ -624,7 +558,7 @@ const ChatTextArea = forwardRef( setFileSearchResults([]) // Clear file search results. } }, - [setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading, historyIndex], + [setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading, resetOnInputChange], ) useEffect(() => { 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..2eec7cf1a3 --- /dev/null +++ b/webview-ui/src/components/chat/hooks/usePromptHistory.ts @@ -0,0 +1,176 @@ +import { useCallback, useEffect, useMemo, useState } from "react" + +interface TaskHistoryItem { + task: string + workspace?: string +} + +interface UsePromptHistoryProps { + taskHistory: TaskHistoryItem[] | 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: (newValue: string) => void +} + +export const usePromptHistory = ({ + 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 from task history with performance optimization + const filteredPromptHistory = useMemo(() => { + if (!taskHistory || taskHistory.length === 0 || !cwd) { + return [] + } + + // Extract user prompts from task history for the current workspace only + const prompts = 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) + // Limit history size to prevent memory issues + .slice(-MAX_PROMPT_HISTORY_SIZE) + + // taskHistory is already in chronological order (oldest first) + // We keep it as-is so that navigation works correctly: + // - Arrow up increases index to go back in history (older prompts) + // - Arrow down decreases index to go forward (newer prompts) + return prompts + }, [taskHistory, cwd]) + + // Update prompt history when filtered history changes + useEffect(() => { + setPromptHistory(filteredPromptHistory) + }, [filteredPromptHistory]) + + // Reset history navigation when user types (but not when we're setting it programmatically) + const resetOnInputChange = useCallback( + (newValue: string) => { + 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] + 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] + 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, + } +} From 8a892d3873dd04f31570fb1477597ce615bdb5c7 Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Mon, 9 Jun 2025 12:36:23 -0600 Subject: [PATCH 06/11] refactor: clean up unused code and fix linting issues in prompt history - Remove unused CursorPositionState interface from ChatTextArea - Remove unused destructured variables from usePromptHistory hook - Fix missing dependency in useEffect dependency array - Rename unused parameter with underscore prefix Related to #4139 --- webview-ui/src/components/chat/ChatTextArea.tsx | 12 +----------- .../src/components/chat/hooks/usePromptHistory.ts | 2 +- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index b66ecae938..4694deb831 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -46,11 +46,6 @@ interface ChatTextAreaProps { modeShortcutText: string } -interface CursorPositionState { - value: string - afterRender?: "SET_CURSOR_FIRST_LINE" | "SET_CURSOR_LAST_LINE" | "SET_CURSOR_START" -} - const ChatTextArea = forwardRef( ( { @@ -162,11 +157,6 @@ const ChatTextArea = forwardRef( // Use custom hook for prompt history navigation const { - historyIndex, - setHistoryIndex, - tempInput, - setTempInput, - promptHistory, inputValueWithCursor, setInputValueWithCursor, handleHistoryNavigation, @@ -491,7 +481,7 @@ const ChatTextArea = forwardRef( } setInputValueWithCursor({ value: inputValueWithCursor.value }) - }, [inputValueWithCursor]) + }, [inputValueWithCursor, setInputValueWithCursor]) // Ref to store the search timeout. const searchTimeoutRef = useRef(null) diff --git a/webview-ui/src/components/chat/hooks/usePromptHistory.ts b/webview-ui/src/components/chat/hooks/usePromptHistory.ts index 2eec7cf1a3..e3c2c6faf3 100644 --- a/webview-ui/src/components/chat/hooks/usePromptHistory.ts +++ b/webview-ui/src/components/chat/hooks/usePromptHistory.ts @@ -79,7 +79,7 @@ export const usePromptHistory = ({ // Reset history navigation when user types (but not when we're setting it programmatically) const resetOnInputChange = useCallback( - (newValue: string) => { + (_newValue: string) => { if (historyIndex !== -1) { setHistoryIndex(-1) setTempInput("") From 19abea3ae06f61ec75294b7637a72d8816fcea5f Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Wed, 11 Jun 2025 15:48:35 -0600 Subject: [PATCH 07/11] feat: implement hybrid prompt history with position reset - In chat: Use conversation messages (user_feedback), newest first - Out of chat: Use task history, oldest first - Reset navigation position when switching between history sources - Switch from taskHistory to clineMessages for active conversations - Maintain backward compatibility with task history fallback - Add comprehensive tests for hybrid behavior and position reset This provides intuitive UX where: - Users navigate recent conversation messages during tasks (newest first) - Users access initial task prompts when starting fresh (oldest first) - Navigation always starts fresh when switching contexts --- .../src/components/chat/ChatTextArea.tsx | 4 +- .../chat/__tests__/ChatTextArea.test.tsx | 155 ++++++++++++++---- .../components/chat/hooks/usePromptHistory.ts | 88 ++++++---- 3 files changed, 184 insertions(+), 63 deletions(-) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 4694deb831..5e51edadce 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -77,6 +77,7 @@ const ChatTextArea = forwardRef( pinnedApiConfigs, togglePinnedApiConfig, taskHistory, + clineMessages, } = useExtensionState() // Find the ID and display text for the currently selected API configuration @@ -163,6 +164,7 @@ const ChatTextArea = forwardRef( resetHistoryNavigation, resetOnInputChange, } = usePromptHistory({ + clineMessages, taskHistory, cwd, inputValue, @@ -492,7 +494,7 @@ const ChatTextArea = forwardRef( setInputValue(newValue) // Reset history navigation when user types - resetOnInputChange(newValue) + resetOnInputChange() const newCursorPosition = e.target.selectionStart setCursorPosition(newCursorPosition) diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx index 275ccbe5b6..7f01245144 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx @@ -422,10 +422,10 @@ describe("ChatTextArea", () => { }) describe("prompt history navigation", () => { - const mockTaskHistory = [ - { task: "First prompt", workspace: "/test/workspace" }, - { task: "Second prompt", workspace: "/test/workspace" }, - { task: "Third prompt", workspace: "/test/workspace" }, + 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(() => { @@ -435,7 +435,8 @@ describe("ChatTextArea", () => { apiConfiguration: { apiProvider: "anthropic", }, - taskHistory: mockTaskHistory, + taskHistory: [], + clineMessages: mockClineMessages, cwd: "/test/workspace", }) }) @@ -451,8 +452,8 @@ describe("ChatTextArea", () => { // Simulate arrow up key press fireEvent.keyDown(textarea, { key: "ArrowUp" }) - // Should set the most recent prompt (last in array) - expect(setInputValue).toHaveBeenCalledWith("First prompt") + // 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", () => { @@ -463,14 +464,14 @@ describe("ChatTextArea", () => { const textarea = container.querySelector("textarea")! - // First arrow up - most recent prompt + // First arrow up - newest conversation message fireEvent.keyDown(textarea, { key: "ArrowUp" }) - expect(setInputValue).toHaveBeenCalledWith("First prompt") + expect(setInputValue).toHaveBeenCalledWith("Third prompt") // Update input value to simulate the state change setInputValue.mockClear() - // Second arrow up - previous prompt + // Second arrow up - previous conversation message fireEvent.keyDown(textarea, { key: "ArrowUp" }) expect(setInputValue).toHaveBeenCalledWith("Second prompt") }) @@ -483,14 +484,14 @@ describe("ChatTextArea", () => { const textarea = container.querySelector("textarea")! - // Go back in history first (index 0 -> "First prompt", then index 1 -> "Second prompt") + // 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("First prompt") + expect(setInputValue).toHaveBeenCalledWith("Third prompt") }) it("should preserve current input when starting navigation", () => { @@ -503,13 +504,12 @@ describe("ChatTextArea", () => { // Navigate to history fireEvent.keyDown(textarea, { key: "ArrowUp" }) - expect(setInputValue).toHaveBeenCalledWith("First prompt") + expect(setInputValue).toHaveBeenCalledWith("Third prompt") setInputValue.mockClear() // Navigate back to current input fireEvent.keyDown(textarea, { key: "ArrowDown" }) - fireEvent.keyDown(textarea, { key: "ArrowDown" }) expect(setInputValue).toHaveBeenCalledWith("Current input") }) @@ -570,14 +570,14 @@ describe("ChatTextArea", () => { // With empty input, cursor is at first line by default // Arrow up should navigate history fireEvent.keyDown(textarea, { key: "ArrowUp" }) - expect(setInputValue).toHaveBeenCalledWith("First prompt") + expect(setInputValue).toHaveBeenCalledWith("Third prompt") }) it("should filter history by current workspace", () => { - const mixedTaskHistory = [ - { task: "Workspace 1 prompt", workspace: "/test/workspace" }, - { task: "Other workspace prompt", workspace: "/other/workspace" }, - { task: "Workspace 1 prompt 2", workspace: "/test/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({ @@ -586,7 +586,8 @@ describe("ChatTextArea", () => { apiConfiguration: { apiProvider: "anthropic", }, - taskHistory: mixedTaskHistory, + taskHistory: [], + clineMessages: mixedClineMessages, cwd: "/test/workspace", }) @@ -597,16 +598,16 @@ describe("ChatTextArea", () => { const textarea = container.querySelector("textarea")! - // Should only show prompts from current workspace + // Should show conversation messages newest first (after reverse) fireEvent.keyDown(textarea, { key: "ArrowUp" }) - expect(setInputValue).toHaveBeenCalledWith("Workspace 1 prompt") + expect(setInputValue).toHaveBeenCalledWith("Workspace 1 prompt 2") setInputValue.mockClear() fireEvent.keyDown(textarea, { key: "ArrowUp" }) - expect(setInputValue).toHaveBeenCalledWith("Workspace 1 prompt 2") + expect(setInputValue).toHaveBeenCalledWith("Other workspace prompt") }) - it("should handle empty task history gracefully", () => { + it("should handle empty conversation history gracefully", () => { ;(useExtensionState as jest.Mock).mockReturnValue({ filePaths: [], openedTabs: [], @@ -614,6 +615,7 @@ describe("ChatTextArea", () => { apiProvider: "anthropic", }, taskHistory: [], + clineMessages: [], cwd: "/test/workspace", }) @@ -629,12 +631,12 @@ describe("ChatTextArea", () => { expect(setInputValue).not.toHaveBeenCalled() }) - it("should ignore empty or whitespace-only tasks", () => { - const taskHistoryWithEmpty = [ - { task: "Valid prompt", workspace: "/test/workspace" }, - { task: "", workspace: "/test/workspace" }, - { task: " ", workspace: "/test/workspace" }, - { task: "Another valid prompt", workspace: "/test/workspace" }, + 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({ @@ -643,7 +645,8 @@ describe("ChatTextArea", () => { apiConfiguration: { apiProvider: "anthropic", }, - taskHistory: taskHistoryWithEmpty, + taskHistory: [], + clineMessages: clineMessagesWithEmpty, cwd: "/test/workspace", }) @@ -654,13 +657,99 @@ describe("ChatTextArea", () => { const textarea = container.querySelector("textarea")! - // Should skip empty tasks + // 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("Another valid prompt") + 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") }) }) }) diff --git a/webview-ui/src/components/chat/hooks/usePromptHistory.ts b/webview-ui/src/components/chat/hooks/usePromptHistory.ts index e3c2c6faf3..d390736540 100644 --- a/webview-ui/src/components/chat/hooks/usePromptHistory.ts +++ b/webview-ui/src/components/chat/hooks/usePromptHistory.ts @@ -1,11 +1,20 @@ import { useCallback, useEffect, useMemo, useState } from "react" +interface ClineMessage { + type: "say" | "ask" + say?: string + ask?: string + ts: number + text?: string +} + interface TaskHistoryItem { task: string workspace?: string } interface UsePromptHistoryProps { + clineMessages: ClineMessage[] | undefined taskHistory: TaskHistoryItem[] | undefined cwd: string | undefined inputValue: string @@ -31,10 +40,11 @@ export interface UsePromptHistoryReturn { isComposing: boolean, ) => boolean resetHistoryNavigation: () => void - resetOnInputChange: (newValue: string) => void + resetOnInputChange: () => void } export const usePromptHistory = ({ + clineMessages, taskHistory, cwd, inputValue, @@ -49,14 +59,33 @@ export const usePromptHistory = ({ const [promptHistory, setPromptHistory] = useState([]) const [inputValueWithCursor, setInputValueWithCursor] = useState({ value: inputValue }) - // Initialize prompt history from task history with performance optimization + // 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 + } + + // Fall back to task history if no conversation messages if (!taskHistory || taskHistory.length === 0 || !cwd) { return [] } // Extract user prompts from task history for the current workspace only - const prompts = taskHistory + 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) @@ -64,29 +93,26 @@ export const usePromptHistory = ({ .map((item) => item.task) // Limit history size to prevent memory issues .slice(-MAX_PROMPT_HISTORY_SIZE) + // No reverse - keep chronological order so up arrow shows older tasks first - // taskHistory is already in chronological order (oldest first) - // We keep it as-is so that navigation works correctly: - // - Arrow up increases index to go back in history (older prompts) - // - Arrow down decreases index to go forward (newer prompts) - return prompts - }, [taskHistory, cwd]) + return taskPrompts + }, [clineMessages, taskHistory, cwd]) - // Update prompt history when filtered history changes + // 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( - (_newValue: string) => { - if (historyIndex !== -1) { - setHistoryIndex(-1) - setTempInput("") - } - }, - [historyIndex], - ) + const resetOnInputChange = useCallback(() => { + if (historyIndex !== -1) { + setHistoryIndex(-1) + setTempInput("") + } + }, [historyIndex]) const handleHistoryNavigation = useCallback( (event: React.KeyboardEvent, showContextMenu: boolean, isComposing: boolean): boolean => { @@ -116,11 +142,13 @@ export const usePromptHistory = ({ if (newIndex < promptHistory.length) { setHistoryIndex(newIndex) const historicalPrompt = promptHistory[newIndex] - setInputValue(historicalPrompt) - setInputValueWithCursor({ - value: historicalPrompt, - afterRender: "SET_CURSOR_FIRST_LINE", - }) + if (historicalPrompt) { + setInputValue(historicalPrompt) + setInputValueWithCursor({ + value: historicalPrompt, + afterRender: "SET_CURSOR_FIRST_LINE", + }) + } } return true } @@ -133,11 +161,13 @@ export const usePromptHistory = ({ const newIndex = historyIndex - 1 setHistoryIndex(newIndex) const historicalPrompt = promptHistory[newIndex] - setInputValue(historicalPrompt) - setInputValueWithCursor({ - value: historicalPrompt, - afterRender: "SET_CURSOR_LAST_LINE", - }) + if (historicalPrompt) { + setInputValue(historicalPrompt) + setInputValueWithCursor({ + value: historicalPrompt, + afterRender: "SET_CURSOR_LAST_LINE", + }) + } } else if (historyIndex === 0) { // Return to current input setHistoryIndex(-1) From f26d5cc3127de922f2c3e194fb7cbe3271ba8ca7 Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Wed, 11 Jun 2025 16:29:41 -0600 Subject: [PATCH 08/11] fix: correct task history slicing order for prompt navigation Task history was using .slice(-100) which gets the newest 100 tasks, but we want to show oldest tasks first when navigating. Changed to .slice(0, 100) to get the oldest 100 tasks instead. This ensures that when starting fresh (no conversation), up arrow shows the oldest task prompts first, which is the intended behavior. --- webview-ui/src/components/chat/hooks/usePromptHistory.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webview-ui/src/components/chat/hooks/usePromptHistory.ts b/webview-ui/src/components/chat/hooks/usePromptHistory.ts index d390736540..f7f2694d7e 100644 --- a/webview-ui/src/components/chat/hooks/usePromptHistory.ts +++ b/webview-ui/src/components/chat/hooks/usePromptHistory.ts @@ -91,8 +91,8 @@ export const usePromptHistory = ({ return item.task && item.task.trim() !== "" && (!item.workspace || item.workspace === cwd) }) .map((item) => item.task) - // Limit history size to prevent memory issues - .slice(-MAX_PROMPT_HISTORY_SIZE) + // Limit history size to prevent memory issues - take oldest tasks first + .slice(0, MAX_PROMPT_HISTORY_SIZE) // No reverse - keep chronological order so up arrow shows older tasks first return taskPrompts From cdea909fe85d79cfe9851a31159949cd8bebfb01 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Wed, 11 Jun 2025 18:09:57 -0500 Subject: [PATCH 09/11] refactor: remove comment on task history size limitation and clarify order preservation --- webview-ui/src/components/chat/hooks/usePromptHistory.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/webview-ui/src/components/chat/hooks/usePromptHistory.ts b/webview-ui/src/components/chat/hooks/usePromptHistory.ts index f7f2694d7e..7de60de379 100644 --- a/webview-ui/src/components/chat/hooks/usePromptHistory.ts +++ b/webview-ui/src/components/chat/hooks/usePromptHistory.ts @@ -91,9 +91,7 @@ export const usePromptHistory = ({ return item.task && item.task.trim() !== "" && (!item.workspace || item.workspace === cwd) }) .map((item) => item.task) - // Limit history size to prevent memory issues - take oldest tasks first .slice(0, MAX_PROMPT_HISTORY_SIZE) - // No reverse - keep chronological order so up arrow shows older tasks first return taskPrompts }, [clineMessages, taskHistory, cwd]) From 9f9823936aa95ad81bda949759d725a40c127aa8 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Wed, 11 Jun 2025 18:19:49 -0500 Subject: [PATCH 10/11] refactor: replace local ClineMessage and TaskHistoryItem interfaces with imported types --- .../components/chat/hooks/usePromptHistory.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/webview-ui/src/components/chat/hooks/usePromptHistory.ts b/webview-ui/src/components/chat/hooks/usePromptHistory.ts index 7de60de379..fa15395e9e 100644 --- a/webview-ui/src/components/chat/hooks/usePromptHistory.ts +++ b/webview-ui/src/components/chat/hooks/usePromptHistory.ts @@ -1,21 +1,9 @@ +import { ClineMessage, HistoryItem } from "@roo-code/types" import { useCallback, useEffect, useMemo, useState } from "react" -interface ClineMessage { - type: "say" | "ask" - say?: string - ask?: string - ts: number - text?: string -} - -interface TaskHistoryItem { - task: string - workspace?: string -} - interface UsePromptHistoryProps { clineMessages: ClineMessage[] | undefined - taskHistory: TaskHistoryItem[] | undefined + taskHistory: HistoryItem[] | undefined cwd: string | undefined inputValue: string setInputValue: (value: string) => void From cc7bab0fcac342cdd961d1e5ef3d636192bb15fd Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Wed, 11 Jun 2025 22:17:03 -0600 Subject: [PATCH 11/11] fix: prevent prompt history fallback to task list during active conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an active task has only an initial prompt with no follow-up user messages, the prompt history should return empty instead of falling back to task history. This fixes the "Starting Fresh" behavior appearing inappropriately. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- webview-ui/src/components/chat/hooks/usePromptHistory.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/hooks/usePromptHistory.ts b/webview-ui/src/components/chat/hooks/usePromptHistory.ts index fa15395e9e..810c4a606a 100644 --- a/webview-ui/src/components/chat/hooks/usePromptHistory.ts +++ b/webview-ui/src/components/chat/hooks/usePromptHistory.ts @@ -67,7 +67,13 @@ export const usePromptHistory = ({ return conversationPrompts.slice(-MAX_PROMPT_HISTORY_SIZE).reverse() // newest first for conversation messages } - // Fall back to task history if no 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 [] }