+
+ {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