diff --git a/packages/db/src/migrations/0079_company_search_document_indexes.sql b/packages/db/src/migrations/0079_company_search_document_indexes.sql new file mode 100644 index 00000000..5f31d42c --- /dev/null +++ b/packages/db/src/migrations/0079_company_search_document_indexes.sql @@ -0,0 +1,2 @@ +CREATE INDEX IF NOT EXISTS "documents_title_search_idx" ON "documents" USING gin ("title" gin_trgm_ops);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "documents_latest_body_search_idx" ON "documents" USING gin ("latest_body" gin_trgm_ops); diff --git a/packages/db/src/migrations/0080_company_search_fuzzystrmatch.sql b/packages/db/src/migrations/0080_company_search_fuzzystrmatch.sql new file mode 100644 index 00000000..1805a989 --- /dev/null +++ b/packages/db/src/migrations/0080_company_search_fuzzystrmatch.sql @@ -0,0 +1 @@ +CREATE EXTENSION IF NOT EXISTS fuzzystrmatch; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 4688f5cd..8ad509cb 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -554,6 +554,20 @@ "when": 1778004024976, "tag": "0078_white_darwin", "breakpoints": true + }, + { + "idx": 79, + "version": "7", + "when": 1777821410992, + "tag": "0079_company_search_document_indexes", + "breakpoints": true + }, + { + "idx": 80, + "version": "7", + "when": 1777849000000, + "tag": "0080_company_search_fuzzystrmatch", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/documents.ts b/packages/db/src/schema/documents.ts index 53d5f358..1dae7e1d 100644 --- a/packages/db/src/schema/documents.ts +++ b/packages/db/src/schema/documents.ts @@ -22,5 +22,7 @@ export const documents = pgTable( (table) => ({ companyUpdatedIdx: index("documents_company_updated_idx").on(table.companyId, table.updatedAt), companyCreatedIdx: index("documents_company_created_idx").on(table.companyId, table.createdAt), + titleSearchIdx: index("documents_title_search_idx").using("gin", table.title.op("gin_trgm_ops")), + bodySearchIdx: index("documents_latest_body_search_idx").using("gin", table.latestBody.op("gin_trgm_ops")), }), ); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f4cdcdf2..d4e63897 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -317,6 +317,13 @@ export type { ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace, + CompanySearchHighlight, + CompanySearchIssueSummary, + CompanySearchResponse, + CompanySearchResult, + CompanySearchResultType, + CompanySearchScope, + CompanySearchSnippet, ExecutionWorkspace, ExecutionWorkspaceSummary, ExecutionWorkspaceConfig, @@ -573,6 +580,7 @@ export type { QuotaWindow, ProviderQuotaResult, } from "./types/index.js"; +export { COMPANY_SEARCH_SCOPES } from "./types/index.js"; export { ISSUE_REFERENCE_IDENTIFIER_RE, buildIssueReferenceHref, @@ -697,6 +705,13 @@ export { type CreateProjectWorkspace, type UpdateProjectWorkspace, projectExecutionWorkspacePolicySchema, + companySearchQuerySchema, + COMPANY_SEARCH_DEFAULT_LIMIT, + COMPANY_SEARCH_MAX_LIMIT, + COMPANY_SEARCH_MAX_OFFSET, + COMPANY_SEARCH_MAX_QUERY_LENGTH, + COMPANY_SEARCH_MAX_TOKENS, + type CompanySearchQuery, createIssueSchema, createChildIssueSchema, createIssueLabelSchema, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 82088c7a..a722991e 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -90,6 +90,16 @@ export type { } from "./agent.js"; export type { AssetImage } from "./asset.js"; export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js"; +export type { + CompanySearchHighlight, + CompanySearchIssueSummary, + CompanySearchResponse, + CompanySearchResult, + CompanySearchResultType, + CompanySearchScope, + CompanySearchSnippet, +} from "./search.js"; +export { COMPANY_SEARCH_SCOPES } from "./search.js"; export type { ExecutionWorkspace, ExecutionWorkspaceSummary, diff --git a/packages/shared/src/types/search.ts b/packages/shared/src/types/search.ts new file mode 100644 index 00000000..145c5ba3 --- /dev/null +++ b/packages/shared/src/types/search.ts @@ -0,0 +1,56 @@ +import type { IssuePriority, IssueStatus } from "../constants.js"; + +export const COMPANY_SEARCH_SCOPES = ["all", "issues", "comments", "documents", "agents", "projects"] as const; +export type CompanySearchScope = (typeof COMPANY_SEARCH_SCOPES)[number]; + +export type CompanySearchResultType = "issue" | "agent" | "project"; + +export interface CompanySearchHighlight { + start: number; + end: number; +} + +export interface CompanySearchSnippet { + field: string; + label: string; + text: string; + highlights: CompanySearchHighlight[]; +} + +export interface CompanySearchIssueSummary { + id: string; + identifier: string | null; + title: string; + status: IssueStatus; + priority: IssuePriority; + assigneeAgentId: string | null; + assigneeUserId: string | null; + projectId: string | null; + updatedAt: string; +} + +export interface CompanySearchResult { + id: string; + type: CompanySearchResultType; + score: number; + title: string; + href: string; + matchedFields: string[]; + sourceLabel: string | null; + snippet: string | null; + snippets: CompanySearchSnippet[]; + issue?: CompanySearchIssueSummary; + updatedAt: string | null; + previewImageUrl: string | null; +} + +export interface CompanySearchResponse { + query: string; + normalizedQuery: string; + scope: CompanySearchScope; + limit: number; + offset: number; + results: CompanySearchResult[]; + countsByType: Record; + hasMore: boolean; +} diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index afb7cebc..f030762b 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -210,6 +210,16 @@ export { type RestoreIssueDocumentRevision, } from "./issue.js"; +export { + COMPANY_SEARCH_DEFAULT_LIMIT, + COMPANY_SEARCH_MAX_LIMIT, + COMPANY_SEARCH_MAX_OFFSET, + COMPANY_SEARCH_MAX_QUERY_LENGTH, + COMPANY_SEARCH_MAX_TOKENS, + companySearchQuerySchema, + type CompanySearchQuery, +} from "./search.js"; + export { createIssueTreeHoldSchema, issueTreeControlModeSchema, diff --git a/packages/shared/src/validators/search.ts b/packages/shared/src/validators/search.ts new file mode 100644 index 00000000..4f5419c8 --- /dev/null +++ b/packages/shared/src/validators/search.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; +import { COMPANY_SEARCH_SCOPES } from "../types/search.js"; + +export const COMPANY_SEARCH_MAX_QUERY_LENGTH = 200; +export const COMPANY_SEARCH_MAX_TOKENS = 8; +export const COMPANY_SEARCH_DEFAULT_LIMIT = 20; +export const COMPANY_SEARCH_MAX_LIMIT = 50; +export const COMPANY_SEARCH_MAX_OFFSET = 200; + +function firstQueryValue(value: unknown): unknown { + return Array.isArray(value) ? value[0] : value; +} + +function clampInteger(value: unknown, fallback: number, min: number, max: number) { + const raw = firstQueryValue(value); + const numeric = typeof raw === "number" + ? raw + : typeof raw === "string" && raw.trim().length > 0 + ? Number.parseInt(raw, 10) + : Number.NaN; + if (!Number.isFinite(numeric)) return fallback; + return Math.min(max, Math.max(min, Math.floor(numeric))); +} + +export const companySearchQuerySchema = z.object({ + q: z.preprocess(firstQueryValue, z.string().optional().default("")) + .transform((value) => value.slice(0, COMPANY_SEARCH_MAX_QUERY_LENGTH)), + scope: z.preprocess(firstQueryValue, z.enum(COMPANY_SEARCH_SCOPES).catch("all")).optional().default("all"), + limit: z.unknown() + .optional() + .transform((value) => clampInteger(value, COMPANY_SEARCH_DEFAULT_LIMIT, 1, COMPANY_SEARCH_MAX_LIMIT)), + offset: z.unknown() + .optional() + .transform((value) => clampInteger(value, 0, 0, COMPANY_SEARCH_MAX_OFFSET)), +}); + +export type CompanySearchQuery = z.infer; diff --git a/server/src/__tests__/company-search-rate-limit-routes.test.ts b/server/src/__tests__/company-search-rate-limit-routes.test.ts new file mode 100644 index 00000000..1c52c42b --- /dev/null +++ b/server/src/__tests__/company-search-rate-limit-routes.test.ts @@ -0,0 +1,53 @@ +import express from "express"; +import request from "supertest"; +import { describe, expect, it, vi } from "vitest"; +import { issueRoutes } from "../routes/issues.js"; +import { createCompanySearchRateLimiter } from "../services/company-search-rate-limit.js"; +import type { CompanySearchQuery, CompanySearchResponse } from "@paperclipai/shared"; + +function createSearchResponse(query: CompanySearchQuery): CompanySearchResponse { + return { + query: query.q, + normalizedQuery: query.q.trim().toLowerCase(), + scope: query.scope, + limit: query.limit, + offset: query.offset, + results: [], + countsByType: { issue: 0, agent: 0, project: 0 }, + hasMore: false, + }; +} + +describe("company search route rate limiting", () => { + it("rejects repeated same-actor search calls before invoking search", async () => { + const search = vi.fn(async (_companyId: string, query: CompanySearchQuery) => createSearchResponse(query)); + const app = express(); + app.use((req, _res, next) => { + req.actor = { + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + }; + next(); + }); + app.use("/api", issueRoutes({} as never, {} as never, { + searchService: { search }, + searchRateLimiter: createCompanySearchRateLimiter({ + maxRequests: 1, + windowMs: 60_000, + now: () => 1_000, + }), + })); + + await request(app).get("/api/companies/company-1/search?q=wizard").expect(200); + const limited = await request(app).get("/api/companies/company-1/search?q=wizard").expect(429); + + expect(search).toHaveBeenCalledTimes(1); + expect(limited.body).toMatchObject({ + error: "Search rate limit exceeded", + retryAfterSeconds: 60, + }); + expect(limited.headers["retry-after"]).toBe("60"); + }); +}); diff --git a/server/src/__tests__/company-search-service.test.ts b/server/src/__tests__/company-search-service.test.ts new file mode 100644 index 00000000..ada5a888 --- /dev/null +++ b/server/src/__tests__/company-search-service.test.ts @@ -0,0 +1,454 @@ +import { randomUUID } from "node:crypto"; +import { sql } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + companies, + createDb, + documents, + issueComments, + issueDocuments, + issues, + projects, +} from "@paperclipai/db"; +import { companySearchQuerySchema, COMPANY_SEARCH_MAX_QUERY_LENGTH } from "@paperclipai/shared"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { + COMPANY_SEARCH_BRANCH_FETCH_LIMIT, + companySearchBranchFetchLimit, + companySearchService, +} from "../services/company-search.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres company search tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describe("company search query validation", () => { + it("clamps query length, limit, and offset without rejecting the request", () => { + const parsed = companySearchQuerySchema.parse({ + q: "x".repeat(COMPANY_SEARCH_MAX_QUERY_LENGTH + 50), + limit: "500", + offset: "9000", + scope: "not-a-scope", + }); + + expect(parsed.q).toHaveLength(COMPANY_SEARCH_MAX_QUERY_LENGTH); + expect(parsed.limit).toBe(50); + expect(parsed.offset).toBe(200); + expect(parsed.scope).toBe("all"); + }); + + it("includes offset in the internal per-branch fetch window", () => { + const lowOffset = companySearchQuerySchema.parse({ q: "needle", limit: "50", offset: "0" }); + const highOffset = companySearchQuerySchema.parse({ q: "needle", limit: "50", offset: "9000" }); + + expect(companySearchBranchFetchLimit(lowOffset.limit, lowOffset.offset)).toBe(51); + expect(companySearchBranchFetchLimit(highOffset.limit, highOffset.offset)).toBe(COMPANY_SEARCH_BRANCH_FETCH_LIMIT); + }); +}); + +describeEmbeddedPostgres("companySearchService", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-search-"); + db = createDb(tempDb.connectionString); + svc = companySearchService(db); + await db.execute(sql.raw("CREATE EXTENSION IF NOT EXISTS pg_trgm")); + }, 20_000); + + afterEach(async () => { + await db.delete(issueDocuments); + await db.delete(documents); + await db.delete(issueComments); + await db.delete(issues); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function createCompany(name = "Paperclip") { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name, + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + return companyId; + } + + async function createIssue(companyId: string, values: Partial = {}) { + const id = values.id ?? randomUUID(); + await db.insert(issues).values({ + id, + companyId, + title: values.title ?? "Search target", + description: values.description ?? null, + status: values.status ?? "todo", + priority: values.priority ?? "medium", + identifier: values.identifier ?? null, + hiddenAt: values.hiddenAt ?? null, + ...values, + }); + return id; + } + + async function createAgent(companyId: string, values: Partial = {}) { + const id = values.id ?? randomUUID(); + await db.insert(agents).values({ + id, + companyId, + name: values.name ?? "Search agent", + role: values.role ?? "engineer", + title: values.title ?? null, + capabilities: values.capabilities ?? null, + ...values, + }); + return id; + } + + async function createProject(companyId: string, values: Partial = {}) { + const id = values.id ?? randomUUID(); + await db.insert(projects).values({ + id, + companyId, + name: values.name ?? "Search project", + description: values.description ?? null, + ...values, + }); + return id; + } + + it("ranks exact issue identifiers before weaker title matches", async () => { + const companyId = await createCompany(); + const exactId = await createIssue(companyId, { + identifier: "TST-42", + title: "Backend endpoint", + }); + await createIssue(companyId, { + identifier: "TST-43", + title: "TST-42 mentioned in title only", + }); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "TST-42" })); + + expect(result.results[0]?.id).toBe(exactId); + expect(result.results[0]?.matchedFields).toContain("identifier"); + }); + + it("matches multiple tokens across the same issue thread and returns comment snippets", async () => { + const companyId = await createCompany(); + const issueId = await createIssue(companyId, { + identifier: "TST-7", + title: "Checkout semantics", + description: "Atomic ownership is enforced here.", + }); + await db.insert(issueComments).values({ + companyId, + issueId, + body: "The ranking snippet should explain why this thread matched.", + }); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "checkout snippet" })); + const match = result.results.find((item) => item.id === issueId); + + expect(match).toBeTruthy(); + expect(match?.matchedFields).toEqual(expect.arrayContaining(["title", "comment"])); + expect(match?.snippets.some((snippet) => /snippet/i.test(snippet.text))).toBe(true); + }); + + it("searches issue documents and returns document metadata for snippets", async () => { + const companyId = await createCompany(); + const issueId = await createIssue(companyId, { + identifier: "TST-8", + title: "Adapter manager", + }); + const documentId = randomUUID(); + await db.insert(documents).values({ + id: documentId, + companyId, + title: "Hermes Parser Plan", + latestBody: "The external adapter parser should be discovered from the plugin package.", + format: "markdown", + }); + await db.insert(issueDocuments).values({ + companyId, + issueId, + documentId, + key: "plan", + }); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "Hermes parser", scope: "documents" })); + + expect(result.results).toHaveLength(1); + expect(result.results[0]?.id).toBe(issueId); + expect(result.results[0]?.matchedFields).toContain("document"); + expect(result.results[0]?.href).toContain("#document-plan"); + expect(result.results[0]?.snippet).toMatch(/parser/i); + }); + + it("excludes hidden issues and other companies' data", async () => { + const companyId = await createCompany("Visible Co"); + const otherCompanyId = await createCompany("Other Co"); + const visibleId = await createIssue(companyId, { + identifier: "VIS-1", + title: "Visible needle", + }); + await createIssue(companyId, { + identifier: "HID-1", + title: "Hidden needle", + hiddenAt: new Date(), + }); + await createIssue(otherCompanyId, { + identifier: "OTH-1", + title: "Other company needle", + }); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "needle" })); + + expect(result.results.map((item) => item.id)).toEqual([visibleId]); + }); + + it("treats bare SQL wildcard characters as literals instead of match-all queries", async () => { + const companyId = await createCompany(); + const issueId = await createIssue(companyId, { + identifier: "TST-20", + title: "Plain issue target", + description: "Plain issue description", + }); + await db.insert(issueComments).values({ + companyId, + issueId, + body: "Plain comment body", + }); + const documentId = randomUUID(); + await db.insert(documents).values({ + id: documentId, + companyId, + title: "Plain document", + latestBody: "Plain document body", + format: "markdown", + }); + await db.insert(issueDocuments).values({ + companyId, + issueId, + documentId, + key: "plain", + }); + await createAgent(companyId, { + name: "Plain Agent", + role: "engineer", + capabilities: "Plain agent capabilities", + }); + await createProject(companyId, { + name: "Plain Project", + description: "Plain project description", + }); + + for (const q of ["%", "_", "\\"]) { + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q })); + expect(result.results, `q=${q}`).toEqual([]); + } + }); + + it("matches percent characters literally across issue, comment, document, agent, and project results", async () => { + const companyId = await createCompany(); + const issueMatchId = await createIssue(companyId, { + identifier: "TST-21", + title: "Release 100% checklist", + }); + const issueDecoyId = await createIssue(companyId, { + identifier: "TST-22", + title: "Release 1000 checklist", + }); + const commentMatchId = await createIssue(companyId, { + identifier: "TST-23", + title: "Comment literal holder", + }); + const commentDecoyId = await createIssue(companyId, { + identifier: "TST-24", + title: "Comment decoy holder", + }); + await db.insert(issueComments).values([ + { + companyId, + issueId: commentMatchId, + body: "QA is 100% confident in this result.", + }, + { + companyId, + issueId: commentDecoyId, + body: "QA is 1000 confident in this result.", + }, + ]); + const documentMatchIssueId = await createIssue(companyId, { + identifier: "TST-25", + title: "Document literal holder", + }); + const documentDecoyIssueId = await createIssue(companyId, { + identifier: "TST-26", + title: "Document decoy holder", + }); + const documentMatchId = randomUUID(); + const documentDecoyId = randomUUID(); + await db.insert(documents).values([ + { + id: documentMatchId, + companyId, + title: "Literal rollout", + latestBody: "Ship 100% complete adapter support.", + format: "markdown", + }, + { + id: documentDecoyId, + companyId, + title: "Decoy rollout", + latestBody: "Ship 1000 complete adapter support.", + format: "markdown", + }, + ]); + await db.insert(issueDocuments).values([ + { + companyId, + issueId: documentMatchIssueId, + documentId: documentMatchId, + key: "literal", + }, + { + companyId, + issueId: documentDecoyIssueId, + documentId: documentDecoyId, + key: "decoy", + }, + ]); + const agentMatchId = await createAgent(companyId, { + name: "100% Specialist", + role: "engineer", + }); + const agentDecoyId = await createAgent(companyId, { + name: "1000 Specialist", + role: "engineer", + }); + const projectMatchId = await createProject(companyId, { + name: "100% Launch Plan", + }); + const projectDecoyId = await createProject(companyId, { + name: "1000 Launch Plan", + }); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "100%" })); + const ids = result.results.map((row) => row.id); + + expect(ids).toEqual(expect.arrayContaining([ + issueMatchId, + commentMatchId, + documentMatchIssueId, + agentMatchId, + projectMatchId, + ])); + expect(ids).not.toEqual(expect.arrayContaining([ + issueDecoyId, + commentDecoyId, + documentDecoyIssueId, + agentDecoyId, + projectDecoyId, + ])); + }); + + it("applies offset after merging cross-type result ranking", async () => { + const companyId = await createCompany(); + const base = new Date("2026-01-01T00:00:00.000Z").getTime(); + const agentIds = await Promise.all([ + createAgent(companyId, { name: "Needle agent 1", updatedAt: new Date(base + 6_000) }), + createAgent(companyId, { name: "Needle agent 2", updatedAt: new Date(base + 5_000) }), + createAgent(companyId, { name: "Needle agent 3", updatedAt: new Date(base + 4_000) }), + ]); + const projectIds = await Promise.all([ + createProject(companyId, { name: "Needle project 1", updatedAt: new Date(base + 3_000) }), + createProject(companyId, { name: "Needle project 2", updatedAt: new Date(base + 2_000) }), + createProject(companyId, { name: "Needle project 3", updatedAt: new Date(base + 1_000) }), + ]); + + 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.hasMore).toBe(true); + }); + + it("escapes underscore and backslash characters in issue phrase and token patterns", async () => { + const companyId = await createCompany(); + const literalId = await createIssue(companyId, { + identifier: "TST-27", + title: "Literal foo_bar path c:\\tmp", + }); + const decoyId = await createIssue(companyId, { + identifier: "TST-28", + title: "Decoy fooXbar path c:tmp", + }); + + for (const q of ["foo_bar", "c:\\tmp"]) { + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q, scope: "issues" })); + const ids = result.results.map((row) => row.id); + expect(ids, `q=${q}`).toContain(literalId); + expect(ids, `q=${q}`).not.toContain(decoyId); + } + }); + + it("uses pg_trgm for conservative fuzzy title matches", async () => { + const companyId = await createCompany(); + const issueId = await createIssue(companyId, { + identifier: "TST-9", + title: "Onboarding wizard polish", + }); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "onbordng wizard" })); + + expect(result.results[0]?.id).toBe(issueId); + expect(result.results[0]?.matchedFields).toContain("title"); + }); + + it("matches transposition typos against multi-word titles", async () => { + const companyId = await createCompany(); + const searchIssueId = await createIssue(companyId, { + identifier: "TST-10", + title: "Improve search performance", + }); + const mobileIssueId = await createIssue(companyId, { + identifier: "TST-11", + title: "Polish mobile navigation", + }); + const otherIssueId = await createIssue(companyId, { + identifier: "TST-12", + title: "Refactor billing reports", + }); + + const transpositionCases: Array<{ query: string; expectedId: string; rejected: string }> = [ + { query: "serach", expectedId: searchIssueId, rejected: otherIssueId }, + { query: "mibile", expectedId: mobileIssueId, rejected: otherIssueId }, + { query: "mobail", expectedId: mobileIssueId, rejected: otherIssueId }, + ]; + + for (const { query, expectedId, rejected } of transpositionCases) { + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: query })); + const ids = result.results.map((row) => row.id); + expect(ids, `query=${query}`).toContain(expectedId); + expect(ids, `query=${query} should not match unrelated issue`).not.toContain(rejected); + } + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 4a298780..0b23ed51 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -9,6 +9,7 @@ import { addIssueCommentSchema, acceptIssueThreadInteractionSchema, cancelIssueThreadInteractionSchema, + companySearchQuerySchema, createIssueAttachmentMetadataSchema, createIssueThreadInteractionSchema, createIssueWorkProductSchema, @@ -32,6 +33,8 @@ import { getClosedIsolatedExecutionWorkspaceMessage, isClosedIsolatedExecutionWorkspace, normalizeIssueIdentifier as normalizeIssueReferenceIdentifier, + type CompanySearchQuery, + type CompanySearchResponse, type ExecutionWorkspace, type SuccessfulRunHandoffState, } from "@paperclipai/shared"; @@ -44,6 +47,7 @@ import { accessService, agentService, companyService, + companySearchService, executionWorkspaceService, goalService, heartbeatService, @@ -81,6 +85,10 @@ import { feedbackService } from "../services/feedback.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { environmentService } from "../services/environments.js"; import { redactSensitiveText } from "../redaction.js"; +import { + createCompanySearchRateLimiter, + type CompanySearchRateLimiter, +} from "../services/company-search-rate-limit.js"; import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy, @@ -97,6 +105,9 @@ const updateIssueRouteSchema = updateIssueSchema.extend({ type ParsedExecutionState = NonNullable>; type NormalizedExecutionPolicy = NonNullable>; +type CompanySearchService = { + search(companyId: string, query: CompanySearchQuery): Promise; +}; type ActivityIssueRelationSummary = { id: string; identifier: string | null; @@ -253,6 +264,23 @@ function summarizeIssueRelationForActivity(relation: { }; } +const defaultCompanySearchRateLimiter = createCompanySearchRateLimiter(); + +function companySearchRateLimitActor(req: Request, companyId: string) { + if (req.actor.type === "agent") { + return { + companyId, + actorType: "agent" as const, + actorId: req.actor.agentId ?? req.actor.keyId ?? "unknown-agent", + }; + } + return { + companyId, + actorType: "board" as const, + actorId: req.actor.userId ?? req.actor.source ?? "board", + }; +} + function summarizeIssueReferenceActivityDetails(input: | { addedReferencedIssues: ActivityIssueRelationSummary[]; @@ -548,6 +576,8 @@ export function issueRoutes( now?: Date; }): Promise; }; + searchService?: CompanySearchService; + searchRateLimiter?: CompanySearchRateLimiter; pluginWorkerManager?: PluginWorkerManager; } = {}, ) { @@ -559,6 +589,12 @@ export function issueRoutes( }); const feedback = feedbackService(db); const companiesSvc = companyService(db); + let searchSvc = opts.searchService ?? null; + const getSearchService = () => { + searchSvc ??= companySearchService(db); + return searchSvc; + }; + const searchRateLimiter = opts.searchRateLimiter ?? defaultCompanySearchRateLimiter; const instanceSettings = instanceSettingsService(db); const agentsSvc = agentService(db); const projectsSvc = projectService(db); @@ -1048,6 +1084,25 @@ export function issueRoutes( }); }); + router.get("/companies/:companyId/search", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const query = companySearchQuerySchema.parse(req.query); + const rateLimit = searchRateLimiter.consume(companySearchRateLimitActor(req, companyId)); + res.setHeader("X-RateLimit-Limit", String(rateLimit.limit)); + res.setHeader("X-RateLimit-Remaining", String(rateLimit.remaining)); + if (!rateLimit.allowed) { + res.setHeader("Retry-After", String(rateLimit.retryAfterSeconds)); + res.status(429).json({ + error: "Search rate limit exceeded", + retryAfterSeconds: rateLimit.retryAfterSeconds, + }); + return; + } + const result = await getSearchService().search(companyId, query); + res.json(result); + }); + router.get("/companies/:companyId/issues", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); diff --git a/server/src/services/company-search-rate-limit.ts b/server/src/services/company-search-rate-limit.ts new file mode 100644 index 00000000..4ac32e80 --- /dev/null +++ b/server/src/services/company-search-rate-limit.ts @@ -0,0 +1,63 @@ +export const COMPANY_SEARCH_RATE_LIMIT_WINDOW_MS = 60_000; +export const COMPANY_SEARCH_RATE_LIMIT_MAX_REQUESTS = 60; + +export type CompanySearchRateLimitActor = { + companyId: string; + actorType: "agent" | "board"; + actorId: string; +}; + +export type CompanySearchRateLimitResult = { + allowed: boolean; + limit: number; + remaining: number; + retryAfterSeconds: number; +}; + +export type CompanySearchRateLimiter = { + consume(actor: CompanySearchRateLimitActor): CompanySearchRateLimitResult; +}; + +export function createCompanySearchRateLimiter(options: { + windowMs?: number; + maxRequests?: number; + now?: () => number; +} = {}): CompanySearchRateLimiter { + const windowMs = options.windowMs ?? COMPANY_SEARCH_RATE_LIMIT_WINDOW_MS; + const maxRequests = options.maxRequests ?? COMPANY_SEARCH_RATE_LIMIT_MAX_REQUESTS; + const now = options.now ?? Date.now; + const hitsByKey = new Map(); + + function key(actor: CompanySearchRateLimitActor) { + return `${actor.companyId}:${actor.actorType}:${actor.actorId}`; + } + + return { + consume(actor) { + const currentTime = now(); + const cutoff = currentTime - windowMs; + const actorKey = key(actor); + const recentHits = (hitsByKey.get(actorKey) ?? []).filter((hit) => hit > cutoff); + + if (recentHits.length >= maxRequests) { + const oldestHit = recentHits[0] ?? currentTime; + hitsByKey.set(actorKey, recentHits); + return { + allowed: false, + limit: maxRequests, + remaining: 0, + retryAfterSeconds: Math.max(1, Math.ceil((oldestHit + windowMs - currentTime) / 1000)), + }; + } + + recentHits.push(currentTime); + hitsByKey.set(actorKey, recentHits); + return { + allowed: true, + limit: maxRequests, + remaining: Math.max(0, maxRequests - recentHits.length), + retryAfterSeconds: 0, + }; + }, + }; +} diff --git a/server/src/services/company-search.ts b/server/src/services/company-search.ts new file mode 100644 index 00000000..1816e074 --- /dev/null +++ b/server/src/services/company-search.ts @@ -0,0 +1,696 @@ +import { and, desc, eq, isNull, sql } from "drizzle-orm"; +import type { SQL } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { agents, companies, issues, projects } from "@paperclipai/db"; +import { + COMPANY_SEARCH_MAX_LIMIT, + COMPANY_SEARCH_MAX_OFFSET, + COMPANY_SEARCH_MAX_TOKENS, + type CompanySearchIssueSummary, + type CompanySearchQuery, + type CompanySearchResponse, + type CompanySearchResult, + type CompanySearchResultType, + type CompanySearchScope, + type CompanySearchSnippet, +} from "@paperclipai/shared"; + +const MIN_TOKEN_LENGTH = 2; +const MIN_FUZZY_QUERY_LENGTH = 4; +const MIN_FUZZY_TOKEN_LENGTH = 4; +// Cap fuzzy edits using the shorter of (query token, title word) so common +// 4–5 letter English words don't sweep in noise (e.g. "serach" vs "each"). +const FUZZY_PAIR_LONG_LENGTH = 6; +const FUZZY_PAIR_LONG_MAX_EDITS = 2; +const FUZZY_PAIR_MEDIUM_LENGTH = 5; +const FUZZY_PAIR_MEDIUM_MAX_EDITS = 1; +const FUZZY_PAIR_SHORT_MAX_EDITS = 0; +const FUZZY_IDENTIFIER_SIMILARITY_THRESHOLD = 0.45; +const SNIPPET_MAX_CHARS = 240; +export const COMPANY_SEARCH_BRANCH_FETCH_LIMIT = COMPANY_SEARCH_MAX_OFFSET + COMPANY_SEARCH_MAX_LIMIT + 1; + +type IssueSearchRow = { + id: string; + identifier: string | null; + title: string; + description: string | null; + status: string; + priority: string; + assigneeAgentId: string | null; + assigneeUserId: string | null; + projectId: string | null; + updatedAt: Date; + score: number | string; + matchedFields: string[] | null; + commentSnippet: string | null; + commentId: string | null; + documentSnippet: string | null; + documentTitle: string | null; + documentKey: string | null; +}; + +type SimpleSearchRow = { + id: string; + title: string; + description: string | null; + role?: string | null; + updatedAt: Date; +}; + +function normalizeQuery(query: string) { + return query.trim().replace(/\s+/g, " ").toLowerCase(); +} + +function escapeLikePattern(value: string): string { + return value.replace(/[\\%_]/g, "\\$&"); +} + +function tokenizeQuery(normalizedQuery: string) { + const matches = normalizedQuery.match(/"[^"]+"|[^\s]+/g) ?? []; + const tokens: string[] = []; + for (const match of matches) { + const token = match.replace(/^"|"$/g, "").replace(/^[^\p{L}\p{N}%_\\-]+|[^\p{L}\p{N}%_\\-]+$/gu, ""); + if (token.length < MIN_TOKEN_LENGTH) continue; + if (!tokens.includes(token)) tokens.push(token); + if (tokens.length >= COMPANY_SEARCH_MAX_TOKENS) break; + } + return tokens; +} + +function fuzzyEligibleTokens(tokens: string[]): string[] { + return tokens.filter((token) => token.length >= MIN_FUZZY_TOKEN_LENGTH); +} + +function sqlTextArray(values: string[]) { + if (values.length === 0) return sql`ARRAY[]::text[]`; + return sql`ARRAY[${sql.join(values.map((value) => sql`${value}`), sql`, `)}]::text[]`; +} + +function tokenMatchExpression(textExpression: SQL, tokenArray: SQL) { + return sql` + EXISTS ( + SELECT 1 + FROM unnest(${tokenArray}) AS search_token(value) + WHERE lower(coalesce(${textExpression}, '')) LIKE '%' || search_token.value || '%' ESCAPE '\\' + ) + `; +} + +function noMatchSql() { + return sql`false`; +} + +function plainText(value: string | null | undefined) { + return (value ?? "") + .replace(/```[\s\S]*?```/g, " ") + .replace(/`([^`]+)`/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/[#>*_~|]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/; + +function extractFirstImageUrl(value: string | null | undefined): string | null { + if (!value) return null; + const match = MARKDOWN_IMAGE_PATTERN.exec(value); + return match ? match[1] : null; +} + +function findFirstMatchIndex(value: string, terms: string[]) { + const lower = value.toLowerCase(); + let best = -1; + for (const term of terms) { + if (term.length === 0) continue; + const index = lower.indexOf(term.toLowerCase()); + if (index >= 0 && (best < 0 || index < best)) best = index; + } + return best; +} + +function highlightRanges(value: string, terms: string[]) { + const lower = value.toLowerCase(); + const ranges: Array<{ start: number; end: number }> = []; + for (const term of terms) { + const normalized = term.toLowerCase(); + if (normalized.length === 0) continue; + let index = lower.indexOf(normalized); + while (index >= 0) { + const next = { start: index, end: index + normalized.length }; + const overlaps = ranges.some((range) => next.start < range.end && next.end > range.start); + if (!overlaps) ranges.push(next); + index = lower.indexOf(normalized, index + normalized.length); + } + } + return ranges.sort((left, right) => left.start - right.start); +} + +function createSnippet(field: string, label: string, source: string | null | undefined, terms: string[]): CompanySearchSnippet | null { + const text = plainText(source); + if (!text) return null; + const firstMatch = findFirstMatchIndex(text, terms); + const windowStart = firstMatch < 0 ? 0 : Math.max(0, firstMatch - 80); + const windowEnd = Math.min(text.length, windowStart + SNIPPET_MAX_CHARS); + const prefix = windowStart > 0 ? "..." : ""; + const suffix = windowEnd < text.length ? "..." : ""; + const slice = text.slice(windowStart, windowEnd).trim(); + const snippetText = `${prefix}${slice}${suffix}`; + const offset = prefix.length - windowStart; + return { + field, + label, + text: snippetText, + highlights: highlightRanges(text, terms) + .filter((range) => range.end > windowStart && range.start < windowEnd) + .map((range) => ({ + start: Math.max(0, range.start + offset), + end: Math.min(snippetText.length, range.end + offset), + })), + }; +} + +function iso(value: Date | string | null | undefined) { + if (!value) return null; + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} + +function routePrefix(issuePrefix: string | null | undefined) { + return issuePrefix?.trim() || "company"; +} + +function issueHref(prefix: string, issue: { id: string; identifier: string | null }, suffix = "") { + return `/${prefix}/issues/${encodeURIComponent(issue.identifier ?? issue.id)}${suffix}`; +} + +function matchTerms(normalizedQuery: string, tokens: string[]) { + return [normalizedQuery, ...tokens].filter((term, index, terms) => term.length > 0 && terms.indexOf(term) === index); +} + +function makeCounts(results: CompanySearchResult[]) { + const counts: Record = { issue: 0, agent: 0, project: 0 }; + for (const result of results) counts[result.type] += 1; + return counts; +} + +function scopeIncludesIssues(scope: CompanySearchScope) { + return scope === "all" || scope === "issues" || scope === "comments" || scope === "documents"; +} + +function scopeIncludesAgents(scope: CompanySearchScope) { + return scope === "all" || scope === "agents"; +} + +function scopeIncludesProjects(scope: CompanySearchScope) { + return scope === "all" || scope === "projects"; +} + +function issueSearchCondition(scope: CompanySearchScope, input: { + issueTextMatch: SQL; + commentMatch: SQL; + documentMatch: SQL; + fuzzyMatch: SQL; +}) { + if (scope === "comments") return input.commentMatch; + if (scope === "documents") return input.documentMatch; + if (scope === "issues") return sql`(${input.issueTextMatch} OR ${input.fuzzyMatch})`; + return sql`(${input.issueTextMatch} OR ${input.commentMatch} OR ${input.documentMatch} OR ${input.fuzzyMatch})`; +} + +function selectPrimarySnippets(row: IssueSearchRow, normalizedQuery: string, tokens: string[]) { + const terms = matchTerms(normalizedQuery, tokens); + const matchedFields = new Set(row.matchedFields ?? []); + const candidates: Array = []; + if (matchedFields.has("identifier")) { + candidates.push(createSnippet("identifier", "Identifier", row.identifier, terms)); + } + if (matchedFields.has("title")) { + candidates.push(createSnippet("title", "Title", row.title, terms)); + } + if (matchedFields.has("comment")) { + candidates.push(createSnippet("comment", "Comment", row.commentSnippet, terms)); + } + if (matchedFields.has("document")) { + candidates.push(createSnippet("document", row.documentTitle || "Document", row.documentSnippet, terms)); + } + if (matchedFields.has("description")) { + candidates.push(createSnippet("description", "Description", row.description, terms)); + } + return candidates.filter((snippet): snippet is CompanySearchSnippet => Boolean(snippet)).slice(0, 2); +} + +function issueResult(row: IssueSearchRow, prefix: string, normalizedQuery: string, tokens: string[]): CompanySearchResult { + const snippets = selectPrimarySnippets(row, normalizedQuery, tokens); + const sourceLabel = snippets[0]?.label ?? null; + const documentSuffix = row.documentKey ? `#document-${encodeURIComponent(row.documentKey)}` : ""; + const commentSuffix = row.commentId ? `#comment-${encodeURIComponent(row.commentId)}` : ""; + const suffix = row.commentId ? commentSuffix : documentSuffix; + const issue: CompanySearchIssueSummary = { + id: row.id, + identifier: row.identifier, + title: row.title, + status: row.status as CompanySearchIssueSummary["status"], + priority: row.priority as CompanySearchIssueSummary["priority"], + assigneeAgentId: row.assigneeAgentId, + assigneeUserId: row.assigneeUserId, + projectId: row.projectId, + updatedAt: iso(row.updatedAt)!, + }; + const previewImageUrl = + extractFirstImageUrl(row.description) ?? + extractFirstImageUrl(row.commentSnippet) ?? + extractFirstImageUrl(row.documentSnippet); + return { + id: row.id, + type: "issue", + score: Number(row.score), + title: row.identifier ? `${row.identifier} ${row.title}` : row.title, + href: issueHref(prefix, row, suffix), + matchedFields: row.matchedFields ?? [], + sourceLabel, + snippet: snippets[0]?.text ?? null, + snippets, + issue, + updatedAt: issue.updatedAt, + previewImageUrl, + }; +} + +function scoreSimpleRow(row: SimpleSearchRow, normalizedQuery: string, tokens: string[]) { + const haystack = [row.title, row.description, row.role].filter(Boolean).join(" ").toLowerCase(); + let score = haystack.includes(normalizedQuery) ? 90 : 0; + for (const token of tokens) { + if (haystack.includes(token)) score += 20; + } + if (row.title.toLowerCase().startsWith(normalizedQuery)) score += 80; + return score; +} + +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)); + return sql`(${sql.join([...phraseConditions, ...tokenConditions], sql` OR `)})`; +} + +export function companySearchBranchFetchLimit(limit: number, offset = 0) { + const normalizedLimit = Number.isFinite(limit) ? Math.max(1, Math.floor(limit)) : COMPANY_SEARCH_MAX_LIMIT; + const normalizedOffset = Number.isFinite(offset) ? Math.max(0, Math.floor(offset)) : 0; + return Math.min(COMPANY_SEARCH_BRANCH_FETCH_LIMIT, normalizedOffset + normalizedLimit + 1); +} + +export function companySearchService(db: Db) { + return { + search: async (companyId: string, query: CompanySearchQuery): Promise => { + const normalizedQuery = normalizeQuery(query.q); + const tokens = tokenizeQuery(normalizedQuery); + const scope = query.scope; + const limit = query.limit; + const offset = query.offset; + const emptyCounts: Record = { issue: 0, agent: 0, project: 0 }; + if (normalizedQuery.length === 0) { + return { + query: query.q, + normalizedQuery, + scope, + limit, + offset, + results: [], + countsByType: emptyCounts, + hasMore: false, + }; + } + + const company = await db + .select({ issuePrefix: companies.issuePrefix }) + .from(companies) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null); + const prefix = routePrefix(company?.issuePrefix); + const fetchLimit = companySearchBranchFetchLimit(limit, offset); + const escapedTokens = tokens.map(escapeLikePattern); + const tokenArray = sqlTextArray(escapedTokens); + const fuzzyTokens = fuzzyEligibleTokens(tokens); + const fuzzyTokenArray = sqlTextArray(fuzzyTokens); + const escapedQuery = escapeLikePattern(normalizedQuery); + const containsPattern = `%${escapedQuery}%`; + const startsWithPattern = `${escapedQuery}%`; + const fuzzyEnabled = normalizedQuery.length >= MIN_FUZZY_QUERY_LENGTH && !/[\\%_]/.test(normalizedQuery); + const fuzzyTokensEnabled = fuzzyEnabled && fuzzyTokens.length > 0; + + const titlePhraseMatch = sql`lower(${issues.title}) LIKE ${containsPattern} ESCAPE '\\'`; + const titleStartsWith = sql`lower(${issues.title}) LIKE ${startsWithPattern} ESCAPE '\\'`; + const identifierPhraseMatch = sql`lower(coalesce(${issues.identifier}, '')) LIKE ${containsPattern} ESCAPE '\\'`; + const identifierStartsWith = sql`lower(coalesce(${issues.identifier}, '')) LIKE ${startsWithPattern} ESCAPE '\\'`; + const descriptionPhraseMatch = sql`lower(coalesce(${issues.description}, '')) LIKE ${containsPattern} ESCAPE '\\'`; + const titleTokenMatch = tokenMatchExpression(sql`${issues.title}`, tokenArray); + const identifierTokenMatch = tokenMatchExpression(sql`${issues.identifier}`, tokenArray); + const descriptionTokenMatch = tokenMatchExpression(sql`${issues.description}`, tokenArray); + const issueTextMatch = sql` + ${titlePhraseMatch} + OR ${identifierPhraseMatch} + OR ${descriptionPhraseMatch} + OR ${titleTokenMatch} + OR ${identifierTokenMatch} + OR ${descriptionTokenMatch} + `; + const commentMatch = sql` + EXISTS ( + SELECT 1 + FROM issue_comments search_comments + WHERE search_comments.company_id = ${companyId} + AND search_comments.issue_id = issues.id + AND ( + lower(search_comments.body) LIKE ${containsPattern} ESCAPE '\\' + OR ${tokenMatchExpression(sql`search_comments.body`, tokenArray)} + ) + ) + `; + const documentMatch = sql` + EXISTS ( + SELECT 1 + FROM issue_documents search_issue_documents + INNER JOIN documents search_documents + ON search_documents.id = search_issue_documents.document_id + WHERE search_issue_documents.company_id = ${companyId} + AND search_documents.company_id = ${companyId} + AND search_issue_documents.issue_id = issues.id + AND ( + lower(coalesce(search_documents.title, '')) LIKE ${containsPattern} ESCAPE '\\' + OR lower(search_documents.latest_body) LIKE ${containsPattern} ESCAPE '\\' + OR ${tokenMatchExpression(sql`search_documents.title`, tokenArray)} + OR ${tokenMatchExpression(sql`search_documents.latest_body`, tokenArray)} + ) + ) + `; + // Each query token (length >= MIN_FUZZY_TOKEN_LENGTH) must have at least + // one title word within Levenshtein edit distance. This handles typos + // like "serach" -> "search" (transposition) and "mibile" -> "mobile" + // (substitution) without the trigram noise that drop-character variants + // produced (e.g. "serac" matching "service"). Edit budget is gated on + // the SHORTER of the two strings so 4–5 letter English words don't get + // swept in by lev=2 collisions. + const fuzzyMaxEditsExpr = sql.raw( + `CASE + WHEN least(length(qt.value), length(title_word.value)) >= ${FUZZY_PAIR_LONG_LENGTH} THEN ${FUZZY_PAIR_LONG_MAX_EDITS} + WHEN least(length(qt.value), length(title_word.value)) >= ${FUZZY_PAIR_MEDIUM_LENGTH} THEN ${FUZZY_PAIR_MEDIUM_MAX_EDITS} + ELSE ${FUZZY_PAIR_SHORT_MAX_EDITS} + END`, + ); + const fuzzyMinTitleWordLengthExpr = sql.raw(`${MIN_FUZZY_TOKEN_LENGTH}`); + const fuzzyTokenTitleMatch = fuzzyTokensEnabled + ? sql` + coalesce(( + SELECT bool_and( + EXISTS ( + SELECT 1 + FROM regexp_split_to_table(lower(${issues.title}), '[^a-z0-9]+') AS title_word(value) + WHERE length(title_word.value) >= ${fuzzyMinTitleWordLengthExpr} + AND levenshtein_less_equal(qt.value, title_word.value, ${fuzzyMaxEditsExpr}) <= ${fuzzyMaxEditsExpr} + ) + ) + FROM unnest(${fuzzyTokenArray}) AS qt(value) + ), false) + ` + : noMatchSql(); + const fuzzyIdentifierMatch = fuzzyEnabled + ? sql`similarity(lower(coalesce(${issues.identifier}, '')), ${normalizedQuery}) >= ${FUZZY_IDENTIFIER_SIMILARITY_THRESHOLD}` + : noMatchSql(); + const fuzzyMatch = sql`(${fuzzyTokenTitleMatch} OR ${fuzzyIdentifierMatch})`; + const tokenCoverage = sql` + ( + SELECT count(*)::int + FROM unnest(${tokenArray}) AS search_token(value) + WHERE lower(${issues.title}) LIKE '%' || search_token.value || '%' ESCAPE '\\' + OR lower(coalesce(${issues.identifier}, '')) LIKE '%' || search_token.value || '%' ESCAPE '\\' + OR lower(coalesce(${issues.description}, '')) LIKE '%' || search_token.value || '%' ESCAPE '\\' + OR EXISTS ( + SELECT 1 + FROM issue_comments coverage_comments + WHERE coverage_comments.company_id = ${companyId} + AND coverage_comments.issue_id = issues.id + AND lower(coverage_comments.body) LIKE '%' || search_token.value || '%' ESCAPE '\\' + ) + OR EXISTS ( + SELECT 1 + FROM issue_documents coverage_issue_documents + INNER JOIN documents coverage_documents + ON coverage_documents.id = coverage_issue_documents.document_id + WHERE coverage_issue_documents.company_id = ${companyId} + AND coverage_documents.company_id = ${companyId} + AND coverage_issue_documents.issue_id = issues.id + AND ( + lower(coalesce(coverage_documents.title, '')) LIKE '%' || search_token.value || '%' ESCAPE '\\' + OR lower(coverage_documents.latest_body) LIKE '%' || search_token.value || '%' ESCAPE '\\' + ) + ) + ) + `; + const tokenCount = tokens.length; + const allTokensMatch = tokenCount > 0 + ? sql`${tokenCoverage} = ${tokenCount}` + : noMatchSql(); + const score = sql` + ( + CASE WHEN lower(coalesce(${issues.identifier}, '')) = ${normalizedQuery} THEN 1200 ELSE 0 END + + CASE WHEN ${identifierStartsWith} THEN 700 ELSE 0 END + + CASE WHEN lower(${issues.title}) = ${normalizedQuery} THEN 900 ELSE 0 END + + CASE WHEN ${titleStartsWith} THEN 550 ELSE 0 END + + CASE WHEN ${titlePhraseMatch} THEN 350 ELSE 0 END + + CASE WHEN ${identifierPhraseMatch} THEN 320 ELSE 0 END + + CASE WHEN ${commentMatch} THEN 180 ELSE 0 END + + CASE WHEN ${documentMatch} THEN 170 ELSE 0 END + + CASE WHEN ${descriptionPhraseMatch} THEN 120 ELSE 0 END + + CASE WHEN ${allTokensMatch} THEN 260 ELSE 0 END + + (${tokenCoverage} * 70) + + CASE WHEN ${fuzzyMatch} THEN 110 ELSE 0 END + + CASE ${issues.status} WHEN 'done' THEN 0 WHEN 'cancelled' THEN -30 ELSE 20 END + )::double precision + `; + const matchedFields = sql` + array_remove(ARRAY[ + CASE WHEN ${identifierPhraseMatch} OR ${identifierTokenMatch} OR ${fuzzyIdentifierMatch} THEN 'identifier' END, + CASE WHEN ${titlePhraseMatch} OR ${titleTokenMatch} OR ${fuzzyTokenTitleMatch} THEN 'title' END, + CASE WHEN ${descriptionPhraseMatch} OR ${descriptionTokenMatch} THEN 'description' END, + CASE WHEN ${commentMatch} THEN 'comment' END, + CASE WHEN ${documentMatch} THEN 'document' END + ], NULL)::text[] + `; + + const issueRows = scopeIncludesIssues(scope) + ? await db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + description: issues.description, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + projectId: issues.projectId, + updatedAt: issues.updatedAt, + score, + matchedFields, + commentSnippet: sql` + ( + SELECT search_comments.body + FROM issue_comments search_comments + WHERE search_comments.company_id = ${companyId} + AND search_comments.issue_id = issues.id + AND ( + lower(search_comments.body) LIKE ${containsPattern} ESCAPE '\\' + OR ${tokenMatchExpression(sql`search_comments.body`, tokenArray)} + ) + ORDER BY + CASE WHEN lower(search_comments.body) LIKE ${containsPattern} ESCAPE '\\' THEN 0 ELSE 1 END, + search_comments.updated_at DESC, + search_comments.id DESC + LIMIT 1 + ) + `, + commentId: sql` + ( + SELECT search_comments.id + FROM issue_comments search_comments + WHERE search_comments.company_id = ${companyId} + AND search_comments.issue_id = issues.id + AND ( + lower(search_comments.body) LIKE ${containsPattern} ESCAPE '\\' + OR ${tokenMatchExpression(sql`search_comments.body`, tokenArray)} + ) + ORDER BY + CASE WHEN lower(search_comments.body) LIKE ${containsPattern} ESCAPE '\\' THEN 0 ELSE 1 END, + search_comments.updated_at DESC, + search_comments.id DESC + LIMIT 1 + ) + `, + documentSnippet: sql` + ( + SELECT search_documents.latest_body + FROM issue_documents search_issue_documents + INNER JOIN documents search_documents + ON search_documents.id = search_issue_documents.document_id + WHERE search_issue_documents.company_id = ${companyId} + AND search_documents.company_id = ${companyId} + AND search_issue_documents.issue_id = issues.id + AND ( + lower(coalesce(search_documents.title, '')) LIKE ${containsPattern} ESCAPE '\\' + OR lower(search_documents.latest_body) LIKE ${containsPattern} ESCAPE '\\' + OR ${tokenMatchExpression(sql`search_documents.title`, tokenArray)} + OR ${tokenMatchExpression(sql`search_documents.latest_body`, tokenArray)} + ) + ORDER BY + CASE + WHEN lower(coalesce(search_documents.title, '')) LIKE ${containsPattern} ESCAPE '\\' THEN 0 + WHEN lower(search_documents.latest_body) LIKE ${containsPattern} ESCAPE '\\' THEN 1 + ELSE 2 + END, + search_documents.updated_at DESC, + search_documents.id DESC + LIMIT 1 + ) + `, + documentTitle: sql` + ( + SELECT search_documents.title + FROM issue_documents search_issue_documents + INNER JOIN documents search_documents + ON search_documents.id = search_issue_documents.document_id + WHERE search_issue_documents.company_id = ${companyId} + AND search_documents.company_id = ${companyId} + AND search_issue_documents.issue_id = issues.id + AND ( + lower(coalesce(search_documents.title, '')) LIKE ${containsPattern} ESCAPE '\\' + OR lower(search_documents.latest_body) LIKE ${containsPattern} ESCAPE '\\' + OR ${tokenMatchExpression(sql`search_documents.title`, tokenArray)} + OR ${tokenMatchExpression(sql`search_documents.latest_body`, tokenArray)} + ) + ORDER BY search_documents.updated_at DESC, search_documents.id DESC + LIMIT 1 + ) + `, + documentKey: sql` + ( + SELECT search_issue_documents.key + FROM issue_documents search_issue_documents + INNER JOIN documents search_documents + ON search_documents.id = search_issue_documents.document_id + WHERE search_issue_documents.company_id = ${companyId} + AND search_documents.company_id = ${companyId} + AND search_issue_documents.issue_id = issues.id + AND ( + lower(coalesce(search_documents.title, '')) LIKE ${containsPattern} ESCAPE '\\' + OR lower(search_documents.latest_body) LIKE ${containsPattern} ESCAPE '\\' + OR ${tokenMatchExpression(sql`search_documents.title`, tokenArray)} + OR ${tokenMatchExpression(sql`search_documents.latest_body`, tokenArray)} + ) + ORDER BY search_documents.updated_at DESC, search_documents.id DESC + LIMIT 1 + ) + `, + }) + .from(issues) + .where(and( + eq(issues.companyId, companyId), + isNull(issues.hiddenAt), + issueSearchCondition(scope, { issueTextMatch, commentMatch, documentMatch, fuzzyMatch }), + )) + .orderBy(desc(score), desc(issues.updatedAt), desc(issues.id)) + .limit(fetchLimit) + : []; + + const simpleCondition = simpleTextCondition([ + sql`${agents.name}`, + sql`${agents.role}`, + sql`${agents.title}`, + sql`${agents.capabilities}`, + ], containsPattern, tokenArray); + const agentRows = scopeIncludesAgents(scope) + ? await db + .select({ + id: agents.id, + title: agents.name, + description: agents.capabilities, + role: agents.role, + updatedAt: agents.updatedAt, + }) + .from(agents) + .where(and(eq(agents.companyId, companyId), simpleCondition)) + .orderBy(desc(agents.updatedAt), desc(agents.id)) + .limit(fetchLimit) + : []; + + const projectCondition = simpleTextCondition([ + sql`${projects.name}`, + sql`${projects.description}`, + ], containsPattern, tokenArray); + const projectRows = scopeIncludesProjects(scope) + ? await db + .select({ + id: projects.id, + title: projects.name, + description: projects.description, + updatedAt: projects.updatedAt, + }) + .from(projects) + .where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition)) + .orderBy(desc(projects.updatedAt), desc(projects.id)) + .limit(fetchLimit) + : []; + + const results: CompanySearchResult[] = [ + ...(issueRows as IssueSearchRow[]).map((row) => issueResult(row, prefix, normalizedQuery, tokens)), + ...(agentRows as SimpleSearchRow[]).map((row) => { + const terms = matchTerms(normalizedQuery, tokens); + const snippet = createSnippet("capabilities", "Agent", row.description ?? row.role ?? row.title, terms); + return { + id: row.id, + type: "agent" as const, + score: scoreSimpleRow(row, normalizedQuery, tokens), + title: row.title, + href: `/${prefix}/agents/${encodeURIComponent(row.id)}`, + matchedFields: ["agent"], + sourceLabel: snippet?.label ?? null, + snippet: snippet?.text ?? null, + snippets: snippet ? [snippet] : [], + updatedAt: iso(row.updatedAt), + previewImageUrl: null, + }; + }), + ...(projectRows as SimpleSearchRow[]).map((row) => { + const terms = matchTerms(normalizedQuery, tokens); + const snippet = createSnippet("description", "Project", row.description ?? row.title, terms); + return { + id: row.id, + type: "project" as const, + score: scoreSimpleRow(row, normalizedQuery, tokens), + title: row.title, + href: `/${prefix}/projects/${encodeURIComponent(row.id)}`, + matchedFields: ["project"], + sourceLabel: snippet?.label ?? null, + snippet: snippet?.text ?? null, + snippets: snippet ? [snippet] : [], + updatedAt: iso(row.updatedAt), + previewImageUrl: null, + }; + }), + ].sort((left, right) => { + if (right.score !== left.score) return right.score - left.score; + return (right.updatedAt ?? "").localeCompare(left.updatedAt ?? ""); + }); + + const paged = results.slice(offset, offset + limit); + return { + query: query.q, + normalizedQuery, + scope, + limit, + offset, + results: paged, + countsByType: makeCounts(results), + hasMore: results.length > offset + limit, + }; + }, + }; +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 6d2d530f..5cdf0a1d 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -1,4 +1,5 @@ export { companyService } from "./companies.js"; +export { companySearchService } from "./company-search.js"; export { feedbackService } from "./feedback.js"; export { companySkillService } from "./company-skills.js"; export { agentService, deduplicateAgentName } from "./agents.js"; diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 2facd9ec..2a19c816 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 00000000..a660dd33 --- /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 165f1390..11303f29 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 28530042..f5f2fa5f 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 00000000..7d7b9854 --- /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 00000000..798ed7ac --- /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 00000000..3913e4f5 --- /dev/null +++ b/ui/src/components/search/SearchResultRow.tsx @@ -0,0 +1,217 @@ +import { memo, type ComponentType, type SVGProps } from "react"; +import { Bot, FileText, Hexagon, MessageSquare, Quote } from "lucide-react"; +import type { Agent, CompanySearchResult } from "@paperclipai/shared"; +import { Link } from "@/lib/router"; +import { cn } from "@/lib/utils"; +import { StatusIcon } from "../StatusIcon"; +import { Identity } from "../Identity"; +import { HighlightedText, type HighlightedTextProps } from "./HighlightedText"; + +type SnippetStyle = { + Icon: ComponentType>; + label: string; +}; + +const SNIPPET_STYLES: Record = { + comment: { Icon: MessageSquare, label: "Comment" }, + document: { Icon: FileText, label: "Doc" }, + description: { Icon: Quote, label: "Description" }, +}; + +function snippetStyle(field: string, fallbackLabel: string): SnippetStyle { + return SNIPPET_STYLES[field] ?? { Icon: Quote, label: fallbackLabel }; +} + +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>; + isActive?: boolean; + className?: string; +} + +const ROW_BASE = + "group flex items-start gap-3 rounded-md px-3 transition-colors no-underline text-inherit hover:bg-muted/40"; + +function SearchResultRowImpl({ + result, + agentsById, + isActive, + className, +}: SearchResultRowProps) { + if (result.type === "agent") { + return ( + + + + +
+
+ {result.title} +
+ {result.snippet ? ( + + ) : null} +
+ + ); + } + + if (result.type === "project") { + return ( + + +
+ {result.title} + {result.snippet ? ( + + ) : null} +
+ + ); + } + + const issue = result.issue; + if (!issue) return null; + const assigneeName = issue.assigneeAgentId + ? agentsById?.get(issue.assigneeAgentId)?.name ?? null + : null; + const updated = formatRelativeTime(result.updatedAt ?? issue.updatedAt); + const titleHighlights = result.snippets.find((snippet) => snippet.field === "title")?.highlights; + const bodySnippets = result.snippets.filter((snippet) => snippet.field !== "title").slice(0, 2); + const previewImageUrl = result.previewImageUrl; + const hasRightRail = previewImageUrl || assigneeName || updated; + + return ( + +
+ +
+
+
+ {issue.identifier ? ( + + {issue.identifier} + + ) : null} + +
+ {bodySnippets.map((snippet, index) => ( + + ))} + {hasRightRail ? ( +
+ {assigneeName ? {assigneeName} : null} + {updated ? {updated} : null} +
+ ) : null} +
+ {hasRightRail ? ( +
+ {assigneeName || updated ? ( +
+ {assigneeName ? : null} + {updated ? {updated} : null} +
+ ) : null} + {previewImageUrl ? ( + + ) : null} +
+ ) : null} + + ); +} + +export const SearchResultRow = memo(SearchResultRowImpl); + +interface SnippetLineProps { + text: string; + highlights?: HighlightedTextProps["highlights"]; + field: string; + fallbackLabel: string; + multiline?: boolean; +} + +function SnippetLine({ text, highlights, field, fallbackLabel, multiline = false }: SnippetLineProps) { + const { Icon, label } = snippetStyle(field, fallbackLabel); + return ( +
+ + {label}: + +
+ ); +} diff --git a/ui/src/index.css b/ui/src/index.css index 461bea99..02dca5b5 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/company-routes.test.ts b/ui/src/lib/company-routes.test.ts index 9b778398..0c2ed6a0 100644 --- a/ui/src/lib/company-routes.test.ts +++ b/ui/src/lib/company-routes.test.ts @@ -27,4 +27,12 @@ describe("company routes", () => { "/execution-workspaces/workspace-123/routines", ); }); + + it("treats /search as a board route that needs a company prefix", () => { + expect(isBoardPathWithoutPrefix("/search")).toBe(true); + expect(extractCompanyPrefixFromPath("/search")).toBeNull(); + expect(applyCompanyPrefix("/search", "PAP")).toBe("/PAP/search"); + expect(applyCompanyPrefix("/search?q=hello%20world", "PAP")).toBe("/PAP/search?q=hello%20world"); + expect(toCompanyRelativePath("/PAP/search?q=foo")).toBe("/search?q=foo"); + }); }); diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts index 04659a61..bde7b10f 100644 --- a/ui/src/lib/company-routes.ts +++ b/ui/src/lib/company-routes.ts @@ -18,6 +18,7 @@ const BOARD_ROUTE_ROOTS = new Set([ "inbox", "u", "design-guide", + "search", ]); const GLOBAL_ROUTE_ROOTS = new Set(["auth", "invite", "board-claim", "cli-auth", "docs", "instance"]); diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index b6e67cfb..d9b8f4da 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -136,6 +136,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 00000000..42ed4e0a --- /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 00000000..046e39ae --- /dev/null +++ b/ui/src/pages/Search.test.tsx @@ -0,0 +1,358 @@ +// @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("auto-redirects an exact identifier match to the issue root, dropping any deep-link suffix", async () => { + searchApiMock.search.mockResolvedValueOnce({ + query: "PAP-3366", + normalizedQuery: "pap-3366", + scope: "all", + limit: 20, + offset: 0, + countsByType: { issue: 1, agent: 0, project: 0 }, + hasMore: false, + results: [ + { + id: "issue-3366", + type: "issue", + score: 1300, + title: "PAP-3366 Continuation summary", + href: "/PAP/issues/PAP-3366#document-continuation-summary", + matchedFields: ["identifier", "document"], + sourceLabel: "Document", + snippet: "Continuation summary excerpt", + snippets: [ + { + field: "document", + label: "Continuation summary", + text: "Continuation summary excerpt", + highlights: [], + }, + ], + issue: { + id: "issue-3366", + identifier: "PAP-3366", + title: "Continuation summary", + status: "in_progress", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + projectId: null, + updatedAt: new Date().toISOString(), + }, + updatedAt: new Date().toISOString(), + }, + ], + }); + + const { root } = renderSearch("/search?q=PAP-3366", container); + + await waitForAssertion(() => { + expect(navigateMock).toHaveBeenCalledWith("/PAP/issues/PAP-3366", { replace: true }); + }); + + 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 00000000..1271914a --- /dev/null +++ b/ui/src/pages/Search.tsx @@ -0,0 +1,631 @@ +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 { cn } from "@/lib/utils"; +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 { 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 } 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 agentsById = useMemo>>(() => { + const map = new Map>(); + for (const agent of agents ?? []) map.set(agent.id, agent); + return map; + }, [agents]); + + // 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; + // Strip the comment/document deep-link suffix so an exact identifier match + // lands on the issue root, not the top-scored snippet. + const baseHref = exact.href.split("#")[0] ?? exact.href; + const navigateHref = baseHref.startsWith("/") ? baseHref : `/${baseHref}`; + navigate(navigateHref, { 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} + /> + ) : 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>; +} + +function SearchTabContent({ + showInitialState, + isLoading, + hasResults, + hasError, + apiError, + isEmpty, + trimmedQuery, + scope, + showAllScope, + navigateIssuesFallback, + openNewIssue, + refetch, + recentSearches, + onRecentClick, + subgroups, + totalResults, + isFetching, + agentsById, +}: 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, groupIndex) => ( +
0 && "mt-6")} + > + + {group.results.length} + + } + className="pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground" + /> +
+ {group.results.map((result) => ( + + ))} +
+
+ )) + ) : ( +
+ {subgroups + .flatMap((group) => group.results) + .map((result) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/ui/storybook/stories/search.stories.tsx b/ui/storybook/stories/search.stories.tsx new file mode 100644 index 00000000..258d8c71 --- /dev/null +++ b/ui/storybook/stories/search.stories.tsx @@ -0,0 +1,618 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { CompanySearchResult, CompanySearchResponse } from "@paperclipai/shared"; +import { Badge } from "@/components/ui/badge"; +import { IssueGroupHeader } from "@/components/IssueGroupHeader"; +import { Input } from "@/components/ui/input"; +import { PageTabBar, type PageTabItem } from "@/components/PageTabBar"; +import { MatchSourceChip } from "@/components/search/MatchSourceChip"; +import { SearchResultRow } from "@/components/search/SearchResultRow"; +import { Tabs } from "@/components/ui/tabs"; +import { + Bot, + CircleDot, + DollarSign, + Hexagon, + History, + Inbox, + LayoutDashboard, + Plus, + Search as SearchIcon, + SquarePen, + Target, +} from "lucide-react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { StatusBadge } from "@/components/StatusBadge"; +import { storybookAgents, storybookProjects, storybookIssues } from "../fixtures/paperclipData"; + +const agentsById = new Map(storybookAgents.map((agent) => [agent.id, agent])); + +type IssueResultOverrides = Omit, "issue"> & { + issue?: Partial>; +}; + +function buildIssueResult(overrides: IssueResultOverrides): CompanySearchResult { + const baseIssue = { + id: overrides.issue?.id ?? "issue-1", + identifier: overrides.issue?.identifier ?? "PAP-3142", + title: overrides.issue?.title ?? "Auth middleware flakes on cold-start when session token is rotated", + status: overrides.issue?.status ?? "in_progress", + priority: overrides.issue?.priority ?? "high", + assigneeAgentId: overrides.issue?.assigneeAgentId ?? storybookAgents[0]?.id ?? null, + assigneeUserId: overrides.issue?.assigneeUserId ?? null, + projectId: overrides.issue?.projectId ?? storybookProjects[0]?.id ?? null, + updatedAt: overrides.issue?.updatedAt ?? new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(), + } satisfies NonNullable; + return { + id: overrides.id ?? baseIssue.id, + type: "issue", + score: 100, + title: `${baseIssue.identifier} ${baseIssue.title}`, + href: `/PAP/issues/${baseIssue.identifier}`, + matchedFields: overrides.matchedFields ?? ["title"], + sourceLabel: overrides.sourceLabel ?? null, + snippet: overrides.snippet ?? null, + snippets: overrides.snippets ?? [], + issue: baseIssue, + updatedAt: baseIssue.updatedAt, + previewImageUrl: overrides.previewImageUrl ?? null, + }; +} + +const fixtureResults: CompanySearchResult[] = [ + buildIssueResult({ + id: "issue-1", + matchedFields: ["title", "comment"], + sourceLabel: "Comment", + snippet: "we hit another flake in the morning batch — auth middleware", + snippets: [ + { + field: "title", + label: "Title", + text: "Auth middleware flakes on cold-start when session token is rotated", + highlights: [{ start: 0, end: 4 }], + }, + { + field: "comment", + label: "Comment", + text: "we hit another flake in the morning batch — auth middleware ate the request", + highlights: [{ start: 16, end: 21 }, { start: 47, end: 51 }], + }, + ], + }), + buildIssueResult({ + id: "issue-2", + issue: { + id: "issue-2", + identifier: "PAP-3091", + title: "Audit auth flake telemetry from last quarter", + status: "in_review", + assigneeAgentId: storybookAgents[1]?.id ?? null, + }, + matchedFields: ["title", "document"], + sourceLabel: "Document", + snippet: "the deflake plan ranks auth regressions above latency tickets", + snippets: [ + { + field: "title", + label: "Title", + text: "Audit auth flake telemetry from last quarter", + highlights: [{ start: 6, end: 10 }], + }, + { + field: "document", + label: "PLAN", + text: "the deflake plan ranks auth regressions above latency tickets", + highlights: [{ start: 12, end: 16 }, { start: 26, end: 30 }], + }, + ], + previewImageUrl: + "data:image/svg+xml;utf8,chart", + }), + buildIssueResult({ + id: "issue-3", + issue: { + id: "issue-3", + identifier: "PAP-2748", + title: "Pin worker registration to a single auth backend", + status: "done", + assigneeAgentId: null, + }, + matchedFields: ["title", "identifier"], + snippets: [ + { + field: "title", + label: "Title", + text: "Pin worker registration to a single auth backend", + highlights: [{ start: 36, end: 40 }], + }, + ], + }), +]; + +const fixtureAgents: CompanySearchResult[] = storybookAgents.slice(0, 1).map((agent) => ({ + id: agent.id, + type: "agent" as const, + score: 80, + title: agent.name, + href: `/PAP/agents/${agent.id}`, + matchedFields: ["agent"], + sourceLabel: "Agent", + snippet: agent.capabilities ?? null, + snippets: agent.capabilities + ? [ + { + field: "capabilities", + label: "Agent", + text: agent.capabilities, + highlights: [], + }, + ] + : [], + updatedAt: new Date().toISOString(), + previewImageUrl: null, +})); + +const fixtureProjects: CompanySearchResult[] = storybookProjects.slice(0, 1).map((project) => ({ + id: project.id, + type: "project" as const, + score: 70, + title: project.name, + href: `/PAP/projects/${project.id}`, + matchedFields: ["project"], + sourceLabel: "Project", + snippet: project.description ?? null, + snippets: project.description + ? [ + { + field: "description", + label: "Project", + text: project.description, + highlights: [], + }, + ] + : [], + updatedAt: new Date().toISOString(), + previewImageUrl: null, +})); + +const fixtureResponse: CompanySearchResponse = { + query: "auth flake", + normalizedQuery: "auth flake", + scope: "all", + limit: 20, + offset: 0, + results: [...fixtureResults, ...fixtureAgents, ...fixtureProjects], + countsByType: { + issue: fixtureResults.length, + agent: fixtureAgents.length, + project: fixtureProjects.length, + }, + hasMore: false, +}; + +function ScopeTabsPreview({ + active, + response, +}: { + active: "all" | "issues" | "comments" | "documents" | "agents" | "projects"; + response: CompanySearchResponse; +}) { + const total = + (response.countsByType.issue ?? 0) + + (response.countsByType.agent ?? 0) + + (response.countsByType.project ?? 0); + const items: PageTabItem[] = [ + { value: "all", label: }, + { value: "issues", label: }, + { value: "comments", label: result.matchedFields.includes("comment")).length} /> }, + { value: "documents", label: result.matchedFields.includes("document")).length} /> }, + { value: "agents", label: }, + { value: "projects", label: }, + ]; + return ( + + + + ); +} + +function ScopeTabLabel({ label, count }: { label: string; count: number }) { + return ( + + {label} + + {count} + + + ); +} + +function SearchPagePreview({ + response, + state, + query, +}: { + response: CompanySearchResponse; + state: "results" | "empty" | "loading" | "initial"; + query: string; +}) { + return ( +
+
+
+ + + + ⌘K + +
+
+
+ +
+ + {state === "results" ? ( +
+
+ {response.results.length} results · sorted by relevance +
+
+ + {fixtureResults.length} + + } + className="pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground" + /> +
+ {fixtureResults.map((result) => ( + + ))} +
+
+
+ + {fixtureAgents.length} + + } + className="pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground" + /> +
+ {fixtureAgents.map((result) => ( + + ))} +
+
+
+ + {fixtureProjects.length} + + } + className="pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground" + /> +
+ {fixtureProjects.map((result) => ( + + ))} +
+
+
+ ) : null} + + {state === "empty" ? ( +
+
+ No results for “{query}” +
+

+ We couldn’t find a match in all scopes. Try widening the scope or rephrasing your query. +

+
    +
  • Try fewer tokens or a single distinctive term.
  • +
  • Use an identifier shortcut like PAP-123.
  • +
+
+ ) : null} + + {state === "loading" ? ( +
+
Searching for “{query}”…
+
+ {Array.from({ length: 5 }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+
+ ) : null} + + {state === "initial" ? ( +
+
+

Type to search company memory.

+

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

+
+
    +
  • + 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. +
  • +
+
+ ) : null} +
+ ); +} + +function CommandPaletteWithSearchAll({ + query, + emptyResults = false, +}: { + query: string; + emptyResults?: boolean; +}) { + return ( + + + + {emptyResults ? ( + + + No quick issue matches. Press{" "} + {" "} + to search all or keep typing to refine. + + + ) : null} + + + + + Search all for “{query}” + + + open full search + + + + + + + + + Create new issue + C + + + + Create new agent + + + + + + + Dashboard + + + + Inbox + + + + Issues + + + + Goals + + + + Agents + + + + Costs + + + + Activity + + + {!emptyResults ? ( + <> + + + {storybookIssues.slice(0, 3).map((issue) => ( + + + {issue.identifier} + {issue.title} + + + ))} + + + ) : null} + + + {storybookProjects.slice(0, 2).map((project) => ( + + + {project.name} + + ))} + + + + ); +} + +function SearchStories() { + return ( +
+
+
+
Search
+

Full search page and Command K handoff

+

+ Snippet-forward results, scope tabs, match-source chips, and the supporting empty / loading / initial + states. Cmd K palette renders the persistent “Search all for…” row when a query is non-empty. +

+
+ +
+
+
/search
+

Results, query “auth flake”

+
+ +
+ +
+
+
/search
+

Initial state — no query

+
+ +
+ +
+
+
/search
+

Loading skeleton

+
+ +
+ +
+
+
/search
+

No results state

+
+ +
+ +
+
+
Match-source chips
+

Type-coded chip variants

+
+
+ + + + + +
+
+ +
+
+
Cmd+K palette
+

Search-all row with quick results

+
+
+
+
+ With quick issue matches +
+ +
+
+
+ Empty results — Enter routes to /search +
+ +
+
+
+ +
+
+
Search result row
+

Issue, agent, project rows

+
+
+ {fixtureResults.map((result) => ( + + ))} + {fixtureAgents.map((result) => ( + + ))} + {fixtureProjects.map((result) => ( + + ))} +
+
+
+
+ ); +} + +const meta = { + title: "Product/Search & Command K", + component: SearchStories, + parameters: { + docs: { + description: { + component: + "Full search page surfaces and Command K Search-all handoff. Reuses StatusIcon, StatusBadge, Identity, IssueGroupHeader, and PageTabBar; adds MatchSourceChip + SearchResultRow.", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const SearchSurfaces: Story = {};