Add artifacts to company search

This commit is contained in:
Dotta committed 2026-06-05 22:24:32 +00:00
1 parent f76513e3d5
commit 2b62cebf57
10 files changed
+263 -29

No files matched your search

+1
View File
@@ -404,6 +404,7 @@ export type {
ProjectManagedByPlugin,
ProjectWorkspace,
CompanySearchHighlight,
CompanySearchArtifactSummary,
CompanySearchIssueSummary,
CompanySearchResponse,
CompanySearchResult,
+1
View File
@@ -162,6 +162,7 @@ export type {
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
export type {
CompanySearchHighlight,
CompanySearchArtifactSummary,
CompanySearchIssueSummary,
CompanySearchResponse,
CompanySearchResult,
+15 -2
View File
@@ -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;
}
@@ -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,
};
}
@@ -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);
});
+61 -2
View File
@@ -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<CompanySearchResultType, number> = { issue: 0, agent: 0, project: 0 };
const counts: Record<CompanySearchResultType, number> = { 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<boolean>`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<CompanySearchResultType, number> = { issue: 0, agent: 0, project: 0 };
const emptyCounts: Record<CompanySearchResultType, number> = { 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);
+51 -1
View File
@@ -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<string, SnippetStyle> = {
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 (
<Link
to={result.href}
disableIssueQuicklook
className={cn(ROW_BASE, "py-4", isActive && "bg-muted/40", className)}
data-result-type="artifact"
>
<Paperclip className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2.5 gap-y-1">
<span className="truncate text-sm font-medium text-foreground">{result.title}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{artifact.issueIdentifier}
</span>
</div>
{result.snippet ? (
<SnippetLine
text={result.snippets[0]?.text ?? result.snippet}
highlights={result.snippets[0]?.highlights}
field="artifact"
fallbackLabel={result.sourceLabel ?? "Artifact"}
multiline
/>
) : null}
<div className="mt-1.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground sm:hidden">
<span className="truncate">{artifact.issueTitle}</span>
{updated ? <span className="ml-auto shrink-0 tabular-nums">{updated}</span> : null}
</div>
</div>
<div className="ml-2 hidden shrink-0 flex-col items-end gap-2 sm:flex">
{updated ? <span className="text-xs tabular-nums text-muted-foreground">{updated}</span> : null}
{result.previewImageUrl ? (
<img
src={result.previewImageUrl}
alt=""
loading="lazy"
decoding="async"
className="h-[88px] w-[88px] shrink-0 rounded-md border border-border bg-muted object-cover"
/>
) : null}
</div>
</Link>
);
}
const issue = result.issue;
if (!issue) return null;
const assigneeName = issue.assigneeAgentId
+78 -14
View File
@@ -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(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialPath]}>
@@ -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();
});
});
+10 -6
View File
@@ -35,23 +35,26 @@ const SCOPE_LABELS: Record<CompanySearchScope, string> = {
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<SubGroupKey, string> = {
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<PageTabItem[]>(() => {
@@ -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({
<div>
<h2 className="text-lg font-semibold">Type to search company memory.</h2>
<p className="mt-1 text-sm text-muted-foreground">
Issues, comments, plan documents, agents, projects — same surface, ranked by relevance.
Issues, comments, plan documents, artifacts, agents, projects — same surface, ranked by relevance.
</p>
</div>
{recentSearches.length > 0 ? (
+5 -2
View File
@@ -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: <ScopeTabLabel label="Issues" count={response.countsByType.issue} /> },
{ value: "comments", label: <ScopeTabLabel label="Comments" count={response.results.filter((result) => result.matchedFields.includes("comment")).length} /> },
{ value: "documents", label: <ScopeTabLabel label="Documents" count={response.results.filter((result) => result.matchedFields.includes("document")).length} /> },
{ value: "artifacts", label: <ScopeTabLabel label="Artifacts" count={response.countsByType.artifact} /> },
{ value: "agents", label: <ScopeTabLabel label="Agents" count={response.countsByType.agent} /> },
{ value: "projects", label: <ScopeTabLabel label="Projects" count={response.countsByType.project} /> },
];
@@ -534,7 +537,7 @@ function SearchStories() {
<div className="paperclip-story__label">/search</div>
<h2 className="mt-1 text-lg font-semibold">No results state</h2>
</div>
<SearchPagePreview response={{ ...fixtureResponse, results: [], countsByType: { issue: 0, agent: 0, project: 0 } }} state="empty" query="ghostbuster" />
<SearchPagePreview response={{ ...fixtureResponse, results: [], countsByType: { issue: 0, artifact: 0, agent: 0, project: 0 } }} state="empty" query="ghostbuster" />
</section>
<section className="paperclip-story__frame overflow-hidden p-4">