Skip to content
Open
73 changes: 73 additions & 0 deletions webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,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(
<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="Unsent draft" />,
)
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(<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="Previous task" />)
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(
<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="Current input" />,
)
const textarea = container.querySelector("textarea")!

textarea.setSelectionRange(0, 0)
fireEvent.keyDown(textarea, { key: "ArrowUp" })
expect(setInputValue).toHaveBeenCalledWith("Third prompt")
;(useExtensionState as ReturnType<typeof vi.fn>).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(<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="Third prompt" />)
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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UsePromptHistoryReturn, { clineMessages: ClineMessage[] | undefined }>(
({ 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<UsePromptHistoryReturn, { clineMessages: ClineMessage[] }>(
({ 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("")
})
})
42 changes: 25 additions & 17 deletions webview-ui/src/components/chat/hooks/usePromptHistory.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -38,26 +38,24 @@
const [historyIndex, setHistoryIndex] = useState(-1)
const [tempInput, setTempInput] = useState("")
const [promptHistory, setPromptHistory] = useState<string[]>([])
const conversationPrompts = useMemo(
() =>
clineMessages

Check warning on line 43 in webview-ui/src/components/chat/hooks/usePromptHistory.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/hooks/usePromptHistory.ts:43: Survived OptionalChaining mutant (replacement: clineMessages.filter). See the job summary for the complete list and resolution guidance.
?.filter((message) => message.type === "say" && message.say === "user_feedback" && message.text?.trim())

Check warning on line 44 in webview-ui/src/components/chat/hooks/usePromptHistory.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/hooks/usePromptHistory.ts:44: 3 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
.map((message) => message.text!),
[clineMessages],
)
const historySource = conversationPrompts?.length ? "conversation" : "task"

Check warning on line 48 in webview-ui/src/components/chat/hooks/usePromptHistory.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/hooks/usePromptHistory.ts:48: 3 mutation test gaps; example: Survived OptionalChaining mutant (replacement: conversationPrompts.length). See the job summary for the complete list and resolution guidance.
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old if (clineMessages?.length) return [] guard is gone, so a fresh task (whose first prompt is stored as say: "text", not user_feedback) now falls back to workspace task history on ArrowUp. After the first follow-up, the list collapses to that one prompt. Is recalling other tasks' prompts here intended? If so, could the PR description mention it and a test cover "active task, no user_feedback, ArrowUp"? If not, would keeping the guard in the fallback and relying on the new source-change reset still fix #1600?

if (!taskHistory?.length || !cwd) {
return []
}
Expand All @@ -67,15 +65,25 @@
.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])

Check warning on line 74 in webview-ui/src/components/chat/hooks/usePromptHistory.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/hooks/usePromptHistory.ts:74: 3 mutation test gaps; example: Survived MethodExpression mutant (replacement: promptHistory.every((prompt, index) => prompt !== filteredPromptHistory[index])). See the job summary for the complete list and resolution guidance.
const historySourceChanged = previousHistorySource.current !== historySource
previousHistorySource.current = historySource

if (!historyChanged && !historySourceChanged) return

if (historyChanged) {

Check warning on line 80 in webview-ui/src/components/chat/hooks/usePromptHistory.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/hooks/usePromptHistory.ts:80: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
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(() => {
Expand Down
Loading