Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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(
<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.
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