Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/x/apps/main/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
},
});
}
32 changes: 31 additions & 1 deletion apps/x/apps/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 ? (
Expand Down Expand Up @@ -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
Expand Down
208 changes: 208 additions & 0 deletions apps/x/apps/renderer/src/components/search-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
Loading