perf: add search debounce and increase global search timeout

- SearchBar: 300ms debounce with local input state, flush on Enter
- CommandPalette: global search debounce 200→400ms
- Show "500+" when search results are capped
- Fix: syncSearchMatchesWithRendered now updates searchMatchItemIds

Ported from upstream 5c7f921e (SearchBar/CommandPalette parts).
This commit is contained in:
iliya 2026-03-25 14:34:22 +02:00
parent c21350713c
commit 22387ca0cb
3 changed files with 63 additions and 7 deletions

View file

@ -288,7 +288,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
setLoading(false);
}
}
}, 200);
}, 400);
return () => clearTimeout(timeoutId);
}, [query, selectedProjectId, commandPaletteOpen, searchMode, globalSearchEnabled]);

View file

@ -1,14 +1,19 @@
/**
* SearchBar - In-session search interface component.
* Appears at the top of the chat view when Cmd+F is pressed.
*
* Uses a local input state with debouncing to avoid triggering expensive
* search on every keystroke.
*/
import { useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useStore } from '@renderer/store';
import { ChevronDown, ChevronUp, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
const SEARCH_DEBOUNCE_MS = 300;
interface SearchBarProps {
tabId?: string;
}
@ -19,6 +24,7 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
searchVisible,
searchResultCount,
currentSearchIndex,
searchResultsCapped,
conversation,
setSearchQuery,
hideSearch,
@ -30,6 +36,7 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
searchVisible: s.searchVisible,
searchResultCount: s.searchResultCount,
currentSearchIndex: s.currentSearchIndex,
searchResultsCapped: s.searchResultsCapped,
conversation: tabId
? (s.tabSessionData[tabId]?.conversation ?? s.conversation)
: s.conversation,
@ -40,8 +47,43 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
}))
);
// Local input value for responsive typing — debounced before triggering search
const [localQuery, setLocalQuery] = useState(searchQuery);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(
0 as unknown as ReturnType<typeof setTimeout>
);
const inputRef = useRef<HTMLInputElement>(null);
// Sync local state when store query changes externally (e.g., hideSearch clears it)
useEffect(() => {
setLocalQuery(searchQuery);
}, [searchQuery]);
// Debounced search dispatch
const handleChange = useCallback(
(value: string) => {
setLocalQuery(value);
clearTimeout(debounceRef.current);
// Clear immediately when input is emptied
if (!value.trim()) {
setSearchQuery('', conversation);
return;
}
debounceRef.current = setTimeout(() => {
setSearchQuery(value, conversation);
}, SEARCH_DEBOUNCE_MS);
},
[conversation, setSearchQuery]
);
// Cleanup timeout on unmount
useEffect(() => {
return () => clearTimeout(debounceRef.current);
}, []);
// Auto-focus input when search becomes visible
useEffect(() => {
if (searchVisible && inputRef.current) {
@ -55,6 +97,11 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
if (e.key === 'Escape') {
hideSearch();
} else if (e.key === 'Enter') {
// Flush any pending debounce immediately on Enter
clearTimeout(debounceRef.current);
if (localQuery !== searchQuery) {
setSearchQuery(localQuery, conversation);
}
if (e.shiftKey) {
previousSearchResult();
} else {
@ -67,14 +114,18 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
return null;
}
const resultLabel = searchResultsCapped
? `${currentSearchIndex + 1} of ${searchResultCount}+`
: `${currentSearchIndex + 1} of ${searchResultCount}`;
return (
<div className="absolute right-4 top-2 z-20 flex items-center gap-2 rounded-lg border border-border bg-surface px-3 py-2 shadow-lg">
{/* Search input */}
<input
ref={inputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value, conversation)}
value={localQuery}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Find in conversation..."
className="w-48 rounded border border-border bg-surface-raised px-3 py-1.5 text-sm text-text focus:border-text-secondary focus:outline-none"
@ -83,9 +134,7 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
{/* Result count */}
{searchQuery && (
<span className="whitespace-nowrap text-xs text-text-secondary">
{searchResultCount > 0
? `${currentSearchIndex + 1} of ${searchResultCount}`
: 'No results'}
{searchResultCount > 0 ? resultLabel : 'No results'}
</span>
)}

View file

@ -397,10 +397,17 @@ export const createConversationSlice: StateCreator<AppState, [], [], Conversatio
});
}
// Rebuild matchItemIds from synced matches
const syncedMatchItemIds = new Set<string>();
for (const match of nextMatches) {
syncedMatchItemIds.add(match.itemId);
}
set({
searchMatches: nextMatches,
searchResultCount: nextMatches.length,
currentSearchIndex: newCurrentIndex,
searchMatchItemIds: syncedMatchItemIds,
});
},