From 2b62cebf57734d011155495dcf7f6e4bd2faa9f1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 5 Jun 2026 18:14:40 +0000 Subject: [PATCH] Add artifacts to company search --- packages/shared/src/index.ts | 1 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/search.ts | 17 +++- .../company-search-rate-limit-routes.test.ts | 2 +- .../__tests__/company-search-service.test.ts | 41 ++++++++- server/src/services/company-search.ts | 63 ++++++++++++- ui/src/components/search/SearchResultRow.tsx | 52 ++++++++++- ui/src/pages/Search.test.tsx | 92 ++++++++++++++++--- ui/src/pages/Search.tsx | 16 ++-- ui/storybook/stories/search.stories.tsx | 7 +- 10 files changed, 263 insertions(+), 29 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c9169cfb313..1cd4d2974ee 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -404,6 +404,7 @@ export type { ProjectManagedByPlugin, ProjectWorkspace, CompanySearchHighlight, + CompanySearchArtifactSummary, CompanySearchIssueSummary, CompanySearchResponse, CompanySearchResult, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 238308f809d..9526d13bf39 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -162,6 +162,7 @@ export type { export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js"; export type { CompanySearchHighlight, + CompanySearchArtifactSummary, CompanySearchIssueSummary, CompanySearchResponse, CompanySearchResult, diff --git a/packages/shared/src/types/search.ts b/packages/shared/src/types/search.ts index 145c5ba3151..3b453cfe51b 100644 --- a/packages/shared/src/types/search.ts +++ b/packages/shared/src/types/search.ts @@ -1,9 +1,9 @@ import type { IssuePriority, IssueStatus } from "../constants.js"; -export const COMPANY_SEARCH_SCOPES = ["all", "issues", "comments", "documents", "agents", "projects"] as const; +export const COMPANY_SEARCH_SCOPES = ["all", "issues", "comments", "documents", "artifacts", "agents", "projects"] as const; export type CompanySearchScope = (typeof COMPANY_SEARCH_SCOPES)[number]; -export type CompanySearchResultType = "issue" | "agent" | "project"; +export type CompanySearchResultType = "issue" | "artifact" | "agent" | "project"; export interface CompanySearchHighlight { start: number; @@ -29,6 +29,18 @@ export interface CompanySearchIssueSummary { updatedAt: string; } +export interface CompanySearchArtifactSummary { + id: string; + source: "document" | "attachment" | "work_product"; + mediaKind: "image" | "video" | "text" | "document" | "file" | "empty"; + issueId: string; + issueIdentifier: string; + issueTitle: string; + projectId: string | null; + projectName: string | null; + updatedAt: string; +} + export interface CompanySearchResult { id: string; type: CompanySearchResultType; @@ -40,6 +52,7 @@ export interface CompanySearchResult { snippet: string | null; snippets: CompanySearchSnippet[]; issue?: CompanySearchIssueSummary; + artifact?: CompanySearchArtifactSummary; updatedAt: string | null; previewImageUrl: string | null; } diff --git a/server/src/__tests__/company-search-rate-limit-routes.test.ts b/server/src/__tests__/company-search-rate-limit-routes.test.ts index ed070930960..092a2c7bf11 100644 --- a/server/src/__tests__/company-search-rate-limit-routes.test.ts +++ b/server/src/__tests__/company-search-rate-limit-routes.test.ts @@ -13,7 +13,7 @@ function createSearchResponse(query: CompanySearchQuery): CompanySearchResponse limit: query.limit, offset: query.offset, results: [], - countsByType: { issue: 0, agent: 0, project: 0 }, + countsByType: { issue: 0, artifact: 0, agent: 0, project: 0 }, hasMore: false, }; } diff --git a/server/src/__tests__/company-search-service.test.ts b/server/src/__tests__/company-search-service.test.ts index ada5a888525..62beb6a1500 100644 --- a/server/src/__tests__/company-search-service.test.ts +++ b/server/src/__tests__/company-search-service.test.ts @@ -202,6 +202,45 @@ describeEmbeddedPostgres("companySearchService", () => { expect(result.results[0]?.snippet).toMatch(/parser/i); }); + it("searches artifact projections through the artifacts scope", async () => { + const companyId = await createCompany(); + const agentId = await createAgent(companyId, { name: "Artifact Writer" }); + const issueId = await createIssue(companyId, { + identifier: "TST-88", + title: "Produce artifact", + }); + const documentId = randomUUID(); + await db.insert(documents).values({ + id: documentId, + companyId, + title: "Launch Artifact Brief", + latestBody: "The searchable artifact body mentions a comet-tail preview.", + format: "markdown", + createdByAgentId: agentId, + }); + await db.insert(issueDocuments).values({ + companyId, + issueId, + documentId, + key: "brief", + }); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "comet-tail", scope: "artifacts" })); + + expect(result.results).toHaveLength(1); + expect(result.results[0]).toMatchObject({ + type: "artifact", + title: "Launch Artifact Brief", + href: expect.stringContaining("#document-brief"), + artifact: expect.objectContaining({ + mediaKind: "document", + issueIdentifier: "TST-88", + }), + }); + expect(result.results[0]?.snippet).toMatch(/comet tail/i); + expect(result.countsByType).toEqual({ issue: 0, artifact: 1, agent: 0, project: 0 }); + }); + it("excludes hidden issues and other companies' data", async () => { const companyId = await createCompany("Visible Co"); const otherCompanyId = await createCompany("Other Co"); @@ -387,7 +426,7 @@ describeEmbeddedPostgres("companySearchService", () => { const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "needle", limit: "2", offset: "2" })); expect(result.results.map((row) => row.id)).toEqual([agentIds[2], projectIds[0]]); - expect(result.countsByType).toEqual({ issue: 0, agent: 3, project: 3 }); + expect(result.countsByType).toEqual({ issue: 0, artifact: 0, agent: 3, project: 3 }); expect(result.hasMore).toBe(true); }); diff --git a/server/src/services/company-search.ts b/server/src/services/company-search.ts index 9738dd90a6b..b7c06670fe2 100644 --- a/server/src/services/company-search.ts +++ b/server/src/services/company-search.ts @@ -6,6 +6,9 @@ import { COMPANY_SEARCH_MAX_LIMIT, COMPANY_SEARCH_MAX_OFFSET, COMPANY_SEARCH_MAX_TOKENS, + COMPANY_ARTIFACTS_MAX_QUERY_LENGTH, + type CompanyArtifact, + type CompanySearchArtifactSummary, type CompanySearchIssueSummary, type CompanySearchQuery, type CompanySearchResponse, @@ -14,6 +17,7 @@ import { type CompanySearchScope, type CompanySearchSnippet, } from "@paperclipai/shared"; +import { companyArtifactsService } from "./company-artifacts.js"; const MIN_TOKEN_LENGTH = 2; const MIN_FUZZY_QUERY_LENGTH = 4; @@ -188,7 +192,7 @@ function matchTerms(normalizedQuery: string, tokens: string[]) { } function makeCounts(results: CompanySearchResult[]) { - const counts: Record = { issue: 0, agent: 0, project: 0 }; + const counts: Record = { issue: 0, artifact: 0, agent: 0, project: 0 }; for (const result of results) counts[result.type] += 1; return counts; } @@ -201,6 +205,10 @@ function scopeIncludesAgents(scope: CompanySearchScope) { return scope === "all" || scope === "agents"; } +function scopeIncludesArtifacts(scope: CompanySearchScope) { + return scope === "all" || scope === "artifacts"; +} + function scopeIncludesProjects(scope: CompanySearchScope) { return scope === "all" || scope === "projects"; } @@ -286,6 +294,49 @@ function scoreSimpleRow(row: SimpleSearchRow, normalizedQuery: string, tokens: s return score; } +function artifactResult(artifact: CompanyArtifact, normalizedQuery: string, tokens: string[]): CompanySearchResult { + const terms = matchTerms(normalizedQuery, tokens); + const snippet = createSnippet( + "artifact", + "Artifact", + artifact.previewText ?? artifact.title, + terms, + ); + const summary: CompanySearchArtifactSummary = { + id: artifact.id, + source: artifact.source, + mediaKind: artifact.mediaKind, + issueId: artifact.issue.id, + issueIdentifier: artifact.issue.identifier, + issueTitle: artifact.issue.title, + projectId: artifact.project?.id ?? null, + projectName: artifact.project?.name ?? null, + updatedAt: artifact.updatedAt, + }; + const score = scoreSimpleRow({ + id: artifact.id, + title: artifact.title, + description: [artifact.previewText, artifact.issue.identifier, artifact.issue.title, artifact.project?.name] + .filter(Boolean) + .join(" "), + updatedAt: new Date(artifact.updatedAt), + }, normalizedQuery, tokens); + return { + id: artifact.id, + type: "artifact", + score: score + 120, + title: artifact.title, + href: artifact.href, + matchedFields: ["artifact"], + sourceLabel: snippet?.label ?? "Artifact", + snippet: snippet?.text ?? artifact.previewText, + snippets: snippet ? [snippet] : [], + artifact: summary, + updatedAt: artifact.updatedAt, + previewImageUrl: artifact.mediaKind === "image" ? artifact.contentPath : null, + }; +} + function simpleTextCondition(fields: SQL[], containsPattern: string, tokenArray: SQL) { const phraseConditions = fields.map((field) => sql`lower(coalesce(${field}, '')) LIKE ${containsPattern} ESCAPE '\\'`); const tokenConditions = fields.map((field) => tokenMatchExpression(field, tokenArray)); @@ -306,7 +357,7 @@ export function companySearchService(db: Db) { const scope = query.scope; const limit = query.limit; const offset = query.offset; - const emptyCounts: Record = { issue: 0, agent: 0, project: 0 }; + const emptyCounts: Record = { issue: 0, artifact: 0, agent: 0, project: 0 }; if (normalizedQuery.length === 0) { return { query: query.q, @@ -643,8 +694,16 @@ export function companySearchService(db: Db) { .limit(fetchLimit) : []; + const artifactRows = scopeIncludesArtifacts(scope) + ? await companyArtifactsService(db).list(companyId, { + q: normalizedQuery.slice(0, COMPANY_ARTIFACTS_MAX_QUERY_LENGTH), + limit: fetchLimit, + }).then((result) => result.artifacts) + : []; + const results: CompanySearchResult[] = [ ...(issueRows as IssueSearchRow[]).map((row) => issueResult(row, prefix, normalizedQuery, tokens)), + ...artifactRows.map((artifact) => artifactResult(artifact, normalizedQuery, tokens)), ...(agentRows as SimpleSearchRow[]).map((row) => { const terms = matchTerms(normalizedQuery, tokens); const snippet = createSnippet("capabilities", "Agent", row.description ?? row.role ?? row.title, terms); diff --git a/ui/src/components/search/SearchResultRow.tsx b/ui/src/components/search/SearchResultRow.tsx index 3913e4f52de..408427e5b31 100644 --- a/ui/src/components/search/SearchResultRow.tsx +++ b/ui/src/components/search/SearchResultRow.tsx @@ -1,5 +1,5 @@ import { memo, type ComponentType, type SVGProps } from "react"; -import { Bot, FileText, Hexagon, MessageSquare, Quote } from "lucide-react"; +import { Bot, FileText, Hexagon, MessageSquare, Paperclip, Quote } from "lucide-react"; import type { Agent, CompanySearchResult } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { cn } from "@/lib/utils"; @@ -15,6 +15,7 @@ type SnippetStyle = { const SNIPPET_STYLES: Record = { comment: { Icon: MessageSquare, label: "Comment" }, document: { Icon: FileText, label: "Doc" }, + artifact: { Icon: Paperclip, label: "Artifact" }, description: { Icon: Quote, label: "Description" }, }; @@ -109,6 +110,55 @@ function SearchResultRowImpl({ ); } + if (result.type === "artifact") { + const artifact = result.artifact; + if (!artifact) return null; + const updated = formatRelativeTime(result.updatedAt ?? artifact.updatedAt); + return ( + + +
+
+ {result.title} + + {artifact.issueIdentifier} + +
+ {result.snippet ? ( + + ) : null} +
+ {artifact.issueTitle} + {updated ? {updated} : null} +
+
+
+ {updated ? {updated} : null} + {result.previewImageUrl ? ( + + ) : null} +
+ + ); + } + const issue = result.issue; if (!issue) return null; const assigneeName = issue.assigneeAgentId diff --git a/ui/src/pages/Search.test.tsx b/ui/src/pages/Search.test.tsx index 046e39ae5d6..b271583530b 100644 --- a/ui/src/pages/Search.test.tsx +++ b/ui/src/pages/Search.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { act } from "react"; import type { ReactNode } from "react"; +import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -86,9 +86,8 @@ vi.mock("../components/Identity", () => ({ (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; async function flush() { - await act(async () => { - await Promise.resolve(); - }); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); } async function waitForAssertion(assertion: () => void, attempts = 50) { @@ -110,7 +109,7 @@ function renderSearch(initialPath: string, container: HTMLDivElement, node?: Rea const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); - act(() => { + flushSync(() => { root.render( @@ -170,7 +169,7 @@ describe("Search page", () => { scope: "all", limit: 20, offset: 0, - countsByType: { issue: 1, agent: 0, project: 0 }, + countsByType: { issue: 1, artifact: 0, agent: 0, project: 0 }, hasMore: false, results: [ { @@ -228,7 +227,72 @@ describe("Search page", () => { expect(container.textContent).toContain("1 result"); }); - act(() => { + flushSync(() => { + root.unmount(); + }); + }); + + it("renders artifact search results in the company search surface", async () => { + searchApiMock.search.mockResolvedValueOnce({ + query: "launch brief", + normalizedQuery: "launch brief", + scope: "artifacts", + limit: 20, + offset: 0, + countsByType: { issue: 0, artifact: 1, agent: 0, project: 0 }, + hasMore: false, + results: [ + { + id: "document:artifact-1", + type: "artifact", + score: 140, + title: "Launch Artifact Brief", + href: "/PAP/issues/PAP-42#document-brief", + matchedFields: ["artifact"], + sourceLabel: "Artifact", + snippet: "launch brief preview text", + snippets: [ + { + field: "artifact", + label: "Artifact", + text: "launch brief preview text", + highlights: [{ start: 0, end: 6 }], + }, + ], + artifact: { + id: "document:artifact-1", + source: "document", + mediaKind: "document", + issueId: "issue-42", + issueIdentifier: "PAP-42", + issueTitle: "Ship launch artifacts", + projectId: null, + projectName: null, + updatedAt: new Date().toISOString(), + }, + updatedAt: new Date().toISOString(), + previewImageUrl: null, + }, + ], + }); + + const { root } = renderSearch("/search?q=launch+brief&scope=artifacts", container); + + await waitForAssertion(() => { + expect(searchApiMock.search).toHaveBeenCalledWith("company-1", { + q: "launch brief", + scope: "artifacts", + limit: 20, + }); + }); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Launch Artifact Brief"); + expect(container.textContent).toContain("PAP-42"); + expect(container.textContent).toContain("launch brief preview text"); + }); + + flushSync(() => { root.unmount(); }); }); @@ -240,7 +304,7 @@ describe("Search page", () => { scope: "all", limit: 20, offset: 0, - countsByType: { issue: 0, agent: 0, project: 0 }, + countsByType: { issue: 0, artifact: 0, agent: 0, project: 0 }, hasMore: false, results: [], }); @@ -250,7 +314,7 @@ describe("Search page", () => { const input = container.querySelector('input[aria-label="Search query"]') as HTMLInputElement; expect(input).not.toBeNull(); - act(() => { + flushSync(() => { const nativeSetter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value", @@ -272,7 +336,7 @@ describe("Search page", () => { }); }); - act(() => { + flushSync(() => { root.unmount(); }); }); @@ -284,7 +348,7 @@ describe("Search page", () => { scope: "all", limit: 20, offset: 0, - countsByType: { issue: 1, agent: 0, project: 0 }, + countsByType: { issue: 1, artifact: 0, agent: 0, project: 0 }, hasMore: false, results: [ { @@ -326,7 +390,7 @@ describe("Search page", () => { expect(navigateMock).toHaveBeenCalledWith("/PAP/issues/PAP-3366", { replace: true }); }); - act(() => { + flushSync(() => { root.unmount(); }); }); @@ -338,7 +402,7 @@ describe("Search page", () => { scope: "comments", limit: 20, offset: 0, - countsByType: { issue: 0, agent: 0, project: 0 }, + countsByType: { issue: 0, artifact: 0, agent: 0, project: 0 }, hasMore: false, results: [], }); @@ -351,7 +415,7 @@ describe("Search page", () => { expect(container.textContent).toContain("Search all scopes"); }); - act(() => { + flushSync(() => { root.unmount(); }); }); diff --git a/ui/src/pages/Search.tsx b/ui/src/pages/Search.tsx index 1271914a139..1a1506a68ea 100644 --- a/ui/src/pages/Search.tsx +++ b/ui/src/pages/Search.tsx @@ -35,23 +35,26 @@ const SCOPE_LABELS: Record = { issues: "Issues", comments: "Comments", documents: "Documents", + artifacts: "Artifacts", agents: "Agents", projects: "Projects", }; -type SubGroupKey = "issues" | "comments" | "documents" | "agents" | "projects"; +type SubGroupKey = "issues" | "comments" | "documents" | "artifacts" | "agents" | "projects"; -const SUBGROUP_ORDER: SubGroupKey[] = ["issues", "comments", "documents", "agents", "projects"]; +const SUBGROUP_ORDER: SubGroupKey[] = ["issues", "comments", "documents", "artifacts", "agents", "projects"]; const SUBGROUP_LABELS: Record = { issues: "Issues", comments: "Comments", documents: "Documents", + artifacts: "Artifacts", agents: "Agents", projects: "Projects", }; function classifyResult(result: CompanySearchResult): SubGroupKey { + if (result.type === "artifact") return "artifacts"; if (result.type === "agent") return "agents"; if (result.type === "project") return "projects"; const matched = new Set(result.matchedFields); @@ -261,7 +264,7 @@ export function Search() { return () => window.removeEventListener("keydown", handler); }, [focusInput]); - const counts = data?.countsByType ?? { issue: 0, agent: 0, project: 0 }; + const counts = data?.countsByType ?? { issue: 0, artifact: 0, agent: 0, project: 0 }; const totalResults = data?.results.length ?? 0; const tabItems = useMemo(() => { @@ -276,8 +279,9 @@ export function Search() { 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); + if (value === "all") count = (counts.issue ?? 0) + (counts.artifact ?? 0) + (counts.agent ?? 0) + (counts.project ?? 0); else if (value === "issues") count = issuesTotal; + else if (value === "artifacts") count = counts.artifact ?? 0; else if (value === "agents") count = counts.agent ?? 0; else if (value === "projects") count = counts.project ?? 0; return { @@ -342,7 +346,7 @@ export function Search() { } } }} - placeholder="Search issues, comments, documents, agents, projects…" + placeholder="Search issues, comments, documents, artifacts, agents, projects…" aria-label="Search query" className="h-10 pl-9 pr-20 text-sm" /> @@ -452,7 +456,7 @@ function SearchTabContent({

Type to search company memory.

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

{recentSearches.length > 0 ? ( diff --git a/ui/storybook/stories/search.stories.tsx b/ui/storybook/stories/search.stories.tsx index 258d8c716c0..f926a863c74 100644 --- a/ui/storybook/stories/search.stories.tsx +++ b/ui/storybook/stories/search.stories.tsx @@ -192,6 +192,7 @@ const fixtureResponse: CompanySearchResponse = { results: [...fixtureResults, ...fixtureAgents, ...fixtureProjects], countsByType: { issue: fixtureResults.length, + artifact: 0, agent: fixtureAgents.length, project: fixtureProjects.length, }, @@ -202,11 +203,12 @@ function ScopeTabsPreview({ active, response, }: { - active: "all" | "issues" | "comments" | "documents" | "agents" | "projects"; + active: "all" | "issues" | "comments" | "documents" | "artifacts" | "agents" | "projects"; response: CompanySearchResponse; }) { const total = (response.countsByType.issue ?? 0) + + (response.countsByType.artifact ?? 0) + (response.countsByType.agent ?? 0) + (response.countsByType.project ?? 0); const items: PageTabItem[] = [ @@ -214,6 +216,7 @@ function ScopeTabsPreview({ { value: "issues", label: }, { value: "comments", label: result.matchedFields.includes("comment")).length} /> }, { value: "documents", label: result.matchedFields.includes("document")).length} /> }, + { value: "artifacts", label: }, { value: "agents", label: }, { value: "projects", label: }, ]; @@ -534,7 +537,7 @@ function SearchStories() {
/search

No results state

- +