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>
)
}

View file

@ -0,0 +1,375 @@
import path from 'path';
import fs from 'fs';
import fsp from 'fs/promises';
import readline from 'readline';
import { execFile } from 'child_process';
import { WorkDir } from '../config/config.js';
interface SearchResult {
type: 'knowledge' | 'chat';
title: string;
preview: string;
path: string;
}
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
const RUNS_DIR = path.join(WorkDir, 'runs');
type SearchType = 'knowledge' | 'chat';
/**
* Search across knowledge files and chat history.
* @param types - optional filter to search only specific types (default: both)
*/
export async function search(query: string, limit = 20, types?: SearchType[]): Promise<{ results: SearchResult[] }> {
const trimmed = query.trim();
if (!trimmed) {
return { results: [] };
}
const searchKnowledgeEnabled = !types || types.includes('knowledge');
const searchChatsEnabled = !types || types.includes('chat');
const [knowledgeResults, chatResults] = await Promise.all([
searchKnowledgeEnabled ? searchKnowledge(trimmed, limit) : Promise.resolve([]),
searchChatsEnabled ? searchChats(trimmed, limit) : Promise.resolve([]),
]);
const results = [...knowledgeResults, ...chatResults].slice(0, limit);
return { results };
}
/**
* Search knowledge markdown files by content and filename.
*/
async function searchKnowledge(query: string, limit: number): Promise<SearchResult[]> {
if (!fs.existsSync(KNOWLEDGE_DIR)) {
return [];
}
const results: SearchResult[] = [];
const seenPaths = new Set<string>();
const lowerQuery = query.toLowerCase();
// Content search via grep
try {
const grepMatches = await grepFiles(query, KNOWLEDGE_DIR, '*.md');
for (const match of grepMatches) {
if (results.length >= limit) break;
const relPath = path.relative(WorkDir, match.file);
if (seenPaths.has(relPath)) continue;
seenPaths.add(relPath);
const title = path.basename(match.file, '.md');
results.push({
type: 'knowledge',
title,
preview: match.line.trim().substring(0, 150),
path: relPath,
});
}
} catch {
// grep failed (no matches or dir issue) — continue
}
// Filename search — check files whose name matches the query
try {
const allFiles = await listMarkdownFiles(KNOWLEDGE_DIR);
for (const file of allFiles) {
if (results.length >= limit) break;
const relPath = path.relative(WorkDir, file);
if (seenPaths.has(relPath)) continue;
const basename = path.basename(file, '.md');
if (basename.toLowerCase().includes(lowerQuery)) {
seenPaths.add(relPath);
const preview = await readFirstLines(file, 2);
results.push({
type: 'knowledge',
title: basename,
preview,
path: relPath,
});
}
}
} catch {
// ignore errors
}
return results;
}
/**
* Search chat history by title and message content.
*/
async function searchChats(query: string, limit: number): Promise<SearchResult[]> {
if (!fs.existsSync(RUNS_DIR)) {
return [];
}
const results: SearchResult[] = [];
const seenIds = new Set<string>();
const lowerQuery = query.toLowerCase();
// Content search via grep on JSONL files
try {
const grepMatches = await grepFiles(query, RUNS_DIR, '*.jsonl');
for (const match of grepMatches) {
if (results.length >= limit) break;
const runId = path.basename(match.file, '.jsonl');
if (seenIds.has(runId)) continue;
const meta = await readRunMetadata(match.file);
if (meta.agentName !== 'copilot') {
seenIds.add(runId);
continue;
}
seenIds.add(runId);
// Extract a content preview from the matching line
let preview = '';
try {
const parsed = JSON.parse(match.line);
if (parsed.message?.content && typeof parsed.message.content === 'string') {
preview = parsed.message.content.replace(/<attached-files>[\s\S]*?<\/attached-files>/g, '').trim().substring(0, 150);
}
} catch {
preview = match.line.substring(0, 150);
}
results.push({
type: 'chat',
title: meta.title || runId,
preview,
path: runId,
});
}
} catch {
// grep failed — continue
}
// Title search — scan run files for matching titles
try {
const entries = await fsp.readdir(RUNS_DIR, { withFileTypes: true });
const jsonlFiles = entries
.filter(e => e.isFile() && e.name.endsWith('.jsonl'))
.map(e => e.name)
.sort()
.reverse(); // newest first
for (const name of jsonlFiles) {
if (results.length >= limit) break;
const runId = path.basename(name, '.jsonl');
if (seenIds.has(runId)) continue;
const filePath = path.join(RUNS_DIR, name);
const meta = await readRunMetadata(filePath);
if (meta.agentName !== 'copilot') {
seenIds.add(runId);
continue;
}
if (meta.title && meta.title.toLowerCase().includes(lowerQuery)) {
seenIds.add(runId);
results.push({
type: 'chat',
title: meta.title,
preview: meta.title,
path: runId,
});
}
}
} catch {
// ignore errors
}
return results;
}
/**
* Use grep to find files matching a query.
*/
function grepFiles(query: string, dir: string, includeGlob: string): Promise<Array<{ file: string; line: string }>> {
return new Promise((resolve, reject) => {
execFile(
'grep',
['-ril', '--include=' + includeGlob, query, dir],
{ maxBuffer: 1024 * 1024 },
(error, stdout) => {
if (error) {
// Exit code 1 = no matches
if (error.code === 1) {
resolve([]);
return;
}
reject(error);
return;
}
const files = stdout.trim().split('\n').filter(Boolean);
// For each matching file, get the first matching line
const promises = files.map(file =>
getFirstMatchingLine(file, query).then(line => ({ file, line }))
);
Promise.all(promises).then(resolve).catch(reject);
}
);
});
}
/**
* Get the first line in a file that matches the query (case-insensitive).
*/
function getFirstMatchingLine(filePath: string, query: string): Promise<string> {
return new Promise((resolve) => {
let resolved = false;
const done = (value: string) => {
if (resolved) return;
resolved = true;
resolve(value);
};
const lowerQuery = query.toLowerCase();
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
rl.on('line', (line) => {
if (line.toLowerCase().includes(lowerQuery)) {
done(line);
rl.close();
stream.destroy();
}
});
rl.on('close', () => done(''));
stream.on('error', () => done(''));
});
}
interface RunMetadata {
title: string | undefined;
agentName: string | undefined;
}
/**
* Read metadata from a run JSONL file (agent name from start event, title from first user message).
*/
function readRunMetadata(filePath: string): Promise<RunMetadata> {
return new Promise((resolve) => {
let resolved = false;
const done = (value: RunMetadata) => {
if (resolved) return;
resolved = true;
resolve(value);
};
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let lineIndex = 0;
let agentName: string | undefined;
rl.on('line', (line) => {
if (resolved) return;
const trimmed = line.trim();
if (!trimmed) return;
try {
if (lineIndex === 0) {
// Start event — extract agentName
const start = JSON.parse(trimmed);
agentName = start.agentName;
lineIndex++;
return;
}
const event = JSON.parse(trimmed);
if (event.type === 'message') {
const msg = event.message;
if (msg?.role === 'user') {
const content = msg.content;
if (typeof content === 'string' && content.trim()) {
let cleaned = content.replace(/<attached-files>[\s\S]*?<\/attached-files>/g, '');
cleaned = cleaned.replace(/\s+/g, ' ').trim();
if (cleaned) {
done({ title: cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned, agentName });
rl.close();
stream.destroy();
return;
}
}
done({ title: undefined, agentName });
rl.close();
stream.destroy();
return;
} else if (msg?.role === 'assistant') {
done({ title: undefined, agentName });
rl.close();
stream.destroy();
return;
}
}
lineIndex++;
} catch {
lineIndex++;
}
});
rl.on('close', () => done({ title: undefined, agentName }));
rl.on('error', () => done({ title: undefined, agentName: undefined }));
stream.on('error', () => {
rl.close();
done({ title: undefined, agentName: undefined });
});
});
}
/**
* Recursively list all .md files in a directory.
*/
async function listMarkdownFiles(dir: string): Promise<string[]> {
const results: string[] = [];
try {
const entries = await fsp.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const nested = await listMarkdownFiles(fullPath);
results.push(...nested);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
results.push(fullPath);
}
}
} catch {
// ignore
}
return results;
}
/**
* Read the first N non-empty lines of a file for preview.
*/
async function readFirstLines(filePath: string, n: number): Promise<string> {
return new Promise((resolve) => {
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
const lines: string[] = [];
rl.on('line', (line) => {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#')) {
lines.push(trimmed);
}
if (lines.length >= n) {
rl.close();
stream.destroy();
}
});
rl.on('close', () => {
resolve(lines.join(' ').substring(0, 150));
});
stream.on('error', () => {
resolve('');
});
});
}

View file

@ -396,6 +396,22 @@ const ipcSchemas = {
req: z.object({ path: z.string() }),
res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }),
},
// Search channels
'search:query': {
req: z.object({
query: z.string(),
limit: z.number().optional(),
types: z.array(z.enum(['knowledge', 'chat'])).optional(),
}),
res: z.object({
results: z.array(z.object({
type: z.enum(['knowledge', 'chat']),
title: z.string(),
preview: z.string(),
path: z.string(),
})),
}),
},
} as const;
// ============================================================================