mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
Add artifacts to company search
This commit is contained in:
1 parent
f76513e3d5
commit
2b62cebf57
10 files changed
+263
-29
No files matched your search
@@ -404,6 +404,7 @@ export type {
|
||||
ProjectManagedByPlugin,
|
||||
ProjectWorkspace,
|
||||
CompanySearchHighlight,
|
||||
CompanySearchArtifactSummary,
|
||||
CompanySearchIssueSummary,
|
||||
CompanySearchResponse,
|
||||
CompanySearchResult,
|
||||
|
||||
@@ -162,6 +162,7 @@ export type {
|
||||
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
|
||||
export type {
|
||||
CompanySearchHighlight,
|
||||
CompanySearchArtifactSummary,
|
||||
CompanySearchIssueSummary,
|
||||
CompanySearchResponse,
|
||||
CompanySearchResult,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 ? (
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user