import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Search as SearchIcon, AlertTriangle, FileQuestion, Plus, X } from "lucide-react"; import { COMPANY_SEARCH_DEFAULT_LIMIT, COMPANY_SEARCH_SCOPES, type CompanySearchResponse, type CompanySearchResult, type CompanySearchScope, } from "@paperclipai/shared"; import { Tabs, TabsContent } from "@/components/ui/tabs"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; import { useNavigate, useSearchParams } from "@/lib/router"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useDialogActions } from "../context/DialogContext"; import { searchApi } from "../api/search"; import { agentsApi } from "../api/agents"; import { queryKeys } from "../lib/queryKeys"; import { loadRecentSearches, pushRecentSearch } from "../lib/recent-searches"; import { PageTabBar, type PageTabItem } from "../components/PageTabBar"; import { IssueGroupHeader } from "../components/IssueGroupHeader"; import { SearchResultRow } from "../components/search/SearchResultRow"; import type { Agent } from "@paperclipai/shared"; const SEARCH_DEBOUNCE_MS = 250; const IDENTIFIER_PATTERN = /^[A-Z]+-\d+$/; const SCOPE_LABELS: Record = { all: "All", issues: "Issues", comments: "Comments", documents: "Documents", agents: "Agents", projects: "Projects", }; type SubGroupKey = "issues" | "comments" | "documents" | "agents" | "projects"; const SUBGROUP_ORDER: SubGroupKey[] = ["issues", "comments", "documents", "agents", "projects"]; const SUBGROUP_LABELS: Record = { issues: "Issues", comments: "Comments", documents: "Documents", agents: "Agents", projects: "Projects", }; function classifyResult(result: CompanySearchResult): SubGroupKey { if (result.type === "agent") return "agents"; if (result.type === "project") return "projects"; const matched = new Set(result.matchedFields); if (matched.has("title") || matched.has("identifier") || matched.has("description")) return "issues"; if (matched.has("comment")) return "comments"; if (matched.has("document")) return "documents"; return "issues"; } function buildSubgroups(results: CompanySearchResult[]): Array<{ key: SubGroupKey; results: CompanySearchResult[] }> { const buckets = new Map(); for (const result of results) { const key = classifyResult(result); const list = buckets.get(key) ?? []; list.push(result); buckets.set(key, list); } return SUBGROUP_ORDER.filter((key) => (buckets.get(key)?.length ?? 0) > 0).map((key) => ({ key, results: buckets.get(key) ?? [], })); } function isCompanySearchScope(value: string | null): value is CompanySearchScope { return Boolean(value) && (COMPANY_SEARCH_SCOPES as readonly string[]).includes(value as string); } function describeScope(scope: CompanySearchScope) { if (scope === "all") return "All scopes"; return SCOPE_LABELS[scope]; } export function buildSearchUrl(href: string, query: string, scope: CompanySearchScope): string { const url = new URL(href); if (query.length === 0) { url.searchParams.delete("q"); } else { url.searchParams.set("q", query); } if (scope === "all") { url.searchParams.delete("scope"); } else { url.searchParams.set("scope", scope); } return `${url.pathname}${url.search}${url.hash}`; } function shapeError(error: unknown): { message: string; status?: number } { if (!error) return { message: "Unknown error" }; if (error instanceof Error) { const status = (error as Error & { status?: number }).status; return { message: error.message, status: typeof status === "number" ? status : undefined }; } return { message: String(error) }; } export function Search() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const { openNewIssue } = useDialogActions(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const urlQuery = searchParams.get("q") ?? ""; const urlScopeRaw = searchParams.get("scope"); const urlScope: CompanySearchScope = isCompanySearchScope(urlScopeRaw) ? urlScopeRaw : "all"; const [draftQuery, setDraftQuery] = useState(urlQuery); const [committedQuery, setCommittedQuery] = useState(urlQuery); const [scope, setScope] = useState(urlScope); const inputRef = useRef(null); const lastUrlSyncRef = useRef(""); const lastIdentifierRedirectRef = useRef(""); const [recentSearches, setRecentSearches] = useState([]); useEffect(() => { setBreadcrumbs([{ label: "Search" }]); }, [setBreadcrumbs]); useEffect(() => { if (!selectedCompanyId) return; setRecentSearches(loadRecentSearches(selectedCompanyId)); }, [selectedCompanyId]); // Pull URL changes back into local state (e.g. browser back/forward). useEffect(() => { setDraftQuery(urlQuery); setCommittedQuery(urlQuery); }, [urlQuery]); useEffect(() => { setScope(urlScope); }, [urlScope]); // Debounce the draft query into committedQuery and write to URL via replaceState. useEffect(() => { if (draftQuery === committedQuery) return; const handle = window.setTimeout(() => { setCommittedQuery(draftQuery); if (typeof window !== "undefined") { const next = buildSearchUrl(window.location.href, draftQuery, scope); if (next !== `${window.location.pathname}${window.location.search}${window.location.hash}` && next !== lastUrlSyncRef.current) { lastUrlSyncRef.current = next; window.history.replaceState(window.history.state, "", next); } } }, SEARCH_DEBOUNCE_MS); return () => window.clearTimeout(handle); }, [draftQuery, committedQuery, scope]); const handleScopeChange = useCallback( (next: string) => { if (!isCompanySearchScope(next) || next === scope) return; setScope(next); if (typeof window !== "undefined") { const url = buildSearchUrl(window.location.href, committedQuery, next); window.history.pushState(window.history.state, "", url); } }, [committedQuery, scope], ); const trimmedQuery = committedQuery.trim(); const queryEnabled = !!selectedCompanyId && trimmedQuery.length > 0; const { data, isFetching, error, refetch } = useQuery({ queryKey: queryKeys.companySearch.search( selectedCompanyId ?? "__no-company__", trimmedQuery, scope, COMPANY_SEARCH_DEFAULT_LIMIT, 0, ), queryFn: () => searchApi.search(selectedCompanyId!, { q: trimmedQuery, scope, limit: COMPANY_SEARCH_DEFAULT_LIMIT, }), enabled: queryEnabled, placeholderData: (previousData) => previousData, }); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const agentsById = useMemo>>(() => { const map = new Map>(); for (const agent of agents ?? []) map.set(agent.id, agent); return map; }, [agents]); // Persist recent searches once we have a successful response with a non-empty query. useEffect(() => { if (!selectedCompanyId) return; if (!data || !trimmedQuery) return; const next = pushRecentSearch(selectedCompanyId, trimmedQuery); setRecentSearches(next); }, [data, trimmedQuery, selectedCompanyId]); // Identifier shortcut: when q matches PAP-123 and the API returns an exact identifier match, redirect to it. useEffect(() => { if (!data) return; const upper = trimmedQuery.toUpperCase(); if (!IDENTIFIER_PATTERN.test(upper)) return; if (lastIdentifierRedirectRef.current === upper) return; const exact = data.results.find( (result) => result.type === "issue" && result.issue?.identifier?.toUpperCase() === upper, ); if (!exact?.issue) return; lastIdentifierRedirectRef.current = upper; // Strip the comment/document deep-link suffix so an exact identifier match // lands on the issue root, not the top-scored snippet. const baseHref = exact.href.split("#")[0] ?? exact.href; const navigateHref = baseHref.startsWith("/") ? baseHref : `/${baseHref}`; navigate(navigateHref, { replace: true }); }, [data, navigate, trimmedQuery]); const handleClear = useCallback(() => { setDraftQuery(""); setCommittedQuery(""); inputRef.current?.focus(); if (typeof window !== "undefined") { const next = buildSearchUrl(window.location.href, "", scope); window.history.replaceState(window.history.state, "", next); } }, [scope]); const focusInput = useCallback(() => { inputRef.current?.focus(); }, []); // Global "/" focus shortcut. useEffect(() => { function handler(event: KeyboardEvent) { if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target as HTMLElement | null; const tag = target?.tagName?.toLowerCase(); if (target?.isContentEditable || tag === "input" || tag === "textarea") return; event.preventDefault(); focusInput(); } window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [focusInput]); const counts = data?.countsByType ?? { issue: 0, agent: 0, project: 0 }; const totalResults = data?.results.length ?? 0; const tabItems = useMemo(() => { function pill(value: number) { if (!data) return null; return ( {value} ); } const issuesTotal = counts.issue ?? 0; return COMPANY_SEARCH_SCOPES.map((value) => { let count: number | null = null; if (value === "all") count = (counts.issue ?? 0) + (counts.agent ?? 0) + (counts.project ?? 0); else if (value === "issues") count = issuesTotal; else if (value === "agents") count = counts.agent ?? 0; else if (value === "projects") count = counts.project ?? 0; return { value, label: ( {SCOPE_LABELS[value as CompanySearchScope]} {count !== null ? pill(count) : null} ), } satisfies PageTabItem; }); }, [counts, data]); const subgroups = useMemo(() => buildSubgroups(data?.results ?? []), [data?.results]); const showInitialState = !trimmedQuery; const isLoading = queryEnabled && isFetching && !data; const hasResults = !!data && totalResults > 0; const isEmpty = !!data && !isFetching && totalResults === 0; const hasError = !!error && !isLoading; const apiError = hasError ? shapeError(error) : null; const apiMessage = data?.results === undefined && data ? null : null; void apiMessage; function navigateIssuesFallback() { navigate(`/issues?q=${encodeURIComponent(trimmedQuery)}`); } function handleRecentClick(value: string) { setDraftQuery(value); setCommittedQuery(value); if (typeof window !== "undefined") { const next = buildSearchUrl(window.location.href, value, scope); window.history.replaceState(window.history.state, "", next); } } function showAllScope() { if (scope === "all") return; handleScopeChange("all"); } return (

Search

setDraftQuery(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === "Escape") { if (draftQuery.length > 0) { event.preventDefault(); handleClear(); } else { event.currentTarget.blur(); } } }} placeholder="Search issues, comments, documents, agents, projects…" aria-label="Search query" className="h-10 pl-9 pr-20 text-sm" /> {draftQuery.length > 0 ? ( ) : null} ⌘K
{COMPANY_SEARCH_SCOPES.map((scopeValue) => ( {scopeValue === scope ? ( openNewIssue({ title: trimmedQuery })} refetch={() => void refetch()} recentSearches={recentSearches} onRecentClick={handleRecentClick} subgroups={subgroups} totalResults={totalResults} isFetching={isFetching && !!data} agentsById={agentsById} /> ) : null} ))}
); } interface SearchTabContentProps { showInitialState: boolean; isLoading: boolean; hasResults: boolean; hasError: boolean; apiError: { message: string; status?: number } | null; isEmpty: boolean; trimmedQuery: string; scope: CompanySearchScope; showAllScope: () => void; navigateIssuesFallback: () => void; openNewIssue: () => void; refetch: () => void; recentSearches: string[]; onRecentClick: (query: string) => void; subgroups: Array<{ key: SubGroupKey; results: CompanySearchResult[] }>; totalResults: number; isFetching: boolean; agentsById: ReadonlyMap>; } function SearchTabContent({ showInitialState, isLoading, hasResults, hasError, apiError, isEmpty, trimmedQuery, scope, showAllScope, navigateIssuesFallback, openNewIssue, refetch, recentSearches, onRecentClick, subgroups, totalResults, isFetching, agentsById, }: SearchTabContentProps) { if (showInitialState) { return (

Type to search company memory.

Issues, comments, plan documents, agents, projects — same surface, ranked by relevance.

{recentSearches.length > 0 ? (
Recent searches
    {recentSearches.map((entry) => (
  • ))}
) : null}
  • Identifier lookup: type{" "} PAP-123 to jump straight to an issue.
  • Quoted phrases: wrap a phrase in quotes to match the exact sequence.
  • ⌘K: reopens the command palette pre-seeded with your current query.
); } if (hasError) { const status = apiError?.status; return (
Couldn’t run that search

{status ? `The server returned ${status}.` : "The request failed."} Your input and filters are still here, so you can retry or fall back to the Issues filter.

); } if (isLoading) { return (
Searching for “{trimmedQuery}”…
{Array.from({ length: 5 }).map((_, index) => (
))}
); } if (isEmpty) { return (
No results for “{trimmedQuery}”

We couldn’t find a match in {describeScope(scope).toLowerCase()}. Try widening the scope or rephrasing your query.

{scope !== "all" ? ( ) : null}
  • Try fewer tokens or a single distinctive term.
  • Use an identifier shortcut like PAP-123.
  • Wrap multi-word phrases in quotes.
); } if (!hasResults) return null; return (
{totalResults === 1 ? "1 result" : `${totalResults} results`} · sorted by relevance {isFetching ? Updating… : null}
{scope === "all" ? ( subgroups.map((group, groupIndex) => (
0 && "mt-6")} > {group.results.length} } className="pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground" />
{group.results.map((result) => ( ))}
)) ) : (
{subgroups .flatMap((group) => group.results) .map((result) => ( ))}
)}
); }