mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
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:
committed by
GitHub
parent
86f463496c
commit
90c8112257
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user