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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions webview-ui/playwright/gallery/stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ export const stories: Record<string, Story> = {
const { ChatTextAreaStory } = await import("@/components/chat/__tests__/ChatTextArea.visual.fixture")
return <ChatTextAreaStory />
},
"chat-text-area-history": async () => {
const { ChatTextAreaHistoryStory } = await import("@/components/chat/__tests__/ChatTextArea.visual.fixture")
return <ChatTextAreaHistoryStory />
},
"history-empty": async () => {
const [{ AppProviders }, { default: HistoryView }] = await Promise.all([
import("../AppProviders"),
Expand Down
110 changes: 95 additions & 15 deletions webview-ui/src/components/chat/ChatTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
ContextMenuOptionType,
getContextMenuOptions,
insertMention,
isHistoryTrigger,
removeMention,
shouldShowContextMenu,
SearchResult,
Expand Down Expand Up @@ -231,14 +232,21 @@
const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false)
const [isFocused, setIsFocused] = useState(false)

// Text for which the user explicitly dismissed the prompt history picker.
// Without this, recalling (or typing) a message that starts with "!" would
// pop the picker back open on the very next keystroke.
const suppressedHistoryRef = useRef<string | null>(null)

// Use custom hook for prompt history navigation
const { handleHistoryNavigation, resetHistoryNavigation, resetOnInputChange } = usePromptHistory({
clineMessages,
taskHistory,
cwd,
inputValue: normalizedInputValue,
setInputValue,
})
const { handleHistoryNavigation, resetHistoryNavigation, resetOnInputChange, promptHistory } = usePromptHistory(
{
clineMessages,
taskHistory,
cwd,
inputValue: normalizedInputValue,
setInputValue,
},
)

// Fetch git commits when Git is selected or when typing a hash.
useEffect(() => {
Expand Down Expand Up @@ -326,6 +334,27 @@
return
}

if (type === ContextMenuOptionType.History && value) {

Check warning on line 337 in webview-ui/src/components/chat/ChatTextArea.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatTextArea.tsx:337: 3 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
// Replace the whole input with the recalled prompt instead of
// inserting it as a mention: the "!" prefix is only a trigger
// and must not survive into the message. Deliberately does not
// send, so the user can edit the prompt before re-sending.
suppressedHistoryRef.current = value
setShowContextMenu(false)

Check warning on line 343 in webview-ui/src/components/chat/ChatTextArea.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatTextArea.tsx:343: Survived BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.
setSelectedType(null)

Check warning on line 344 in webview-ui/src/components/chat/ChatTextArea.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatTextArea.tsx:344: Survived CallExpression mutant (replacement: ;). See the job summary for the complete list and resolution guidance.
setSelectedMenuIndex(-1)

Check warning on line 345 in webview-ui/src/components/chat/ChatTextArea.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatTextArea.tsx:345: Survived UnaryOperator mutant (replacement: +1). See the job summary for the complete list and resolution guidance.
setSearchQuery("")

Check warning on line 346 in webview-ui/src/components/chat/ChatTextArea.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatTextArea.tsx:346: Survived StringLiteral mutant (replacement: "Stryker was here!"). See the job summary for the complete list and resolution guidance.
setInputValue(value)
setCursorPosition(value.length)

Check warning on line 348 in webview-ui/src/components/chat/ChatTextArea.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatTextArea.tsx:348: Survived CallExpression mutant (replacement: ;). See the job summary for the complete list and resolution guidance.
setIntendedCursorPosition(value.length)

Check warning on line 349 in webview-ui/src/components/chat/ChatTextArea.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatTextArea.tsx:349: Survived CallExpression mutant (replacement: ;). See the job summary for the complete list and resolution guidance.
resetHistoryNavigation()

setTimeout(() => {
textAreaRef.current?.focus()
}, 0)
return
}

if (type === ContextMenuOptionType.Mode && value) {
// Handle mode selection.
setMode(value)
Expand Down Expand Up @@ -421,8 +450,14 @@
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showContextMenu) {
if (event.key === "Escape") {
// Dismiss the menu outright. `!` is easy to type by
// accident, so Escape must actually close the picker
// rather than leave the arrow keys captured by it.
suppressedHistoryRef.current = normalizedInputValue
setShowContextMenu(false)
setSearchQuery("")
setSelectedType(null)
setSelectedMenuIndex(3) // File by default
setSelectedMenuIndex(-1)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return
}

Expand All @@ -437,6 +472,7 @@
fileSearchResults,
allModes,
commands,
promptHistory,
)
const optionsLength = options.length

Expand Down Expand Up @@ -467,24 +503,29 @@
return
}
if ((event.key === "Enter" || event.key === "Tab") && selectedMenuIndex !== -1) {
event.preventDefault()
const selectedOption = getContextMenuOptions(
searchQuery,
selectedType,
queryItems,
fileSearchResults,
allModes,
commands,
promptHistory,
)[selectedMenuIndex]
if (
selectedOption &&
selectedOption.type !== ContextMenuOptionType.URL &&
selectedOption.type !== ContextMenuOptionType.NoResults &&
selectedOption.type !== ContextMenuOptionType.SectionHeader
) {
event.preventDefault()
handleMentionSelect(selectedOption.type, selectedOption.value)
return
}
return

// Nothing selectable (e.g. "No results"). Do not swallow the
// key: fall through so Enter still sends the message instead
// of leaving the composer unresponsive.
}
}

Expand Down Expand Up @@ -576,6 +617,7 @@
handleHistoryNavigation,
resetHistoryNavigation,
commands,
promptHistory,
enterBehavior,
],
)
Expand All @@ -602,10 +644,31 @@
setCursorPosition(newCursorPosition)

const showMenu = shouldShowContextMenu(newValue, newCursorPosition)

if (isHistoryTrigger(newValue)) {
const suppressed = suppressedHistoryRef.current

// Keep the picker closed when the user dismissed it for this text,
// or when there is nothing to recall. Without the second guard an
// empty picker would swallow Enter for a message starting with "!".
if ((suppressed !== null && newValue.startsWith(suppressed)) || promptHistory.length === 0) {
setShowContextMenu(false)
setSearchQuery("")
setSelectedMenuIndex(-1)
return
}
} else {
suppressedHistoryRef.current = null
}

setShowContextMenu(showMenu)

if (showMenu) {
if (newValue.startsWith("/") && !newValue.includes(" ")) {
if (isHistoryTrigger(newValue)) {
// getContextMenuOptions dispatches on the leading "!" itself.
setSearchQuery(newValue)
setSelectedMenuIndex(0)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else if (newValue.startsWith("/") && !newValue.includes(" ")) {
// Handle slash command - request fresh commands
const query = newValue
setSearchQuery(query)
Expand Down Expand Up @@ -655,7 +718,14 @@
setFileSearchResults([]) // Clear file search results.
}
},
[setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading, resetOnInputChange],
[
setInputValue,
setSearchRequestId,
setFileSearchResults,
setSearchLoading,
resetOnInputChange,
promptHistory,
],
)

useEffect(() => {
Expand Down Expand Up @@ -935,7 +1005,8 @@
}
})

const placeholderBottomText = `\n(${t("chat:addContext")}${shouldDisableImages ? `, ${t("chat:dragFiles")}` : `, ${t("chat:dragFilesImages")}`})`
const dragHint = shouldDisableImages ? t("chat:dragFiles") : t("chat:dragFilesImages")
const placeholderBottomText = `\n(${t("chat:addContext")}, ${t("chat:historyHint")}, ${dragHint})`

// Common mode selector handler
const handleModeChange = useCallback(
Expand Down Expand Up @@ -1016,6 +1087,7 @@
loading={searchLoading}
dynamicSearchResults={fileSearchResults}
commands={commands}
history={promptHistory}
/>
</div>
)}
Expand Down Expand Up @@ -1076,8 +1148,16 @@
}}
onFocus={() => setIsFocused(true)}
onKeyDown={(e) => {
// Handle ESC to cancel in edit mode
if (isEditMode && e.key === "Escape" && !e.nativeEvent?.isComposing) {
// Handle ESC to cancel in edit mode. The context menu
// takes precedence: while the history picker is open,
// ESC only closes it, so cancelling the edit (which
// discards the draft) needs a second press.
if (
isEditMode &&
e.key === "Escape" &&
!showContextMenu &&
!e.nativeEvent?.isComposing
) {
e.preventDefault()
onCancel?.()
return
Expand Down
33 changes: 31 additions & 2 deletions webview-ui/src/components/chat/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
loading?: boolean
dynamicSearchResults?: SearchResult[]
commands?: Command[]
history?: string[]
}

const ContextMenu: React.FC<ContextMenuProps> = ({
Expand All @@ -43,13 +44,22 @@
modes,
dynamicSearchResults = [],
commands = [],
history = [],

Check warning on line 47 in webview-ui/src/components/chat/ContextMenu.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ContextMenu.tsx:47: NoCoverage ArrayDeclaration mutant (replacement: ["Stryker was here"]). See the job summary for the complete list and resolution guidance.
}) => {
const [materialIconsBaseUri, setMaterialIconsBaseUri] = useState("")
const menuRef = useRef<HTMLDivElement>(null)

const filteredOptions = useMemo(() => {
return getContextMenuOptions(searchQuery, selectedType, queryItems, dynamicSearchResults, modes, commands)
}, [searchQuery, selectedType, queryItems, dynamicSearchResults, modes, commands])
return getContextMenuOptions(
searchQuery,
selectedType,
queryItems,
dynamicSearchResults,
modes,
commands,
history,
)
}, [searchQuery, selectedType, queryItems, dynamicSearchResults, modes, commands, history])

Check warning on line 62 in webview-ui/src/components/chat/ContextMenu.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ContextMenu.tsx:62: Survived ArrayDeclaration mutant (replacement: []). See the job summary for the complete list and resolution guidance.

useEffect(() => {
if (menuRef.current) {
Expand Down Expand Up @@ -138,6 +148,23 @@
)}
</div>
)
case ContextMenuOptionType.History:
// Prompts can be long and multi-line; keep every row to one line so
// the picker stays scannable. The full text lands in the input on
// selection, and line breaks become visible there.
return (
<span
title={option.value}
style={{

Check warning on line 158 in webview-ui/src/components/chat/ContextMenu.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ContextMenu.tsx:158: Survived ObjectLiteral mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
lineHeight: "1.4",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
minWidth: 0,
}}>
{option.value?.replace(/\s+/g, " ").trim()}
</span>
)
case ContextMenuOptionType.Problems:
return <span>{t("chat:contextMenu.problems")}</span>
case ContextMenuOptionType.Terminal:
Expand Down Expand Up @@ -230,6 +257,8 @@
return "link"
case ContextMenuOptionType.Git:
return "git-commit"
case ContextMenuOptionType.History:
return "history"
case ContextMenuOptionType.NoResults:
return "info"
default:
Expand Down
84 changes: 84 additions & 0 deletions webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,90 @@ describe("ChatTextArea", () => {
expect(setInputValue).toHaveBeenCalledWith("New input")
})

it("should open the prompt history picker when typing a leading !", () => {
const setInputValue = vi.fn()
const onSend = vi.fn()
const { container } = render(
<ChatTextArea {...defaultProps} onSend={onSend} setInputValue={setInputValue} inputValue="" />,
)

const textarea = container.querySelector("textarea")!

fireEvent.change(textarea, { target: { value: "!", selectionStart: 1 } })

// Newest prompt first.
expect(container.textContent).toContain("Third prompt")

fireEvent.keyDown(textarea, { key: "Enter" })

// Selecting only fills the input; it must not send the message.
expect(setInputValue).toHaveBeenCalledWith("Third prompt")
expect(onSend).not.toHaveBeenCalled()
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("should close the prompt history picker on Escape without sending", () => {
const onSend = vi.fn()
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="" />)

const textarea = container.querySelector("textarea")!

fireEvent.change(textarea, { target: { value: "!thi", selectionStart: 4 } })
expect(container.textContent).toContain("Third prompt")

fireEvent.keyDown(textarea, { key: "Escape" })

expect(container.textContent).not.toContain("Third prompt")
expect(onSend).not.toHaveBeenCalled()
})

it("should not open the prompt history picker for a mid-message exclamation mark", () => {
const { container } = render(<ChatTextArea {...defaultProps} inputValue="" />)

const textarea = container.querySelector("textarea")!

fireEvent.change(textarea, { target: { value: "Great!" } })

expect(container.textContent).not.toContain("Third prompt")
})

it("should close the picker before cancelling an edit on Escape", () => {
const onCancel = vi.fn()
const { container } = render(
<ChatTextArea {...defaultProps} isEditMode onCancel={onCancel} inputValue="" />,
)

const textarea = container.querySelector("textarea")!

fireEvent.change(textarea, { target: { value: "!", selectionStart: 1 } })
expect(container.textContent).toContain("Third prompt")

// First press closes the picker and keeps the edit (and its draft).
fireEvent.keyDown(textarea, { key: "Escape" })
expect(onCancel).not.toHaveBeenCalled()
expect(container.textContent).not.toContain("Third prompt")

// Second press, with no picker open, cancels the edit.
fireEvent.keyDown(textarea, { key: "Escape" })
expect(onCancel).toHaveBeenCalledTimes(1)
})

it("should still send on Enter when the history query matches nothing", () => {
const onSend = vi.fn()
const setInputValue = vi.fn()
const { container } = render(
<ChatTextArea {...defaultProps} onSend={onSend} setInputValue={setInputValue} inputValue="" />,
)

const textarea = container.querySelector("textarea")!

fireEvent.change(textarea, { target: { value: "!zzz", selectionStart: 4 } })
fireEvent.keyDown(textarea, { key: "Enter" })

// "No results" is not selectable, so Enter must not be swallowed.
expect(onSend).toHaveBeenCalledTimes(1)
expect(setInputValue).not.toHaveBeenCalledWith("Third prompt")
})

it("should reset history navigation when sending message", () => {
const onSend = vi.fn()
const setInputValue = vi.fn()
Expand Down
Loading
Loading