Просмотр исходного кода

feat: Navigate prompt history in prompt field via arrow up/down (#4139) (#4450)

* 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

* 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

* 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

* 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.

* 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

* 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

* 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

* 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.

* refactor: remove comment on task history size limitation and clarify order preservation

* refactor: replace local ClineMessage and TaskHistoryItem interfaces with imported types

* fix: prevent prompt history fallback to task list during active conversation

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 <[email protected]>

---------

Co-authored-by: Daniel Riccio <[email protected]>
Co-authored-by: Claude <[email protected]>
Hannes Rudolph 6 месяцев назад
Родитель
Сommit
69472099ea

+ 52 - 1
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<HTMLTextAreaElement, ChatTextAreaProps>(
 			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<HTMLTextAreaElement, ChatTextAreaProps>(
 		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<HTMLTextAreaElement, ChatTextAreaProps>(
 
 				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<HTMLTextAreaElement, ChatTextAreaProps>(
 				queryItems,
 				customModes,
 				fileSearchResults,
+				handleHistoryNavigation,
+				resetHistoryNavigation,
 			],
 		)
 
@@ -437,6 +464,27 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 			}
 		}, [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<NodeJS.Timeout | null>(null)
 
@@ -445,6 +493,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 				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<HTMLTextAreaElement, ChatTextAreaProps>(
 					setFileSearchResults([]) // Clear file search results.
 				}
 			},
-			[setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading],
+			[setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading, resetOnInputChange],
 		)
 
 		useEffect(() => {

+ 344 - 0
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(<ChatTextArea {...defaultProps} sendingDisabled={true} />)
 			const enhanceButton = getEnhancePromptButton()
@@ -94,6 +98,8 @@ describe("ChatTextArea", () => {
 				filePaths: [],
 				openedTabs: [],
 				apiConfiguration,
+				taskHistory: [],
+				cwd: "/test/workspace",
 			})
 
 			render(<ChatTextArea {...defaultProps} inputValue="Test prompt" />)
@@ -114,6 +120,8 @@ describe("ChatTextArea", () => {
 				apiConfiguration: {
 					apiProvider: "openrouter",
 				},
+				taskHistory: [],
+				cwd: "/test/workspace",
 			})
 
 			render(<ChatTextArea {...defaultProps} inputValue="" />)
@@ -131,6 +139,8 @@ describe("ChatTextArea", () => {
 				apiConfiguration: {
 					apiProvider: "openrouter",
 				},
+				taskHistory: [],
+				cwd: "/test/workspace",
 			})
 
 			render(<ChatTextArea {...defaultProps} inputValue="Test prompt" />)
@@ -155,6 +165,8 @@ describe("ChatTextArea", () => {
 					apiProvider: "openrouter",
 					newSetting: "test",
 				},
+				taskHistory: [],
+				cwd: "/test/workspace",
 			})
 
 			rerender(<ChatTextArea {...defaultProps} />)
@@ -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(
+					<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />,
+				)
+
+				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(
+					<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />,
+				)
+
+				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(
+					<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />,
+				)
+
+				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(
+					<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="Current input" />,
+				)
+
+				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(
+					<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />,
+				)
+
+				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(
+					<ChatTextArea
+						{...defaultProps}
+						onSend={onSend}
+						setInputValue={setInputValue}
+						inputValue="Test message"
+					/>,
+				)
+
+				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(
+					<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />,
+				)
+
+				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(
+					<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />,
+				)
+
+				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(
+					<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />,
+				)
+
+				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(
+					<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />,
+				)
+
+				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(
+					<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />,
+				)
+
+				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(
+					<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />,
+				)
+
+				// 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(<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />)
+
+				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(<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />)
+
+				// Should start from beginning of conversation history (newest first)
+				fireEvent.keyDown(textarea, { key: "ArrowUp" })
+				expect(setInputValue).toHaveBeenCalledWith("Message 2")
+			})
+		})
 	})
 
 	describe("selectApiConfig", () => {

+ 198 - 0
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<HTMLTextAreaElement>,
+		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<string[]>([])
+	const [inputValueWithCursor, setInputValueWithCursor] = useState<CursorPositionState>({ 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<HTMLTextAreaElement>, 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,
+	}
+}