At-mention picker: show "Searching..." instead of misleading "No results found" (#10478)

* At-mention picker: show "Searching..." instead of misleading "No results found"

When the @-mention picker fires its initial empty-query searchFiles, slow
workspaces (e.g. network mounts) leave the call in flight for several
seconds. Three small UX bugs combined to make this look broken:

1. The 500ms delayed-loading effect was gated on `searchQuery` being
   non-empty, so the spinner never appeared during the initial open —
   the user just saw "No results found" forever.
2. While loading, the spinner row stacked above the "No results found"
   row, claiming both states at once.
3. The spinner also stacked above the static root-menu items
   ("Paste URL", "Problems", "Git Commits", "Add File", "Add Folder")
   when the picker first opens with empty input, even though those
   items are already actionable.

Fixes:
- Drop the `&& searchQuery` guard so the loading effect arms on empty
  queries too.
- In `filteredOptions`, strip the lone `NoResults` entry while
  `showDelayedLoading` is true — searching is not the same as nothing
  matched.
- Render the spinner only when `filteredOptions.length === 0`, so it
  never stacks above existing options.

The 500ms delay before the spinner appears is preserved, so fast
searches stay visually quiet.

* fixes

* Drop stale @-mention searchFiles responses to fix "No results" flash

* Track in-flight searches with a monotonic latestSearchTokenRef in
  ChatTextArea; resolve/error handlers bail when their captured token
  is no longer the latest.
* Send the token as mentionsRequestId; proto already supports it.
* Drop the never-read currentSearchQueryRef scaffold.
* Fixes the cancel-then-re-pick race (Add File → cancel → Add Folder)
  reported in CLINE-1814.
This commit is contained in:
Mikołaj Kondratek
2026-05-04 15:46:28 +02:00
committed by GitHub
parent 86f463496c
commit 90c8112257
3 changed files with 41 additions and 13 deletions
+1 -5
View File
@@ -60,11 +60,7 @@ export async function searchFiles(controller: Controller, request: FileSearchReq
if (!workspacePath) {
Logger.error("Error in searchFiles: No workspace path available")
telemetryService.captureMentionFailed(
"folder",
"workspace_unavailable",
"No workspace path available",
)
telemetryService.captureMentionFailed("folder", "workspace_unavailable", "No workspace path available")
return {
results: [],
mentionsRequestId: request.mentionsRequestId,
@@ -353,18 +353,26 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
searchType = FileSearchType.FOLDER
}
const myToken = ++latestSearchTokenRef.current
FileServiceClient.searchFiles(
FileSearchRequest.create({
query: "",
mentionsRequestId: "",
mentionsRequestId: String(myToken),
selectedType: searchType,
}),
)
.then((results) => {
if (myToken !== latestSearchTokenRef.current) {
// Stale response — a newer search has been issued.
return
}
setFileSearchResults((results.results || []) as SearchResult[])
setSearchLoading(false)
})
.catch((error) => {
if (myToken !== latestSearchTokenRef.current) {
return
}
console.error("Error searching files:", error)
setFileSearchResults([])
setSearchLoading(false)
@@ -698,7 +706,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const currentSearchQueryRef = useRef<string>("")
// Monotonic token; every searchFiles dispatch bumps it, and resolve
// handlers drop their result when the token they captured at fire time
// is no longer the latest. Prevents stale results from a cancelled or
// superseded picker (e.g. "Add File" still in flight when user picks
// "Add Folder") from clobbering fresh state.
const latestSearchTokenRef = useRef(0)
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
@@ -734,7 +747,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1)
const query = newValue.slice(lastAtIndex + 1, newCursorPosition)
setSearchQuery(query)
currentSearchQueryRef.current = query
if (query.length > 0) {
setSelectedMenuIndex(0)
@@ -764,19 +776,27 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
// Set a timeout to debounce the search requests
searchTimeoutRef.current = setTimeout(() => {
const myToken = ++latestSearchTokenRef.current
FileServiceClient.searchFiles(
FileSearchRequest.create({
query: searchQuery,
mentionsRequestId: query,
mentionsRequestId: String(myToken),
selectedType: searchType,
workspaceHint: workspaceHint,
}),
)
.then((results) => {
if (myToken !== latestSearchTokenRef.current) {
// Stale response — a newer search has been issued.
return
}
setFileSearchResults((results.results || []) as SearchResult[])
setSearchLoading(false)
})
.catch((error) => {
if (myToken !== latestSearchTokenRef.current) {
return
}
console.error("Error searching files:", error)
setFileSearchResults([])
setSearchLoading(false)
+16 -4
View File
@@ -35,8 +35,15 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
const filteredOptions = useMemo(() => {
const options = getContextMenuOptions(searchQuery, selectedType, queryItems, dynamicSearchResults)
// While a search is in flight, don't tell the user "No results found" —
// the answer is "still searching", not "nothing matched". Suppress
// eagerly on `isLoading` (not just after the 500 ms spinner delay) so
// there's no NoResults flicker before the spinner appears.
if (isLoading && options.length === 1 && options[0].type === ContextMenuOptionType.NoResults) {
return []
}
return options
}, [searchQuery, selectedType, queryItems, dynamicSearchResults])
}, [searchQuery, selectedType, queryItems, dynamicSearchResults, isLoading])
// Effect to handle delayed loading indicator (show "Searching..." after 500ms of searching)
useEffect(() => {
@@ -45,13 +52,18 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
loadingTimeoutRef.current = null
}
if (isLoading && searchQuery) {
// Arm the timer whenever a search is in flight. Don't gate on
// `searchQuery`: the "Add File"/"Add Folder" flow runs ripgrep on an
// empty query, and the render site already guards with
// `filteredOptions.length === 0` so the spinner stays hidden when
// real options are showing.
if (isLoading) {
setShowDelayedLoading(false)
loadingTimeoutRef.current = setTimeout(() => {
if (isLoading) {
setShowDelayedLoading(true)
}
}, 500) // 500ms delay before showing "Searching..."
}, 500)
} else {
setShowDelayedLoading(false)
}
@@ -254,7 +266,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
overflowY: "auto",
}}>
{/* Can't use virtuoso since it requires fixed height and menu height is dynamic based on # of items */}
{showDelayedLoading && searchQuery && (
{showDelayedLoading && filteredOptions.length === 0 && (
<div
style={{
padding: "8px 12px",