/** * CommandPalette - Spotlight/Alfred-like search modal. * Triggered by Cmd+K. * * Behavior: * - When NO project is selected: Searches projects by name/path * - When a project IS selected: Searches conversations within that project */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; import { formatModifierShortcut } from '@renderer/utils/keyboardUtils'; import { createLogger } from '@shared/utils/logger'; import { useShallow } from 'zustand/react/shallow'; const logger = createLogger('Component:CommandPalette'); import { formatDistanceToNow } from 'date-fns'; import { Bot, FileText, FolderGit2, Globe, Loader2, MessageSquare, Search, User, X, } from 'lucide-react'; import type { RepositoryGroup, SearchResult } from '@renderer/types/data'; // ============================================================================= // Search Mode Type // ============================================================================= type SearchMode = 'projects' | 'sessions'; // ============================================================================= // Project Search Result Item // ============================================================================= interface ProjectResultItemProps { repo: RepositoryGroup; isSelected: boolean; onClick: () => void; } const ProjectResultItemInner = ({ repo, isSelected, onClick, }: Readonly): React.JSX.Element => { const lastActivity = repo.mostRecentSession ? formatDistanceToNow(new Date(repo.mostRecentSession), { addSuffix: true }) : 'No recent activity'; return ( ); }; const ProjectResultItem = React.memo(ProjectResultItemInner); // ============================================================================= // Session Search Result Item // ============================================================================= interface SessionResultItemProps { result: SearchResult; isSelected: boolean; onClick: () => void; highlightMatch: (context: string, matchedText: string) => React.ReactNode; showProjectName?: boolean; projectName?: string; } const SessionResultItemInner = ({ result, isSelected, onClick, highlightMatch, showProjectName = false, projectName, }: Readonly): React.JSX.Element => { return ( ); }; const SessionResultItem = React.memo(SessionResultItemInner); // ============================================================================= // Main Component // ============================================================================= export const CommandPalette = (): React.JSX.Element | null => { const { commandPaletteOpen, closeCommandPalette, selectedProjectId, navigateToSession, repositoryGroups, fetchRepositoryGroups, selectRepository, } = useStore( useShallow((s) => ({ commandPaletteOpen: s.commandPaletteOpen, closeCommandPalette: s.closeCommandPalette, selectedProjectId: s.selectedProjectId, navigateToSession: s.navigateToSession, repositoryGroups: s.repositoryGroups, fetchRepositoryGroups: s.fetchRepositoryGroups, selectRepository: s.selectRepository, })) ); const inputRef = useRef(null); const [query, setQuery] = useState(''); const [sessionResults, setSessionResults] = useState([]); const [loading, setLoading] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); const [totalMatches, setTotalMatches] = useState(0); const [searchIsPartial, setSearchIsPartial] = useState(false); const [globalSearchEnabled, setGlobalSearchEnabled] = useState(false); const latestSearchRequestRef = useRef(0); // Determine search mode based on whether a project is selected OR global search is enabled const searchMode: SearchMode = selectedProjectId || globalSearchEnabled ? 'sessions' : 'projects'; // Filter projects for project search mode const filteredProjects = useMemo(() => { if (searchMode !== 'projects' || query.trim().length < 1) { return repositoryGroups.slice(0, 10); } const q = query.toLowerCase().trim(); return repositoryGroups .filter((repo) => { if (repo.name.toLowerCase().includes(q)) return true; const path = repo.worktrees[0]?.path || ''; if (path.toLowerCase().includes(q)) return true; return false; }) .slice(0, 10); }, [repositoryGroups, query, searchMode]); // Results count for current mode const resultsCount = searchMode === 'projects' ? filteredProjects.length : sessionResults.length; // Fetch repository groups if needed useEffect(() => { if ( commandPaletteOpen && (searchMode === 'projects' || globalSearchEnabled) && repositoryGroups.length === 0 ) { void fetchRepositoryGroups(); } }, [ commandPaletteOpen, searchMode, globalSearchEnabled, repositoryGroups.length, fetchRepositoryGroups, ]); // Focus input when palette opens useEffect(() => { if (commandPaletteOpen && inputRef.current) { inputRef.current.focus(); setQuery(''); setSessionResults([]); setSelectedIndex(0); setTotalMatches(0); setSearchIsPartial(false); setGlobalSearchEnabled(false); } }, [commandPaletteOpen]); // Search sessions with debounce (only in session mode) useEffect(() => { // Only clear results when query is too short or palette is closed if (!commandPaletteOpen || query.trim().length < 2) { setSessionResults([]); setTotalMatches(0); setSearchIsPartial(false); return; } // Early return without clearing if we're not in the right mode if (searchMode !== 'sessions' || (!globalSearchEnabled && !selectedProjectId)) { return; } const timeoutId = setTimeout(async () => { const requestId = latestSearchRequestRef.current + 1; latestSearchRequestRef.current = requestId; setLoading(true); try { const searchResult = globalSearchEnabled ? await api.searchAllProjects(query.trim(), 50) : await api.searchSessions(selectedProjectId!, query.trim(), 50); if (latestSearchRequestRef.current !== requestId) { return; } setSessionResults(searchResult.results); setTotalMatches(searchResult.totalMatches); setSearchIsPartial(!!searchResult.isPartial); setSelectedIndex(0); } catch (error) { if (latestSearchRequestRef.current !== requestId) { return; } logger.error('Search error:', error); setSessionResults([]); setTotalMatches(0); setSearchIsPartial(false); } finally { if (latestSearchRequestRef.current === requestId) { setLoading(false); } } }, 200); return () => clearTimeout(timeoutId); }, [query, selectedProjectId, commandPaletteOpen, searchMode, globalSearchEnabled]); // Reset selected index when results change useEffect(() => { setSelectedIndex(0); }, [filteredProjects, sessionResults]); // Handle project click const handleProjectClick = useCallback( (repo: RepositoryGroup) => { closeCommandPalette(); selectRepository(repo.id); }, [closeCommandPalette, selectRepository] ); // Handle session result click const handleSessionResultClick = useCallback( (result: SearchResult) => { closeCommandPalette(); navigateToSession(result.projectId, result.sessionId, true, { query: query.trim(), messageTimestamp: result.timestamp, matchedText: result.matchedText, targetGroupId: result.groupId, targetMatchIndexInItem: result.matchIndexInItem, targetMatchStartOffset: result.matchStartOffset, targetMessageUuid: result.messageUuid, }); }, [closeCommandPalette, navigateToSession, query] ); // Handle backdrop click const handleBackdropClick = useCallback( (e: React.MouseEvent) => { if (e.target === e.currentTarget) { closeCommandPalette(); } }, [closeCommandPalette] ); // Handle keyboard navigation const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'g' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setGlobalSearchEnabled((prev) => !prev); return; } if (e.key === 'Escape') { e.preventDefault(); closeCommandPalette(); return; } if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex((prev) => Math.min(prev + 1, resultsCount - 1)); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex((prev) => Math.max(prev - 1, 0)); return; } if (e.key === 'Enter' && resultsCount > 0) { e.preventDefault(); if (searchMode === 'projects') { const selected = filteredProjects[selectedIndex]; if (selected) { handleProjectClick(selected); } } else { const selected = sessionResults[selectedIndex]; if (selected) { handleSessionResultClick(selected); } } } }, [ resultsCount, selectedIndex, closeCommandPalette, searchMode, filteredProjects, sessionResults, handleProjectClick, handleSessionResultClick, ] ); // Highlight matched text in context const highlightMatch = useCallback((context: string, matchedText: string) => { const lowerContext = context.toLowerCase(); const lowerMatch = matchedText.toLowerCase(); const matchIndex = lowerContext.indexOf(lowerMatch); if (matchIndex === -1) { return {context}; } const before = context.slice(0, matchIndex); const match = context.slice(matchIndex, matchIndex + matchedText.length); const after = context.slice(matchIndex + matchedText.length); return ( <> {before} {match} {after} ); }, []); if (!commandPaletteOpen) { return null; } return (
{/* Mode indicator */}
{searchMode === 'projects' ? ( <> Search projects ) : ( <> {globalSearchEnabled ? 'Search across all projects' : 'Search in project'} {!globalSearchEnabled && ( <> · {repositoryGroups.find((r) => r.worktrees.some((w) => w.id === selectedProjectId) )?.name ?? 'Current project'} )} )}
{/* Search input */}
setQuery(e.target.value)} onKeyDown={handleKeyDown} placeholder={ searchMode === 'projects' ? 'Search projects...' : 'Search conversations...' } className="placeholder:text-text-muted/50 flex-1 bg-transparent text-base text-text focus:outline-none" /> {loading && }
{/* Results */}
{searchMode === 'projects' ? ( // Project search results filteredProjects.length === 0 ? (
{query.trim() ? `No projects found for "${query}"` : 'No projects found'}
) : (
{filteredProjects.map((repo, index) => ( handleProjectClick(repo)} /> ))}
) ) : // Session search results query.trim().length < 2 ? (
Type at least 2 characters to search
) : sessionResults.length === 0 && !loading ? (
{searchIsPartial ? `No fast results in recent sessions for "${query}"` : `No results found for "${query}"`}
) : (
{sessionResults.map((result, index) => { // Find project name for this result when in global search mode const projectName = globalSearchEnabled ? repositoryGroups.find((r) => r.worktrees.some((w) => w.id === result.projectId)) ?.name : undefined; return ( handleSessionResultClick(result)} highlightMatch={highlightMatch} showProjectName={globalSearchEnabled} projectName={projectName} /> ); })}
)}
{/* Footer */}
{searchMode === 'projects' ? `${filteredProjects.length} project${filteredProjects.length !== 1 ? 's' : ''}` : totalMatches > 0 ? `${totalMatches} ${searchIsPartial ? 'fast ' : ''}result${totalMatches !== 1 ? 's' : ''}${globalSearchEnabled ? ' across all projects' : ''}` : 'Type to search'}
↑↓{' '} navigate {' '} {searchMode === 'projects' ? 'select' : 'open'} {formatModifierShortcut('G')} {' '} global esc close
); };