diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx index aae9ac3cc6..c276f2f688 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx @@ -583,6 +583,79 @@ describe("ChatTextArea", () => { expect(setInputValue).toHaveBeenCalledWith("Current input") }) + it("should restore the task-history draft after the first assistant-only stream", () => { + const state = { + filePaths: [], + openedTabs: [], + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + taskHistory: [ + { + id: "previous-task", + number: 1, + ts: 1, + task: "Previous task", + workspace: "/test/workspace", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + ], + clineMessages: [], + cwd: "/test/workspace", + } + vi.mocked(useExtensionState).mockReturnValue({ ...vi.mocked(useExtensionState)(), ...state }) + const setInputValue = vi.fn() + const { container, rerender } = render( + , + ) + const textarea = container.querySelector("textarea")! + textarea.setSelectionRange(0, 0) + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + expect(setInputValue).toHaveBeenLastCalledWith("Previous task") + + vi.mocked(useExtensionState).mockReturnValue({ + ...vi.mocked(useExtensionState)(), + clineMessages: [{ ts: 1, type: "say", say: "text", text: "Streaming response", partial: true }], + }) + rerender() + setInputValue.mockClear() + textarea.setSelectionRange(textarea.value.length, textarea.value.length) + fireEvent.keyDown(textarea, { key: "ArrowDown" }) + expect(setInputValue).toHaveBeenLastCalledWith("Unsent draft") + }) + + it("should preserve current input while assistant messages stream", () => { + const setInputValue = vi.fn() + const { container, rerender } = render( + , + ) + const textarea = container.querySelector("textarea")! + + textarea.setSelectionRange(0, 0) + fireEvent.keyDown(textarea, { key: "ArrowUp" }) + expect(setInputValue).toHaveBeenCalledWith("Third prompt") + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + }, + taskHistory: [], + clineMessages: [ + ...mockClineMessages, + { type: "say", say: "text", text: "Streaming assistant output", ts: 4000 }, + ], + cwd: "/test/workspace", + }) + setInputValue.mockClear() + rerender() + textarea.setSelectionRange(textarea.value.length, textarea.value.length) + + fireEvent.keyDown(textarea, { key: "ArrowDown" }) + + expect(setInputValue).toHaveBeenCalledWith("Current input") + }) + it("should reset history navigation when user types", () => { const setInputValue = vi.fn() const { container } = render( diff --git a/webview-ui/src/components/chat/hooks/__tests__/usePromptHistory.spec.ts b/webview-ui/src/components/chat/hooks/__tests__/usePromptHistory.spec.ts new file mode 100644 index 0000000000..92cfec1997 --- /dev/null +++ b/webview-ui/src/components/chat/hooks/__tests__/usePromptHistory.spec.ts @@ -0,0 +1,90 @@ +import { ClineMessage, HistoryItem } from "@roo-code/types" +import { act, renderHook } from "@testing-library/react" + +import { usePromptHistory, type UsePromptHistoryReturn } from "../usePromptHistory" + +describe("usePromptHistory", () => { + it("preserves navigation for assistant-only streams and resets when identical conversation prompts appear", () => { + const prompt = "Explain this code" + const taskHistory: HistoryItem[] = [ + { + id: "task-1", + number: 1, + ts: 1, + task: prompt, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + workspace: "/workspace", + }, + ] + const conversationHistory: ClineMessage[] = [{ ts: 2, type: "say", say: "user_feedback", text: prompt }] + const setInputValue = vi.fn() + + const { result, rerender } = renderHook( + ({ clineMessages }) => + usePromptHistory({ + clineMessages, + taskHistory, + cwd: "/workspace", + inputValue: "draft", + setInputValue, + }), + { initialProps: { clineMessages: [] } }, + ) + + act(() => { + result.current.setHistoryIndex(0) + result.current.setTempInput("draft") + }) + + expect(result.current.promptHistory).toEqual([prompt]) + expect(result.current.historyIndex).toBe(0) + expect(result.current.tempInput).toBe("draft") + + rerender({ clineMessages: [{ ts: 2, type: "say", say: "text", text: "Assistant output", partial: true }] }) + + expect(result.current.promptHistory).toEqual([prompt]) + expect(result.current.historyIndex).toBe(0) + expect(result.current.tempInput).toBe("draft") + + rerender({ clineMessages: conversationHistory }) + + expect(result.current.promptHistory).toEqual([prompt]) + expect(result.current.historyIndex).toBe(-1) + expect(result.current.tempInput).toBe("") + }) + + it("resets navigation when the current history source gains a prompt", () => { + const firstPrompt = "Explain this code" + const secondPrompt = "Now simplify it" + const initialHistory: ClineMessage[] = [{ ts: 1, type: "say", say: "user_feedback", text: firstPrompt }] + const updatedHistory: ClineMessage[] = [ + ...initialHistory, + { ts: 2, type: "say", say: "user_feedback", text: secondPrompt }, + ] + + const { result, rerender } = renderHook( + ({ clineMessages }) => + usePromptHistory({ + clineMessages, + taskHistory: undefined, + cwd: "/workspace", + inputValue: "draft", + setInputValue: vi.fn(), + }), + { initialProps: { clineMessages: initialHistory } }, + ) + + act(() => { + result.current.setHistoryIndex(0) + result.current.setTempInput("draft") + }) + + rerender({ clineMessages: updatedHistory }) + + expect(result.current.promptHistory).toEqual([secondPrompt, firstPrompt]) + expect(result.current.historyIndex).toBe(-1) + expect(result.current.tempInput).toBe("") + }) +}) diff --git a/webview-ui/src/components/chat/hooks/usePromptHistory.ts b/webview-ui/src/components/chat/hooks/usePromptHistory.ts index 402538182a..03111f2a4e 100644 --- a/webview-ui/src/components/chat/hooks/usePromptHistory.ts +++ b/webview-ui/src/components/chat/hooks/usePromptHistory.ts @@ -1,5 +1,5 @@ import { ClineMessage, HistoryItem } from "@roo-code/types" -import { useCallback, useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" interface UsePromptHistoryProps { clineMessages: ClineMessage[] | undefined @@ -38,26 +38,24 @@ export const usePromptHistory = ({ const [historyIndex, setHistoryIndex] = useState(-1) const [tempInput, setTempInput] = useState("") const [promptHistory, setPromptHistory] = useState([]) + const conversationPrompts = useMemo( + () => + clineMessages + ?.filter((message) => message.type === "say" && message.say === "user_feedback" && message.text?.trim()) + .map((message) => message.text!), + [clineMessages], + ) + const historySource = conversationPrompts?.length ? "conversation" : "task" + const previousHistorySource = useRef(historySource) - // Initialize prompt history with hybrid approach: conversation messages if in task, otherwise task history + // Use conversation prompts when available, otherwise keep task history even during assistant-only streams. const filteredPromptHistory = useMemo(() => { - // First try to get conversation messages (user_feedback from clineMessages) - const conversationPrompts = clineMessages - ?.filter((message) => message.type === "say" && message.say === "user_feedback" && message.text?.trim()) - .map((message) => message.text!) - // If we have conversation messages, use those (newest first when navigating up) if (conversationPrompts?.length) { return conversationPrompts.slice(-MAX_PROMPT_HISTORY_SIZE).reverse() } - // 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?.length) { - return [] - } - - // Fall back to task history only when starting fresh (no active conversation) + // Fall back to task history until a conversation prompt exists. if (!taskHistory?.length || !cwd) { return [] } @@ -67,15 +65,25 @@ export const usePromptHistory = ({ .filter((item) => item.task?.trim() && (!item.workspace || item.workspace === cwd)) .map((item) => item.task) .slice(0, MAX_PROMPT_HISTORY_SIZE) - }, [clineMessages, taskHistory, cwd]) + }, [conversationPrompts, taskHistory, cwd]) // Update prompt history when filtered history changes and reset navigation useEffect(() => { - setPromptHistory(filteredPromptHistory) + const historyChanged = + promptHistory.length !== filteredPromptHistory.length || + promptHistory.some((prompt, index) => prompt !== filteredPromptHistory[index]) + const historySourceChanged = previousHistorySource.current !== historySource + previousHistorySource.current = historySource + + if (!historyChanged && !historySourceChanged) return + + if (historyChanged) { + setPromptHistory(filteredPromptHistory) + } // Reset navigation state when switching between history sources setHistoryIndex(-1) setTempInput("") - }, [filteredPromptHistory]) + }, [filteredPromptHistory, historySource, promptHistory]) // Reset history navigation when user types (but not when we're setting it programmatically) const resetOnInputChange = useCallback(() => {