From 7329d0ad0dc20c00581641c445f5b5a6e7e26923 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:08:24 +0530 Subject: [PATCH] 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 --- apps/x/apps/main/src/ipc.ts | 5 + apps/x/apps/renderer/src/App.tsx | 32 +- .../renderer/src/components/search-dialog.tsx | 208 ++++++++++ apps/x/packages/core/src/search/search.ts | 375 ++++++++++++++++++ apps/x/packages/shared/src/ipc.ts | 16 + 5 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 apps/x/apps/renderer/src/components/search-dialog.tsx create mode 100644 apps/x/packages/core/src/search/search.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 72e1e589..b0757881 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -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); + }, }); } diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 7658c4c8..ed3dd739 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -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() { {headerTitle} + {selectedPath && (
{isSaving ? ( @@ -2568,6 +2592,12 @@ function App() { /> )}
+ { void navigateToView({ type: 'chat', runId: id }) }} + /> 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([]) + const [isSearching, setIsSearching] = useState(false) + const [activeTypes, setActiveTypes] = useState>( + () => 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 ( + + +
+ toggleType('knowledge')} + icon={} + label="Knowledge" + /> + toggleType('chat')} + icon={} + label="Chats" + /> +
+ + {!query.trim() && ( + Type to search... + )} + {query.trim() && !isSearching && results.length === 0 && ( + No results found. + )} + {knowledgeResults.length > 0 && ( + + {knowledgeResults.map((result) => ( + handleSelect(result)} + > + +
+ {result.title} + {result.preview} +
+
+ ))} +
+ )} + {chatResults.length > 0 && ( + + {chatResults.map((result) => ( + handleSelect(result)} + > + +
+ {result.title} + {result.preview} +
+
+ ))} +
+ )} +
+
+ ) +} + +function FilterToggle({ + active, + onClick, + icon, + label, +}: { + active: boolean + onClick: () => void + icon: React.ReactNode + label: string +}) { + return ( + + ) +} diff --git a/apps/x/packages/core/src/search/search.ts b/apps/x/packages/core/src/search/search.ts new file mode 100644 index 00000000..d68449f5 --- /dev/null +++ b/apps/x/packages/core/src/search/search.ts @@ -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 { + if (!fs.existsSync(KNOWLEDGE_DIR)) { + return []; + } + + const results: SearchResult[] = []; + const seenPaths = new Set(); + 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 { + if (!fs.existsSync(RUNS_DIR)) { + return []; + } + + const results: SearchResult[] = []; + const seenIds = new Set(); + 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(/[\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> { + 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 { + 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 { + 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(/[\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 { + 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 { + 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(''); + }); + }); +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 7af39efb..1fa9b423 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -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; // ============================================================================