0 ? "justify-between" : "justify-end",
+ memoryEntries?.length > 0 ? "justify-between" : "justify-end",
)}
>
- {memoryEntries.length > 0 && (
+ {memoryEntries?.length > 0 && (
type DocumentWithMemories = DocumentsResponse["documents"][0]
+type SearchResult = z.infer["results"][number]
+
+type PaletteItem =
+ | { kind: "action"; id: string; label: string; icon: React.ReactNode; action: () => void }
+ | { kind: "document"; doc: DocumentWithMemories }
+ | { kind: "search-result"; result: SearchResult }
interface DocumentsCommandPaletteProps {
open: boolean
onOpenChange: (open: boolean) => void
projectId: string
onOpenDocument: (document: DocumentWithMemories) => void
+ onAddMemory?: () => void
+ onOpenMCP?: () => void
initialSearch?: string
}
@@ -27,17 +47,66 @@ export function DocumentsCommandPalette({
onOpenChange,
projectId,
onOpenDocument,
+ onAddMemory,
+ onOpenMCP,
initialSearch = "",
}: DocumentsCommandPaletteProps) {
const isMobile = useIsMobile()
+ const router = useRouter()
const queryClient = useQueryClient()
const [search, setSearch] = useState("")
const [selectedIndex, setSelectedIndex] = useState(0)
- const [documents, setDocuments] = useState([])
+ const [cachedDocs, setCachedDocs] = useState([])
+ const [searchResults, setSearchResults] = useState([])
+ const [isSearching, setIsSearching] = useState(false)
const inputRef = useRef(null)
const listRef = useRef(null)
+ const debounceRef = useRef | null>(null)
+ const abortRef = useRef(null)
+
+ const close = useCallback((then?: () => void) => {
+ onOpenChange(false)
+ setSearch("")
+ setSearchResults([])
+ if (then) setTimeout(then, 0)
+ }, [onOpenChange])
- // Get documents from the existing query cache when dialog opens
+ const actions: PaletteItem[] = [
+ {
+ kind: "action",
+ id: "home",
+ label: "Go to Home",
+ icon: ,
+ action: () => close(() => router.push("/")),
+ },
+ {
+ kind: "action",
+ id: "settings",
+ label: "Go to Settings",
+ icon: ,
+ action: () => close(() => router.push("/settings")),
+ },
+ ...(onAddMemory
+ ? [{
+ kind: "action" as const,
+ id: "add-memory",
+ label: "Add Memory",
+ icon: ,
+ action: () => { close(); onAddMemory() },
+ }]
+ : []),
+ ...(onOpenMCP
+ ? [{
+ kind: "action" as const,
+ id: "mcp",
+ label: "Open MCP",
+ icon: ,
+ action: () => { close(); onOpenMCP() },
+ }]
+ : []),
+ ]
+
+ // Load cached docs when opening
useEffect(() => {
if (open) {
const queryData = queryClient.getQueryData<{
@@ -46,63 +115,215 @@ export function DocumentsCommandPalette({
}>(["documents-with-memories", projectId])
if (queryData?.pages) {
- setDocuments(queryData.pages.flatMap((page) => page.documents ?? []))
+ setCachedDocs(queryData.pages.flatMap((page) => page.documents ?? []))
}
setTimeout(() => inputRef.current?.focus(), 0)
setSearch(initialSearch)
setSelectedIndex(0)
+ setSearchResults([])
}
}, [open, queryClient, projectId, initialSearch])
- const filteredDocuments = useMemo(() => {
- if (!search.trim()) return documents
- const searchLower = search.toLowerCase()
- return documents.filter((doc) =>
- doc.title?.toLowerCase().includes(searchLower),
- )
- }, [documents, search])
+ // Debounced semantic search
+ useEffect(() => {
+ if (debounceRef.current) clearTimeout(debounceRef.current)
+ if (abortRef.current) abortRef.current.abort()
- // Reset selection when filtered results change
- const handleSearchChange = useCallback((value: string) => {
- setSearch(value)
+ if (!search.trim()) {
+ setSearchResults([])
+ setIsSearching(false)
+ return
+ }
+
+ setIsSearching(true)
+ debounceRef.current = setTimeout(async () => {
+ const controller = new AbortController()
+ abortRef.current = controller
+
+ try {
+ const res = await $fetch("@post/search", {
+ body: {
+ q: search.trim(),
+ limit: 10,
+ containerTags: projectId ? [projectId] : undefined,
+ includeSummary: true,
+ },
+ signal: controller.signal,
+ })
+ if (!controller.signal.aborted && res.data) {
+ setSearchResults(res.data.results)
+ }
+ } catch {
+ // aborted or failed - ignore
+ } finally {
+ if (!controller.signal.aborted) setIsSearching(false)
+ }
+ }, 250)
+
+ return () => {
+ if (debounceRef.current) clearTimeout(debounceRef.current)
+ }
+ }, [search, projectId])
+
+ // Build the item list
+ const hasQuery = search.trim().length > 0
+ const items: PaletteItem[] = []
+
+ if (hasQuery) {
+ for (const r of searchResults) {
+ items.push({ kind: "search-result", result: r })
+ }
+ const q = search.toLowerCase()
+ for (const a of actions) {
+ if (a.kind === "action" && a.label.toLowerCase().includes(q)) items.push(a)
+ }
+ } else {
+ for (const doc of cachedDocs.slice(0, 10)) items.push({ kind: "document", doc })
+ for (const a of actions) items.push(a)
+ }
+
+ // Reset selection on items change
+ useEffect(() => {
setSelectedIndex(0)
- }, [])
+ }, [search, searchResults.length])
- // Scroll selected item into view
+ // Scroll selected into view
useEffect(() => {
- const selectedElement = listRef.current?.querySelector(
- `[data-index="${selectedIndex}"]`,
- )
- selectedElement?.scrollIntoView({ block: "nearest" })
+ listRef.current
+ ?.querySelector(`[data-index="${selectedIndex}"]`)
+ ?.scrollIntoView({ block: "nearest" })
}, [selectedIndex])
const handleSelect = useCallback(
- (document: DocumentWithMemories) => {
- if (!document.id) return
- onOpenDocument(document)
- onOpenChange(false)
- setSearch("")
+ (item: PaletteItem) => {
+ if (item.kind === "action") {
+ item.action()
+ } else if (item.kind === "document") {
+ if (!item.doc.id) return
+ onOpenDocument(item.doc)
+ close()
+ } else {
+ // search result -> convert to DocumentWithMemories shape for the modal
+ onOpenDocument({
+ id: item.result.documentId,
+ title: item.result.title,
+ type: item.result.type,
+ createdAt: item.result.createdAt as unknown as string,
+ updatedAt: item.result.updatedAt as unknown as string,
+ url: (item.result.metadata?.url as string) ?? null,
+ content: item.result.content ?? item.result.chunks?.[0]?.content ?? null,
+ summary: item.result.summary ?? null,
+ } as unknown as DocumentWithMemories)
+ close()
+ }
},
- [onOpenDocument, onOpenChange],
+ [onOpenDocument, close],
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault()
- setSelectedIndex((i) => (i < filteredDocuments.length - 1 ? i + 1 : i))
+ setSelectedIndex((i) => Math.min(i + 1, items.length - 1))
} else if (e.key === "ArrowUp") {
e.preventDefault()
- setSelectedIndex((i) => (i > 0 ? i - 1 : i))
+ setSelectedIndex((i) => Math.max(i - 1, 0))
} else if (e.key === "Enter") {
e.preventDefault()
- const document = filteredDocuments[selectedIndex]
- if (document) handleSelect(document)
+ const item = items[selectedIndex]
+ if (item) handleSelect(item)
}
},
- [filteredDocuments, selectedIndex, handleSelect],
+ [items, selectedIndex, handleSelect],
)
+ function renderItem(item: PaletteItem, index: number) {
+ const isSelected = index === selectedIndex
+ const baseClass = cn(
+ "flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer text-left transition-colors",
+ isSelected
+ ? "bg-[#293952]/40"
+ : "opacity-70 hover:opacity-100 hover:bg-[#293952]/40",
+ )
+
+ if (item.kind === "action") {
+ return (
+
+ )
+ }
+
+ const title =
+ item.kind === "document" ? item.doc.title : item.result.title
+ const type =
+ item.kind === "document" ? item.doc.type : item.result.type
+ const url =
+ item.kind === "document"
+ ? item.doc.url
+ : (item.result.metadata?.url as string) ?? null
+ const date =
+ item.kind === "document"
+ ? item.doc.createdAt
+ : item.result.createdAt
+ const key =
+ item.kind === "document" ? item.doc.id : item.result.documentId
+ const snippet =
+ item.kind === "search-result"
+ ? item.result.chunks?.find((c) => c.isRelevant)?.content
+ : null
+
+ return (
+
+ )
+ }
+
return (