Skip to content
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
142 changes: 123 additions & 19 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 @@ -251,14 +252,29 @@
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)

// A controlled reset (for example sending the message) does not go through
// handleInputChange, so clear the dismissal once the input is empty again.
useEffect(() => {
if (!normalizedInputValue) {

Check warning on line 263 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:263: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
suppressedHistoryRef.current = null
}
}, [normalizedInputValue])

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

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.

Could this picker include the initial task prompt?

@yarcowang yarcowang Nov 1, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, fixed in f92b896.

The initial prompt is stored as the first message with say: "text", and
ChatView skips that entry when it renders the message rows, so the picker
never saw it. usePromptHistory now adds it as the oldest entry, but only
when the first message is a nonblank say: "text" — later say: "text"
entries are assistant output, so we do not scan for them. Image-only tasks
have no text to recall and are skipped.

Covered by tests: initial prompt included, no-feedback task, assistant text
ignored, image-only task.

{
clineMessages,
taskHistory,
cwd,
inputValue: normalizedInputValue,
setInputValue,
},
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

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

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

Check warning on line 365 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:365: 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 371 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:371: Survived BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.
setSelectedType(null)

Check warning on line 372 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:372: Survived CallExpression mutant (replacement: ;). See the job summary for the complete list and resolution guidance.
setSelectedMenuIndex(-1)

Check warning on line 373 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:373: Survived UnaryOperator mutant (replacement: +1). See the job summary for the complete list and resolution guidance.
setSearchQuery("")

Check warning on line 374 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:374: 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 376 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:376: Survived CallExpression mutant (replacement: ;). See the job summary for the complete list and resolution guidance.
setIntendedCursorPosition(value.length)
resetHistoryNavigation()

setTimeout(() => {
textAreaRef.current?.focus()
}, 0)
return
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

if (type === ContextMenuOptionType.Mode && value) {
// Handle mode selection.
setMode(value)
Expand Down Expand Up @@ -439,10 +476,20 @@

const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isComposing = event.nativeEvent?.isComposing ?? false
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

if (showContextMenu) {
if (event.key === "Escape") {
if (event.key === "Escape" && !isComposing) {
// 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.
// While composing, Escape belongs to the IME candidate
// window and must not dismiss the picker.
suppressedHistoryRef.current = normalizedInputValue
setShowContextMenu(false)
setSearchQuery("")
setSelectedType(null)
setSelectedMenuIndex(3) // File by default
setSelectedMenuIndex(-1)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return
}

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

Expand Down Expand Up @@ -486,30 +534,47 @@
})
return
}
if ((event.key === "Enter" || event.key === "Tab") && selectedMenuIndex !== -1) {
event.preventDefault()
// Only a plain Enter/Tab commits a row. Shift+Enter, Ctrl/Cmd+Enter
// and an IME-composed Enter stay on their normal newline/send path.
const isRowSelectKey =
(event.key === "Enter" || event.key === "Tab") &&
!event.shiftKey &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!isComposing

if (isRowSelectKey && selectedMenuIndex !== -1) {
const selectedOption = getContextMenuOptions(
searchQuery,
selectedType,
queryItems,
fileSearchResults,
allModes,
commands,
promptHistory,

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.

Could history selection require an unmodified non-composing key event?

@yarcowang yarcowang Nov 1, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes — fixed in 7646282. A row is selected only for an unmodified, non-composing Enter or Tab. Shift+Enter, Ctrl/Cmd+Enter and IME Enter keep their normal path, with tests for each.

)[selectedMenuIndex]
if (
selectedOption &&
selectedOption.type !== ContextMenuOptionType.URL &&
selectedOption.type !== ContextMenuOptionType.NoResults &&
selectedOption.type !== ContextMenuOptionType.SectionHeader
) {
event.preventDefault()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

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.

Could this fall-through be limited to unmatched history queries?

@yarcowang yarcowang Nov 1, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes — fixed in 7646282. The fallthrough now only applies when the query is a history query; other menus keep absorbing the key.

handleMentionSelect(selectedOption.type, selectedOption.value)
return
}

// Nothing selectable (e.g. "No results"). A history query is the
// whole message, so let Enter send it. Other menus keep their
// previous behavior and absorb the key.
if (!isHistoryTrigger(searchQuery)) {
event.preventDefault()
return
}
return
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const isComposing = event.nativeEvent?.isComposing ?? false

// Handle prompt history navigation using custom hook
if (handleHistoryNavigation(event, showContextMenu, isComposing)) {
return
Expand Down Expand Up @@ -596,6 +661,7 @@
handleHistoryNavigation,
resetHistoryNavigation,
commands,
promptHistory,
enterBehavior,
],
)
Expand All @@ -622,10 +688,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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

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.

Could external empty-value resets clear this suppression?

@yarcowang yarcowang Nov 1, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes — fixed in 7646282. An effect clears the dismissed state when the controlled value becomes empty, so typing ! again reopens the picker. Covered by a controlled-component test.

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.
Resolved
} else if (newValue.startsWith("/") && !newValue.includes(" ")) {
// Handle slash command - request fresh commands
const query = newValue
setSearchQuery(query)
Expand Down Expand Up @@ -675,7 +762,14 @@
setFileSearchResults([]) // Clear file search results.
}
},
[setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading, resetOnInputChange],
[
setInputValue,
setSearchRequestId,
setFileSearchResults,
setSearchLoading,
resetOnInputChange,
promptHistory,
],
)

useEffect(() => {
Expand Down Expand Up @@ -955,7 +1049,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 @@ -1039,6 +1134,7 @@
loading={searchLoading}
dynamicSearchResults={fileSearchResults}
commands={commands}
history={promptHistory}
/>
</div>
)}
Expand Down Expand Up @@ -1099,8 +1195,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
28 changes: 26 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,17 @@
)}
</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

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.

Could this new row use Tailwind classes?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 91e5c919c. The row now uses min-w-0 overflow-hidden text-ellipsis whitespace-nowrap leading-[1.4]; the computed styles are unchanged, so the existing snapshots still apply.

title={option.value}
className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap leading-[1.4]">
{option.value?.replace(/\s+/g, " ").trim()}

Check warning on line 159 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:159: 3 mutation test gaps; example: Survived MethodExpression mutant (replacement: option.value?.replace(/\s+/g, " ")). See the job summary for the complete list and resolution guidance.
</span>
)
case ContextMenuOptionType.Problems:
return <span>{t("chat:contextMenu.problems")}</span>
case ContextMenuOptionType.Terminal:
Expand Down Expand Up @@ -230,6 +251,8 @@
return "link"
case ContextMenuOptionType.Git:
return "git-commit"
case ContextMenuOptionType.History:
return "history"
case ContextMenuOptionType.NoResults:
return "info"
default:
Expand Down Expand Up @@ -267,6 +290,7 @@

return (
<div
data-testid="context-menu"
style={{
position: "absolute",
bottom: "calc(100% - 10px)",
Expand Down
Loading
Loading