diff --git a/src/renderer/components/search/CommandPalette.tsx b/src/renderer/components/search/CommandPalette.tsx index 58d8ebe5..86e50668 100644 --- a/src/renderer/components/search/CommandPalette.tsx +++ b/src/renderer/components/search/CommandPalette.tsx @@ -288,7 +288,7 @@ export const CommandPalette = (): React.JSX.Element | null => { setLoading(false); } } - }, 200); + }, 400); return () => clearTimeout(timeoutId); }, [query, selectedProjectId, commandPaletteOpen, searchMode, globalSearchEnabled]); diff --git a/src/renderer/components/search/SearchBar.tsx b/src/renderer/components/search/SearchBar.tsx index d7e6b448..ab620535 100644 --- a/src/renderer/components/search/SearchBar.tsx +++ b/src/renderer/components/search/SearchBar.tsx @@ -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>( + 0 as unknown as ReturnType + ); + const inputRef = useRef(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 (
{/* Search input */} 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 && ( - {searchResultCount > 0 - ? `${currentSearchIndex + 1} of ${searchResultCount}` - : 'No results'} + {searchResultCount > 0 ? resultLabel : 'No results'} )} diff --git a/src/renderer/store/slices/conversationSlice.ts b/src/renderer/store/slices/conversationSlice.ts index d6ca21ac..218fcae9 100644 --- a/src/renderer/store/slices/conversationSlice.ts +++ b/src/renderer/store/slices/conversationSlice.ts @@ -397,10 +397,17 @@ export const createConversationSlice: StateCreator(); + for (const match of nextMatches) { + syncedMatchItemIds.add(match.itemId); + } + set({ searchMatches: nextMatches, searchResultCount: nextMatches.length, currentSearchIndex: newCurrentIndex, + searchMatchItemIds: syncedMatchItemIds, }); },