Add global search across knowledge and chats

Cmd+K / Ctrl+K opens a spotlight-style search dialog that searches
knowledge files (by content and filename) and chat history (by title
and message content). Results are grouped by type with filter toggles
preselected based on the active sidebar tab.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Arjun 2026-02-18 18:08:24 +05:30
parent 5e2be4531a
commit 7329d0ad0d
5 changed files with 635 additions and 1 deletions

View file

@ -30,6 +30,7 @@ import * as composioHandler from './composio-handler.js';
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
import { search } from '@x/core/dist/search/search.js';
type InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels;
@ -497,5 +498,9 @@ export function setupIpcHandlers() {
const mimeType = mimeMap[ext] || 'application/octet-stream';
return { data: buffer.toString('base64'), mimeType, size: stat.size };
},
// Search handler
'search:query': async (_event, args) => {
return search(args.query, args.limit, args.types);
},
});
}

View file

@ -6,7 +6,7 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css'
import z from 'zod';
import { Button } from './components/ui/button';
import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square, X, ChevronLeftIcon, ChevronRightIcon, SquarePen } from 'lucide-react';
import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square, X, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MarkdownEditor } from './components/markdown-editor';
import { ChatInputBar } from './components/chat-button';
@ -50,6 +50,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"
import { Toaster } from "@/components/ui/sonner"
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { OnboardingModal } from '@/components/onboarding-modal'
import { SearchDialog } from '@/components/search-dialog'
import { BackgroundTaskDetail } from '@/components/background-task-detail'
import { FileCardProvider } from '@/contexts/file-card-context'
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
@ -675,6 +676,9 @@ function App() {
// Onboarding state
const [showOnboarding, setShowOnboarding] = useState(false)
// Search state
const [isSearchOpen, setIsSearchOpen] = useState(false)
// Background tasks state
type BackgroundTaskItem = {
name: string
@ -1829,6 +1833,18 @@ function App() {
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleCloseFullScreenChat, isFullScreenChat, expandedFrom, navigateToFullScreenChat])
// Keyboard shortcut: Cmd+K / Ctrl+K to open search
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
setIsSearchOpen(true)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
if (kind === 'file') {
navigateToFile(path)
@ -2336,6 +2352,14 @@ function App() {
<span className="text-sm font-medium text-muted-foreground flex-1 min-w-0 truncate">
{headerTitle}
</span>
<button
type="button"
onClick={() => setIsSearchOpen(true)}
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
aria-label="Search"
>
<SearchIcon className="size-4" />
</button>
{selectedPath && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{isSaving ? (
@ -2568,6 +2592,12 @@ function App() {
/>
)}
</div>
<SearchDialog
open={isSearchOpen}
onOpenChange={setIsSearchOpen}
onSelectFile={navigateToFile}
onSelectRun={(id) => { void navigateToView({ type: 'chat', runId: id }) }}
/>
</SidebarSectionProvider>
<Toaster />
<OnboardingModal

View file

@ -0,0 +1,208 @@
import { useState, useEffect, useCallback } from 'react'
import { FileTextIcon, MessageSquareIcon } from 'lucide-react'
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from '@/components/ui/command'
import { useDebounce } from '@/hooks/use-debounce'
import { useSidebarSection, type ActiveSection } from '@/contexts/sidebar-context'
import { cn } from '@/lib/utils'
interface SearchResult {
type: 'knowledge' | 'chat'
title: string
preview: string
path: string
}
type SearchType = 'knowledge' | 'chat'
function activeTabToTypes(section: ActiveSection): SearchType[] {
if (section === 'knowledge') return ['knowledge']
return ['chat'] // "tasks" tab maps to chat
}
interface SearchDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSelectFile: (path: string) => void
onSelectRun: (runId: string) => void
}
export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: SearchDialogProps) {
const { activeSection } = useSidebarSection()
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [isSearching, setIsSearching] = useState(false)
const [activeTypes, setActiveTypes] = useState<Set<SearchType>>(
() => new Set(activeTabToTypes(activeSection))
)
const debouncedQuery = useDebounce(query, 250)
// Sync filter preselection when dialog opens
useEffect(() => {
if (open) {
setActiveTypes(new Set(activeTabToTypes(activeSection)))
}
}, [open, activeSection])
const toggleType = useCallback((type: SearchType) => {
setActiveTypes(new Set([type]))
}, [])
useEffect(() => {
if (!debouncedQuery.trim()) {
setResults([])
return
}
let cancelled = false
setIsSearching(true)
const types = Array.from(activeTypes) as ('knowledge' | 'chat')[]
window.ipc.invoke('search:query', { query: debouncedQuery, limit: 20, types })
.then((res) => {
if (!cancelled) {
setResults(res.results)
}
})
.catch((err) => {
console.error('Search failed:', err)
if (!cancelled) {
setResults([])
}
})
.finally(() => {
if (!cancelled) {
setIsSearching(false)
}
})
return () => { cancelled = true }
}, [debouncedQuery, activeTypes])
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setQuery('')
setResults([])
}
}, [open])
const handleSelect = useCallback((result: SearchResult) => {
onOpenChange(false)
if (result.type === 'knowledge') {
onSelectFile(result.path)
} else {
onSelectRun(result.path)
}
}, [onOpenChange, onSelectFile, onSelectRun])
const knowledgeResults = results.filter(r => r.type === 'knowledge')
const chatResults = results.filter(r => r.type === 'chat')
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title="Search"
description="Search across knowledge and chats"
showCloseButton={false}
className="top-[20%] translate-y-0"
>
<CommandInput
placeholder="Search..."
value={query}
onValueChange={setQuery}
/>
<div className="flex items-center gap-1.5 px-3 py-2 border-b">
<FilterToggle
active={activeTypes.has('knowledge')}
onClick={() => toggleType('knowledge')}
icon={<FileTextIcon className="size-3" />}
label="Knowledge"
/>
<FilterToggle
active={activeTypes.has('chat')}
onClick={() => toggleType('chat')}
icon={<MessageSquareIcon className="size-3" />}
label="Chats"
/>
</div>
<CommandList>
{!query.trim() && (
<CommandEmpty>Type to search...</CommandEmpty>
)}
{query.trim() && !isSearching && results.length === 0 && (
<CommandEmpty>No results found.</CommandEmpty>
)}
{knowledgeResults.length > 0 && (
<CommandGroup heading="Knowledge">
{knowledgeResults.map((result) => (
<CommandItem
key={`knowledge-${result.path}`}
value={`knowledge-${result.title}-${result.path}`}
onSelect={() => handleSelect(result)}
>
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col gap-0.5 min-w-0">
<span className="truncate font-medium">{result.title}</span>
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
</div>
</CommandItem>
))}
</CommandGroup>
)}
{chatResults.length > 0 && (
<CommandGroup heading="Chats">
{chatResults.map((result) => (
<CommandItem
key={`chat-${result.path}`}
value={`chat-${result.title}-${result.path}`}
onSelect={() => handleSelect(result)}
>
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col gap-0.5 min-w-0">
<span className="truncate font-medium">{result.title}</span>
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</CommandDialog>
)
}
function FilterToggle({
active,
onClick,
icon,
label,
}: {
active: boolean
onClick: () => void
icon: React.ReactNode
label: string
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
)}
>
{icon}
{label}
</button>
)
}