diff --git a/ui/src/App.tsx b/ui/src/App.tsx index e29e6efe5c5..743c415a51b 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -13,6 +13,7 @@ import { ProjectDetail } from "./pages/ProjectDetail"; import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail"; import { Workspaces } from "./pages/Workspaces"; import { Issues } from "./pages/Issues"; +import { Search } from "./pages/Search"; import { IssueDetail } from "./pages/IssueDetail"; import { IssueChatLongThreadPerf } from "./pages/IssueChatLongThreadPerf"; import { Routines } from "./pages/Routines"; @@ -95,6 +96,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/search.ts b/ui/src/api/search.ts new file mode 100644 index 00000000000..a660dd33ba4 --- /dev/null +++ b/ui/src/api/search.ts @@ -0,0 +1,23 @@ +import type { CompanySearchResponse, CompanySearchScope } from "@paperclipai/shared"; +import { api } from "./client"; + +export interface CompanySearchParams { + q: string; + scope?: CompanySearchScope; + limit?: number; + offset?: number; +} + +export const searchApi = { + search: (companyId: string, params: CompanySearchParams) => { + const search = new URLSearchParams(); + search.set("q", params.q); + if (params.scope) search.set("scope", params.scope); + if (params.limit !== undefined) search.set("limit", String(params.limit)); + if (params.offset !== undefined) search.set("offset", String(params.offset)); + const qs = search.toString(); + return api.get( + `/companies/${companyId}/search${qs ? `?${qs}` : ""}`, + ); + }, +}; diff --git a/ui/src/components/CommandPalette.test.tsx b/ui/src/components/CommandPalette.test.tsx index 165f1390c20..11303f2943c 100644 --- a/ui/src/components/CommandPalette.test.tsx +++ b/ui/src/components/CommandPalette.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { act } from "react"; -import type { ReactNode } from "react"; +import type { KeyboardEventHandler, ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -46,8 +46,12 @@ vi.mock("../context/SidebarContext", () => ({ useSidebar: () => sidebarState, })); +const navigateState = vi.hoisted(() => ({ + navigate: vi.fn(), +})); + vi.mock("@/lib/router", () => ({ - useNavigate: () => vi.fn(), + useNavigate: () => navigateState.navigate, })); vi.mock("../api/issues", () => ({ @@ -73,15 +77,18 @@ vi.mock("@/components/ui/command", () => ({ CommandInput: ({ value, onValueChange, + onKeyDown, }: { value: string; onValueChange: (value: string) => void; + onKeyDown?: KeyboardEventHandler; }) => (
onValueChange(event.currentTarget.value)} + onKeyDown={onKeyDown} />
@@ -89,10 +96,16 @@ vi.mock("@/components/ui/command", () => ({ CommandItem: ({ children, onSelect, + "data-testid": testId, }: { children: ReactNode; onSelect?: () => void; - }) => , + "data-testid"?: string; + }) => ( + + ), CommandList: ({ children }: { children: ReactNode }) =>
{children}
, CommandSeparator: () =>
, })); @@ -153,6 +166,7 @@ describe("CommandPalette", () => { mockIssuesApi.list.mockReset(); mockAgentsApi.list.mockReset(); mockProjectsApi.list.mockReset(); + navigateState.navigate.mockReset(); mockIssuesApi.list.mockResolvedValue([]); mockAgentsApi.list.mockResolvedValue([]); mockProjectsApi.list.mockResolvedValue([]); @@ -188,4 +202,78 @@ describe("CommandPalette", () => { root.unmount(); }); }); + + it("offers a Search-all command when the query is non-empty and routes Enter to /search when no issues match", async () => { + mockIssuesApi.list.mockResolvedValue([]); + const { root } = renderWithQueryClient(, container); + + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true })); + }); + + const input = container.querySelector('input[aria-label="Command search"]') as HTMLInputElement; + expect(input).not.toBeNull(); + + act(() => { + const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + nativeSetter.call(input, "auth flake"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await waitForAssertion(() => { + const searchAllButton = container.querySelector( + 'button[data-testid="command-search-all"]', + ) as HTMLButtonElement | null; + expect(searchAllButton).not.toBeNull(); + expect(searchAllButton!.textContent).toContain("auth flake"); + }); + + act(() => { + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + + await waitForAssertion(() => { + expect(navigateState.navigate).toHaveBeenCalledWith("/search?q=auth%20flake"); + }); + + act(() => { + root.unmount(); + }); + }); + + it("navigates to /search when the user clicks the Search-all command", async () => { + mockIssuesApi.list.mockResolvedValue([]); + const { root } = renderWithQueryClient(, container); + + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true })); + }); + + const input = container.querySelector('input[aria-label="Command search"]') as HTMLInputElement; + act(() => { + const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + nativeSetter.call(input, "deflake"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + let searchAllButton: HTMLButtonElement | null = null; + await waitForAssertion(() => { + searchAllButton = container.querySelector( + 'button[data-testid="command-search-all"]', + ) as HTMLButtonElement | null; + expect(searchAllButton).not.toBeNull(); + }); + + act(() => { + searchAllButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitForAssertion(() => { + expect(navigateState.navigate).toHaveBeenCalledWith("/search?q=deflake"); + }); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 28530042525..f5f2fa5fbfa 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -28,10 +28,18 @@ import { History, SquarePen, Plus, + Search, } from "lucide-react"; import { Identity } from "./Identity"; import { agentUrl, projectUrl } from "../lib/utils"; +const SEARCH_ALL_VALUE = "__paperclip-search-all__"; + +export function buildFullSearchPath(query: string) { + const trimmed = query.trim(); + return trimmed.length === 0 ? "/search" : `/search?q=${encodeURIComponent(trimmed)}`; +} + export function CommandPalette() { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); @@ -90,6 +98,10 @@ export function CommandPalette() { navigate(path); } + function goFullSearch() { + go(buildFullSearchPath(searchQuery)); + } + const agentName = (id: string | null) => { if (!id) return null; return agents.find((a) => a.id === id)?.name ?? null; @@ -100,6 +112,9 @@ export function CommandPalette() { [issues, searchedIssues, searchQuery], ); + const showSearchAll = searchQuery.length > 0; + const showEmptyHint = showSearchAll && visibleIssues.length === 0; + return ( { setOpen(v); @@ -109,9 +124,47 @@ export function CommandPalette() { placeholder="Search issues, agents, projects..." value={query} onValueChange={setQuery} + onKeyDown={(event) => { + if (event.key === "Enter" && showEmptyHint) { + event.preventDefault(); + goFullSearch(); + } + }} /> - No results found. + + {showSearchAll ? ( + + No quick issue matches. Press{" "} + ↵{" "} + to search all or keep typing to refine. + + ) : ( + "No results found." + )} + + + {showSearchAll ? ( + + + + + Search all for “{searchQuery}” + + + open full search + ↵ + + + + ) : null} + + {showSearchAll ? : null} + {showWorkspacesLink ? ( diff --git a/ui/src/components/search/HighlightedText.tsx b/ui/src/components/search/HighlightedText.tsx new file mode 100644 index 00000000000..7d7b9854680 --- /dev/null +++ b/ui/src/components/search/HighlightedText.tsx @@ -0,0 +1,68 @@ +import type { CompanySearchHighlight } from "@paperclipai/shared"; +import { cn } from "@/lib/utils"; + +export interface HighlightedTextProps { + text: string; + highlights?: readonly CompanySearchHighlight[] | null; + className?: string; + markClassName?: string; +} + +function clampedRanges(text: string, highlights: readonly CompanySearchHighlight[]) { + const result: Array<{ start: number; end: number }> = []; + for (const range of highlights) { + const start = Math.max(0, Math.min(text.length, range.start)); + const end = Math.max(start, Math.min(text.length, range.end)); + if (end <= start) continue; + result.push({ start, end }); + } + result.sort((a, b) => a.start - b.start); + const merged: Array<{ start: number; end: number }> = []; + for (const range of result) { + const last = merged[merged.length - 1]; + if (last && range.start <= last.end) { + last.end = Math.max(last.end, range.end); + } else { + merged.push({ ...range }); + } + } + return merged; +} + +export function HighlightedText({ text, highlights, className, markClassName }: HighlightedTextProps) { + const ranges = highlights && highlights.length > 0 ? clampedRanges(text, highlights) : []; + if (ranges.length === 0) { + return {text}; + } + const segments: Array<{ key: string; text: string; highlight: boolean }> = []; + let cursor = 0; + ranges.forEach((range, index) => { + if (range.start > cursor) { + segments.push({ key: `t-${index}`, text: text.slice(cursor, range.start), highlight: false }); + } + segments.push({ key: `m-${index}`, text: text.slice(range.start, range.end), highlight: true }); + cursor = range.end; + }); + if (cursor < text.length) { + segments.push({ key: "t-end", text: text.slice(cursor), highlight: false }); + } + return ( + + {segments.map((segment) => + segment.highlight ? ( + + {segment.text} + + ) : ( + {segment.text} + ), + )} + + ); +} diff --git a/ui/src/components/search/MatchSourceChip.tsx b/ui/src/components/search/MatchSourceChip.tsx new file mode 100644 index 00000000000..798ed7ac785 --- /dev/null +++ b/ui/src/components/search/MatchSourceChip.tsx @@ -0,0 +1,46 @@ +import { cn } from "@/lib/utils"; + +export type MatchSourceChipKind = "title" | "identifier" | "comment" | "document"; + +const chipStyles: Record = { + title: + "bg-[var(--chip-match-title-bg)] text-[var(--chip-match-title-fg)] border-[var(--chip-match-title-border)]", + identifier: + "bg-[var(--chip-match-identifier-bg)] text-[var(--chip-match-identifier-fg)] border-[var(--chip-match-identifier-border)]", + comment: + "bg-[var(--chip-match-comment-bg)] text-[var(--chip-match-comment-fg)] border-[var(--chip-match-comment-border)]", + document: + "bg-[var(--chip-match-document-bg)] text-[var(--chip-match-document-fg)] border-[var(--chip-match-document-border)]", +}; + +const chipLabels: Record = { + title: "Title", + identifier: "Identifier", + comment: "Comment", + document: "Doc", +}; + +export interface MatchSourceChipProps { + kind: MatchSourceChipKind; + count?: number; + label?: string; + className?: string; +} + +export function MatchSourceChip({ kind, count, label, className }: MatchSourceChipProps) { + const text = label ?? chipLabels[kind]; + const showCount = typeof count === "number" && count > 1; + return ( + + {text} + {showCount ? ×{count} : null} + + ); +} diff --git a/ui/src/components/search/SearchResultRow.tsx b/ui/src/components/search/SearchResultRow.tsx new file mode 100644 index 00000000000..9a341bf022a --- /dev/null +++ b/ui/src/components/search/SearchResultRow.tsx @@ -0,0 +1,223 @@ +import { useMemo } from "react"; +import { Bot, Hexagon } from "lucide-react"; +import type { Agent, CompanySearchResult, Project } from "@paperclipai/shared"; +import { Link } from "@/lib/router"; +import { cn } from "@/lib/utils"; +import { StatusIcon } from "../StatusIcon"; +import { StatusBadge } from "../StatusBadge"; +import { Identity } from "../Identity"; +import { HighlightedText } from "./HighlightedText"; +import { MatchSourceChip, type MatchSourceChipKind } from "./MatchSourceChip"; + +const SNIPPET_FIELD_LABEL: Record = { + comment: "COMMENT", + document: "DOC", + identifier: "IDENTIFIER", + description: "DESCRIPTION", + title: "TITLE", +}; + +function deriveChips(matchedFields: string[]): Array<{ kind: MatchSourceChipKind; count?: number }> { + const counts = new Map(); + for (const field of matchedFields) { + let kind: MatchSourceChipKind | null = null; + if (field === "title" || field === "description") kind = "title"; + else if (field === "identifier") kind = "identifier"; + else if (field === "comment") kind = "comment"; + else if (field === "document") kind = "document"; + if (!kind) continue; + counts.set(kind, (counts.get(kind) ?? 0) + 1); + } + const order: MatchSourceChipKind[] = ["title", "identifier", "comment", "document"]; + return order + .filter((kind) => counts.has(kind)) + .map((kind) => ({ kind, count: counts.get(kind) ?? 1 })); +} + +function formatRelativeTime(input: string | null): string { + if (!input) return ""; + const value = new Date(input); + if (Number.isNaN(value.getTime())) return ""; + const diffMs = Date.now() - value.getTime(); + const seconds = Math.round(diffMs / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h`; + const days = Math.round(hours / 24); + if (days < 7) return `${days}d`; + const weeks = Math.round(days / 7); + if (weeks < 5) return `${weeks}w`; + const months = Math.round(days / 30); + if (months < 12) return `${months}mo`; + const years = Math.round(days / 365); + return `${years}y`; +} + +export interface SearchResultRowProps { + result: CompanySearchResult; + agentsById?: ReadonlyMap>; + projectsById?: ReadonlyMap>; + isActive?: boolean; + className?: string; +} + +export function SearchResultRow({ + result, + agentsById, + projectsById, + isActive, + className, +}: SearchResultRowProps) { + const chips = useMemo(() => deriveChips(result.matchedFields), [result.matchedFields]); + + if (result.type === "agent") { + return ( + + + + +
+
+ {result.title} +
+ {result.snippet ? ( +
+ + {result.sourceLabel ?? "Agent"} + + +
+ ) : null} +
+ + ); + } + + if (result.type === "project") { + return ( + + +
+ {result.title} + {result.snippet ? ( +
+ + {result.sourceLabel ?? "Project"} + + +
+ ) : null} +
+ + ); + } + + const issue = result.issue; + if (!issue) return null; + const assigneeName = issue.assigneeAgentId + ? agentsById?.get(issue.assigneeAgentId)?.name ?? null + : null; + const projectName = issue.projectId ? projectsById?.get(issue.projectId)?.name ?? null : null; + const updated = formatRelativeTime(result.updatedAt ?? issue.updatedAt); + const titleHighlights = result.snippets.find((snippet) => snippet.field === "title")?.highlights; + + return ( + +
+ +
+
+
+ {issue.identifier ? ( + + {issue.identifier} + + ) : null} + +
+ + {assigneeName ? : null} + {projectName ? ( + + + {projectName} + + ) : null} + {updated ? ( + {updated} + ) : null} +
+
+ {chips.length > 0 ? ( +
+ {chips.map((chip) => ( + + ))} +
+ ) : null} + {result.snippets + .filter((snippet) => snippet.field !== "title") + .slice(0, 2) + .map((snippet, index) => ( +
+ + {SNIPPET_FIELD_LABEL[snippet.field] ?? snippet.label.toUpperCase()} + + +
+ ))} +
+ + {assigneeName ? {assigneeName} : null} + {projectName ? · {projectName} : null} + {updated ? {updated} : null} +
+
+ + ); +} diff --git a/ui/src/index.css b/ui/src/index.css index 5335e4f6f39..0f2d62fa242 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -77,6 +77,18 @@ --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); + --chip-match-title-bg: oklch(0.97 0.02 265); + --chip-match-title-fg: oklch(0.4 0.13 265); + --chip-match-title-border: oklch(0.85 0.05 265); + --chip-match-comment-bg: oklch(0.97 0.02 145); + --chip-match-comment-fg: oklch(0.4 0.10 145); + --chip-match-comment-border: oklch(0.85 0.05 145); + --chip-match-document-bg: oklch(0.97 0.02 295); + --chip-match-document-fg: oklch(0.4 0.10 295); + --chip-match-document-border: oklch(0.85 0.05 295); + --chip-match-identifier-bg: var(--muted); + --chip-match-identifier-fg: var(--muted-foreground); + --chip-match-identifier-border: var(--border); } .dark { @@ -112,6 +124,18 @@ --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(0.269 0 0); --sidebar-ring: oklch(0.439 0 0); + --chip-match-title-bg: oklch(0.27 0.04 265 / 0.5); + --chip-match-title-fg: oklch(0.78 0.10 265); + --chip-match-title-border: oklch(0.4 0.06 265); + --chip-match-comment-bg: oklch(0.27 0.04 145 / 0.5); + --chip-match-comment-fg: oklch(0.78 0.08 145); + --chip-match-comment-border: oklch(0.4 0.05 145); + --chip-match-document-bg: oklch(0.27 0.04 295 / 0.5); + --chip-match-document-fg: oklch(0.78 0.08 295); + --chip-match-document-border: oklch(0.4 0.05 295); + --chip-match-identifier-bg: var(--muted); + --chip-match-identifier-fg: var(--muted-foreground); + --chip-match-identifier-border: var(--border); } @layer base { diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 590af1067a1..36396e26e88 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -130,6 +130,10 @@ export const queryKeys = { list: (companyId: string) => ["secrets", companyId] as const, providers: (companyId: string) => ["secret-providers", companyId] as const, }, + companySearch: { + search: (companyId: string, q: string, scope: string, limit: number, offset: number) => + ["company-search", companyId, q, scope, limit, offset] as const, + }, dashboard: (companyId: string) => ["dashboard", companyId] as const, userProfile: (companyId: string, userSlug: string) => ["user-profile", companyId, userSlug] as const, diff --git a/ui/src/lib/recent-searches.ts b/ui/src/lib/recent-searches.ts new file mode 100644 index 00000000000..42ed4e0a0e0 --- /dev/null +++ b/ui/src/lib/recent-searches.ts @@ -0,0 +1,57 @@ +const STORAGE_PREFIX = "paperclip:recent-searches:"; +const MAX_RECENT_SEARCHES = 5; + +function storageKey(companyId: string) { + return `${STORAGE_PREFIX}${companyId}`; +} + +function isStorageAvailable() { + return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; +} + +export function loadRecentSearches(companyId: string): string[] { + if (!isStorageAvailable() || !companyId) return []; + try { + const raw = window.localStorage.getItem(storageKey(companyId)); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + const cleaned: string[] = []; + for (const value of parsed) { + if (typeof value !== "string") continue; + const trimmed = value.trim(); + if (!trimmed) continue; + cleaned.push(trimmed); + if (cleaned.length >= MAX_RECENT_SEARCHES) break; + } + return cleaned; + } catch { + return []; + } +} + +export function pushRecentSearch(companyId: string, query: string): string[] { + if (!isStorageAvailable() || !companyId) return []; + const trimmed = query.trim(); + if (!trimmed) return loadRecentSearches(companyId); + const existing = loadRecentSearches(companyId); + const filtered = existing.filter((entry) => entry.toLowerCase() !== trimmed.toLowerCase()); + const next = [trimmed, ...filtered].slice(0, MAX_RECENT_SEARCHES); + try { + window.localStorage.setItem(storageKey(companyId), JSON.stringify(next)); + } catch { + // ignore + } + return next; +} + +export function clearRecentSearches(companyId: string): void { + if (!isStorageAvailable() || !companyId) return; + try { + window.localStorage.removeItem(storageKey(companyId)); + } catch { + // ignore + } +} + +export const RECENT_SEARCHES_LIMIT = MAX_RECENT_SEARCHES; diff --git a/ui/src/pages/Search.test.tsx b/ui/src/pages/Search.test.tsx new file mode 100644 index 00000000000..c6bee27426a --- /dev/null +++ b/ui/src/pages/Search.test.tsx @@ -0,0 +1,304 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import type { ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Search, buildSearchUrl } from "./Search"; + +const companyState = vi.hoisted(() => ({ + selectedCompanyId: "company-1", +})); + +const breadcrumbState = vi.hoisted(() => ({ + setBreadcrumbs: vi.fn(), +})); + +const dialogState = vi.hoisted(() => ({ + openNewIssue: vi.fn(), +})); + +const navigateMock = vi.hoisted(() => vi.fn()); + +const searchApiMock = vi.hoisted(() => ({ + search: vi.fn(), +})); + +const agentsApiMock = vi.hoisted(() => ({ + list: vi.fn(), +})); + +const projectsApiMock = vi.hoisted(() => ({ + list: vi.fn(), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => companyState, +})); + +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => breadcrumbState, +})); + +vi.mock("../context/DialogContext", () => ({ + useDialogActions: () => dialogState, +})); + +vi.mock("../context/SidebarContext", () => ({ + useSidebar: () => ({ isMobile: false, setSidebarOpen: vi.fn() }), +})); + +vi.mock("../api/search", () => ({ + searchApi: searchApiMock, +})); + +vi.mock("../api/agents", () => ({ + agentsApi: agentsApiMock, +})); + +vi.mock("../api/projects", () => ({ + projectsApi: projectsApiMock, +})); + +vi.mock("@/lib/router", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useNavigate: () => navigateMock, + }; +}); + +vi.mock("../components/StatusIcon", () => ({ + StatusIcon: ({ status }: { status: string }) => , +})); + +vi.mock("../components/StatusBadge", () => ({ + StatusBadge: ({ status }: { status: string }) => {status}, +})); + +vi.mock("../components/Identity", () => ({ + Identity: ({ name }: { name: string }) => {name}, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flush() { + await act(async () => { + await Promise.resolve(); + }); +} + +async function waitForAssertion(assertion: () => void, attempts = 50) { + let lastError: unknown; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await flush(); + } + } + throw lastError; +} + +function renderSearch(initialPath: string, container: HTMLDivElement, node?: ReactNode) { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + act(() => { + root.render( + + + + } /> + + + , + ); + }); + return { root, queryClient }; +} + +describe("buildSearchUrl", () => { + it("writes q and scope when provided", () => { + expect(buildSearchUrl("http://x/search", "auth flake", "comments")).toBe( + "/search?q=auth+flake&scope=comments", + ); + }); + + it("clears q when empty and omits scope when scope=all", () => { + expect(buildSearchUrl("http://x/search?q=stale&scope=issues", "", "all")).toBe("/search"); + }); + + it("preserves the existing pathname and hash", () => { + expect(buildSearchUrl("http://x/PAP/search?q=x#anchor", "y", "issues")).toBe( + "/PAP/search?q=y&scope=issues#anchor", + ); + }); +}); + +describe("Search page", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + breadcrumbState.setBreadcrumbs.mockReset(); + dialogState.openNewIssue.mockReset(); + navigateMock.mockReset(); + searchApiMock.search.mockReset(); + agentsApiMock.list.mockReset(); + projectsApiMock.list.mockReset(); + agentsApiMock.list.mockResolvedValue([]); + projectsApiMock.list.mockResolvedValue([]); + window.localStorage.clear(); + }); + + afterEach(() => { + container.remove(); + }); + + it("issues a search request when ?q is in the URL and renders the result", async () => { + searchApiMock.search.mockResolvedValueOnce({ + query: "auth flake", + normalizedQuery: "auth flake", + scope: "all", + limit: 20, + offset: 0, + countsByType: { issue: 1, agent: 0, project: 0 }, + hasMore: false, + results: [ + { + id: "issue-1", + type: "issue", + score: 100, + title: "PAP-3142 Auth middleware flakes", + href: "/PAP/issues/PAP-3142", + matchedFields: ["title", "comment"], + sourceLabel: "Comment", + snippet: "we hit another flake", + snippets: [ + { + field: "title", + label: "Title", + text: "Auth middleware flakes", + highlights: [{ start: 0, end: 4 }], + }, + { + field: "comment", + label: "Comment", + text: "we hit another flake in the morning batch", + highlights: [{ start: 16, end: 21 }], + }, + ], + issue: { + id: "issue-1", + identifier: "PAP-3142", + title: "Auth middleware flakes", + status: "in_progress", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + projectId: null, + updatedAt: new Date().toISOString(), + }, + updatedAt: new Date().toISOString(), + }, + ], + }); + + const { root } = renderSearch("/search?q=auth+flake", container); + + await waitForAssertion(() => { + expect(searchApiMock.search).toHaveBeenCalledWith("company-1", { + q: "auth flake", + scope: "all", + limit: 20, + }); + }); + + await waitForAssertion(() => { + expect(container.textContent).toContain("PAP-3142"); + expect(container.textContent).toContain("Auth middleware flakes"); + expect(container.textContent).toContain("1 result"); + }); + + act(() => { + root.unmount(); + }); + }); + + it("debounces typing into the input and dispatches a search after the debounce window", async () => { + searchApiMock.search.mockResolvedValue({ + query: "deflake", + normalizedQuery: "deflake", + scope: "all", + limit: 20, + offset: 0, + countsByType: { issue: 0, agent: 0, project: 0 }, + hasMore: false, + results: [], + }); + + const { root } = renderSearch("/search", container); + + const input = container.querySelector('input[aria-label="Search query"]') as HTMLInputElement; + expect(input).not.toBeNull(); + + act(() => { + const nativeSetter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!; + nativeSetter.call(input, "deflake"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + // The debounce hasn't fired yet, so no API call should be made synchronously. + expect(searchApiMock.search).not.toHaveBeenCalled(); + + await new Promise((resolve) => setTimeout(resolve, 350)); + + await waitForAssertion(() => { + expect(searchApiMock.search).toHaveBeenCalledWith("company-1", { + q: "deflake", + scope: "all", + limit: 20, + }); + }); + + act(() => { + root.unmount(); + }); + }); + + it("renders the no-results state with a Search-all action when scope is non-default", async () => { + searchApiMock.search.mockResolvedValueOnce({ + query: "ghost", + normalizedQuery: "ghost", + scope: "comments", + limit: 20, + offset: 0, + countsByType: { issue: 0, agent: 0, project: 0 }, + hasMore: false, + results: [], + }); + + const { root } = renderSearch("/search?q=ghost&scope=comments", container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("No results for"); + expect(container.textContent).toContain("ghost"); + expect(container.textContent).toContain("Search all scopes"); + }); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/pages/Search.tsx b/ui/src/pages/Search.tsx new file mode 100644 index 00000000000..0736582ce05 --- /dev/null +++ b/ui/src/pages/Search.tsx @@ -0,0 +1,640 @@ +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 { 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 { projectsApi } from "../api/projects"; +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, Project } 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 { data: projects } = useQuery({ + queryKey: queryKeys.projects.list(selectedCompanyId!), + queryFn: () => projectsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const agentsById = useMemo>>(() => { + const map = new Map>(); + for (const agent of agents ?? []) map.set(agent.id, agent); + return map; + }, [agents]); + + const projectsById = useMemo>>(() => { + const map = new Map>(); + for (const project of projects ?? []) map.set(project.id, project); + return map; + }, [projects]); + + // 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; + navigate(exact.href.startsWith("/") ? exact.href : `/${exact.href}`, { 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} + projectsById={projectsById} + /> + ) : 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>; + projectsById: ReadonlyMap>; +} + +function SearchTabContent({ + showInitialState, + isLoading, + hasResults, + hasError, + apiError, + isEmpty, + trimmedQuery, + scope, + showAllScope, + navigateIssuesFallback, + openNewIssue, + refetch, + recentSearches, + onRecentClick, + subgroups, + totalResults, + isFetching, + agentsById, + projectsById, +}: 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) => ( +
+ + {group.results.length} + + } + className="border-b border-border bg-muted/30" + /> +
+ {group.results.map((result) => ( + + ))} +
+
+ )) + ) : ( +
+ {subgroups + .flatMap((group) => group.results) + .map((result) => ( + + ))} +
+ )} +
+
+ ); +}