"use client"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CornerDownLeft, Search, X } from "lucide-react"; import { useParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { usePanelStore } from "@/lib/stores/panel-store"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; import { SearchResultRow, highlightText, type SearchResultItem, } from "./search-result"; const DEBOUNCE_MS = 300; const MOCK_RESULTS: SearchResultItem[] = [ { id: "10000000-0000-4000-8000-000000000101", type: "task", title: "Design system audit", status: "in_progress", parentId: "10000000-0000-4000-8000-000000000002", workspaceId: null, descriptionSnippet: "…align tokens with the new brand palette before the next release.", parentBreadcrumb: "Project Alpha > Sprint 1", }, { id: "10000000-0000-4000-8000-000000000102", type: "document", title: "Q1 roadmap", status: "open", parentId: "10000000-0000-4000-8000-000000000001", workspaceId: null, descriptionSnippet: "Goals, milestones, and risks for the quarter…", parentBreadcrumb: "Project Alpha", }, { id: "10000000-0000-4000-8000-000000000103", type: "project", title: "Mobile launch", status: "open", parentId: null, workspaceId: null, descriptionSnippet: "Cross-functional initiative spanning design and eng…", parentBreadcrumb: null, }, { id: "10000000-0000-4000-8000-000000000104", type: "whiteboard", title: "Architecture brainstorm", status: "open", parentId: "10000000-0000-4000-8000-000000000001", workspaceId: null, descriptionSnippet: "Service diagram and sequence flows for the API layer…", parentBreadcrumb: "Project Alpha", }, ]; const MOCK_RECENT: SearchResultItem[] = MOCK_RESULTS.slice(0, 3); const GROUP_ORDER = ["task", "document", "project", "whiteboard", "group", "workspace"] as const; const GROUP_LABEL: Record = { task: "Tasks", document: "Documents", project: "Projects", whiteboard: "Whiteboards", group: "Groups", workspace: "Workspace", }; function isUuid(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( value, ); } function groupKey(type: string): string { if (type === "group") return "project"; return type; } function sortGroupEntries( map: Map, ): { key: string; label: string; items: SearchResultItem[] }[] { const out: { key: string; label: string; items: SearchResultItem[] }[] = []; for (const k of GROUP_ORDER) { const items = map.get(k); if (items?.length) { out.push({ key: k, label: GROUP_LABEL[k] ?? k, items, }); } } for (const [k, items] of map) { if (!GROUP_ORDER.includes(k as (typeof GROUP_ORDER)[number]) && items.length) { out.push({ key: k, label: GROUP_LABEL[k] ?? k, items, }); } } return out; } function groupResults(rows: SearchResultItem[]) { const map = new Map(); for (const r of rows) { const g = groupKey(r.type); if (!map.has(g)) map.set(g, []); map.get(g)!.push(r); } return sortGroupEntries(map); } function useDebouncedValue(value: T, delay: number): T { const [debounced, setDebounced] = useState(value); useEffect(() => { const t = window.setTimeout(() => setDebounced(value), delay); return () => window.clearTimeout(t); }, [value, delay]); return debounced; } function ResultSkeleton() { return (
); } export function SearchDialog({ open, onOpenChange, }: { open: boolean; onOpenChange: (open: boolean) => void; }) { const params = useParams<{ workspaceSlug?: string }>(); const workspace = useWorkspaceStore((s) => s.currentWorkspace); const openPanel = usePanelStore((s) => s.open); const workspaceHandle = workspace?.slug ?? workspace?.id; const [query, setQuery] = useState(""); const debounced = useDebouncedValue(query, DEBOUNCE_MS); const inputRef = useRef(null); const searchEnabled = open && debounced.trim().length > 0 && Boolean(workspaceHandle); const searchQuery = api.search.search.useQuery( { workspace: workspaceHandle!, query: debounced.trim(), limit: 20, }, { enabled: searchEnabled, retry: false, }, ); const recentQuery = api.search.recent.useQuery( { workspace: workspaceHandle!, limit: 10 }, { enabled: open && Boolean(workspaceHandle), retry: false, }, ); const useMockSearch = searchQuery.isError; const useMockRecent = recentQuery.isError; const resultRows = useMemo(() => { if (!searchEnabled) return []; if (useMockSearch) { const q = debounced.trim().toLowerCase(); return MOCK_RESULTS.filter( (m) => m.title.toLowerCase().includes(q) || (m.descriptionSnippet?.toLowerCase().includes(q) ?? false), ); } return searchQuery.data?.results ?? []; }, [ searchEnabled, useMockSearch, debounced, searchQuery.data?.results, ]); const recentRows = useMemo(() => { if (useMockRecent) return MOCK_RECENT; return recentQuery.data?.results ?? []; }, [useMockRecent, recentQuery.data?.results]); const grouped = useMemo(() => groupResults(resultRows), [resultRows]); const flatList = useMemo(() => { const list: { group: string; label: string; item: SearchResultItem }[] = []; for (const g of grouped) { for (const item of g.items) { list.push({ group: g.key, label: g.label, item }); } } return list; }, [grouped]); const emptyQuery = !query.trim(); const showLoading = searchEnabled && (searchQuery.isFetching || (searchQuery.isPending && !useMockSearch)); const keyboardNavItems = useMemo(() => { if (showLoading) return []; if (searchEnabled && resultRows.length > 0) { return flatList.map((f) => f.item); } if (emptyQuery && recentRows.length > 0 && !recentQuery.isFetching) { return recentRows; } return []; }, [ showLoading, searchEnabled, resultRows.length, flatList, emptyQuery, recentRows, recentQuery.isFetching, ]); const activeIndexById = useMemo(() => { const m = new Map(); keyboardNavItems.forEach((item, i) => m.set(item.id, i)); return m; }, [keyboardNavItems]); const [active, setActive] = useState(0); useEffect(() => { setActive(0); }, [query, keyboardNavItems.length, searchEnabled, emptyQuery]); useEffect(() => { if (!open) { setQuery(""); setActive(0); } }, [open]); useEffect(() => { if (!open) return; const id = window.requestAnimationFrame(() => inputRef.current?.focus()); return () => window.cancelAnimationFrame(id); }, [open]); const selectObject = useCallback( (obj: SearchResultItem) => { openPanel("object-detail", obj.id); onOpenChange(false); setQuery(""); }, [openPanel, onOpenChange], ); const onKeyDown = (e: React.KeyboardEvent) => { const len = keyboardNavItems.length; if (e.key === "ArrowDown") { e.preventDefault(); setActive((i) => (len ? (i + 1) % len : 0)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive((i) => (len ? (i - 1 + len) % len : 0)); } else if (e.key === "Enter") { e.preventDefault(); const row = keyboardNavItems[active]; if (row) selectObject(row); } }; const showRecentEmpty = emptyQuery && !showLoading; const showNoResults = searchEnabled && !showLoading && resultRows.length === 0; return ( ev.preventDefault()} onKeyDown={onKeyDown} >
setQuery(e.target.value)} placeholder="Search tasks, docs, projects…" className="h-10 flex-1 border-0 bg-transparent px-0 text-base shadow-none placeholder:text-muted-foreground/80 focus-visible:ring-0" autoComplete="off" aria-label="Search workspace" /> {query ? ( ) : null}
{showLoading ? (
{Array.from({ length: 6 }).map((_, i) => ( ))}
) : showRecentEmpty ? (
{recentQuery.isFetching ? "Loading…" : "Recent"}
{recentQuery.isFetching && !useMockRecent ? (
{Array.from({ length: 4 }).map((_, i) => ( ))}
) : (
{recentRows.map((item) => ( setActive(activeIndexById.get(item.id) ?? 0) } onClick={() => selectObject(item)} /> ))}
)}

Tip: press{" "} / {" "} anytime to search.

) : showNoResults ? (

No results for{" "} {highlightText(debounced.trim(), debounced.trim())}

Try a shorter keyword, check spelling, or search in another workspace.

  • Use words from the title or description
  • Remove filters in the sidebar if any
  • Browse recent items below when the query is empty
) : (
{grouped.map((g) => (
{g.label}
{g.items.map((item) => { const idx = activeIndexById.get(item.id) ?? -1; return ( setActive(idx >= 0 ? idx : 0) } onClick={() => selectObject(item)} /> ); })}
))}
)}
↑↓ navigate · open {params?.workspaceSlug ? `/${params.workspaceSlug}` : ""}
Search workspace Find tasks, documents, projects, and whiteboards. Use arrow keys to navigate and Enter to open the detail panel.
); }