"use client" import { useState, useCallback, useEffect, useRef } from "react" import { useRouter } from "next/navigation" import { useQueryClient } from "@tanstack/react-query" import type { DocumentsWithMemoriesResponseSchema, SearchResponseSchema, } from "@repo/validation/api" import type { z } from "zod" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" import { useIsMobile } from "@hooks/use-mobile" import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog" import { SearchIcon, Settings, Home, Plus, Code2, Loader2 } from "lucide-react" import { DocumentIcon } from "@/components/document-icon" import { $fetch } from "@lib/api" type DocumentsResponse = z.infer 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 onOpenIntegrations?: () => void initialSearch?: string } export function DocumentsCommandPalette({ open, onOpenChange, projectId, onOpenDocument, onAddMemory, onOpenIntegrations, initialSearch = "", }: DocumentsCommandPaletteProps) { const isMobile = useIsMobile() const router = useRouter() const queryClient = useQueryClient() const [search, setSearch] = useState("") const [selectedIndex, setSelectedIndex] = useState(0) 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], ) 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() }, }, ] : []), ...(onOpenIntegrations ? [ { kind: "action" as const, id: "integrations", label: "Open Integrations", icon: , action: () => { close() onOpenIntegrations() }, }, ] : []), ] // Load cached docs when opening useEffect(() => { if (open) { const queryData = queryClient.getQueryData<{ pages: DocumentsResponse[] pageParams: number[] }>(["documents-with-memories", projectId]) if (queryData?.pages) { setCachedDocs(queryData.pages.flatMap((page) => page.documents ?? [])) } const focusTimer = setTimeout(() => inputRef.current?.focus(), 0) setSearch(initialSearch) setSelectedIndex(0) setSearchResults([]) return () => clearTimeout(focusTimer) } }, [open, queryClient, projectId, initialSearch]) // Debounced semantic search useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current) if (abortRef.current) abortRef.current.abort() 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) }, []) // Scroll selected into view useEffect(() => { listRef.current ?.querySelector(`[data-index="${selectedIndex}"]`) ?.scrollIntoView({ block: "nearest" }) }, [selectedIndex]) const handleSelect = useCallback( (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, close], ) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault() setSelectedIndex((i) => Math.min(i + 1, items.length - 1)) } else if (e.key === "ArrowUp") { e.preventDefault() setSelectedIndex((i) => Math.max(i - 1, 0)) } else if (e.key === "Enter") { e.preventDefault() const item = items[selectedIndex] if (item) handleSelect(item) } }, [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 ( Search
{isSearching ? ( ) : ( )} setSearch(e.target.value)} className={cn( "flex-1 bg-transparent text-white text-sm placeholder:text-[#737373] outline-none", dmSansClassName(), )} />
{!hasQuery && cachedDocs.length > 0 && (

Recent

)} {hasQuery && searchResults.length > 0 && (

Results

)} {items .map((item, i) => ({ item, globalIndex: i })) .filter(({ item }) => item.kind !== "action") .map(({ item, globalIndex }) => renderItem(item, globalIndex))} {items.some((i) => i.kind === "action") && (

Actions

)} {items .map((item, i) => ({ item, globalIndex: i })) .filter(({ item }) => item.kind === "action") .map(({ item, globalIndex }) => renderItem(item, globalIndex))} {hasQuery && !isSearching && searchResults.length === 0 && items.every((i) => i.kind === "action") && (

No results found

)}
Navigate Open Esc Close
{hasQuery && searchResults.length > 0 && ( {searchResults.length} results )}
) }