diff --git a/packages/db/src/migrations/0142_company_search_sort_indexes.sql b/packages/db/src/migrations/0142_company_search_sort_indexes.sql new file mode 100644 index 0000000000..377001bdfe --- /dev/null +++ b/packages/db/src/migrations/0142_company_search_sort_indexes.sql @@ -0,0 +1,3 @@ +CREATE INDEX IF NOT EXISTS "issues_company_updated_idx" ON "issues" ("company_id","updated_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issues_company_created_idx" ON "issues" ("company_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issues_company_priority_idx" ON "issues" ("company_id","priority"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index c06cb52cf0..6c793f8906 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -981,6 +981,13 @@ "when": 1783555301000, "tag": "0141_heartbeat_runs_company_created_at_index", "breakpoints": true + }, + { + "idx": 142, + "version": "7", + "when": 1783555301100, + "tag": "0142_company_search_sort_indexes", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/issues.ts b/packages/db/src/schema/issues.ts index f70957efab..9735c8700b 100644 --- a/packages/db/src/schema/issues.ts +++ b/packages/db/src/schema/issues.ts @@ -92,6 +92,9 @@ export const issues = pgTable( projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId), executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId), dueMonitorIdx: index("issues_company_monitor_due_idx").on(table.companyId, table.monitorNextCheckAt), + companyUpdatedIdx: index("issues_company_updated_idx").on(table.companyId, table.updatedAt), + companyCreatedIdx: index("issues_company_created_idx").on(table.companyId, table.createdAt), + companyPriorityIdx: index("issues_company_priority_idx").on(table.companyId, table.priority), identifierIdx: uniqueIndex("issues_identifier_idx").on(table.identifier), titleSearchIdx: index("issues_title_search_idx").using("gin", table.title.op("gin_trgm_ops")), identifierSearchIdx: index("issues_identifier_search_idx").using("gin", table.identifier.op("gin_trgm_ops")), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0067de1e6f..e95910b8f0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -583,14 +583,21 @@ export type { ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace, + CompanySearchCountType, + CompanySearchFilterOptionCounts, CompanySearchHighlight, CompanySearchArtifactSummary, + CompanySearchIssueFilterKey, CompanySearchIssueSummary, CompanySearchResponse, CompanySearchResult, CompanySearchResultType, CompanySearchScope, CompanySearchSnippet, + CompanySearchSort, + CompanySearchUpdatedWithinOption, + CompanySearchZeroResults, + CompanySearchZeroResultsLoosenSuggestion, ExecutionWorkspace, ExecutionWorkspaceSummary, ExecutionWorkspaceConfig, @@ -994,7 +1001,7 @@ export type { QuotaWindow, ProviderQuotaResult, } from "./types/index.js"; -export { COMPANY_SEARCH_SCOPES } from "./types/index.js"; +export { COMPANY_SEARCH_SCOPES, COMPANY_SEARCH_SORTS, COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS } from "./types/index.js"; export { ISSUE_REFERENCE_IDENTIFIER_RE, buildIssueReferenceHref, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index a8edf41318..c336dd262a 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -219,16 +219,23 @@ export type { } from "./document-annotation.js"; export type { Project, ProjectBudgetSummary, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js"; export type { + CompanySearchCountType, + CompanySearchFilterOptionCounts, CompanySearchHighlight, CompanySearchArtifactSummary, + CompanySearchIssueFilterKey, CompanySearchIssueSummary, CompanySearchResponse, CompanySearchResult, CompanySearchResultType, CompanySearchScope, CompanySearchSnippet, + CompanySearchSort, + CompanySearchUpdatedWithinOption, + CompanySearchZeroResults, + CompanySearchZeroResultsLoosenSuggestion, } from "./search.js"; -export { COMPANY_SEARCH_SCOPES } from "./search.js"; +export { COMPANY_SEARCH_SCOPES, COMPANY_SEARCH_SORTS, COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS } from "./search.js"; export type { ExecutionWorkspace, ExecutionWorkspaceSummary, diff --git a/packages/shared/src/types/search.ts b/packages/shared/src/types/search.ts index 3b453cfe51..7ed7790424 100644 --- a/packages/shared/src/types/search.ts +++ b/packages/shared/src/types/search.ts @@ -3,7 +3,23 @@ import type { IssuePriority, IssueStatus } from "../constants.js"; export const COMPANY_SEARCH_SCOPES = ["all", "issues", "comments", "documents", "artifacts", "agents", "projects"] as const; export type CompanySearchScope = (typeof COMPANY_SEARCH_SCOPES)[number]; +export const COMPANY_SEARCH_SORTS = ["relevance", "updated", "created", "priority"] as const; +export type CompanySearchSort = (typeof COMPANY_SEARCH_SORTS)[number]; + +export const COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS = ["24h", "7d", "30d", "90d"] as const; +export type CompanySearchUpdatedWithinOption = (typeof COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS)[number]; + export type CompanySearchResultType = "issue" | "artifact" | "agent" | "project"; +export type CompanySearchCountType = CompanySearchResultType | "comment" | "document"; +export type CompanySearchIssueFilterKey = + | "status" + | "assigneeAgentId" + | "assigneeUserId" + | "projectId" + | "labelId" + | "priority" + | "updatedWithin" + | "updatedAfter"; export interface CompanySearchHighlight { start: number; @@ -57,13 +73,38 @@ export interface CompanySearchResult { previewImageUrl: string | null; } +export interface CompanySearchFilterOptionCounts { + status: Partial>; + priority: Partial>; + assigneeAgentId: Record; + assigneeUserId: Record; + projectId: Record; + labelId: Record; + updatedWithin: Partial>; +} + +export interface CompanySearchZeroResultsLoosenSuggestion { + filter: CompanySearchIssueFilterKey; + values: string[]; + resultCount: number; + additionalCount: number; +} + +export interface CompanySearchZeroResults { + unfilteredTotal: number; + loosenSuggestions: CompanySearchZeroResultsLoosenSuggestion[]; +} + export interface CompanySearchResponse { query: string; normalizedQuery: string; scope: CompanySearchScope; + sort: CompanySearchSort; limit: number; offset: number; results: CompanySearchResult[]; - countsByType: Record; + countsByType: Record; + filterOptionCounts: CompanySearchFilterOptionCounts; + zeroResults: CompanySearchZeroResults | null; hasMore: boolean; } diff --git a/packages/shared/src/validators/search.ts b/packages/shared/src/validators/search.ts index 4f5419c820..5c995dd823 100644 --- a/packages/shared/src/validators/search.ts +++ b/packages/shared/src/validators/search.ts @@ -1,5 +1,7 @@ import { z } from "zod"; -import { COMPANY_SEARCH_SCOPES } from "../types/search.js"; +import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js"; +import { isUuidLike } from "../agent-url-key.js"; +import { COMPANY_SEARCH_SCOPES, COMPANY_SEARCH_SORTS } from "../types/search.js"; export const COMPANY_SEARCH_MAX_QUERY_LENGTH = 200; export const COMPANY_SEARCH_MAX_TOKENS = 8; @@ -7,31 +9,174 @@ export const COMPANY_SEARCH_DEFAULT_LIMIT = 20; export const COMPANY_SEARCH_MAX_LIMIT = 50; export const COMPANY_SEARCH_MAX_OFFSET = 200; +const UPDATED_WITHIN_RE = /^[1-9]\d{0,2}(h|d|w|m)$/; + function firstQueryValue(value: unknown): unknown { return Array.isArray(value) ? value[0] : value; } -function clampInteger(value: unknown, fallback: number, min: number, max: number) { +function queryValues(value: unknown): unknown[] { + if (value === undefined || value === null) return []; + return Array.isArray(value) ? value : [value]; +} + +function parseOptionalString(value: unknown, ctx: z.RefinementCtx, field: string): string | undefined { 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))); + if (raw === undefined || raw === null) return undefined; + if (typeof raw !== "string" && typeof raw !== "number") { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${field} must be a string` }); + return undefined; + } + const normalized = String(raw).trim(); + return normalized.length > 0 ? normalized : undefined; +} + +function parseIntegerQuery( + value: unknown, + ctx: z.RefinementCtx, + field: string, + fallback: number, + min: number, + max: number, +): number { + const raw = firstQueryValue(value); + if (raw === undefined || raw === null || raw === "") return fallback; + const text = typeof raw === "number" ? String(raw) : typeof raw === "string" ? raw.trim() : ""; + if (!/^-?\d+$/.test(text)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${field} must be an integer` }); + return fallback; + } + const numeric = Number.parseInt(text, 10); + if (!Number.isInteger(numeric) || numeric < min || numeric > max) { + const range = min === 0 ? `between 0 and ${max}` : `between ${min} and ${max}`; + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${field} must be ${range}` }); + return fallback; + } + return numeric; +} + +function parseEnumList( + value: unknown, + ctx: z.RefinementCtx, + field: string, + allowed: readonly T[], +): T[] { + const allowedSet = new Set(allowed); + const values: T[] = []; + for (const rawEntry of queryValues(value)) { + if (typeof rawEntry !== "string") { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${field} must be a comma-separated string` }); + continue; + } + for (const rawItem of rawEntry.split(",")) { + const item = rawItem.trim(); + if (!item) continue; + if (!allowedSet.has(item)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${field} contains an unsupported value` }); + continue; + } + if (!values.includes(item as T)) values.push(item as T); + } + } + return values; +} + +function parseOptionalUuid(value: unknown, ctx: z.RefinementCtx, field: string): string | undefined { + const normalized = parseOptionalString(value, ctx, field); + if (normalized === undefined) return undefined; + if (!isUuidLike(normalized)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${field} must be a UUID` }); + return undefined; + } + return normalized; +} + +function parseAssigneeAgentId(value: unknown, ctx: z.RefinementCtx): string | null | undefined { + const normalized = parseOptionalString(value, ctx, "assigneeAgentId"); + if (normalized === undefined) return undefined; + if (normalized.toLowerCase() === "null") return null; + if (!isUuidLike(normalized)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "assigneeAgentId must be a UUID or 'null'" }); + return undefined; + } + return normalized; +} + +function parseUpdatedAfter(value: unknown, ctx: z.RefinementCtx): string | undefined { + const normalized = parseOptionalString(value, ctx, "updatedAfter"); + if (normalized === undefined) return undefined; + const date = new Date(normalized); + if (Number.isNaN(date.getTime())) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "updatedAfter must be a valid date" }); + return undefined; + } + return date.toISOString(); +} + +function parseUpdatedWithin(value: unknown, ctx: z.RefinementCtx): string | undefined { + const normalized = parseOptionalString(value, ctx, "updatedWithin"); + if (normalized === undefined) return undefined; + if (!UPDATED_WITHIN_RE.test(normalized)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "updatedWithin must be a duration like 24h, 7d, 4w, or 3m" }); + return undefined; + } + return normalized; } 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"), + q: z.unknown() + .optional() + .transform((value, ctx) => (parseOptionalString(value, ctx, "q") ?? "").slice(0, COMPANY_SEARCH_MAX_QUERY_LENGTH)), + scope: z.unknown() + .optional() + .transform((value, ctx) => { + const normalized = parseOptionalString(value, ctx, "scope") ?? "all"; + if (!(COMPANY_SEARCH_SCOPES as readonly string[]).includes(normalized)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "scope must be a supported search scope" }); + return "all"; + } + return normalized as (typeof COMPANY_SEARCH_SCOPES)[number]; + }), limit: z.unknown() .optional() - .transform((value) => clampInteger(value, COMPANY_SEARCH_DEFAULT_LIMIT, 1, COMPANY_SEARCH_MAX_LIMIT)), + .transform((value, ctx) => parseIntegerQuery(value, ctx, "limit", COMPANY_SEARCH_DEFAULT_LIMIT, 1, COMPANY_SEARCH_MAX_LIMIT)), offset: z.unknown() .optional() - .transform((value) => clampInteger(value, 0, 0, COMPANY_SEARCH_MAX_OFFSET)), + .transform((value, ctx) => parseIntegerQuery(value, ctx, "offset", 0, 0, COMPANY_SEARCH_MAX_OFFSET)), + status: z.unknown() + .optional() + .transform((value, ctx) => parseEnumList(value, ctx, "status", ISSUE_STATUSES)), + priority: z.unknown() + .optional() + .transform((value, ctx) => parseEnumList(value, ctx, "priority", ISSUE_PRIORITIES)), + assigneeAgentId: z.unknown() + .optional() + .transform((value, ctx) => parseAssigneeAgentId(value, ctx)), + assigneeUserId: z.unknown() + .optional() + .transform((value, ctx) => parseOptionalString(value, ctx, "assigneeUserId")), + projectId: z.unknown() + .optional() + .transform((value, ctx) => parseOptionalUuid(value, ctx, "projectId")), + labelId: z.unknown() + .optional() + .transform((value, ctx) => parseOptionalUuid(value, ctx, "labelId")), + updatedWithin: z.unknown() + .optional() + .transform((value, ctx) => parseUpdatedWithin(value, ctx)), + updatedAfter: z.unknown() + .optional() + .transform((value, ctx) => parseUpdatedAfter(value, ctx)), + sort: z.unknown() + .optional() + .transform((value, ctx) => { + const normalized = parseOptionalString(value, ctx, "sort") ?? "relevance"; + if (!(COMPANY_SEARCH_SORTS as readonly string[]).includes(normalized)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "sort must be relevance, updated, created, or priority" }); + return "relevance"; + } + return normalized as (typeof COMPANY_SEARCH_SORTS)[number]; + }), }); 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 index 092a2c7bf1..f02b600858 100644 --- a/server/src/__tests__/company-search-rate-limit-routes.test.ts +++ b/server/src/__tests__/company-search-rate-limit-routes.test.ts @@ -13,7 +13,18 @@ function createSearchResponse(query: CompanySearchQuery): CompanySearchResponse limit: query.limit, offset: query.offset, results: [], - countsByType: { issue: 0, artifact: 0, agent: 0, project: 0 }, + sort: query.sort, + countsByType: { issue: 0, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 }, + filterOptionCounts: { + status: {}, + priority: {}, + assigneeAgentId: {}, + assigneeUserId: {}, + projectId: {}, + labelId: {}, + updatedWithin: {}, + }, + zeroResults: null, hasMore: false, }; } @@ -51,4 +62,60 @@ describe("company search route rate limiting", () => { }); expect(limited.headers["retry-after"]).toBe("60"); }); + it("resolves assigneeUserId=me for board actors 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: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: true, + }; + next(); + }); + app.use("/api", issueRoutes({} as never, {} as never, { + searchService: { search }, + searchRateLimiter: createCompanySearchRateLimiter({ + maxRequests: 10, + windowMs: 60_000, + now: () => 1_000, + }), + })); + + await request(app).get("/api/companies/company-1/search?q=wizard&assigneeUserId=me").expect(200); + + expect(search).toHaveBeenCalledTimes(1); + expect(search.mock.calls[0]?.[1].assigneeUserId).toBe("user-1"); + }); + + it("rejects invalid filter and sort params 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: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: true, + }; + next(); + }); + app.use("/api", issueRoutes({} as never, {} as never, { + searchService: { search }, + searchRateLimiter: createCompanySearchRateLimiter({ + maxRequests: 10, + windowMs: 60_000, + now: () => 1_000, + }), + })); + + await request(app).get("/api/companies/company-1/search?q=wizard&sort=nope").expect(400); + await request(app).get("/api/companies/company-1/search?q=wizard&assigneeAgentId=nope").expect(400); + + expect(search).not.toHaveBeenCalled(); + }); + }); diff --git a/server/src/__tests__/company-search-service.test.ts b/server/src/__tests__/company-search-service.test.ts index d4edf955eb..0db84ccced 100644 --- a/server/src/__tests__/company-search-service.test.ts +++ b/server/src/__tests__/company-search-service.test.ts @@ -8,7 +8,9 @@ import { documents, issueComments, issueDocuments, + issueLabels, issues, + labels, projects, } from "@paperclipai/db"; import { companySearchQuerySchema, COMPANY_SEARCH_MAX_QUERY_LENGTH } from "@paperclipai/shared"; @@ -32,23 +34,36 @@ if (!embeddedPostgresSupport.supported) { } describe("company search query validation", () => { - it("clamps query length, limit, and offset without rejecting the request", () => { + it("truncates long text queries but rejects invalid filters, sort, and pagination", () => { const parsed = companySearchQuerySchema.parse({ q: "x".repeat(COMPANY_SEARCH_MAX_QUERY_LENGTH + 50), - limit: "500", - offset: "9000", - scope: "not-a-scope", + limit: "50", + offset: "200", + scope: "all", + status: "todo,blocked", + priority: ["critical", "low"], + sort: "priority", + updatedWithin: "7d", }); expect(parsed.q).toHaveLength(COMPANY_SEARCH_MAX_QUERY_LENGTH); - expect(parsed.limit).toBe(50); - expect(parsed.offset).toBe(200); - expect(parsed.scope).toBe("all"); + expect(parsed.status).toEqual(["todo", "blocked"]); + expect(parsed.priority).toEqual(["critical", "low"]); + expect(parsed.sort).toBe("priority"); + expect(parsed.updatedWithin).toBe("7d"); + expect(() => companySearchQuerySchema.parse({ q: "needle", limit: "500" })).toThrow(); + expect(() => companySearchQuerySchema.parse({ q: "needle", offset: "9000" })).toThrow(); + expect(() => companySearchQuerySchema.parse({ q: "needle", scope: "not-a-scope" })).toThrow(); + expect(() => companySearchQuerySchema.parse({ q: "needle", status: "not-a-status" })).toThrow(); + expect(() => companySearchQuerySchema.parse({ q: "needle", priority: "urgent" })).toThrow(); + expect(() => companySearchQuerySchema.parse({ q: "needle", sort: "oldest" })).toThrow(); + expect(() => companySearchQuerySchema.parse({ q: "needle", updatedWithin: "forever" })).toThrow(); + expect(() => companySearchQuerySchema.parse({ q: "needle", projectId: "not-a-uuid" })).toThrow(); }); 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" }); + const highOffset = companySearchQuerySchema.parse({ q: "needle", limit: "50", offset: "200" }); expect(companySearchBranchFetchLimit(lowOffset.limit, lowOffset.offset)).toBe(51); expect(companySearchBranchFetchLimit(highOffset.limit, highOffset.offset)).toBe(COMPANY_SEARCH_BRANCH_FETCH_LIMIT); @@ -71,7 +86,9 @@ describeEmbeddedPostgres("companySearchService", () => { await db.delete(issueDocuments); await db.delete(documents); await db.delete(issueComments); + await db.delete(issueLabels); await db.delete(issues); + await db.delete(labels); await db.delete(projects); await db.delete(agents); await db.delete(companies); @@ -134,6 +151,18 @@ describeEmbeddedPostgres("companySearchService", () => { return id; } + async function createLabel(companyId: string, values: Partial = {}) { + const id = values.id ?? randomUUID(); + await db.insert(labels).values({ + id, + companyId, + name: values.name ?? "Search label", + color: values.color ?? "blue", + ...values, + }); + return id; + } + it("ranks exact issue identifiers before weaker title matches", async () => { const companyId = await createCompany(); const exactId = await createIssue(companyId, { @@ -151,6 +180,30 @@ describeEmbeddedPostgres("companySearchService", () => { expect(result.results[0]?.matchedFields).toContain("identifier"); }); + it("ranks phrase and all-token issue matches before partial scattered-token matches", async () => { + const companyId = await createCompany(); + const base = new Date("2026-01-01T00:00:00.000Z").getTime(); + const partialTokenId = await createIssue(companyId, { + identifier: "TST-50", + title: "Alpha-only deployment", + updatedAt: new Date(base + 3_000), + }); + const allTokenId = await createIssue(companyId, { + identifier: "TST-51", + title: "Alpha rollout beta", + updatedAt: new Date(base + 2_000), + }); + const phraseId = await createIssue(companyId, { + identifier: "TST-52", + title: "Alpha beta deployment", + updatedAt: new Date(base + 1_000), + }); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "alpha beta", scope: "issues" })); + + expect(result.results.map((row) => row.id)).toEqual([phraseId, allTokenId, partialTokenId]); + }); + it("matches multiple tokens across the same issue thread and returns comment snippets", async () => { const companyId = await createCompany(); const issueId = await createIssue(companyId, { @@ -158,7 +211,9 @@ describeEmbeddedPostgres("companySearchService", () => { title: "Checkout semantics", description: "Atomic ownership is enforced here.", }); + const commentId = randomUUID(); await db.insert(issueComments).values({ + id: commentId, companyId, issueId, body: "The ranking snippet should explain why this thread matched.", @@ -169,7 +224,10 @@ describeEmbeddedPostgres("companySearchService", () => { expect(match).toBeTruthy(); expect(match?.matchedFields).toEqual(expect.arrayContaining(["title", "comment"])); - expect(match?.snippets.some((snippet) => /snippet/i.test(snippet.text))).toBe(true); + expect(match?.href).toContain(`#comment-${commentId}`); + expect(match?.snippets.some((snippet) => snippet.field === "comment" && /snippet/i.test(snippet.text))).toBe(true); + expect(match?.snippets.find((snippet) => snippet.field === "comment")?.highlights.length).toBeGreaterThan(0); + expect(result.countsByType.comment).toBe(1); }); it("searches issue documents and returns document metadata for snippets", async () => { @@ -200,6 +258,9 @@ describeEmbeddedPostgres("companySearchService", () => { expect(result.results[0]?.matchedFields).toContain("document"); expect(result.results[0]?.href).toContain("#document-plan"); expect(result.results[0]?.snippet).toMatch(/parser/i); + expect(result.results[0]?.snippets[0]).toMatchObject({ field: "document", label: "Hermes Parser Plan" }); + expect(result.results[0]?.snippets[0]?.highlights.length).toBeGreaterThan(0); + expect(result.countsByType.document).toBe(1); }); it("searches artifact projections through the artifacts scope", async () => { @@ -238,7 +299,7 @@ describeEmbeddedPostgres("companySearchService", () => { }), }); expect(result.results[0]?.snippet).toMatch(/comet tail/i); - expect(result.countsByType).toEqual({ issue: 0, artifact: 1, agent: 0, project: 0 }); + expect(result.countsByType).toEqual({ issue: 0, comment: 0, document: 0, artifact: 1, agent: 0, project: 0 }); }); it("does not pass high-offset search fetch windows through to artifact query validation", async () => { @@ -255,6 +316,159 @@ describeEmbeddedPostgres("companySearchService", () => { expect(result.countsByType.artifact).toBe(0); }); + it("applies issue filters before sorting and pagination", async () => { + const companyId = await createCompany(); + const agentId = await createAgent(companyId, { name: "Needle engineer" }); + const projectId = await createProject(companyId, { name: "Needle project" }); + const labelId = await createLabel(companyId, { name: "Needle label" }); + const base = new Date("2026-01-01T00:00:00.000Z").getTime(); + const newestMatch = await createIssue(companyId, { + identifier: "TST-30", + title: "Needle newest", + status: "todo", + priority: "high", + assigneeAgentId: agentId, + projectId, + updatedAt: new Date(base + 3_000), + }); + const olderMatch = await createIssue(companyId, { + identifier: "TST-31", + title: "Needle older", + status: "todo", + priority: "high", + assigneeAgentId: agentId, + projectId, + updatedAt: new Date(base + 2_000), + }); + const statusDecoy = await createIssue(companyId, { + identifier: "TST-32", + title: "Needle done", + status: "done", + priority: "high", + assigneeAgentId: agentId, + projectId, + updatedAt: new Date(base + 4_000), + }); + await db.insert(issueLabels).values([ + { companyId, issueId: newestMatch, labelId }, + { companyId, issueId: olderMatch, labelId }, + { companyId, issueId: statusDecoy, labelId }, + ]); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ + q: "needle", + status: "todo", + priority: "high", + assigneeAgentId: agentId, + projectId, + labelId, + updatedAfter: new Date(base + 1_000).toISOString(), + sort: "updated", + limit: "1", + offset: "1", + })); + + expect(result.results.map((row) => row.id)).toEqual([olderMatch]); + expect(result.countsByType.issue).toBe(2); + expect(result.countsByType.agent).toBe(0); + expect(result.countsByType.project).toBe(0); + expect(result.filterOptionCounts.status.todo).toBe(2); + expect(result.filterOptionCounts.status.done).toBe(1); + expect(result.hasMore).toBe(false); + }); + + it("returns issue rows for filter-only searches", async () => { + const companyId = await createCompany(); + const agentId = await createAgent(companyId, { name: "Filter owner" }); + const matchingIssue = await createIssue(companyId, { + identifier: "TST-34", + title: "Filtered task", + status: "todo", + assigneeAgentId: agentId, + }); + await createIssue(companyId, { + identifier: "TST-35", + title: "Filtered decoy", + status: "done", + assigneeAgentId: agentId, + }); + await createAgent(companyId, { name: "Todo" }); + await createProject(companyId, { name: "Todo" }); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ + q: "", + status: "todo", + assigneeAgentId: agentId, + })); + + expect(result.results.map((row) => row.id)).toEqual([matchingIssue]); + expect(result.countsByType.issue).toBe(1); + expect(result.countsByType.agent).toBe(0); + expect(result.countsByType.project).toBe(0); + expect(result.results[0]?.snippets).toEqual([]); + }); + + it("returns zero-result loosen data and suppresses agent/project rows while issue filters are active", async () => { + const companyId = await createCompany(); + await createAgent(companyId, { name: "Needle agent", capabilities: "Needle capabilities" }); + await createProject(companyId, { name: "Needle project", description: "Needle roadmap" }); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "needle", status: "todo" })); + + expect(result.results).toEqual([]); + expect(result.countsByType.agent).toBe(0); + expect(result.countsByType.project).toBe(0); + expect(result.zeroResults).toMatchObject({ + unfilteredTotal: 2, + loosenSuggestions: [ + { filter: "status", values: ["todo"], resultCount: 2, additionalCount: 2 }, + ], + }); + }); + + it("does not leak hidden issue-backed artifacts", async () => { + const companyId = await createCompany(); + const agentId = await createAgent(companyId, { name: "Artifact Writer" }); + const visibleIssueId = await createIssue(companyId, { + identifier: "TST-33", + title: "Visible artifact holder", + }); + const hiddenIssueId = await createIssue(companyId, { + identifier: "TST-34", + title: "Hidden artifact holder", + hiddenAt: new Date(), + }); + const visibleDocumentId = randomUUID(); + const hiddenDocumentId = randomUUID(); + await db.insert(documents).values([ + { + id: visibleDocumentId, + companyId, + title: "Visible Artifact", + latestBody: "Searchable artifact body", + format: "markdown", + createdByAgentId: agentId, + }, + { + id: hiddenDocumentId, + companyId, + title: "Hidden Artifact", + latestBody: "Searchable artifact body", + format: "markdown", + createdByAgentId: agentId, + }, + ]); + await db.insert(issueDocuments).values([ + { companyId, issueId: visibleIssueId, documentId: visibleDocumentId, key: "visible" }, + { companyId, issueId: hiddenIssueId, documentId: hiddenDocumentId, key: "hidden" }, + ]); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "artifact", scope: "artifacts" })); + + expect(result.results.map((row) => row.artifact?.issueId)).toEqual([visibleIssueId]); + expect(result.countsByType.artifact).toBe(1); + }); + it("excludes hidden issues and other companies' data", async () => { const companyId = await createCompany("Visible Co"); const otherCompanyId = await createCompany("Other Co"); @@ -445,7 +659,7 @@ describeEmbeddedPostgres("companySearchService", () => { const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "needle", limit: "2", offset: "2" })); expect(result.results.map((row) => row.id)).toEqual([agentIds[2], projectIds[0]]); - expect(result.countsByType).toEqual({ issue: 0, artifact: 0, agent: 3, project: 3 }); + expect(result.countsByType).toEqual({ issue: 0, comment: 0, document: 0, artifact: 0, agent: 3, project: 3 }); expect(result.hasMore).toBe(true); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 7d67db4734..6dc7ab4e3f 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -4432,7 +4432,21 @@ export function issueRoutes( res.status(403).json({ error: "Company search is outside this actor's authorization boundary" }); return; } - const query = companySearchQuerySchema.parse(req.query); + const parsedQuery = companySearchQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + res.status(400).json({ + error: parsedQuery.error.issues[0]?.message ?? "Invalid search query", + }); + return; + } + let query = parsedQuery.data; + if (query.assigneeUserId === "me") { + if (req.actor.type !== "board" || !req.actor.userId) { + res.status(403).json({ error: "assigneeUserId=me requires board authentication" }); + return; + } + query = { ...query, assigneeUserId: req.actor.userId }; + } const rateLimit = searchRateLimiter.consume(companySearchRateLimitActor(req, companyId)); res.setHeader("X-RateLimit-Limit", String(rateLimit.limit)); res.setHeader("X-RateLimit-Remaining", String(rateLimit.remaining)); diff --git a/server/src/services/company-artifacts.ts b/server/src/services/company-artifacts.ts index aa06d102ae..6c5f0ecae9 100644 --- a/server/src/services/company-artifacts.ts +++ b/server/src/services/company-artifacts.ts @@ -315,7 +315,11 @@ function buildArtifactGroups(input: { export function companyArtifactsService(db: Db, storage?: StorageService) { return { - list: async (companyId: string, rawQuery: Partial = {}): Promise => { + list: async ( + companyId: string, + rawQuery: Partial = {}, + options: { issueConditions?: SQL[] } = {}, + ): Promise => { const query = companyArtifactsQuerySchema.parse(rawQuery); const cursor = decodeCursor(query.cursor); const groupBy = query.groupBy === "none" ? null : query.groupBy; @@ -329,6 +333,11 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { const fetchLimit = Math.min(query.limit + 1, COMPANY_ARTIFACTS_MAX_LIMIT + 1); const sourceFetchLimit = groupBy ? GROUPED_ARTIFACT_FETCH_LIMIT : fetchLimit; const q = query.q ? `%${escapeLikePattern(query.q)}%` : null; + const issueConditions: SQL[] = [ + isNull(issues.hiddenAt), + isNull(issues.harnessKind), + ...(options.issueConditions ?? []), + ]; const artifacts: CompanyArtifact[] = []; const workProductAttachmentIds = new Set(); @@ -341,6 +350,7 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { eq(documents.companyId, companyId), or(isNotNull(documents.createdByAgentId), isNotNull(documents.updatedByAgentId))!, notInArray(issueDocuments.key, [...SYSTEM_ISSUE_DOCUMENT_KEYS]), + ...issueConditions, ]; const documentCursor = groupBy ? undefined : cursorCondition(sql`${documents.updatedAt}`, documentArtifactId, cursor); if (documentCursor) documentConditions.push(documentCursor); @@ -442,6 +452,7 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { eq(issueWorkProducts.companyId, companyId), eq(issueWorkProducts.type, "artifact"), eq(issueWorkProducts.provider, "paperclip"), + ...issueConditions, ]; const workProductConditions: SQL[] = [...workProductBaseConditions]; const workProductCursor = groupBy @@ -578,6 +589,7 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { eq(issueAttachments.companyId, companyId), isNull(issueAttachments.issueCommentId), isNotNull(assets.createdByAgentId), + ...issueConditions, ]; const attachmentCursor = groupBy ? undefined diff --git a/server/src/services/company-search.ts b/server/src/services/company-search.ts index 37401c550f..8b90e7b1a8 100644 --- a/server/src/services/company-search.ts +++ b/server/src/services/company-search.ts @@ -1,22 +1,40 @@ -import { and, desc, eq, isNull, sql } from "drizzle-orm"; +import { and, desc, eq, gte, inArray, isNotNull, isNull, notInArray, or, 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 { + agents, + assets, + companies, + documents, + issueAttachments, + issueDocuments, + issueWorkProducts, + issues, + projects, +} from "@paperclipai/db"; import { COMPANY_SEARCH_MAX_LIMIT, COMPANY_SEARCH_MAX_OFFSET, COMPANY_SEARCH_MAX_TOKENS, + COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS, COMPANY_ARTIFACTS_MAX_LIMIT, COMPANY_ARTIFACTS_MAX_QUERY_LENGTH, + ISSUE_PRIORITIES, + ISSUE_STATUSES, + SYSTEM_ISSUE_DOCUMENT_KEYS, type CompanyArtifact, type CompanySearchArtifactSummary, + type CompanySearchCountType, + type CompanySearchFilterOptionCounts, + type CompanySearchIssueFilterKey, type CompanySearchIssueSummary, type CompanySearchQuery, type CompanySearchResponse, type CompanySearchResult, - type CompanySearchResultType, type CompanySearchScope, type CompanySearchSnippet, + type CompanySearchSort, + type CompanySearchUpdatedWithinOption, } from "@paperclipai/shared"; import { companyArtifactsService } from "./company-artifacts.js"; import { visibleIssueCondition } from "./issue-visibility.js"; @@ -45,6 +63,7 @@ type IssueSearchRow = { assigneeAgentId: string | null; assigneeUserId: string | null; projectId: string | null; + createdAt: Date; updatedAt: Date; score: number | string; matchedFields: string[] | null; @@ -60,9 +79,21 @@ type SimpleSearchRow = { title: string; description: string | null; role?: string | null; + createdAt: Date; updatedAt: Date; }; +type SearchResultWithSort = CompanySearchResult & { + sortCreatedAt: string | null; + sortPriorityRank: number; +}; + +type SearchAggregateRow = { + kind: string; + value: string | null; + count: number | string; +}; + function normalizeQuery(query: string) { return query.trim().replace(/\s+/g, " ").toLowerCase(); } @@ -92,14 +123,9 @@ function sqlTextArray(values: string[]) { 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 sqlUuidArray(values: string[]) { + if (values.length === 0) return sql`ARRAY[]::uuid[]`; + return sql`ARRAY[${sql.join(values.map((value) => sql`${value}`), sql`, `)}]::uuid[]`; } function noMatchSql() { @@ -193,10 +219,197 @@ 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, artifact: 0, agent: 0, project: 0 }; - for (const result of results) counts[result.type] += 1; - return counts; +function emptySearchCounts(): Record { + return { issue: 0, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 }; +} + +function emptyFilterOptionCounts(): CompanySearchFilterOptionCounts { + return { + status: {}, + priority: {}, + assigneeAgentId: {}, + assigneeUserId: {}, + projectId: {}, + labelId: {}, + updatedWithin: {}, + }; +} + +function priorityRank(priority: string | null | undefined) { + const index = (ISSUE_PRIORITIES as readonly string[]).indexOf(priority ?? ""); + return index >= 0 ? index : ISSUE_PRIORITIES.length; +} + +function updatedWithinStart(value: string | undefined, now = new Date()): Date | null { + if (!value) return null; + const match = /^(\d+)(h|d|w|m)$/.exec(value); + if (!match) return null; + const amount = Number.parseInt(match[1]!, 10); + const unit = match[2]; + const hours = unit === "h" ? amount : unit === "d" ? amount * 24 : unit === "w" ? amount * 24 * 7 : amount * 24 * 30; + return new Date(now.getTime() - hours * 60 * 60 * 1000); +} + +function issueOnlyFiltersActive(query: CompanySearchQuery) { + return query.status.length > 0 + || query.priority.length > 0 + || query.assigneeAgentId !== undefined + || Boolean(query.assigneeUserId) + || Boolean(query.projectId) + || Boolean(query.labelId) + || Boolean(query.updatedWithin) + || Boolean(query.updatedAfter); +} + +function activeIssueFilters(query: CompanySearchQuery): Array<{ key: CompanySearchIssueFilterKey; values: string[] }> { + const filters: Array<{ key: CompanySearchIssueFilterKey; values: string[] }> = []; + if (query.status.length > 0) filters.push({ key: "status", values: query.status }); + if (query.assigneeAgentId !== undefined) filters.push({ key: "assigneeAgentId", values: [query.assigneeAgentId ?? "null"] }); + if (query.assigneeUserId) filters.push({ key: "assigneeUserId", values: [query.assigneeUserId] }); + if (query.projectId) filters.push({ key: "projectId", values: [query.projectId] }); + if (query.labelId) filters.push({ key: "labelId", values: [query.labelId] }); + if (query.priority.length > 0) filters.push({ key: "priority", values: query.priority }); + if (query.updatedWithin) filters.push({ key: "updatedWithin", values: [query.updatedWithin] }); + if (query.updatedAfter) filters.push({ key: "updatedAfter", values: [query.updatedAfter] }); + return filters; +} + +function queryWithoutFilter(query: CompanySearchQuery, key: CompanySearchIssueFilterKey): CompanySearchQuery { + return { + ...query, + status: key === "status" ? [] : query.status, + priority: key === "priority" ? [] : query.priority, + assigneeAgentId: key === "assigneeAgentId" ? undefined : query.assigneeAgentId, + assigneeUserId: key === "assigneeUserId" ? undefined : query.assigneeUserId, + projectId: key === "projectId" ? undefined : query.projectId, + labelId: key === "labelId" ? undefined : query.labelId, + updatedWithin: key === "updatedWithin" ? undefined : query.updatedWithin, + updatedAfter: key === "updatedAfter" ? undefined : query.updatedAfter, + }; +} + +function queryWithoutIssueFilters(query: CompanySearchQuery): CompanySearchQuery { + return { + ...query, + status: [], + priority: [], + assigneeAgentId: undefined, + assigneeUserId: undefined, + projectId: undefined, + labelId: undefined, + updatedWithin: undefined, + updatedAfter: undefined, + }; +} + +function issueFilterConditions(companyId: string, query: CompanySearchQuery, omit?: CompanySearchIssueFilterKey): SQL[] { + const conditions: SQL[] = []; + if (omit !== "status" && query.status.length > 0) { + conditions.push(query.status.length === 1 ? eq(issues.status, query.status[0]!) : inArray(issues.status, query.status)); + } + if (omit !== "priority" && query.priority.length > 0) { + conditions.push(query.priority.length === 1 ? eq(issues.priority, query.priority[0]!) : inArray(issues.priority, query.priority)); + } + if (omit !== "assigneeAgentId" && query.assigneeAgentId !== undefined) { + conditions.push(query.assigneeAgentId === null ? isNull(issues.assigneeAgentId) : eq(issues.assigneeAgentId, query.assigneeAgentId)); + } + if (omit !== "assigneeUserId" && query.assigneeUserId) { + conditions.push(eq(issues.assigneeUserId, query.assigneeUserId)); + } + if (omit !== "projectId" && query.projectId) conditions.push(eq(issues.projectId, query.projectId)); + if (omit !== "labelId" && query.labelId) { + conditions.push(sql` + EXISTS ( + SELECT 1 + FROM issue_labels search_filter_labels + WHERE search_filter_labels.company_id = ${companyId} + AND search_filter_labels.issue_id = ${issues.id} + AND search_filter_labels.label_id = ${query.labelId} + ) + `); + } + if (omit !== "updatedWithin") { + const updatedWithin = updatedWithinStart(query.updatedWithin); + if (updatedWithin) conditions.push(gte(issues.updatedAt, updatedWithin)); + } + if (omit !== "updatedAfter" && query.updatedAfter) { + conditions.push(gte(issues.updatedAt, new Date(query.updatedAfter))); + } + return conditions; +} + +// Facet conditions expressed against the `m` alias of the aggregate +// matched-issues CTE (plain columns, no drizzle table references). +function matchedFacetConditions(companyId: string, query: CompanySearchQuery, omit?: CompanySearchIssueFilterKey): SQL[] { + const conditions: SQL[] = []; + if (omit !== "status" && query.status.length > 0) { + conditions.push(sql`m.status = ANY(${sqlTextArray(query.status)})`); + } + if (omit !== "priority" && query.priority.length > 0) { + conditions.push(sql`m.priority = ANY(${sqlTextArray(query.priority)})`); + } + if (omit !== "assigneeAgentId" && query.assigneeAgentId !== undefined) { + conditions.push(query.assigneeAgentId === null + ? sql`m.assignee_agent_id IS NULL` + : sql`m.assignee_agent_id = ${query.assigneeAgentId}`); + } + if (omit !== "assigneeUserId" && query.assigneeUserId) { + conditions.push(sql`m.assignee_user_id = ${query.assigneeUserId}`); + } + if (omit !== "projectId" && query.projectId) { + conditions.push(sql`m.project_id = ${query.projectId}`); + } + if (omit !== "labelId" && query.labelId) { + conditions.push(sql` + EXISTS ( + SELECT 1 + FROM issue_labels facet_filter_labels + WHERE facet_filter_labels.company_id = ${companyId} + AND facet_filter_labels.issue_id = m.id + AND facet_filter_labels.label_id = ${query.labelId} + ) + `); + } + if (omit !== "updatedWithin") { + const updatedWithin = updatedWithinStart(query.updatedWithin); + // ISO strings: raw sql params bypass drizzle's column-level Date mapping. + if (updatedWithin) conditions.push(sql`m.updated_at >= ${updatedWithin.toISOString()}::timestamptz`); + } + if (omit !== "updatedAfter" && query.updatedAfter) { + conditions.push(sql`m.updated_at >= ${new Date(query.updatedAfter).toISOString()}::timestamptz`); + } + return conditions; +} + +function stripInternalSortFields(result: SearchResultWithSort): CompanySearchResult { + const { sortCreatedAt: _sortCreatedAt, sortPriorityRank: _sortPriorityRank, ...publicResult } = result; + return publicResult; +} + +function compareSearchResults(sort: CompanySearchSort) { + return (left: SearchResultWithSort, right: SearchResultWithSort) => { + if (sort === "updated") { + const updated = (right.updatedAt ?? "").localeCompare(left.updatedAt ?? ""); + if (updated !== 0) return updated; + if (right.score !== left.score) return right.score - left.score; + } else if (sort === "created") { + const created = (right.sortCreatedAt ?? "").localeCompare(left.sortCreatedAt ?? ""); + if (created !== 0) return created; + const updated = (right.updatedAt ?? "").localeCompare(left.updatedAt ?? ""); + if (updated !== 0) return updated; + } else if (sort === "priority") { + const priority = left.sortPriorityRank - right.sortPriorityRank; + if (priority !== 0) return priority; + const updated = (right.updatedAt ?? "").localeCompare(left.updatedAt ?? ""); + if (updated !== 0) return updated; + if (right.score !== left.score) return right.score - left.score; + } else { + if (right.score !== left.score) return right.score - left.score; + const updated = (right.updatedAt ?? "").localeCompare(left.updatedAt ?? ""); + if (updated !== 0) return updated; + } + return right.id.localeCompare(left.id); + }; } function scopeIncludesIssues(scope: CompanySearchScope) { @@ -215,18 +428,6 @@ 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 ?? []); @@ -321,6 +522,7 @@ function artifactResult(artifact: CompanyArtifact, normalizedQuery: string, toke description: [artifact.previewText, artifact.issue.identifier, artifact.issue.title, artifact.project?.name] .filter(Boolean) .join(" "), + createdAt: new Date(artifact.updatedAt), updatedAt: new Date(artifact.updatedAt), }, normalizedQuery, tokens); return { @@ -339,9 +541,9 @@ function artifactResult(artifact: CompanyArtifact, normalizedQuery: string, toke }; } -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)); +function simpleTextCondition(fields: SQL[], containsPattern: string, tokenPatternArray: SQL) { + const phraseConditions = fields.map((field) => sql`coalesce(${field}, '') ILIKE ${containsPattern}`); + const tokenConditions = fields.map((field) => sql`coalesce(${field}, '') ILIKE ANY(${tokenPatternArray})`); return sql`(${sql.join([...phraseConditions, ...tokenConditions], sql` OR `)})`; } @@ -355,87 +557,113 @@ export function companySearchService(db: Db) { return { search: async (companyId: string, query: CompanySearchQuery): Promise => { const normalizedQuery = normalizeQuery(query.q); + const hasSearchText = normalizedQuery.length > 0; const tokens = tokenizeQuery(normalizedQuery); const scope = query.scope; + const sort = query.sort; const limit = query.limit; const offset = query.offset; - const emptyCounts: Record = { issue: 0, artifact: 0, agent: 0, project: 0 }; - if (normalizedQuery.length === 0) { + if (!hasSearchText && !issueOnlyFiltersActive(query)) { return { query: query.q, normalizedQuery, scope, + sort, limit, offset, results: [], - countsByType: emptyCounts, + countsByType: emptySearchCounts(), + filterOptionCounts: emptyFilterOptionCounts(), + zeroResults: null, 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); + // LIKE/ILIKE both treat backslash as the default escape character, so the + // escaped tokens stay literal inside ILIKE ANY(...) patterns too. + const tokenPatterns = escapedTokens.map((token) => `%${token}%`); + const tokenPatternArray = sqlTextArray(tokenPatterns); 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 containsPattern = hasSearchText ? `%${escapedQuery}%` : "__paperclip_no_match__"; + const startsWithPattern = hasSearchText ? `${escapedQuery}%` : "__paperclip_no_match__"; + const fuzzyEnabled = hasSearchText && normalizedQuery.length >= MIN_FUZZY_QUERY_LENGTH && !/[\\%_]/.test(normalizedQuery); const fuzzyTokensEnabled = fuzzyEnabled && fuzzyTokens.length > 0; + const tokenCount = tokens.length; - 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 search_comments.deleted_at IS NULL - 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)} - ) - ) - `; + // --- shared match expressions against the `issues` table ------------- + // Raw-column ILIKE keeps the predicates compatible with the existing + // pg_trgm GIN indexes (lower(col) LIKE expressions cannot use them). + const titlePhraseMatch = hasSearchText ? sql`issues.title ILIKE ${containsPattern}` : noMatchSql(); + const titleStartsWith = hasSearchText ? sql`issues.title ILIKE ${startsWithPattern}` : noMatchSql(); + const titleExactMatch = hasSearchText ? sql`lower(issues.title) = ${normalizedQuery}` : noMatchSql(); + const identifierPhraseMatch = hasSearchText ? sql`coalesce(issues.identifier, '') ILIKE ${containsPattern}` : noMatchSql(); + const identifierStartsWith = hasSearchText ? sql`coalesce(issues.identifier, '') ILIKE ${startsWithPattern}` : noMatchSql(); + const identifierExactMatch = hasSearchText ? sql`lower(coalesce(issues.identifier, '')) = ${normalizedQuery}` : noMatchSql(); + const descriptionPhraseMatch = hasSearchText ? sql`coalesce(issues.description, '') ILIKE ${containsPattern}` : noMatchSql(); + const titleTokenMatch = tokenCount > 0 ? sql`issues.title ILIKE ANY(${tokenPatternArray})` : noMatchSql(); + const identifierTokenMatch = tokenCount > 0 ? sql`coalesce(issues.identifier, '') ILIKE ANY(${tokenPatternArray})` : noMatchSql(); + const descriptionTokenMatch = tokenCount > 0 ? sql`coalesce(issues.description, '') ILIKE ANY(${tokenPatternArray})` : noMatchSql(); + // Comment/document matches are computed once per request into tagged + // CTEs (issue_id, ord) where ord 1 is the phrase pattern and ord k+1 is + // token k. Flags and per-token coverage become cheap hashed IN probes + // against those sets instead of per-issue-row correlated subqueries. + // Single-pattern queries stay a bare `col ILIKE pattern` so the pg_trgm + // GIN indexes can bitmap-scan them; multi-pattern queries use one tagged + // pass over the table (an OR/ANY form would seq-scan anyway). + const matchPatterns = hasSearchText + ? [containsPattern, ...tokenPatterns.filter((pattern) => pattern !== containsPattern)] + : []; + const matchPatternOrdinal = (pattern: string) => matchPatterns.indexOf(pattern) + 1; + const matchPatternArray = sqlTextArray(matchPatterns); + const commentMatchesCte = !hasSearchText + ? sql`SELECT NULL::uuid AS issue_id, 0 AS ord WHERE false` + : matchPatterns.length === 1 + ? sql` + SELECT search_comments.issue_id, 1 AS ord + FROM issue_comments search_comments + WHERE search_comments.company_id = ${companyId} + AND search_comments.deleted_at IS NULL + AND search_comments.body ILIKE ${matchPatterns[0]!} + GROUP BY 1, 2 + ` + : sql` + SELECT search_comments.issue_id, pat.ord::int AS ord + FROM issue_comments search_comments + INNER JOIN unnest(${matchPatternArray}) WITH ORDINALITY AS pat(pattern, ord) + ON search_comments.body ILIKE pat.pattern + WHERE search_comments.company_id = ${companyId} + AND search_comments.deleted_at IS NULL + GROUP BY 1, 2 + `; + // Documents get one UNION ALL arm per pattern (each arm a bare + // `col ILIKE pattern`) so the planner can pick a pg_trgm bitmap scan per + // pattern; latest_body is large enough that skipping the seq scan for + // selective patterns dwarfs the duplicate-recheck cost on common ones. + const documentMatchesCte = !hasSearchText + ? sql`SELECT NULL::uuid AS issue_id, 0 AS ord WHERE false` + : sql.join(matchPatterns.map((pattern, index) => sql` + SELECT search_issue_documents.issue_id, ${index + 1}::int AS ord + FROM issue_documents search_issue_documents + INNER JOIN documents search_documents + ON search_documents.id = search_issue_documents.document_id + AND search_documents.company_id = search_issue_documents.company_id + WHERE search_issue_documents.company_id = ${companyId} + AND ( + search_documents.title ILIKE ${pattern} + OR search_documents.latest_body ILIKE ${pattern} + ) + GROUP BY 1, 2 + `), sql` UNION ALL `); + const commentMatch = hasSearchText + ? sql`issues.id IN (SELECT comment_matches.issue_id FROM comment_matches)` + : noMatchSql(); + const documentMatch = hasSearchText + ? sql`issues.id IN (SELECT document_matches.issue_id FROM document_matches)` + : noMatchSql(); // 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" @@ -457,7 +685,7 @@ export function companySearchService(db: Db) { SELECT bool_and( EXISTS ( SELECT 1 - FROM regexp_split_to_table(lower(${issues.title}), '[^a-z0-9]+') AS title_word(value) + 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} ) @@ -467,245 +695,562 @@ export function companySearchService(db: Db) { ` : noMatchSql(); const fuzzyIdentifierMatch = fuzzyEnabled - ? sql`similarity(lower(coalesce(${issues.identifier}, '')), ${normalizedQuery}) >= ${FUZZY_IDENTIFIER_SIMILARITY_THRESHOLD}` + ? sql`similarity(lower(coalesce(issues.identifier, '')), ${normalizedQuery}) >= ${FUZZY_IDENTIFIER_SIMILARITY_THRESHOLD}` : noMatchSql(); + + const issueTextMatch = sql`( + ${titlePhraseMatch} + OR ${identifierPhraseMatch} + OR ${descriptionPhraseMatch} + OR ${titleTokenMatch} + OR ${identifierTokenMatch} + OR ${descriptionTokenMatch} + )`; 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 coverage_comments.deleted_at IS NULL - 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 anySearchMatch = sql`(${issueTextMatch} OR ${commentMatch} OR ${documentMatch} OR ${fuzzyMatch})`; - 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 search_comments.deleted_at IS NULL - 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 search_comments.deleted_at IS NULL - 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 - ) - `, + const issueFilters = issueFilterConditions(companyId, query); + const hasIssueOnlyFilters = issueOnlyFiltersActive(query); + + // Scope conditions over precomputed flag columns (alias-qualified). + function flagTextMatch(alias: string) { + return sql`( + ${sql.raw(alias)}.title_phrase OR ${sql.raw(alias)}.ident_phrase OR ${sql.raw(alias)}.desc_phrase + OR ${sql.raw(alias)}.title_token OR ${sql.raw(alias)}.ident_token OR ${sql.raw(alias)}.desc_token + )`; + } + function flagFuzzyMatch(alias: string) { + return sql`(${sql.raw(alias)}.fuzzy_title OR ${sql.raw(alias)}.fuzzy_ident)`; + } + function flagScopeCondition(alias: string, forScope: CompanySearchScope): SQL { + if (!hasSearchText) { + return forScope === "comments" || forScope === "documents" ? noMatchSql() : sql`true`; + } + if (forScope === "comments") return sql`${sql.raw(alias)}.comment_match`; + if (forScope === "documents") return sql`${sql.raw(alias)}.document_match`; + if (forScope === "issues") return sql`(${flagTextMatch(alias)} OR ${flagFuzzyMatch(alias)})`; + return sql`(${flagTextMatch(alias)} OR ${sql.raw(alias)}.comment_match OR ${sql.raw(alias)}.document_match OR ${flagFuzzyMatch(alias)})`; + } + + // --- combined issue results + aggregates statement --------------------- + // One statement computes everything issue-side: the comment/document + // match sets and the matched-issues CTE (flags + per-token coverage) are + // materialized once, then a UNION ALL fans out into the ranked result + // page and every count (type counts, facet option counts, updated-within + // buckets, and totals for zero-result recovery) as cheap aggregations. + type IssueAggregates = { + typeCounts: { issue: number; comment: number; document: number }; + filterOptionCounts: CompanySearchFilterOptionCounts; + totals: { current: number; unfiltered: number; omit: Partial> }; + }; + type IssueSearchData = { rows: IssueSearchRow[]; aggregates: IssueAggregates }; + + async function fetchIssueSearchData(): Promise { + const filtersActive = activeIssueFilters(query); + const scopeCond = flagScopeCondition("m", scope); + const optionCond = scopeIncludesIssues(scope) ? scopeCond : flagScopeCondition("m", "all"); + const titleCond: SQL = hasSearchText ? sql`(${flagTextMatch("m")} OR ${flagFuzzyMatch("m")})` : sql`true`; + const facetsAll = matchedFacetConditions(companyId, query); + const branchWhere = (conditions: SQL[]) => + conditions.length > 0 ? sql`WHERE ${sql.join(conditions, sql` AND `)}` : sql``; + // Count branches must match the result branch's column list; the + // trailing NULLs pad the issue data columns. + const countTail = sql`, NULL::uuid, NULL::text, NULL::text, NULL::text, NULL::text, NULL::text, NULL::uuid, NULL::text, NULL::uuid, NULL::timestamptz, NULL::timestamptz, NULL::double precision, NULL::text[]`; + const branches: SQL[] = []; + + const wantResultRows = scopeIncludesIssues(scope) + && !(!hasSearchText && (scope === "comments" || scope === "documents")); + if (wantResultRows) { + const allTokensBonus = tokenCount > 0 + ? sql`CASE WHEN m.token_coverage = ${tokenCount} THEN 260 ELSE 0 END` + : sql`0`; + const scoreSql = sql`( + CASE WHEN m.ident_exact THEN 1200 ELSE 0 END + + CASE WHEN m.ident_starts THEN 700 ELSE 0 END + + CASE WHEN m.title_exact THEN 900 ELSE 0 END + + CASE WHEN m.title_starts THEN 550 ELSE 0 END + + CASE WHEN m.title_phrase THEN 350 ELSE 0 END + + CASE WHEN m.ident_phrase THEN 320 ELSE 0 END + + CASE WHEN m.comment_match THEN 180 ELSE 0 END + + CASE WHEN m.document_match THEN 170 ELSE 0 END + + CASE WHEN m.desc_phrase THEN 120 ELSE 0 END + + ${allTokensBonus} + + (m.token_coverage * 70) + + CASE WHEN (m.fuzzy_title OR m.fuzzy_ident) THEN 110 ELSE 0 END + + CASE m.status WHEN 'done' THEN 0 WHEN 'cancelled' THEN -30 ELSE 20 END + )::double precision`; + const priorityOrderSql = sql`CASE m.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`; + const orderBySql = sort === "updated" + ? sql`m.updated_at DESC, score DESC, m.id DESC` + : sort === "created" + ? sql`m.created_at DESC, m.updated_at DESC, m.id DESC` + : sort === "priority" + ? sql`${priorityOrderSql} ASC, m.updated_at DESC, score DESC, m.id DESC` + : sql`score DESC, m.updated_at DESC, m.id DESC`; + branches.push(sql`( + SELECT + 'result'::text AS kind, + NULL::text AS value, + 0 AS count, + m.id, + m.identifier, + m.title, + m.description, + m.status, + m.priority, + m.assignee_agent_id AS "assigneeAgentId", + m.assignee_user_id AS "assigneeUserId", + m.project_id AS "projectId", + m.created_at AS "createdAt", + m.updated_at AS "updatedAt", + ${scoreSql} AS score, + array_remove(ARRAY[ + CASE WHEN m.ident_phrase OR m.ident_token OR m.fuzzy_ident THEN 'identifier' END, + CASE WHEN m.title_phrase OR m.title_token OR m.fuzzy_title THEN 'title' END, + CASE WHEN m.desc_phrase OR m.desc_token THEN 'description' END, + CASE WHEN m.comment_match THEN 'comment' END, + CASE WHEN m.document_match THEN 'document' END + ], NULL)::text[] AS "matchedFields" + FROM matched m + ${branchWhere([...facetsAll, scopeCond])} + ORDER BY ${orderBySql} + LIMIT ${fetchLimit} + )`); + } + + if (scope === "all" || scope === "issues") { + branches.push(sql`SELECT 'type:issue' AS kind, NULL::text AS value, count(*)::int AS count ${countTail} FROM matched m ${branchWhere([...facetsAll, titleCond])}`); + } + if (hasSearchText && (scope === "all" || scope === "comments")) { + branches.push(sql`SELECT 'type:comment' AS kind, NULL::text AS value, count(*)::int AS count ${countTail} FROM matched m ${branchWhere([...facetsAll, sql`m.comment_match`])}`); + } + if (hasSearchText && (scope === "all" || scope === "documents")) { + branches.push(sql`SELECT 'type:document' AS kind, NULL::text AS value, count(*)::int AS count ${countTail} FROM matched m ${branchWhere([...facetsAll, sql`m.document_match`])}`); + } + + const facetBranch = (kind: string, valueSql: SQL, omit: CompanySearchIssueFilterKey, extra: SQL[] = []) => sql` + SELECT ${kind}::text AS kind, ${valueSql}::text AS value, count(*)::int AS count ${countTail} + FROM matched m + ${branchWhere([optionCond, ...matchedFacetConditions(companyId, query, omit), ...extra])} + GROUP BY 2 + `; + branches.push(facetBranch("facet:status", sql`m.status`, "status")); + branches.push(facetBranch("facet:priority", sql`m.priority`, "priority")); + branches.push(facetBranch("facet:assigneeAgentId", sql`m.assignee_agent_id`, "assigneeAgentId", [sql`m.assignee_agent_id IS NOT NULL`])); + branches.push(facetBranch("facet:assigneeUserId", sql`m.assignee_user_id`, "assigneeUserId", [sql`m.assignee_user_id IS NOT NULL`])); + branches.push(facetBranch("facet:projectId", sql`m.project_id`, "projectId", [sql`m.project_id IS NOT NULL`])); + branches.push(sql` + SELECT 'facet:labelId' AS kind, matched_labels.label_id::text AS value, count(DISTINCT m.id)::int AS count ${countTail} + FROM matched m + INNER JOIN issue_labels matched_labels + ON matched_labels.issue_id = m.id + AND matched_labels.company_id = ${companyId} + ${branchWhere([optionCond, ...matchedFacetConditions(companyId, query, "labelId")])} + GROUP BY 2 + `); + + const updatedBaseQuery = { ...query, updatedWithin: undefined, updatedAfter: undefined }; + const updatedBaseFacets = matchedFacetConditions(companyId, updatedBaseQuery); + for (const option of COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS) { + const start = updatedWithinStart(option); + if (!start) continue; + branches.push(sql` + SELECT 'facet:updatedWithin'::text AS kind, ${option}::text AS value, count(*)::int AS count ${countTail} + FROM matched m + ${branchWhere([optionCond, ...updatedBaseFacets, sql`m.updated_at >= ${start.toISOString()}::timestamptz`])} + `); + } + + if (scopeIncludesIssues(scope)) { + branches.push(sql`SELECT 'total:current' AS kind, NULL::text AS value, count(*)::int AS count ${countTail} FROM matched m ${branchWhere([scopeCond, ...facetsAll])}`); + if (filtersActive.length > 0) { + branches.push(sql`SELECT 'total:unfiltered' AS kind, NULL::text AS value, count(*)::int AS count ${countTail} FROM matched m ${branchWhere([scopeCond])}`); + for (const filter of filtersActive) { + branches.push(sql` + SELECT ${`total:omit:${filter.key}`}::text AS kind, NULL::text AS value, count(*)::int AS count ${countTail} + FROM matched m + ${branchWhere([scopeCond, ...matchedFacetConditions(companyId, query, filter.key)])} + `); + } + } + } + + // Per-token coverage counts matches across issue text and the tagged + // comment/document match sets (hashed IN probes, one set per token). + const coverageSql = tokenCount > 0 + ? sql`(${sql.join(tokens.map((_, index) => { + const pattern = tokenPatterns[index]!; + const ord = matchPatternOrdinal(pattern); + return sql`(CASE WHEN + issues.title ILIKE ${pattern} + OR coalesce(issues.identifier, '') ILIKE ${pattern} + OR coalesce(issues.description, '') ILIKE ${pattern} + OR issues.id IN (SELECT comment_matches.issue_id FROM comment_matches WHERE comment_matches.ord = ${ord}) + OR issues.id IN (SELECT document_matches.issue_id FROM document_matches WHERE document_matches.ord = ${ord}) + THEN 1 ELSE 0 END)`; + }), sql` + `)})` + : sql`0`; + + const matchedWhere = hasSearchText ? sql` AND ${anySearchMatch}` : sql``; + const resultRows = await db.execute(sql` + WITH comment_matches AS MATERIALIZED (${commentMatchesCte}), + document_matches AS MATERIALIZED (${documentMatchesCte}), + matched AS MATERIALIZED ( + SELECT + issues.id, + issues.identifier, + issues.title, + issues.description, + issues.status, + issues.priority, + issues.assignee_agent_id, + issues.assignee_user_id, + issues.project_id, + issues.created_at, + issues.updated_at, + ${titlePhraseMatch} AS title_phrase, + ${titleStartsWith} AS title_starts, + ${titleExactMatch} AS title_exact, + ${identifierPhraseMatch} AS ident_phrase, + ${identifierStartsWith} AS ident_starts, + ${identifierExactMatch} AS ident_exact, + ${descriptionPhraseMatch} AS desc_phrase, + ${titleTokenMatch} AS title_token, + ${identifierTokenMatch} AS ident_token, + ${descriptionTokenMatch} AS desc_token, + ${commentMatch} AS comment_match, + ${documentMatch} AS document_match, + ${fuzzyTokenTitleMatch} AS fuzzy_title, + ${fuzzyIdentifierMatch} AS fuzzy_ident, + ${coverageSql} AS token_coverage + FROM issues + WHERE issues.company_id = ${companyId} + AND ${visibleIssueCondition()} + ${matchedWhere} + ) + ${sql.join(branches, sql` UNION ALL `)} + `) as unknown as Array>; + + const aggregates: IssueAggregates = { + typeCounts: { issue: 0, comment: 0, document: 0 }, + filterOptionCounts: emptyFilterOptionCounts(), + totals: { current: 0, unfiltered: 0, omit: {} }, + }; + const issueRowsRaw: IssueSearchRow[] = []; + for (const row of resultRows) { + if (row.kind === "result") { + issueRowsRaw.push({ + id: row.id, + identifier: row.identifier, + title: row.title, + description: row.description, + status: row.status, + priority: row.priority, + assigneeAgentId: row.assigneeAgentId, + assigneeUserId: row.assigneeUserId, + projectId: row.projectId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + score: row.score, + matchedFields: row.matchedFields, + commentSnippet: null, + commentId: null, + documentSnippet: null, + documentTitle: null, + documentKey: null, + }); + continue; + } + const count = Number(row.count ?? 0); + if (row.kind === "type:issue") aggregates.typeCounts.issue = count; + else if (row.kind === "type:comment") aggregates.typeCounts.comment = count; + else if (row.kind === "type:document") aggregates.typeCounts.document = count; + else if (row.kind === "facet:status" && row.value && (ISSUE_STATUSES as readonly string[]).includes(row.value)) { + aggregates.filterOptionCounts.status[row.value as keyof CompanySearchFilterOptionCounts["status"]] = count; + } else if (row.kind === "facet:priority" && row.value && (ISSUE_PRIORITIES as readonly string[]).includes(row.value)) { + aggregates.filterOptionCounts.priority[row.value as keyof CompanySearchFilterOptionCounts["priority"]] = count; + } else if (row.kind === "facet:assigneeAgentId" && row.value) aggregates.filterOptionCounts.assigneeAgentId[row.value] = count; + else if (row.kind === "facet:assigneeUserId" && row.value) aggregates.filterOptionCounts.assigneeUserId[row.value] = count; + else if (row.kind === "facet:projectId" && row.value) aggregates.filterOptionCounts.projectId[row.value] = count; + else if (row.kind === "facet:labelId" && row.value) aggregates.filterOptionCounts.labelId[row.value] = count; + else if (row.kind === "facet:updatedWithin" && row.value) { + aggregates.filterOptionCounts.updatedWithin[row.value as CompanySearchUpdatedWithinOption] = count; + } else if (row.kind === "total:current") aggregates.totals.current = count; + else if (row.kind === "total:unfiltered") aggregates.totals.unfiltered = count; + else if (row.kind.startsWith("total:omit:")) { + aggregates.totals.omit[row.kind.slice("total:omit:".length) as CompanySearchIssueFilterKey] = count; + } + } + return { rows: await enrichIssueSnippets(issueRowsRaw), aggregates }; + } + + // Fetch best-matching comment/document snippets only for the fetched + // page window (<= fetchLimit rows) instead of for every matching row. + async function enrichIssueSnippets(rows: IssueSearchRow[]): Promise { + if (!hasSearchText || rows.length === 0) return rows; + const snippetIds = rows + .filter((row) => { + const fields = row.matchedFields ?? []; + return fields.includes("comment") || fields.includes("document"); }) - .from(issues) - .where(and( - eq(issues.companyId, companyId), - visibleIssueCondition(), - issueSearchCondition(scope, { issueTextMatch, commentMatch, documentMatch, fuzzyMatch }), - )) - .orderBy(desc(score), desc(issues.updatedAt), desc(issues.id)) - .limit(fetchLimit) - : []; + .map((row) => row.id); + if (snippetIds.length === 0) return rows; + const snippetRows = await db.execute(sql` + SELECT + target.id AS "issueId", + best_comment.id AS "commentId", + best_comment.body AS "commentSnippet", + best_document.latest_body AS "documentSnippet", + best_document.title AS "documentTitle", + best_document.key AS "documentKey" + FROM unnest(${sqlUuidArray(snippetIds)}) AS target(id) + LEFT JOIN LATERAL ( + SELECT search_comments.id, search_comments.body + FROM issue_comments search_comments + WHERE search_comments.company_id = ${companyId} + AND search_comments.issue_id = target.id + AND search_comments.deleted_at IS NULL + AND ( + search_comments.body ILIKE ${containsPattern} + OR search_comments.body ILIKE ANY(${tokenPatternArray}) + ) + ORDER BY + CASE WHEN search_comments.body ILIKE ${containsPattern} THEN 0 ELSE 1 END, + search_comments.updated_at DESC, + search_comments.id DESC + LIMIT 1 + ) best_comment ON true + LEFT JOIN LATERAL ( + SELECT search_issue_documents.key, search_documents.latest_body, search_documents.title + FROM issue_documents search_issue_documents + INNER JOIN documents search_documents + ON search_documents.id = search_issue_documents.document_id + AND search_documents.company_id = search_issue_documents.company_id + WHERE search_issue_documents.company_id = ${companyId} + AND search_issue_documents.issue_id = target.id + AND ( + coalesce(search_documents.title, '') ILIKE ${containsPattern} + OR search_documents.latest_body ILIKE ${containsPattern} + OR coalesce(search_documents.title, '') ILIKE ANY(${tokenPatternArray}) + OR search_documents.latest_body ILIKE ANY(${tokenPatternArray}) + ) + ORDER BY + CASE + WHEN coalesce(search_documents.title, '') ILIKE ${containsPattern} THEN 0 + WHEN search_documents.latest_body ILIKE ${containsPattern} THEN 1 + ELSE 2 + END, + search_documents.updated_at DESC, + search_documents.id DESC + LIMIT 1 + ) best_document ON true + `) as unknown as Array<{ + issueId: string; + commentId: string | null; + commentSnippet: string | null; + documentSnippet: string | null; + documentTitle: string | null; + documentKey: string | null; + }>; + const byIssueId = new Map(snippetRows.map((row) => [row.issueId, row])); + return rows.map((row) => { + const snippet = byIssueId.get(row.id); + if (!snippet) return row; + return { + ...row, + commentSnippet: snippet.commentSnippet, + commentId: snippet.commentId, + documentSnippet: snippet.documentSnippet, + documentTitle: snippet.documentTitle, + documentKey: snippet.documentKey, + }; + }); + } + // --- agents / projects / artifacts ------------------------------------ const simpleCondition = simpleTextCondition([ sql`${agents.name}`, sql`${agents.role}`, sql`${agents.title}`, sql`${agents.capabilities}`, - ], containsPattern, tokenArray); - const agentRows = scopeIncludesAgents(scope) - ? await db + ], containsPattern, tokenPatternArray); + const projectCondition = simpleTextCondition([ + sql`${projects.name}`, + sql`${projects.description}`, + ], containsPattern, tokenPatternArray); + + async function fetchAgentRows() { + if (!hasSearchText || !scopeIncludesAgents(scope) || hasIssueOnlyFilters) return []; + return db .select({ id: agents.id, title: agents.name, description: agents.capabilities, role: agents.role, + createdAt: agents.createdAt, updatedAt: agents.updatedAt, }) .from(agents) .where(and(eq(agents.companyId, companyId), simpleCondition)) .orderBy(desc(agents.updatedAt), desc(agents.id)) - .limit(fetchLimit) - : []; + .limit(fetchLimit); + } - const projectCondition = simpleTextCondition([ - sql`${projects.name}`, - sql`${projects.description}`, - ], containsPattern, tokenArray); - const projectRows = scopeIncludesProjects(scope) - ? await db + async function fetchProjectRows() { + if (!hasSearchText || !scopeIncludesProjects(scope) || hasIssueOnlyFilters) return []; + return db .select({ id: projects.id, title: projects.name, description: projects.description, + createdAt: projects.createdAt, updatedAt: projects.updatedAt, }) .from(projects) .where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition)) .orderBy(desc(projects.updatedAt), desc(projects.id)) - .limit(fetchLimit) - : []; + .limit(fetchLimit); + } - const artifactRows = scopeIncludesArtifacts(scope) - ? await companyArtifactsService(db).list(companyId, { + async function countArtifacts(filters: CompanySearchQuery = query) { + if (!hasSearchText) return 0; + const artifactIssueFilters = issueFilterConditions(companyId, filters); + const artifactIssueConditions = [ + eq(issues.companyId, companyId), + visibleIssueCondition(), + ...artifactIssueFilters, + ]; + const documentArtifactConditions = [ + eq(issueDocuments.companyId, companyId), + eq(documents.companyId, companyId), + or(isNotNull(documents.createdByAgentId), isNotNull(documents.updatedByAgentId))!, + notInArray(issueDocuments.key, [...SYSTEM_ISSUE_DOCUMENT_KEYS]), + sql`( + coalesce(${documents.title}, '') ILIKE ${containsPattern} ESCAPE '\\' + OR ${documents.latestBody} ILIKE ${containsPattern} ESCAPE '\\' + OR coalesce(${issues.identifier}, '') ILIKE ${containsPattern} ESCAPE '\\' + OR ${issues.title} ILIKE ${containsPattern} ESCAPE '\\' + )`, + ...artifactIssueConditions, + ]; + const workProductConditions = [ + eq(issueWorkProducts.companyId, companyId), + eq(issueWorkProducts.type, "artifact"), + eq(issueWorkProducts.provider, "paperclip"), + sql`( + ${issueWorkProducts.title} ILIKE ${containsPattern} ESCAPE '\\' + OR coalesce(${issueWorkProducts.summary}, '') ILIKE ${containsPattern} ESCAPE '\\' + OR coalesce(${issues.identifier}, '') ILIKE ${containsPattern} ESCAPE '\\' + OR ${issues.title} ILIKE ${containsPattern} ESCAPE '\\' + )`, + ...artifactIssueConditions, + ]; + const attachmentConditions = [ + eq(issueAttachments.companyId, companyId), + isNull(issueAttachments.issueCommentId), + isNotNull(assets.createdByAgentId), + sql`( + coalesce(${assets.originalFilename}, '') ILIKE ${containsPattern} ESCAPE '\\' + OR coalesce(${issues.identifier}, '') ILIKE ${containsPattern} ESCAPE '\\' + OR ${issues.title} ILIKE ${containsPattern} ESCAPE '\\' + )`, + ...artifactIssueConditions, + ]; + const [documentRows, workProductRows, attachmentRows] = await Promise.all([ + db + .select({ count: sql`count(*)::int` }) + .from(issueDocuments) + .innerJoin(documents, and(eq(issueDocuments.documentId, documents.id), eq(documents.companyId, issueDocuments.companyId))) + .innerJoin(issues, and(eq(issueDocuments.issueId, issues.id), eq(issues.companyId, issueDocuments.companyId))) + .where(and(...documentArtifactConditions)), + db + .select({ count: sql`count(*)::int` }) + .from(issueWorkProducts) + .innerJoin(issues, and(eq(issueWorkProducts.issueId, issues.id), eq(issues.companyId, issueWorkProducts.companyId))) + .where(and(...workProductConditions)), + db + .select({ count: sql`count(*)::int` }) + .from(issueAttachments) + .innerJoin(assets, and(eq(issueAttachments.assetId, assets.id), eq(assets.companyId, issueAttachments.companyId))) + .innerJoin(issues, and(eq(issueAttachments.issueId, issues.id), eq(issues.companyId, issueAttachments.companyId))) + .where(and(...attachmentConditions)), + ]); + return Number(documentRows[0]?.count ?? 0) + + Number(workProductRows[0]?.count ?? 0) + + Number(attachmentRows[0]?.count ?? 0); + } + + async function countAgents(filters: CompanySearchQuery = query) { + if (!hasSearchText || issueOnlyFiltersActive(filters)) return 0; + const rows = await db + .select({ count: sql`count(*)::int` }) + .from(agents) + .where(and(eq(agents.companyId, companyId), simpleCondition)); + return Number(rows[0]?.count ?? 0); + } + + async function countProjects(filters: CompanySearchQuery = query) { + if (!hasSearchText || issueOnlyFiltersActive(filters)) return 0; + const rows = await db + .select({ count: sql`count(*)::int` }) + .from(projects) + .where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition)); + return Number(rows[0]?.count ?? 0); + } + + async function fetchArtifactRows() { + if (!hasSearchText || !scopeIncludesArtifacts(scope)) return []; + const result = await companyArtifactsService(db).list(companyId, { q: normalizedQuery.slice(0, COMPANY_ARTIFACTS_MAX_QUERY_LENGTH), limit: Math.min(fetchLimit, COMPANY_ARTIFACTS_MAX_LIMIT), - }).then((result) => result.artifacts) - : []; + }, { issueConditions: issueFilters }); + return result.artifacts; + } - const results: CompanySearchResult[] = [ - ...(issueRows as IssueSearchRow[]).map((row) => issueResult(row, prefix, normalizedQuery, tokens)), - ...artifactRows.map((artifact) => artifactResult(artifact, normalizedQuery, tokens)), + const [company, issueSearchData, artifactRows, agentRows, projectRows, artifactCount, agentCount, projectCount] = await Promise.all([ + db + .select({ issuePrefix: companies.issuePrefix }) + .from(companies) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null), + fetchIssueSearchData(), + fetchArtifactRows(), + fetchAgentRows(), + fetchProjectRows(), + scopeIncludesArtifacts(scope) ? countArtifacts(query) : Promise.resolve(0), + scopeIncludesAgents(scope) ? countAgents(query) : Promise.resolve(0), + scopeIncludesProjects(scope) ? countProjects(query) : Promise.resolve(0), + ]); + const prefix = routePrefix(company?.issuePrefix); + const { rows: issueRows, aggregates } = issueSearchData; + + const countsByType = emptySearchCounts(); + countsByType.issue = aggregates.typeCounts.issue; + countsByType.comment = aggregates.typeCounts.comment; + countsByType.document = aggregates.typeCounts.document; + countsByType.artifact = artifactCount; + countsByType.agent = agentCount; + countsByType.project = projectCount; + + const currentTotalCount = (scopeIncludesIssues(scope) ? aggregates.totals.current : 0) + + artifactCount + + agentCount + + projectCount; + + const results: SearchResultWithSort[] = [ + ...issueRows.map((row) => { + const result = issueResult(row, prefix, normalizedQuery, tokens); + return { + ...result, + sortCreatedAt: iso(row.createdAt), + sortPriorityRank: priorityRank(row.priority), + }; + }), + ...artifactRows.map((artifact) => ({ + ...artifactResult(artifact, normalizedQuery, tokens), + sortCreatedAt: artifact.updatedAt, + sortPriorityRank: ISSUE_PRIORITIES.length, + })), ...(agentRows as SimpleSearchRow[]).map((row) => { const terms = matchTerms(normalizedQuery, tokens); const snippet = createSnippet("capabilities", "Agent", row.description ?? row.role ?? row.title, terms); @@ -721,6 +1266,8 @@ export function companySearchService(db: Db) { snippets: snippet ? [snippet] : [], updatedAt: iso(row.updatedAt), previewImageUrl: null, + sortCreatedAt: iso(row.createdAt), + sortPriorityRank: ISSUE_PRIORITIES.length, }; }), ...(projectRows as SimpleSearchRow[]).map((row) => { @@ -738,22 +1285,51 @@ export function companySearchService(db: Db) { snippets: snippet ? [snippet] : [], updatedAt: iso(row.updatedAt), previewImageUrl: null, + sortCreatedAt: iso(row.createdAt), + sortPriorityRank: ISSUE_PRIORITIES.length, }; }), - ].sort((left, right) => { - if (right.score !== left.score) return right.score - left.score; - return (right.updatedAt ?? "").localeCompare(left.updatedAt ?? ""); - }); + ].sort(compareSearchResults(sort)); - const paged = results.slice(offset, offset + limit); + async function countTotalNonIssue(filters: CompanySearchQuery) { + const [artifactTotal, agentTotal, projectTotal] = await Promise.all([ + scopeIncludesArtifacts(scope) ? countArtifacts(filters) : Promise.resolve(0), + scopeIncludesAgents(scope) ? countAgents(filters) : Promise.resolve(0), + scopeIncludesProjects(scope) ? countProjects(filters) : Promise.resolve(0), + ]); + return artifactTotal + agentTotal + projectTotal; + } + + const filtersActive = activeIssueFilters(query); + const zeroResults = currentTotalCount === 0 && filtersActive.length > 0 + ? { + unfilteredTotal: (scopeIncludesIssues(scope) ? aggregates.totals.unfiltered : 0) + + await countTotalNonIssue(queryWithoutIssueFilters(query)), + loosenSuggestions: (await Promise.all(filtersActive.map(async (filter) => { + const resultCount = (scopeIncludesIssues(scope) ? aggregates.totals.omit[filter.key] ?? 0 : 0) + + await countTotalNonIssue(queryWithoutFilter(query, filter.key)); + return { + filter: filter.key, + values: filter.values, + resultCount, + additionalCount: Math.max(0, resultCount - currentTotalCount), + }; + }))).sort((left, right) => right.additionalCount - left.additionalCount), + } + : null; + + const paged = results.slice(offset, offset + limit).map(stripInternalSortFields); return { query: query.q, normalizedQuery, scope, + sort, limit, offset, results: paged, - countsByType: makeCounts(results), + countsByType, + filterOptionCounts: aggregates.filterOptionCounts, + zeroResults, hasMore: results.length > offset + limit, }; }, diff --git a/ui/src/api/search.ts b/ui/src/api/search.ts index a660dd33ba..5715d8685b 100644 --- a/ui/src/api/search.ts +++ b/ui/src/api/search.ts @@ -1,4 +1,4 @@ -import type { CompanySearchResponse, CompanySearchScope } from "@paperclipai/shared"; +import type { CompanySearchResponse, CompanySearchScope, CompanySearchSort, IssuePriority, IssueStatus } from "@paperclipai/shared"; import { api } from "./client"; export interface CompanySearchParams { @@ -6,6 +6,19 @@ export interface CompanySearchParams { scope?: CompanySearchScope; limit?: number; offset?: number; + status?: IssueStatus[]; + priority?: IssuePriority[]; + assigneeAgentId?: string | null; + assigneeUserId?: string; + projectId?: string; + labelId?: string; + updatedWithin?: string; + updatedAfter?: string; + sort?: CompanySearchSort; +} + +function appendMulti(search: URLSearchParams, key: string, values: readonly string[] | undefined) { + for (const value of values ?? []) search.append(key, value); } export const searchApi = { @@ -15,6 +28,15 @@ export const searchApi = { 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)); + appendMulti(search, "status", params.status); + appendMulti(search, "priority", params.priority); + if (params.assigneeAgentId !== undefined) search.set("assigneeAgentId", params.assigneeAgentId ?? "null"); + if (params.assigneeUserId !== undefined) search.set("assigneeUserId", params.assigneeUserId); + if (params.projectId !== undefined) search.set("projectId", params.projectId); + if (params.labelId !== undefined) search.set("labelId", params.labelId); + if (params.updatedWithin !== undefined) search.set("updatedWithin", params.updatedWithin); + if (params.updatedAfter !== undefined) search.set("updatedAfter", params.updatedAfter); + if (params.sort !== undefined) search.set("sort", params.sort); 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 3c4db32370..0747407068 100644 --- a/ui/src/components/CommandPalette.test.tsx +++ b/ui/src/components/CommandPalette.test.tsx @@ -32,6 +32,7 @@ const sidebarState = vi.hoisted(() => ({ const mockIssuesApi = vi.hoisted(() => ({ list: vi.fn(), + listLabels: vi.fn(), })); const mockAgentsApi = vi.hoisted(() => ({ @@ -46,6 +47,10 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn(), })); +const mockAuthApi = vi.hoisted(() => ({ + getSession: vi.fn(), +})); + vi.mock("../context/CompanyContext", () => ({ useCompany: () => companyState, })); @@ -87,6 +92,10 @@ vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi, })); +vi.mock("../api/auth", () => ({ + authApi: mockAuthApi, +})); + vi.mock("./Identity", () => ({ Identity: ({ name }: { name: string }) => {name}, })); @@ -190,19 +199,23 @@ describe("CommandPalette", () => { dialogState.openNewAgent.mockReset(); sidebarState.setSidebarOpen.mockReset(); mockIssuesApi.list.mockReset(); + mockIssuesApi.listLabels.mockReset(); mockAgentsApi.list.mockReset(); mockProjectsApi.list.mockReset(); mockInstanceSettingsApi.getExperimental.mockReset(); + mockAuthApi.getSession.mockReset(); navigateState.navigate.mockReset(); locationState.location.pathname = "/"; locationState.location.search = ""; locationState.location.hash = ""; mockIssuesApi.list.mockResolvedValue([]); + mockIssuesApi.listLabels.mockResolvedValue([]); mockAgentsApi.list.mockResolvedValue([]); mockProjectsApi.list.mockResolvedValue([]); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableExperimentalFileViewer: false, }); + mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" }, session: { userId: "user-1" } }); }); afterEach(() => { @@ -312,7 +325,7 @@ describe("CommandPalette", () => { }); await waitForAssertion(() => { - expect(navigateState.navigate).toHaveBeenCalledWith("/search?q=auth%20flake"); + expect(navigateState.navigate).toHaveBeenCalledWith("/search?q=auth+flake"); }); act(() => { @@ -416,4 +429,73 @@ describe("CommandPalette", () => { root.unmount(); }); }); + + it("renders quick-filter chips and inserts them into the palette query", async () => { + const { root } = renderWithQueryClient(, container); + + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true })); + }); + + await waitForAssertion(() => { + const chips = Array.from(container.querySelectorAll('button[data-testid="command-filter-chip"]')); + expect(chips.map((chip) => chip.textContent)).toEqual( + expect.arrayContaining([ + expect.stringContaining("assignee:me"), + expect.stringContaining("is:open"), + expect.stringContaining("updated:>7d"), + ]), + ); + }); + + act(() => { + container.querySelector('button[data-testid="command-filter-chip"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const input = container.querySelector('input[aria-label="Command search"]') as HTMLInputElement; + await waitForAssertion(() => { + expect(input.value).toBe("assignee:me"); + }); + + act(() => { + root.unmount(); + }); + }); + + it("parses operators for lightweight issue search but keeps filters for command-enter handoff", 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, "auth status:blocked updated:>7d"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await waitForAssertion(() => { + expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { + q: "auth", + limit: 10, + includeRoutineExecutions: true, + }); + }); + + act(() => { + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true })); + }); + + await waitForAssertion(() => { + expect(navigateState.navigate).toHaveBeenCalledWith("/search?q=auth&status=blocked&updatedWithin=7d"); + }); + + act(() => { + root.unmount(); + }); + }); + }); diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 0dedef05bc..c83c429ea2 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -5,6 +5,7 @@ import { useCompany } from "../context/CompanyContext"; import { useDialogActions } from "../context/DialogContext"; import { useSidebar } from "../context/SidebarContext"; import { issuesApi } from "../api/issues"; +import { authApi } from "../api/auth"; import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; import { instanceSettingsApi } from "../api/instanceSettings"; @@ -34,12 +35,17 @@ import { } from "lucide-react"; import { Identity } from "./Identity"; import { agentUrl, projectUrl } from "../lib/utils"; +import { + SEARCH_OPERATOR_QUICK_FILTERS, + buildSearchPathFromQuery, + parseSearchQuery, + type SearchQueryParserContext, +} from "../lib/search-query-parser"; 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 buildFullSearchPath(query: string, context: SearchQueryParserContext = {}) { + return buildSearchPathFromQuery(query, context); } const ISSUE_DETAIL_PATH_RE = /\/issues\/[^/?#]+(?:$|\?|#|\/)/; @@ -114,18 +120,6 @@ export function CommandPalette() { if (!open) setQuery(""); }, [open]); - const { data: issues = [] } = useQuery({ - queryKey: queryKeys.issues.list(selectedCompanyId!), - queryFn: () => issuesApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId && open && searchQuery.length === 0, - }); - - const { data: searchedIssues = [] } = useQuery({ - queryKey: queryKeys.issues.search(selectedCompanyId!, searchQuery, undefined, 10), - queryFn: () => issuesApi.list(selectedCompanyId!, { q: searchQuery, limit: 10, includeRoutineExecutions: true }), - enabled: !!selectedCompanyId && open && searchQuery.length > 0, - }); - const { data: agents = [] } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), @@ -142,13 +136,47 @@ export function CommandPalette() { [allProjects], ); + const { data: labels = [] } = useQuery({ + queryKey: queryKeys.issues.labels(selectedCompanyId!), + queryFn: () => issuesApi.listLabels(selectedCompanyId!), + enabled: !!selectedCompanyId && open, + }); + + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + enabled: open, + }); + + const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; + const parserContext = useMemo(() => ({ + currentUserId, + agents, + projects, + labels, + }), [agents, currentUserId, labels, projects]); + const parsedQuery = useMemo(() => parseSearchQuery(query, parserContext), [parserContext, query]); + const quickSearchQuery = parsedQuery.query.trim(); + + const { data: issues = [] } = useQuery({ + queryKey: queryKeys.issues.list(selectedCompanyId!), + queryFn: () => issuesApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId && open && searchQuery.length === 0, + }); + + const { data: searchedIssues = [] } = useQuery({ + queryKey: queryKeys.issues.search(selectedCompanyId!, quickSearchQuery, undefined, 10), + queryFn: () => issuesApi.list(selectedCompanyId!, { q: quickSearchQuery, limit: 10, includeRoutineExecutions: true }), + enabled: !!selectedCompanyId && open && quickSearchQuery.length > 0, + }); + function go(path: string) { setOpen(false); navigate(path); } function goFullSearch() { - go(buildFullSearchPath(searchQuery)); + go(buildFullSearchPath(searchQuery, parserContext)); } const agentName = (id: string | null) => { @@ -157,16 +185,16 @@ export function CommandPalette() { }; const visibleIssues = useMemo( - () => (searchQuery.length > 0 ? searchedIssues : issues), - [issues, searchedIssues, searchQuery], + () => (quickSearchQuery.length > 0 ? searchedIssues : issues), + [issues, searchedIssues, quickSearchQuery], ); // Client-side typeahead ranking over the already-loaded projects. cmdk ranks // items by their `value` (which defaults to the rendered name) and would bury // or drop description-only matches, so we rank in JS and force-match below. const matchedProjects = useMemo(() => { - if (searchQuery.length === 0) return []; - const q = searchQuery.toLowerCase(); + if (quickSearchQuery.length === 0) return []; + const q = quickSearchQuery.toLowerCase(); return projects .map((project) => ({ project, @@ -180,7 +208,7 @@ export function CommandPalette() { .sort((a, b) => b.score - a.score) .slice(0, MAX_MATCHED_PROJECTS) .map((entry) => entry.project); - }, [projects, searchQuery]); + }, [projects, quickSearchQuery]); const showSearchAll = searchQuery.length > 0; const showPromotedProjects = showSearchAll && matchedProjects.length > 0; @@ -198,6 +226,11 @@ export function CommandPalette() { value={query} onValueChange={setQuery} onKeyDown={(event) => { + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + goFullSearch(); + return; + } if (event.key === "Enter" && showEmptyHint) { event.preventDefault(); goFullSearch(); @@ -239,6 +272,22 @@ export function CommandPalette() { {showSearchAll ? : null} + + {SEARCH_OPERATOR_QUICK_FILTERS.map((chip) => ( + setQuery((current) => current.trim() ? `${current.trim()} ${chip}` : chip)} + data-testid="command-filter-chip" + > + + {chip} + + ))} + + + + {showPromotedProjects && ( <> diff --git a/ui/src/components/search/SearchFilterBar.tsx b/ui/src/components/search/SearchFilterBar.tsx new file mode 100644 index 0000000000..3c75fb5c0b --- /dev/null +++ b/ui/src/components/search/SearchFilterBar.tsx @@ -0,0 +1,219 @@ +import { useMemo } from "react"; +import { User, UserX } from "lucide-react"; +import { + COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS, + ISSUE_PRIORITIES, + ISSUE_STATUSES, + type CompanySearchFilterOptionCounts, + type CompanySearchSort, + type IssueStatus, +} from "@paperclipai/shared"; +import { StatusIcon } from "@/components/StatusIcon"; +import { PriorityIcon } from "@/components/PriorityIcon"; +import { SearchFilterMenu, type FilterMenuOption } from "./SearchFilterMenu"; +import { SearchSortMenu } from "./SearchSortMenu"; +import { + applyAssigneeToken, + assigneeToken, + updatedWithinLabel, + type SearchFilters, +} from "@/lib/search-filters"; + +export interface SearchFilterAgent { + id: string; + name: string; +} +export interface SearchFilterProject { + id: string; + name: string; +} +export interface SearchFilterLabel { + id: string; + name: string; + color: string; +} + +export interface SearchFilterDataProps { + counts?: CompanySearchFilterOptionCounts; + agents: SearchFilterAgent[]; + projects: SearchFilterProject[]; + labels: SearchFilterLabel[]; + currentUserId: string | null; +} + +// Non-terminal statuses — the single-click "Open items" preset from wireframe screen 2. +const OPEN_STATUS_PRESET: IssueStatus[] = ISSUE_STATUSES.filter( + (status) => status !== "done" && status !== "cancelled", +); + +function humanize(value: string): string { + return value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function count(record: Record | undefined, key: string): number | undefined { + return record?.[key]; +} + +export interface SearchFilterOptionGroups { + status: FilterMenuOption[]; + priority: FilterMenuOption[]; + assignee: FilterMenuOption[]; + project: FilterMenuOption[]; + label: FilterMenuOption[]; + updated: FilterMenuOption[]; +} + +/** Build option lists (with filter-aware counts) shared by the desktop bar and mobile sheet. */ +export function buildSearchFilterOptions({ + counts, + agents, + projects, + labels, + currentUserId, +}: SearchFilterDataProps): SearchFilterOptionGroups { + const status: FilterMenuOption[] = ISSUE_STATUSES.map((value) => ({ + value, + label: humanize(value), + icon: , + count: count(counts?.status as Record | undefined, value), + })); + + const priority: FilterMenuOption[] = ISSUE_PRIORITIES.map((value) => ({ + value, + label: humanize(value), + icon: , + count: count(counts?.priority as Record | undefined, value), + })); + + const assignee: FilterMenuOption[] = []; + if (currentUserId) { + assignee.push({ + value: "me", + label: "Me", + icon: , + count: count(counts?.assigneeUserId, currentUserId), + searchText: "me mine", + }); + } + assignee.push({ + value: "none", + label: "Unassigned", + icon: , + searchText: "unassigned none nobody", + }); + for (const agent of agents) { + assignee.push({ + value: `agent:${agent.id}`, + label: agent.name, + count: count(counts?.assigneeAgentId, agent.id), + searchText: agent.name, + }); + } + + const project: FilterMenuOption[] = projects.map((item) => ({ + value: item.id, + label: item.name, + count: count(counts?.projectId, item.id), + searchText: item.name, + })); + + const label: FilterMenuOption[] = labels.map((item) => ({ + value: item.id, + label: item.name, + swatch: item.color, + count: count(counts?.labelId, item.id), + searchText: item.name, + })); + + const updated: FilterMenuOption[] = COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS.map((value) => ({ + value, + label: updatedWithinLabel(value), + count: count(counts?.updatedWithin as Record | undefined, value), + })); + + return { status, priority, assignee, project, label, updated }; +} + +export function SearchFilterBar({ + filters, + onChange, + sort, + onSortChange, + data, +}: { + filters: SearchFilters; + onChange: (next: SearchFilters) => void; + sort: CompanySearchSort; + onSortChange: (next: CompanySearchSort) => void; + data: SearchFilterDataProps; +}) { + const options = useMemo(() => buildSearchFilterOptions(data), [data]); + + function toggleMulti(dimension: "status" | "priority", value: string) { + const current = (filters[dimension] ?? []) as string[]; + const next = current.includes(value) + ? current.filter((entry) => entry !== value) + : [...current, value]; + onChange({ ...filters, [dimension]: next }); + } + + const selectedAssignee = assigneeToken(filters, data.currentUserId); + + return ( +
+ toggleMulti("status", value)} + onClear={() => onChange({ ...filters, status: [] })} + presets={[{ label: "Open items", values: OPEN_STATUS_PRESET }]} + /> + onChange(applyAssigneeToken(filters, value, data.currentUserId))} + searchable + searchPlaceholder="Search assignees…" + emptyMessage="No assignees" + /> + onChange({ ...filters, projectId: value })} + searchable + searchPlaceholder="Search projects…" + emptyMessage="No projects" + /> + onChange({ ...filters, labelId: value })} + searchable + searchPlaceholder="Search labels…" + emptyMessage="No labels" + /> + toggleMulti("priority", value)} + onClear={() => onChange({ ...filters, priority: [] })} + /> + onChange({ ...filters, updatedWithin: value })} + /> +
+ +
+
+ ); +} diff --git a/ui/src/components/search/SearchFilterChips.tsx b/ui/src/components/search/SearchFilterChips.tsx new file mode 100644 index 0000000000..ae5b62ab99 --- /dev/null +++ b/ui/src/components/search/SearchFilterChips.tsx @@ -0,0 +1,43 @@ +import { X } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { buildFilterChips, type FilterChipLookups, type SearchFilters } from "@/lib/search-filters"; + +export function SearchFilterChips({ + filters, + lookups, + onChange, + onClearAll, +}: { + filters: SearchFilters; + lookups: FilterChipLookups; + onChange: (next: SearchFilters) => void; + onClearAll: () => void; +}) { + const chips = buildFilterChips(filters, lookups); + if (chips.length === 0) return null; + + return ( +
+ {chips.map((chip) => ( + + {chip.label} + + + ))} + +
+ ); +} diff --git a/ui/src/components/search/SearchFilterMenu.tsx b/ui/src/components/search/SearchFilterMenu.tsx new file mode 100644 index 0000000000..7f802924fe --- /dev/null +++ b/ui/src/components/search/SearchFilterMenu.tsx @@ -0,0 +1,217 @@ +import { type ReactNode, useMemo, useState } from "react"; +import { Check, ChevronDown, Search } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +export interface FilterMenuOption { + value: string; + label: string; + count?: number; + icon?: ReactNode; + swatch?: string; + searchText?: string; +} + +export interface FilterMenuPreset { + label: string; + values: string[]; +} + +interface BaseProps { + label: string; + options: FilterMenuOption[]; + /** Values currently selected. */ + selected: string[]; + searchable?: boolean; + searchPlaceholder?: string; + emptyMessage?: string; + triggerClassName?: string; + contentClassName?: string; + align?: "start" | "end"; +} + +interface MultiProps extends BaseProps { + multi: true; + onToggle: (value: string) => void; + onClear: () => void; + presets?: FilterMenuPreset[]; +} + +interface SingleProps extends BaseProps { + multi?: false; + onSelect: (value: string | undefined) => void; +} + +export type SearchFilterMenuProps = MultiProps | SingleProps; + +function summarizeTrigger(label: string, selected: string[], options: FilterMenuOption[]): string { + if (selected.length === 0) return label; + if (selected.length === 1) { + const only = options.find((option) => option.value === selected[0]); + return only ? `${label}: ${only.label}` : label; + } + return `${label}: ${selected.length}`; +} + +export function SearchFilterMenu(props: SearchFilterMenuProps) { + const { + label, + options, + selected, + searchable = false, + searchPlaceholder = "Search…", + emptyMessage = "No options", + triggerClassName, + contentClassName, + align = "start", + } = props; + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + + const normalized = query.trim().toLowerCase(); + const visibleOptions = useMemo(() => { + if (!normalized) return options; + return options.filter((option) => + `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(normalized), + ); + }, [normalized, options]); + + const active = selected.length > 0; + + function handleOptionClick(value: string) { + if (props.multi) { + props.onToggle(value); + return; + } + // Single-select: clicking the selected value clears it, otherwise selects. + props.onSelect(selected.includes(value) ? undefined : value); + setOpen(false); + } + + return ( + + + + + +
+ {label} + {props.multi && active ? ( + + ) : null} +
+ + {props.multi && props.presets && props.presets.length > 0 ? ( +
+ {props.presets.map((preset) => { + const isActive = + preset.values.length === selected.length && + preset.values.every((value) => selected.includes(value)); + return ( + + ); + })} +
+ ) : null} + + {searchable ? ( +
+
+ + setQuery(event.target.value)} + placeholder={searchPlaceholder} + className="h-8 pl-7 text-xs" + /> +
+
+ ) : null} + +
+ {visibleOptions.length === 0 ? ( +
{emptyMessage}
+ ) : ( + visibleOptions.map((option) => { + const isSelected = selected.includes(option.value); + return ( + + ); + }) + )} +
+
+
+ ); +} diff --git a/ui/src/components/search/SearchFilterSheet.tsx b/ui/src/components/search/SearchFilterSheet.tsx new file mode 100644 index 0000000000..b1b3946a61 --- /dev/null +++ b/ui/src/components/search/SearchFilterSheet.tsx @@ -0,0 +1,249 @@ +import { useEffect, useState } from "react"; +import { SlidersHorizontal } from "lucide-react"; +import { COMPANY_SEARCH_SORTS, type CompanySearchSort } from "@paperclipai/shared"; +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetClose, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { cn } from "@/lib/utils"; +import { + applyAssigneeToken, + assigneeToken, + countActiveFilters, + SORT_LABELS, + type SearchFilters, +} from "@/lib/search-filters"; +import { buildSearchFilterOptions, type SearchFilterDataProps } from "./SearchFilterBar"; +import type { FilterMenuOption } from "./SearchFilterMenu"; + +function ChipToggleGroup({ + title, + options, + selected, + onToggle, +}: { + title: string; + options: FilterMenuOption[]; + selected: string[]; + onToggle: (value: string) => void; +}) { + if (options.length === 0) return null; + return ( +
+
{title}
+
+ {options.map((option) => { + const isActive = selected.includes(option.value); + return ( + + ); + })} +
+
+ ); +} + +export function SearchFilterSheet({ + open, + onOpenChange, + filters, + onApply, + onDraftChange, + previewTotal, + data, + sort, + onSortChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + filters: SearchFilters; + onApply: (next: SearchFilters) => void; + /** Fires whenever the in-sheet draft changes so the parent can preview the count. */ + onDraftChange: (draft: SearchFilters) => void; + /** Total result count for the current draft, previewed before applying. */ + previewTotal: number | null; + data: SearchFilterDataProps; + sort: CompanySearchSort; + onSortChange: (next: CompanySearchSort) => void; +}) { + const [draft, setDraft] = useState(filters); + const options = buildSearchFilterOptions(data); + + // Re-seed the draft from committed filters each time the sheet opens. + useEffect(() => { + if (open) { + setDraft(filters); + onDraftChange(filters); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + function update(next: SearchFilters) { + setDraft(next); + onDraftChange(next); + } + + function toggleMulti(dimension: "status" | "priority", value: string) { + const current = (draft[dimension] ?? []) as string[]; + const next = current.includes(value) + ? current.filter((entry) => entry !== value) + : [...current, value]; + update({ ...draft, [dimension]: next }); + } + + function toggleAssignee(token: string) { + const current = assigneeToken(draft, data.currentUserId); + update(applyAssigneeToken(draft, current === token ? undefined : token, data.currentUserId)); + } + + function toggleSingle(dimension: "projectId" | "labelId" | "updatedWithin", value: string) { + const current = draft[dimension]; + update({ ...draft, [dimension]: current === value ? undefined : value }); + } + + const activeCount = countActiveFilters(draft); + const selectedAssignee = assigneeToken(draft, data.currentUserId); + const applyLabel = + previewTotal === null + ? "Show results" + : `Show ${previewTotal} ${previewTotal === 1 ? "result" : "results"}`; + + return ( + + + + Filters + + + +
+ toggleMulti("status", value)} + /> + toggleMulti("priority", value)} + /> + + toggleSingle("projectId", value)} + /> + toggleSingle("labelId", value)} + /> + toggleSingle("updatedWithin", value)} + /> +
+
Sort by
+
+ {COMPANY_SEARCH_SORTS.map((value) => ( + + ))} +
+
+
+ + + + + + + +
+
+ ); +} + +/** The compact "Filters · n" trigger button shown on mobile. */ +export function SearchFilterSheetTrigger({ + activeCount, + onClick, +}: { + activeCount: number; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/ui/src/components/search/SearchResultRow.tsx b/ui/src/components/search/SearchResultRow.tsx index 7e60e4699e..d159fdfd19 100644 --- a/ui/src/components/search/SearchResultRow.tsx +++ b/ui/src/components/search/SearchResultRow.tsx @@ -256,7 +256,11 @@ function SnippetLine({ text, highlights, field, fallbackLabel, multiline = false className={cn("h-3.5 w-3.5 shrink-0 text-muted-foreground/60", multiline && "mt-0.5")} aria-hidden /> - {label}: + + {label} + void; +}) { + return ( + + + + + + Sort by + + {COMPANY_SEARCH_SORTS.map((sort) => ( + onChange(sort)} className="gap-2 text-sm"> + + {SORT_LABELS[sort]} + + ))} + + + ); +} diff --git a/ui/src/components/search/ZeroResultsRecovery.tsx b/ui/src/components/search/ZeroResultsRecovery.tsx new file mode 100644 index 0000000000..f10e777fe7 --- /dev/null +++ b/ui/src/components/search/ZeroResultsRecovery.tsx @@ -0,0 +1,81 @@ +import { FilterX, RotateCcw } from "lucide-react"; +import type { CompanySearchZeroResults } from "@paperclipai/shared"; +import { Button } from "@/components/ui/button"; +import { + clearFilterDimension, + countActiveFilters, + describeLoosenSuggestion, + type FilterChipLookups, + type SearchFilters, +} from "@/lib/search-filters"; + +export function ZeroResultsRecovery({ + query, + filters, + zeroResults, + lookups, + onChange, + onClearAll, +}: { + query: string; + filters: SearchFilters; + zeroResults: CompanySearchZeroResults; + lookups: FilterChipLookups; + onChange: (next: SearchFilters) => void; + onClearAll: () => void; +}) { + const activeCount = countActiveFilters(filters); + const { unfilteredTotal } = zeroResults; + // Rank suggestions by how many results each one recovers (highest impact first). + const suggestions = [...zeroResults.loosenSuggestions].sort( + (a, b) => b.additionalCount - a.additionalCount, + ); + + return ( +
+ +
+
No results with these filters
+

+ {unfilteredTotal === 1 ? "1 result matches" : `${unfilteredTotal} results match`} + {query ? <> “{query}” : null}, but your{" "} + {activeCount === 1 ? "active filter hides" : `${activeCount} active filters hide`} all of them. +

+
+ + {suggestions.length > 0 ? ( +
+
+ Loosen a filter +
+ {suggestions.map((suggestion) => ( + + ))} +
+ ) : null} + + +
+ ); +} diff --git a/ui/src/lib/search-filters.ts b/ui/src/lib/search-filters.ts new file mode 100644 index 0000000000..b08302ba14 --- /dev/null +++ b/ui/src/lib/search-filters.ts @@ -0,0 +1,254 @@ +import { + COMPANY_SEARCH_SORTS, + type CompanySearchSort, +} from "@paperclipai/shared"; +import type { ParsedSearchQuery } from "./search-query-parser"; + +/** + * The issue-scoped filter model for /search. This is the SAME shape the query + * parser (search-query-parser.ts) and the URL round-trip already use — we build + * the P2 filter-bar UI directly on top of it rather than inventing a second + * scheme. `sort` lives alongside the filters but is tracked separately (it is not + * part of the parser's filter set). + */ +export type SearchFilters = ParsedSearchQuery["filters"]; + +export const SORT_LABELS: Record = { + relevance: "Relevance", + updated: "Recently updated", + created: "Newest created", + priority: "Priority", +}; + +export const UPDATED_WITHIN_LABELS: Record = { + "24h": "Last 24 hours", + "7d": "Last 7 days", + "30d": "Last 30 days", + "90d": "Last 90 days", +}; + +export function updatedWithinLabel(value: string): string { + return UPDATED_WITHIN_LABELS[value] ?? `Updated ≤ ${value}`; +} + +const SORT_SET = new Set(COMPANY_SEARCH_SORTS); + +export function parseSearchSort(params: URLSearchParams): CompanySearchSort { + const raw = params.get("sort"); + return raw && SORT_SET.has(raw) ? (raw as CompanySearchSort) : "relevance"; +} + +/** Count active filter *dimensions* (assignee counts once regardless of shape). */ +export function countActiveFilters(filters: SearchFilters): number { + let count = 0; + if (filters.status?.length) count += 1; + if (filters.priority?.length) count += 1; + if (filters.assigneeAgentId !== undefined || filters.assigneeUserId) count += 1; + if (filters.projectId) count += 1; + if (filters.labelId) count += 1; + if (filters.updatedWithin || filters.updatedAfter) count += 1; + return count; +} + +// --------------------------------------------------------------------------- +// Assignee: the UI treats assignee as a single choice, but the wire model splits +// it across assigneeAgentId (string | null) and assigneeUserId (string). These +// helpers translate between a single opaque token and that split representation. +// "me" → assigneeUserId = currentUserId +// "none" → assigneeAgentId = null (unassigned) +// "agent:" → assigneeAgentId +// "user:" → assigneeUserId +// --------------------------------------------------------------------------- + +export function assigneeToken(filters: SearchFilters, currentUserId: string | null): string | undefined { + if (filters.assigneeAgentId === null) return "none"; + if (typeof filters.assigneeAgentId === "string") return `agent:${filters.assigneeAgentId}`; + if (filters.assigneeUserId) { + return filters.assigneeUserId === currentUserId ? "me" : `user:${filters.assigneeUserId}`; + } + return undefined; +} + +export function applyAssigneeToken( + filters: SearchFilters, + token: string | undefined, + currentUserId: string | null, +): SearchFilters { + const next: SearchFilters = { ...filters }; + delete next.assigneeAgentId; + delete next.assigneeUserId; + if (!token) return next; + if (token === "none") { + next.assigneeAgentId = null; + } else if (token === "me") { + if (currentUserId) next.assigneeUserId = currentUserId; + } else if (token.startsWith("agent:")) { + next.assigneeAgentId = token.slice("agent:".length); + } else if (token.startsWith("user:")) { + next.assigneeUserId = token.slice("user:".length); + } + return next; +} + +export interface FilterChipLookups { + agentName: (id: string) => string | undefined; + userName: (id: string) => string | undefined; + projectName: (id: string) => string | undefined; + labelName: (id: string) => string | undefined; + currentUserId: string | null; +} + +export interface FilterChip { + id: string; + label: string; + remove: (filters: SearchFilters) => SearchFilters; +} + +function humanize(value: string): string { + return value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function assigneeChipLabel(filters: SearchFilters, lookups: FilterChipLookups): string { + if (filters.assigneeAgentId === null) return "Unassigned"; + if (typeof filters.assigneeAgentId === "string") { + return lookups.agentName(filters.assigneeAgentId) ?? "Agent"; + } + if (filters.assigneeUserId) { + if (filters.assigneeUserId === lookups.currentUserId) return "Me"; + return lookups.userName(filters.assigneeUserId) ?? "User"; + } + return "Assignee"; +} + +/** Removable chip descriptors for the active-filter row. */ +export function buildFilterChips(filters: SearchFilters, lookups: FilterChipLookups): FilterChip[] { + const chips: FilterChip[] = []; + for (const status of filters.status ?? []) { + chips.push({ + id: `status:${status}`, + label: `Status: ${humanize(status)}`, + remove: (current) => { + const next = { ...current }; + const remaining = (current.status ?? []).filter((value) => value !== status); + if (remaining.length > 0) next.status = remaining; + else delete next.status; + return next; + }, + }); + } + for (const priority of filters.priority ?? []) { + chips.push({ + id: `priority:${priority}`, + label: `Priority: ${humanize(priority)}`, + remove: (current) => { + const next = { ...current }; + const remaining = (current.priority ?? []).filter((value) => value !== priority); + if (remaining.length > 0) next.priority = remaining; + else delete next.priority; + return next; + }, + }); + } + if (filters.assigneeAgentId !== undefined || filters.assigneeUserId) { + chips.push({ + id: "assignee", + label: `Assignee: ${assigneeChipLabel(filters, lookups)}`, + remove: (current) => { + const next = { ...current }; + delete next.assigneeAgentId; + delete next.assigneeUserId; + return next; + }, + }); + } + if (filters.projectId) { + chips.push({ + id: "project", + label: `Project: ${lookups.projectName(filters.projectId) ?? "Project"}`, + remove: (current) => { + const next = { ...current }; + delete next.projectId; + return next; + }, + }); + } + if (filters.labelId) { + chips.push({ + id: "label", + label: `Label: ${lookups.labelName(filters.labelId) ?? "Label"}`, + remove: (current) => { + const next = { ...current }; + delete next.labelId; + return next; + }, + }); + } + if (filters.updatedWithin) { + chips.push({ + id: "updated", + label: `Updated: ${updatedWithinLabel(filters.updatedWithin)}`, + remove: (current) => { + const next = { ...current }; + delete next.updatedWithin; + delete next.updatedAfter; + return next; + }, + }); + } + return chips; +} + +/** Human label for a backend zero-results loosen suggestion. */ +export function describeLoosenSuggestion(filterKey: string, values: string[], lookups: FilterChipLookups): string { + switch (filterKey) { + case "status": + return `Status: ${values.map(humanize).join(", ")}`; + case "priority": + return `Priority: ${values.map(humanize).join(", ")}`; + case "assigneeAgentId": + return `Assignee: ${values.map((id) => lookups.agentName(id) ?? "Agent").join(", ")}`; + case "assigneeUserId": + return `Assignee: ${values.map((id) => (id === lookups.currentUserId ? "Me" : lookups.userName(id) ?? "User")).join(", ")}`; + case "projectId": + return `Project: ${values.map((id) => lookups.projectName(id) ?? "Project").join(", ")}`; + case "labelId": + return `Label: ${values.map((id) => lookups.labelName(id) ?? "Label").join(", ")}`; + case "updatedWithin": + case "updatedAfter": + return "Updated window"; + default: + return humanize(filterKey); + } +} + +/** Clear the filter dimension a loosen suggestion refers to. */ +export function clearFilterDimension(filters: SearchFilters, filterKey: string): SearchFilters { + const next: SearchFilters = { ...filters }; + switch (filterKey) { + case "status": + delete next.status; + break; + case "priority": + delete next.priority; + break; + case "assigneeAgentId": + case "assigneeUserId": + delete next.assigneeAgentId; + delete next.assigneeUserId; + break; + case "projectId": + delete next.projectId; + break; + case "labelId": + delete next.labelId; + break; + case "updatedWithin": + case "updatedAfter": + delete next.updatedWithin; + delete next.updatedAfter; + break; + default: + break; + } + return next; +} diff --git a/ui/src/lib/search-query-parser.test.ts b/ui/src/lib/search-query-parser.test.ts new file mode 100644 index 0000000000..46cdeeaff5 --- /dev/null +++ b/ui/src/lib/search-query-parser.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { + applySearchOperatorSuggestion, + buildSearchPathFromQuery, + parseSearchQuery, + readSearchFiltersFromParams, + searchOperatorSuggestions, +} from "./search-query-parser"; + +const context = { + currentUserId: "user-1", + agents: [ + { id: "agent-1", name: "Codex Coder", urlKey: "codex-coder" }, + { id: "agent-2", name: "QA" }, + ], + projects: [ + { id: "11111111-1111-4111-8111-111111111111", name: "Paperclip App", urlKey: "paperclip-app" }, + ], + labels: [ + { id: "22222222-2222-4222-8222-222222222222", name: "bug" }, + ], +}; + +describe("parseSearchQuery", () => { + it("parses status operators", () => { + expect(parseSearchQuery("status:todo auth", context)).toMatchObject({ + query: "auth", + filters: { status: ["todo"] }, + pills: [{ key: "status", value: "todo", label: "status:todo" }], + }); + }); + + it("parses assignee:me to the current user", () => { + expect(parseSearchQuery("assignee:me", context).filters).toEqual({ + assigneeUserId: "user-1", + }); + }); + + it("parses assignee names including quoted multi-word names", () => { + expect(parseSearchQuery("assignee:\"Codex Coder\" crash", context)).toMatchObject({ + query: "crash", + filters: { assigneeAgentId: "agent-1" }, + pills: [{ key: "assignee", value: "Codex Coder", label: "assignee:Codex Coder" }], + }); + }); + + it("parses project names", () => { + expect(parseSearchQuery("project:paperclip-app", context).filters).toEqual({ + projectId: "11111111-1111-4111-8111-111111111111", + }); + }); + + it("parses label names", () => { + expect(parseSearchQuery("label:bug", context).filters).toEqual({ + labelId: "22222222-2222-4222-8222-222222222222", + }); + }); + + it("parses priority operators", () => { + expect(parseSearchQuery("priority:high", context).filters).toEqual({ + priority: ["high"], + }); + }); + + it("parses updated:>7d as updatedWithin", () => { + expect(parseSearchQuery("updated:>7d", context).filters).toEqual({ + updatedWithin: "7d", + }); + }); + + it("parses is:open quick filters", () => { + expect(parseSearchQuery("is:open", context).filters).toEqual({ + status: ["backlog", "todo", "in_progress", "in_review", "blocked"], + }); + }); + + it("preserves quoted phrases in free text", () => { + expect(parseSearchQuery("\"auth flake\" status:blocked", context)).toMatchObject({ + query: "\"auth flake\"", + filters: { status: ["blocked"] }, + }); + }); + + it("parses mixed free text and multiple operators", () => { + expect(parseSearchQuery("auth status:in_progress priority:critical project:paperclip-app", context)).toMatchObject({ + query: "auth", + filters: { + status: ["in_progress"], + priority: ["critical"], + projectId: "11111111-1111-4111-8111-111111111111", + }, + }); + }); + + it("falls unknown operators through to plain text", () => { + expect(parseSearchQuery("owner:me auth", context)).toMatchObject({ + query: "owner:me auth", + filters: {}, + pills: [], + }); + }); + + it("falls malformed values through to plain text", () => { + expect(parseSearchQuery("status:notreal updated:>soon priority:urgent", context)).toMatchObject({ + query: "status:notreal updated:>soon priority:urgent", + filters: {}, + pills: [], + }); + }); +}); + +describe("search query URLs", () => { + it("builds /search paths with parsed filters", () => { + expect(buildSearchPathFromQuery("auth status:todo updated:>7d", context)).toBe( + "/search?q=auth&status=todo&updatedWithin=7d", + ); + }); + + it("reads filter params back from URLSearchParams", () => { + const filters = readSearchFiltersFromParams( + new URLSearchParams("q=auth&status=todo&status=blocked&priority=high&updatedWithin=7d"), + ); + expect(filters).toEqual({ + status: ["todo", "blocked"], + priority: ["high"], + updatedWithin: "7d", + }); + }); +}); + +describe("search operator suggestions", () => { + it("suggests syntax for the current partial token", () => { + expect(searchOperatorSuggestions("auth sta").map((suggestion) => suggestion.token)).toEqual([ + "status:todo", + "status:blocked", + ]); + }); + + it("replaces only the current token when applying a suggestion", () => { + expect(applySearchOperatorSuggestion("auth sta", "status:todo")).toBe("auth status:todo"); + expect(applySearchOperatorSuggestion("", "assignee:me")).toBe("assignee:me"); + }); +}); diff --git a/ui/src/lib/search-query-parser.ts b/ui/src/lib/search-query-parser.ts new file mode 100644 index 0000000000..be48b1be1f --- /dev/null +++ b/ui/src/lib/search-query-parser.ts @@ -0,0 +1,428 @@ +import { + COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS, + ISSUE_PRIORITIES, + ISSUE_STATUSES, + isUuidLike, + normalizeAgentUrlKey, + type IssuePriority, + type IssueStatus, +} from "@paperclipai/shared"; +import type { CompanySearchParams } from "@/api/search"; + +const SEARCH_FILTER_PARAM_KEYS = [ + "status", + "priority", + "assigneeAgentId", + "assigneeUserId", + "projectId", + "labelId", + "updatedWithin", + "updatedAfter", +] as const; + +const OPEN_STATUSES: IssueStatus[] = ["backlog", "todo", "in_progress", "in_review", "blocked"]; +const CLOSED_STATUSES: IssueStatus[] = ["done", "cancelled"]; + +export type SearchOperatorKey = "status" | "assignee" | "project" | "label" | "priority" | "updated" | "is"; + +export interface SearchOperatorPill { + key: SearchOperatorKey; + value: string; + label: string; +} + +export interface SearchOperatorSuggestion { + token: string; + label: string; + description: string; +} + +export const SEARCH_OPERATOR_QUICK_FILTERS = ["assignee:me", "is:open", "updated:>7d"] as const; + +export const SEARCH_OPERATOR_SUGGESTIONS: SearchOperatorSuggestion[] = [ + { token: "status:todo", label: "Open todo tasks", description: "Filter by task status" }, + { token: "status:blocked", label: "Blocked tasks", description: "Find blocked work" }, + { token: "assignee:me", label: "Assigned to me", description: "Use your current board user" }, + { token: "project:\"Paperclip App\"", label: "Project name", description: "Quote multi-word project names" }, + { token: "label:bug", label: "Label", description: "Filter by issue label" }, + { token: "priority:high", label: "High priority", description: "Filter by priority" }, + { token: "updated:>7d", label: "Recently updated", description: "Updated in the last 7 days" }, +]; + +export interface SearchQueryParserContext { + currentAgentId?: string | null; + currentUserId?: string | null; + agents?: readonly { id: string; name: string; urlKey?: string | null }[]; + projects?: readonly { id: string; name: string; urlKey?: string | null }[]; + labels?: readonly { id: string; name: string }[]; +} + +export interface ParsedSearchQuery { + query: string; + filters: Pick< + CompanySearchParams, + | "status" + | "priority" + | "assigneeAgentId" + | "assigneeUserId" + | "projectId" + | "labelId" + | "updatedWithin" + | "updatedAfter" + >; + pills: SearchOperatorPill[]; +} + +interface QueryToken { + raw: string; + value: string; +} + +function stripValueQuotes(value: string) { + if (value.length >= 2 && value.startsWith("\"") && value.endsWith("\"")) { + return value.slice(1, -1); + } + return value; +} + +function tokenizeQuery(input: string): QueryToken[] { + const tokens: QueryToken[] = []; + let index = 0; + while (index < input.length) { + while (/\s/.test(input[index] ?? "")) index += 1; + if (index >= input.length) break; + + const start = index; + if (input[index] === "\"") { + index += 1; + while (index < input.length && input[index] !== "\"") index += 1; + if (input[index] === "\"") index += 1; + const raw = input.slice(start, index); + tokens.push({ raw, value: raw }); + continue; + } + + while (index < input.length && !/\s/.test(input[index] ?? "")) { + if (input[index] === ":" && input[index + 1] === "\"") { + index += 2; + while (index < input.length && input[index] !== "\"") index += 1; + if (input[index] === "\"") index += 1; + break; + } + index += 1; + } + + const raw = input.slice(start, index); + tokens.push({ raw, value: raw }); + } + return tokens; +} + +function currentTokenBounds(input: string): { start: number; end: number; token: string } { + let end = input.length; + while (end > 0 && /\s/.test(input[end - 1] ?? "")) end -= 1; + let start = end; + while (start > 0 && !/\s/.test(input[start - 1] ?? "")) start -= 1; + return { start, end, token: input.slice(start, end) }; +} + +export function searchOperatorSuggestions(input: string, limit = 5): SearchOperatorSuggestion[] { + const { token } = currentTokenBounds(input); + const normalized = token.toLowerCase(); + const candidates = normalized.length > 0 + ? SEARCH_OPERATOR_SUGGESTIONS.filter((suggestion) => suggestion.token.toLowerCase().startsWith(normalized)) + : SEARCH_OPERATOR_SUGGESTIONS; + return candidates.slice(0, limit); +} + +export function applySearchOperatorSuggestion(input: string, token: string): string { + const { start, end } = currentTokenBounds(input); + const prefix = input.slice(0, start).trimEnd(); + const suffix = input.slice(end).trimStart(); + return [prefix, token, suffix].filter(Boolean).join(" ").trim(); +} + +function normalizedLookup(value: string) { + return normalizeAgentUrlKey(value) ?? value.trim().toLowerCase(); +} + +function findByNameOrId( + entries: readonly T[] | undefined, + value: string, +): T | null { + const normalized = normalizedLookup(value); + return entries?.find((entry) => { + if (entry.id === value) return true; + if (normalizedLookup(entry.name) === normalized) return true; + return entry.urlKey ? normalizedLookup(entry.urlKey) === normalized : false; + }) ?? null; +} + +function addUnique(values: T[] | undefined, value: T): T[] { + return values?.includes(value) ? values : [...(values ?? []), value]; +} + +function appendText(parts: string[], raw: string) { + if (raw.trim().length > 0) parts.push(raw); +} + +function parseStatus(value: string): IssueStatus | null { + return (ISSUE_STATUSES as readonly string[]).includes(value) ? value as IssueStatus : null; +} + +function parsePriority(value: string): IssuePriority | null { + return (ISSUE_PRIORITIES as readonly string[]).includes(value) ? value as IssuePriority : null; +} + +function parseUpdatedWithin(value: string): string | null { + const normalized = value.startsWith(">") ? value.slice(1) : value; + if (!/^[1-9]\d{0,2}(h|d|w|m)$/.test(normalized)) return null; + return normalized; +} + +function operatorLabel(key: SearchOperatorKey, value: string) { + return `${key}:${value}`; +} + +export function parseSearchQuery(input: string, context: SearchQueryParserContext = {}): ParsedSearchQuery { + const textParts: string[] = []; + const filters: ParsedSearchQuery["filters"] = {}; + const pills: SearchOperatorPill[] = []; + + for (const token of tokenizeQuery(input)) { + const match = /^([a-zA-Z]+):(.*)$/s.exec(token.value); + if (!match) { + appendText(textParts, token.raw); + continue; + } + + const key = match[1]!.toLowerCase(); + const rawValue = match[2]!; + const value = stripValueQuotes(rawValue).trim(); + if (!value) { + appendText(textParts, token.raw); + continue; + } + + if (key === "status") { + const status = parseStatus(value); + if (!status) { + appendText(textParts, token.raw); + continue; + } + filters.status = addUnique(filters.status, status); + pills.push({ key: "status", value: status, label: operatorLabel("status", status) }); + continue; + } + + if (key === "priority") { + const priority = parsePriority(value); + if (!priority) { + appendText(textParts, token.raw); + continue; + } + filters.priority = addUnique(filters.priority, priority); + pills.push({ key: "priority", value: priority, label: operatorLabel("priority", priority) }); + continue; + } + + if (key === "assignee") { + if (value.toLowerCase() === "me") { + if (context.currentAgentId) { + filters.assigneeAgentId = context.currentAgentId; + pills.push({ key: "assignee", value: "me", label: "assignee:me" }); + continue; + } + if (context.currentUserId) { + filters.assigneeUserId = context.currentUserId; + pills.push({ key: "assignee", value: "me", label: "assignee:me" }); + continue; + } + appendText(textParts, token.raw); + continue; + } + + const agent = findByNameOrId(context.agents, value); + if (!agent) { + appendText(textParts, token.raw); + continue; + } + filters.assigneeAgentId = agent.id; + pills.push({ key: "assignee", value: agent.name, label: operatorLabel("assignee", agent.name) }); + continue; + } + + if (key === "project") { + const project = findByNameOrId(context.projects, value); + if (!project) { + appendText(textParts, token.raw); + continue; + } + filters.projectId = project.id; + pills.push({ key: "project", value: project.name, label: operatorLabel("project", project.name) }); + continue; + } + + if (key === "label") { + const label = findByNameOrId(context.labels, value); + if (label) { + filters.labelId = label.id; + pills.push({ key: "label", value: label.name, label: operatorLabel("label", label.name) }); + continue; + } + if (isUuidLike(value)) { + filters.labelId = value; + pills.push({ key: "label", value, label: operatorLabel("label", value.slice(0, 8)) }); + continue; + } + appendText(textParts, token.raw); + continue; + } + + if (key === "updated") { + const updatedWithin = parseUpdatedWithin(value); + if (!updatedWithin) { + appendText(textParts, token.raw); + continue; + } + filters.updatedWithin = updatedWithin; + pills.push({ key: "updated", value: `>${updatedWithin}`, label: operatorLabel("updated", `>${updatedWithin}`) }); + continue; + } + + if (key === "is") { + if (value === "open") { + filters.status = OPEN_STATUSES; + pills.push({ key: "is", value: "open", label: "is:open" }); + continue; + } + if (value === "closed") { + filters.status = CLOSED_STATUSES; + pills.push({ key: "is", value: "closed", label: "is:closed" }); + continue; + } + appendText(textParts, token.raw); + continue; + } + + appendText(textParts, token.raw); + } + + return { + query: textParts.join(" ").replace(/\s+/g, " ").trim(), + filters, + pills, + }; +} + +function appendMulti(search: URLSearchParams, key: string, values: readonly string[] | undefined) { + for (const value of values ?? []) search.append(key, value); +} + +export function clearSearchFilterParams(search: URLSearchParams) { + for (const key of SEARCH_FILTER_PARAM_KEYS) search.delete(key); +} + +export function applySearchFiltersToParams(search: URLSearchParams, filters: ParsedSearchQuery["filters"]) { + clearSearchFilterParams(search); + appendMulti(search, "status", filters.status); + appendMulti(search, "priority", filters.priority); + if (filters.assigneeAgentId !== undefined) search.set("assigneeAgentId", filters.assigneeAgentId ?? "null"); + if (filters.assigneeUserId !== undefined) search.set("assigneeUserId", filters.assigneeUserId); + if (filters.projectId !== undefined) search.set("projectId", filters.projectId); + if (filters.labelId !== undefined) search.set("labelId", filters.labelId); + if (filters.updatedWithin !== undefined) search.set("updatedWithin", filters.updatedWithin); + if (filters.updatedAfter !== undefined) search.set("updatedAfter", filters.updatedAfter); +} + +function validValues(values: string[], allowed: readonly T[]): T[] { + return values.filter((value): value is T => (allowed as readonly string[]).includes(value)); +} + +export function readSearchFiltersFromParams(search: URLSearchParams): ParsedSearchQuery["filters"] { + const filters: ParsedSearchQuery["filters"] = {}; + const statuses = validValues(search.getAll("status").flatMap((value) => value.split(",")), ISSUE_STATUSES); + const priorities = validValues(search.getAll("priority").flatMap((value) => value.split(",")), ISSUE_PRIORITIES); + const assigneeAgentId = search.get("assigneeAgentId"); + const assigneeUserId = search.get("assigneeUserId"); + const projectId = search.get("projectId"); + const labelId = search.get("labelId"); + const updatedWithin = search.get("updatedWithin"); + const updatedAfter = search.get("updatedAfter"); + + if (statuses.length > 0) filters.status = statuses; + if (priorities.length > 0) filters.priority = priorities; + if (assigneeAgentId !== null) filters.assigneeAgentId = assigneeAgentId === "null" ? null : assigneeAgentId; + if (assigneeUserId) filters.assigneeUserId = assigneeUserId; + if (projectId && isUuidLike(projectId)) filters.projectId = projectId; + if (labelId && isUuidLike(labelId)) filters.labelId = labelId; + if (updatedWithin && (/^[1-9]\d{0,2}(h|d|w|m)$/.test(updatedWithin) || (COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS as readonly string[]).includes(updatedWithin))) { + filters.updatedWithin = updatedWithin; + } + if (updatedAfter && !Number.isNaN(new Date(updatedAfter).getTime())) filters.updatedAfter = updatedAfter; + return filters; +} + +export function hasSearchFilters(filters: ParsedSearchQuery["filters"]) { + return Boolean( + filters.status?.length + || filters.priority?.length + || filters.assigneeAgentId !== undefined + || filters.assigneeUserId + || filters.projectId + || filters.labelId + || filters.updatedWithin + || filters.updatedAfter, + ); +} + +function nameForId(entries: readonly T[] | undefined, id: string) { + return entries?.find((entry) => entry.id === id)?.name ?? id.slice(0, 8); +} + +export function searchFilterPills( + filters: ParsedSearchQuery["filters"], + context: SearchQueryParserContext = {}, +): SearchOperatorPill[] { + const pills: SearchOperatorPill[] = []; + for (const status of filters.status ?? []) { + pills.push({ key: "status", value: status, label: operatorLabel("status", status) }); + } + for (const priority of filters.priority ?? []) { + pills.push({ key: "priority", value: priority, label: operatorLabel("priority", priority) }); + } + if (filters.assigneeAgentId !== undefined) { + const value = filters.assigneeAgentId === null + ? "unassigned" + : nameForId(context.agents, filters.assigneeAgentId); + pills.push({ key: "assignee", value, label: operatorLabel("assignee", value) }); + } + if (filters.assigneeUserId) { + const value = filters.assigneeUserId === context.currentUserId ? "me" : filters.assigneeUserId.slice(0, 8); + pills.push({ key: "assignee", value, label: operatorLabel("assignee", value) }); + } + if (filters.projectId) { + const value = nameForId(context.projects, filters.projectId); + pills.push({ key: "project", value, label: operatorLabel("project", value) }); + } + if (filters.labelId) { + const value = nameForId(context.labels, filters.labelId); + pills.push({ key: "label", value, label: operatorLabel("label", value) }); + } + if (filters.updatedWithin) { + pills.push({ key: "updated", value: `>${filters.updatedWithin}`, label: operatorLabel("updated", `>${filters.updatedWithin}`) }); + } + if (filters.updatedAfter) { + pills.push({ key: "updated", value: filters.updatedAfter, label: operatorLabel("updated", filters.updatedAfter) }); + } + return pills; +} + +export function buildSearchPathFromQuery(input: string, context: SearchQueryParserContext = {}) { + const parsed = parseSearchQuery(input, context); + const search = new URLSearchParams(); + if (parsed.query.length > 0) search.set("q", parsed.query); + applySearchFiltersToParams(search, parsed.filters); + const qs = search.toString(); + return qs ? `/search?${qs}` : "/search"; +} diff --git a/ui/src/pages/Search.test.tsx b/ui/src/pages/Search.test.tsx index b271583530..2618e93322 100644 --- a/ui/src/pages/Search.test.tsx +++ b/ui/src/pages/Search.test.tsx @@ -34,6 +34,14 @@ const projectsApiMock = vi.hoisted(() => ({ list: vi.fn(), })); +const issuesApiMock = vi.hoisted(() => ({ + listLabels: vi.fn(), +})); + +const authApiMock = vi.hoisted(() => ({ + getSession: vi.fn(), +})); + vi.mock("../context/CompanyContext", () => ({ useCompany: () => companyState, })); @@ -62,6 +70,14 @@ vi.mock("../api/projects", () => ({ projectsApi: projectsApiMock, })); +vi.mock("../api/issues", () => ({ + issuesApi: issuesApiMock, +})); + +vi.mock("../api/auth", () => ({ + authApi: authApiMock, +})); + vi.mock("@/lib/router", async () => { const actual = await vi.importActual("react-router-dom"); return { @@ -153,8 +169,12 @@ describe("Search page", () => { searchApiMock.search.mockReset(); agentsApiMock.list.mockReset(); projectsApiMock.list.mockReset(); + issuesApiMock.listLabels.mockReset(); + authApiMock.getSession.mockReset(); agentsApiMock.list.mockResolvedValue([]); projectsApiMock.list.mockResolvedValue([]); + issuesApiMock.listLabels.mockResolvedValue([]); + authApiMock.getSession.mockResolvedValue({ user: { id: "user-1" }, session: { userId: "user-1" } }); window.localStorage.clear(); }); @@ -169,7 +189,18 @@ describe("Search page", () => { scope: "all", limit: 20, offset: 0, - countsByType: { issue: 1, artifact: 0, agent: 0, project: 0 }, + sort: "relevance", + countsByType: { issue: 1, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 }, + filterOptionCounts: { + status: {}, + priority: {}, + assigneeAgentId: {}, + assigneeUserId: {}, + projectId: {}, + labelId: {}, + updatedWithin: {}, + }, + zeroResults: null, hasMore: false, results: [ { @@ -239,7 +270,18 @@ describe("Search page", () => { scope: "artifacts", limit: 20, offset: 0, - countsByType: { issue: 0, artifact: 1, agent: 0, project: 0 }, + sort: "relevance", + countsByType: { issue: 0, comment: 0, document: 0, artifact: 1, agent: 0, project: 0 }, + filterOptionCounts: { + status: {}, + priority: {}, + assigneeAgentId: {}, + assigneeUserId: {}, + projectId: {}, + labelId: {}, + updatedWithin: {}, + }, + zeroResults: null, hasMore: false, results: [ { @@ -297,6 +339,138 @@ describe("Search page", () => { }); }); + it("renders comment and document result rows with exact anchors, source chips, and highlights", async () => { + searchApiMock.search.mockResolvedValueOnce({ + query: "needle", + normalizedQuery: "needle", + scope: "all", + limit: 20, + offset: 0, + sort: "relevance", + countsByType: { issue: 0, comment: 1, document: 1, artifact: 0, agent: 0, project: 0 }, + filterOptionCounts: { + status: {}, + priority: {}, + assigneeAgentId: {}, + assigneeUserId: {}, + projectId: {}, + labelId: {}, + updatedWithin: {}, + }, + zeroResults: null, + hasMore: false, + results: [ + { + id: "issue-comment", + type: "issue", + score: 180, + title: "PAP-77 Comment source", + href: "/PAP/issues/PAP-77#comment-comment-77", + matchedFields: ["comment"], + sourceLabel: "Comment", + snippet: "thread needle evidence", + snippets: [ + { + field: "comment", + label: "Comment", + text: "thread needle evidence", + highlights: [{ start: 7, end: 13 }], + }, + ], + issue: { + id: "issue-comment", + identifier: "PAP-77", + title: "Comment source", + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + projectId: null, + updatedAt: new Date().toISOString(), + }, + updatedAt: new Date().toISOString(), + previewImageUrl: null, + }, + { + id: "issue-document", + type: "issue", + score: 170, + title: "PAP-78 Document source", + href: "/PAP/issues/PAP-78#document-plan", + matchedFields: ["document"], + sourceLabel: "Plan", + snippet: "plan needle evidence", + snippets: [ + { + field: "document", + label: "Plan", + text: "plan needle evidence", + highlights: [{ start: 5, end: 11 }], + }, + ], + issue: { + id: "issue-document", + identifier: "PAP-78", + title: "Document source", + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + projectId: null, + updatedAt: new Date().toISOString(), + }, + updatedAt: new Date().toISOString(), + previewImageUrl: null, + }, + ], + }); + + const { root } = renderSearch("/search?q=needle", container); + + await waitForAssertion(() => { + expect(container.querySelector('a[href="/PAP/issues/PAP-77#comment-comment-77"]')).not.toBeNull(); + expect(container.querySelector('a[href="/PAP/issues/PAP-78#document-plan"]')).not.toBeNull(); + expect(container.textContent).toContain("Comment"); + expect(container.textContent).toContain("Doc"); + expect(container.querySelectorAll("mark")).toHaveLength(2); + }); + + flushSync(() => { + root.unmount(); + }); + }); + + it("renders the explicit loading state while search is pending", async () => { + searchApiMock.search.mockReturnValueOnce(new Promise(() => {})); + + const { root } = renderSearch("/search?q=slow", container); + + await waitForAssertion(() => { + expect(container.querySelector('[data-testid="search-loading"]')?.textContent).toContain("slow"); + }); + + flushSync(() => { + root.unmount(); + }); + }); + + it("renders the explicit error state with retry and fallback actions", async () => { + searchApiMock.search.mockRejectedValueOnce(Object.assign(new Error("Search failed"), { status: 500 })); + + const { root } = renderSearch("/search?q=broken", container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Couldn’t run that search"); + expect(container.textContent).toContain("The server returned 500."); + expect(container.textContent).toContain("Retry"); + expect(container.textContent).toContain("Open Tasks filter view"); + }); + + flushSync(() => { + root.unmount(); + }); + }); + it("debounces typing into the input and dispatches a search after the debounce window", async () => { searchApiMock.search.mockResolvedValue({ query: "deflake", @@ -304,7 +478,18 @@ describe("Search page", () => { scope: "all", limit: 20, offset: 0, - countsByType: { issue: 0, artifact: 0, agent: 0, project: 0 }, + sort: "relevance", + countsByType: { issue: 0, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 }, + filterOptionCounts: { + status: {}, + priority: {}, + assigneeAgentId: {}, + assigneeUserId: {}, + projectId: {}, + labelId: {}, + updatedWithin: {}, + }, + zeroResults: null, hasMore: false, results: [], }); @@ -348,7 +533,18 @@ describe("Search page", () => { scope: "all", limit: 20, offset: 0, - countsByType: { issue: 1, artifact: 0, agent: 0, project: 0 }, + sort: "relevance", + countsByType: { issue: 1, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 }, + filterOptionCounts: { + status: {}, + priority: {}, + assigneeAgentId: {}, + assigneeUserId: {}, + projectId: {}, + labelId: {}, + updatedWithin: {}, + }, + zeroResults: null, hasMore: false, results: [ { @@ -402,7 +598,18 @@ describe("Search page", () => { scope: "comments", limit: 20, offset: 0, - countsByType: { issue: 0, artifact: 0, agent: 0, project: 0 }, + sort: "relevance", + countsByType: { issue: 0, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 }, + filterOptionCounts: { + status: {}, + priority: {}, + assigneeAgentId: {}, + assigneeUserId: {}, + projectId: {}, + labelId: {}, + updatedWithin: {}, + }, + zeroResults: null, hasMore: false, results: [], }); @@ -419,4 +626,365 @@ describe("Search page", () => { root.unmount(); }); }); + + it("parses URL filters into search params and operator pills", async () => { + searchApiMock.search.mockResolvedValueOnce({ + query: "auth", + normalizedQuery: "auth", + scope: "all", + limit: 20, + offset: 0, + sort: "relevance", + countsByType: { issue: 0, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 }, + filterOptionCounts: { + status: {}, + priority: {}, + assigneeAgentId: {}, + assigneeUserId: {}, + projectId: {}, + labelId: {}, + updatedWithin: {}, + }, + zeroResults: null, + hasMore: false, + results: [], + }); + + const { root } = renderSearch("/search?q=auth&status=todo&updatedWithin=7d", container); + + await waitForAssertion(() => { + expect(searchApiMock.search).toHaveBeenCalledWith("company-1", { + q: "auth", + scope: "all", + limit: 20, + status: ["todo"], + updatedWithin: "7d", + }); + }); + + await waitForAssertion(() => { + expect(container.textContent).toContain("status:todo"); + expect(container.textContent).toContain("updated:>7d"); + }); + + flushSync(() => { + root.unmount(); + }); + }); + + it("parses typed operators before dispatching search", async () => { + searchApiMock.search.mockResolvedValue({ + query: "auth", + normalizedQuery: "auth", + scope: "all", + limit: 20, + offset: 0, + sort: "relevance", + countsByType: { issue: 0, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 }, + filterOptionCounts: { + status: {}, + priority: {}, + assigneeAgentId: {}, + assigneeUserId: {}, + projectId: {}, + labelId: {}, + updatedWithin: {}, + }, + zeroResults: null, + hasMore: false, + results: [], + }); + + const { root } = renderSearch("/search", container); + const input = container.querySelector('input[aria-label="Search query"]') as HTMLInputElement; + + flushSync(() => { + const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + nativeSetter.call(input, "auth status:blocked"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await new Promise((resolve) => setTimeout(resolve, 350)); + + await waitForAssertion(() => { + expect(searchApiMock.search).toHaveBeenCalledWith("company-1", { + q: "auth", + scope: "all", + limit: 20, + status: ["blocked"], + }); + }); + + await waitForAssertion(() => { + expect(container.textContent).toContain("status:blocked"); + }); + + flushSync(() => { + root.unmount(); + }); + }); + + it("drops a committed operator filter from requests when its token is deleted", async () => { + searchApiMock.search.mockResolvedValue(emptyResponse()); + + const { root } = renderSearch("/search", container); + const input = container.querySelector('input[aria-label="Search query"]') as HTMLInputElement; + const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + + flushSync(() => { + nativeSetter.call(input, "auth status:blocked"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await new Promise((resolve) => setTimeout(resolve, 350)); + await waitForAssertion(() => { + expect(searchApiMock.search).toHaveBeenCalledWith("company-1", { + q: "auth", + scope: "all", + limit: 20, + status: ["blocked"], + }); + }); + + // Deleting the operator token must also delete its filter from the request. + flushSync(() => { + nativeSetter.call(input, "auth"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await new Promise((resolve) => setTimeout(resolve, 350)); + await waitForAssertion(() => { + const lastCall = searchApiMock.search.mock.calls.at(-1); + expect(lastCall?.[1]).toEqual({ q: "auth", scope: "all", limit: 20 }); + }); + + flushSync(() => { + root.unmount(); + }); + }); + + it("removes an operator-derived filter chip and strips its token from the query", async () => { + searchApiMock.search.mockResolvedValue(emptyResponse()); + + const { root } = renderSearch("/search", container); + const input = container.querySelector('input[aria-label="Search query"]') as HTMLInputElement; + const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + + flushSync(() => { + nativeSetter.call(input, "auth status:blocked"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await new Promise((resolve) => setTimeout(resolve, 350)); + await waitForAssertion(() => { + expect(searchApiMock.search).toHaveBeenCalledWith("company-1", { + q: "auth", + scope: "all", + limit: 20, + status: ["blocked"], + }); + }); + + const removeButton = await (async () => { + let button: HTMLButtonElement | null = null; + await waitForAssertion(() => { + button = container.querySelector('button[aria-label="Remove filter Status: Blocked"]'); + expect(button).not.toBeNull(); + }); + return button!; + })(); + + flushSync(() => { + removeButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + // The chip removal wins over the typed token: the input keeps only the plain + // text and the re-query carries no status filter. + await waitForAssertion(() => { + expect(input.value).toBe("auth"); + const lastCall = searchApiMock.search.mock.calls.at(-1); + expect(lastCall?.[1]).toEqual({ q: "auth", scope: "all", limit: 20 }); + }); + + flushSync(() => { + root.unmount(); + }); + }); + + it("shows operator autocomplete suggestions and applies one to the current token", async () => { + searchApiMock.search.mockResolvedValue({ + query: "auth", + normalizedQuery: "auth", + scope: "all", + limit: 20, + offset: 0, + sort: "relevance", + countsByType: { issue: 0, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 }, + filterOptionCounts: { + status: {}, + priority: {}, + assigneeAgentId: {}, + assigneeUserId: {}, + projectId: {}, + labelId: {}, + updatedWithin: {}, + }, + zeroResults: null, + hasMore: false, + results: [], + }); + + const { root } = renderSearch("/search", container); + const input = container.querySelector('input[aria-label="Search query"]') as HTMLInputElement; + expect(input).not.toBeNull(); + + flushSync(() => { + input.focus(); + const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + nativeSetter.call(input, "auth sta"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + let suggestionButton: HTMLButtonElement | null = null; + await waitForAssertion(() => { + const suggestions = container.querySelector('[data-testid="search-operator-suggestions"]'); + expect(suggestions).not.toBeNull(); + expect(suggestions!.textContent).toContain("status:todo"); + expect(suggestions!.textContent).toContain("status:blocked"); + expect(suggestions!.textContent).not.toContain("assignee:me"); + suggestionButton = container.querySelector('button[aria-label="Insert operator status:todo"]'); + expect(suggestionButton).not.toBeNull(); + }); + + flushSync(() => { + suggestionButton!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + suggestionButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitForAssertion(() => { + expect(input.value).toBe("auth status:todo"); + }); + + flushSync(() => { + root.unmount(); + }); + }); + + function emptyResponse(overrides: Record = {}) { + return { + query: "auth", + normalizedQuery: "auth", + scope: "all", + limit: 20, + offset: 0, + sort: "relevance", + countsByType: { issue: 0, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 }, + filterOptionCounts: { + status: {}, + priority: {}, + assigneeAgentId: {}, + assigneeUserId: {}, + projectId: {}, + labelId: {}, + updatedWithin: {}, + }, + zeroResults: null, + hasMore: false, + results: [], + ...overrides, + }; + } + + it("round-trips the sort param through the URL and into the search request", async () => { + searchApiMock.search.mockResolvedValue(emptyResponse({ sort: "updated" })); + + const { root } = renderSearch("/search?q=auth&sort=updated", container); + + await waitForAssertion(() => { + expect(searchApiMock.search).toHaveBeenCalledWith("company-1", { + q: "auth", + scope: "all", + limit: 20, + sort: "updated", + }); + }); + + await waitForAssertion(() => { + // The Sort menu trigger reflects the active sort. + expect(container.textContent).toContain("Recently updated"); + }); + + flushSync(() => { + root.unmount(); + }); + }); + + it("renders a removable filter chip and re-queries without the filter when removed", async () => { + searchApiMock.search.mockResolvedValue(emptyResponse()); + + const { root } = renderSearch("/search?q=auth&status=todo", container); + + // First request carries the status filter from the URL. + await waitForAssertion(() => { + expect(searchApiMock.search).toHaveBeenCalledWith("company-1", { + q: "auth", + scope: "all", + limit: 20, + status: ["todo"], + }); + }); + + // A removable chip is rendered for the active filter. + const removeButton = await (async () => { + let button: HTMLButtonElement | null = null; + await waitForAssertion(() => { + button = container.querySelector('button[aria-label="Remove filter Status: Todo"]'); + expect(button).not.toBeNull(); + }); + return button!; + })(); + + flushSync(() => { + removeButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + // After removal the search re-fires with no status filter. + await waitForAssertion(() => { + const lastCall = searchApiMock.search.mock.calls.at(-1); + expect(lastCall?.[1]).toEqual({ q: "auth", scope: "all", limit: 20 }); + }); + + flushSync(() => { + root.unmount(); + }); + }); + + it("renders zero-results recovery with loosen suggestions when filters empty the page", async () => { + searchApiMock.search.mockResolvedValueOnce( + emptyResponse({ + zeroResults: { + unfilteredTotal: 12, + loosenSuggestions: [ + { filter: "status", values: ["done"], resultCount: 12, additionalCount: 12 }, + ], + }, + }), + ); + + const { root } = renderSearch("/search?q=auth&status=done", container); + + await waitForAssertion(() => { + expect(container.querySelector('[data-testid="search-zero-results-recovery"]')).not.toBeNull(); + expect(container.textContent).toContain("No results with these filters"); + expect(container.textContent).toContain("12 results match"); + expect(container.textContent).toContain("Loosen a filter"); + expect(container.textContent).toContain("+12 results"); + expect(container.textContent).toContain("Clear all filters"); + }); + + flushSync(() => { + root.unmount(); + }); + }); + }); diff --git a/ui/src/pages/Search.tsx b/ui/src/pages/Search.tsx index c0da109b1e..25bdf62e98 100644 --- a/ui/src/pages/Search.tsx +++ b/ui/src/pages/Search.tsx @@ -4,9 +4,11 @@ import { Search as SearchIcon, AlertTriangle, FileQuestion, Plus, X } from "luci import { COMPANY_SEARCH_DEFAULT_LIMIT, COMPANY_SEARCH_SCOPES, + type CompanySearchCountType, type CompanySearchResponse, type CompanySearchResult, type CompanySearchScope, + type CompanySearchSort, } from "@paperclipai/shared"; import { Tabs, TabsContent } from "@/components/ui/tabs"; import { Input } from "@/components/ui/input"; @@ -20,12 +22,39 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useDialogActions } from "../context/DialogContext"; import { searchApi } from "../api/search"; import { agentsApi } from "../api/agents"; +import { authApi } from "../api/auth"; +import { issuesApi } from "../api/issues"; +import { projectsApi } from "../api/projects"; import { queryKeys } from "../lib/queryKeys"; import { loadRecentSearches, pushRecentSearch } from "../lib/recent-searches"; import { PageTabBar, type PageTabItem } from "../components/PageTabBar"; +import { + applySearchFiltersToParams, + applySearchOperatorSuggestion, + hasSearchFilters, + parseSearchQuery, + readSearchFiltersFromParams, + searchFilterPills, + searchOperatorSuggestions, + type ParsedSearchQuery, + type SearchQueryParserContext, +} from "../lib/search-query-parser"; import { IssueGroupHeader } from "../components/IssueGroupHeader"; import { SearchResultRow } from "../components/search/SearchResultRow"; -import type { Agent } from "@paperclipai/shared"; +import { SearchFilterBar, type SearchFilterDataProps } from "../components/search/SearchFilterBar"; +import { SearchFilterChips } from "../components/search/SearchFilterChips"; +import { SearchFilterSheet, SearchFilterSheetTrigger } from "../components/search/SearchFilterSheet"; +import { SearchSortMenu } from "../components/search/SearchSortMenu"; +import { ZeroResultsRecovery } from "../components/search/ZeroResultsRecovery"; +import { useSidebar } from "../context/SidebarContext"; +import { + SORT_LABELS, + countActiveFilters, + parseSearchSort, + type FilterChipLookups, +} from "../lib/search-filters"; +import type { ReactNode } from "react"; +import type { Agent, IssueLabel, Project } from "@paperclipai/shared"; const SEARCH_DEBOUNCE_MS = 250; const IDENTIFIER_PATTERN = /^[A-Z]+-\d+$/; @@ -87,7 +116,31 @@ function describeScope(scope: CompanySearchScope) { return SCOPE_LABELS[scope]; } -export function buildSearchUrl(href: string, query: string, scope: CompanySearchScope): string { +function totalMatchCount(counts: Partial>): number { + return ( + (counts.issue ?? 0) + + (counts.comment ?? 0) + + (counts.document ?? 0) + + (counts.artifact ?? 0) + + (counts.agent ?? 0) + + (counts.project ?? 0) + ); +} + +function mergeSearchFilters( + base: ParsedSearchQuery["filters"], + override: ParsedSearchQuery["filters"], +): ParsedSearchQuery["filters"] { + return { ...base, ...override }; +} + +export function buildSearchUrl( + href: string, + query: string, + scope: CompanySearchScope, + filters: ParsedSearchQuery["filters"] = {}, + sort: CompanySearchSort = "relevance", +): string { const url = new URL(href); if (query.length === 0) { url.searchParams.delete("q"); @@ -99,6 +152,12 @@ export function buildSearchUrl(href: string, query: string, scope: CompanySearch } else { url.searchParams.set("scope", scope); } + applySearchFiltersToParams(url.searchParams, filters); + if (sort === "relevance") { + url.searchParams.delete("sort"); + } else { + url.searchParams.set("sort", sort); + } return `${url.pathname}${url.search}${url.hash}`; } @@ -118,14 +177,20 @@ export function Search() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); + const { isMobile } = useSidebar(); const urlQuery = searchParams.get("q") ?? ""; const urlScopeRaw = searchParams.get("scope"); const urlScope: CompanySearchScope = isCompanySearchScope(urlScopeRaw) ? urlScopeRaw : "all"; + const urlSort = useMemo(() => parseSearchSort(searchParams), [searchParams]); const [draftQuery, setDraftQuery] = useState(urlQuery); const [committedQuery, setCommittedQuery] = useState(urlQuery); const [scope, setScope] = useState(urlScope); + const [sort, setSort] = useState(urlSort); + const [sheetOpen, setSheetOpen] = useState(false); + const [draftSheetFilters, setDraftSheetFilters] = useState({}); const inputRef = useRef(null); + const [inputFocused, setInputFocused] = useState(false); const lastUrlSyncRef = useRef(""); const lastIdentifierRedirectRef = useRef(""); const [recentSearches, setRecentSearches] = useState([]); @@ -149,13 +214,69 @@ export function Search() { setScope(urlScope); }, [urlScope]); - // Debounce the draft query into committedQuery and write to URL via replaceState. + useEffect(() => { + setSort(urlSort); + }, [urlSort]); + + const { data: agents = [] } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const { data: projects = [] } = useQuery({ + queryKey: queryKeys.projects.list(selectedCompanyId!), + queryFn: () => projectsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const { data: labels = [] } = useQuery({ + queryKey: queryKeys.issues.labels(selectedCompanyId!), + queryFn: () => issuesApi.listLabels(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + }); + + const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; + const parserContext = useMemo(() => ({ + currentUserId, + agents: agents as Agent[], + projects: projects as Project[], + labels: labels as IssueLabel[], + }), [agents, currentUserId, labels, projects]); + const parsedUrlFilters = useMemo(() => readSearchFiltersFromParams(searchParams), [searchParams]); + const [urlFilters, setUrlFilters] = useState(parsedUrlFilters); + + useEffect(() => { + setUrlFilters(parsedUrlFilters); + }, [parsedUrlFilters]); + const parsedDraftQuery = useMemo(() => parseSearchQuery(draftQuery, parserContext), [draftQuery, parserContext]); + const parsedCommittedQuery = useMemo(() => parseSearchQuery(committedQuery, parserContext), [committedQuery, parserContext]); + const committedOperatorFilters = parsedCommittedQuery.filters; + const draftOperatorFilters = parsedDraftQuery.filters; + const activeFilters = useMemo( + () => mergeSearchFilters(urlFilters, committedOperatorFilters), + [committedOperatorFilters, urlFilters], + ); + const draftFilters = useMemo( + () => mergeSearchFilters(urlFilters, draftOperatorFilters), + [draftOperatorFilters, urlFilters], + ); + + // Debounce the draft query into committedQuery and write parsed filters 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); + // Typed operators live only in the query text and are never folded into + // urlFilters, so deleting a token drops its filter from the next request. + // The URL still carries the merged view for reload/back-forward persistence. + const next = buildSearchUrl(window.location.href, parsedDraftQuery.query, scope, draftFilters, sort); if (next !== `${window.location.pathname}${window.location.search}${window.location.hash}` && next !== lastUrlSyncRef.current) { lastUrlSyncRef.current = next; window.history.replaceState(window.history.state, "", next); @@ -163,60 +284,158 @@ export function Search() { } }, SEARCH_DEBOUNCE_MS); return () => window.clearTimeout(handle); - }, [draftQuery, committedQuery, scope]); + }, [draftFilters, draftQuery, committedQuery, parsedDraftQuery.query, scope, sort]); 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); + const url = buildSearchUrl(window.location.href, parsedCommittedQuery.query, next, activeFilters, sort); window.history.pushState(window.history.state, "", url); } }, - [committedQuery, scope], + [activeFilters, parsedCommittedQuery.query, scope, sort], ); - const trimmedQuery = committedQuery.trim(); - const queryEnabled = !!selectedCompanyId && trimmedQuery.length > 0; + const handleSortChange = useCallback( + (next: CompanySearchSort) => { + setSort(next); + if (typeof window !== "undefined") { + const url = buildSearchUrl(window.location.href, parsedCommittedQuery.query, scope, activeFilters, next); + window.history.pushState(window.history.state, "", url); + } + }, + [activeFilters, parsedCommittedQuery.query, scope], + ); + + // Filter-bar / chip / sheet changes make the controls authoritative: `next` + // already contains any operator-derived values (the controls render the merged + // view), so strip the typed tokens from the query to keep the plain text and + // prevent a removed filter from resurrecting out of the input. + const handleFiltersChange = useCallback( + (next: ParsedSearchQuery["filters"]) => { + const plain = parsedCommittedQuery.query; + setDraftQuery(plain); + setCommittedQuery(plain); + setUrlFilters(next); + if (typeof window !== "undefined") { + const url = buildSearchUrl(window.location.href, plain, scope, next, sort); + window.history.pushState(window.history.state, "", url); + } + }, + [parsedCommittedQuery.query, scope, sort], + ); + + // "Clear all" drops both URL filters and any typed operator tokens (keeping the + // plain text query), so the results snap back to the unfiltered set. + const handleClearAllFilters = useCallback(() => { + const plain = parsedCommittedQuery.query; + setDraftQuery(plain); + setCommittedQuery(plain); + setUrlFilters({}); + if (typeof window !== "undefined") { + const url = buildSearchUrl(window.location.href, plain, scope, {}, sort); + window.history.replaceState(window.history.state, "", url); + } + }, [parsedCommittedQuery.query, scope, sort]); + + const trimmedQuery = parsedCommittedQuery.query.trim(); + const displayQuery = committedQuery.trim(); + const queryEnabled = !!selectedCompanyId && (trimmedQuery.length > 0 || hasSearchFilters(activeFilters)); const { data, isFetching, error, refetch } = useQuery({ - queryKey: queryKeys.companySearch.search( - selectedCompanyId ?? "__no-company__", - trimmedQuery, - scope, - COMPANY_SEARCH_DEFAULT_LIMIT, - 0, - ), + queryKey: [ + ...queryKeys.companySearch.search( + selectedCompanyId ?? "__no-company__", + trimmedQuery, + scope, + COMPANY_SEARCH_DEFAULT_LIMIT, + 0, + ), + activeFilters, + sort, + ] as const, queryFn: () => searchApi.search(selectedCompanyId!, { q: trimmedQuery, scope, limit: COMPANY_SEARCH_DEFAULT_LIMIT, + ...activeFilters, + ...(sort !== "relevance" ? { sort } : {}), }), 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); + for (const agent of agents) map.set(agent.id, agent); return map; }, [agents]); + const projectsById = useMemo(() => new Map((projects as Project[]).map((p) => [p.id, p])), [projects]); + const labelsById = useMemo(() => new Map((labels as IssueLabel[]).map((l) => [l.id, l])), [labels]); + + const filterLookups = useMemo( + () => ({ + agentName: (id) => agentsById.get(id)?.name, + userName: () => undefined, + projectName: (id) => projectsById.get(id)?.name, + labelName: (id) => labelsById.get(id)?.name, + currentUserId, + }), + [agentsById, projectsById, labelsById, currentUserId], + ); + + const filterData = useMemo( + () => ({ + counts: data?.filterOptionCounts, + agents: agents as Agent[], + projects: projects as Project[], + labels: labels as IssueLabel[], + currentUserId, + }), + [data?.filterOptionCounts, agents, projects, labels, currentUserId], + ); + + const filtersActive = hasSearchFilters(activeFilters); + const activeFilterCount = countActiveFilters(activeFilters); + + // Preview query for the mobile bottom sheet: run the draft filters so the apply + // button can show "Show N results" before the user commits. + const { data: previewData } = useQuery({ + queryKey: [ + ...queryKeys.companySearch.search( + selectedCompanyId ?? "__no-company__", + trimmedQuery, + scope, + COMPANY_SEARCH_DEFAULT_LIMIT, + 0, + ), + "preview", + draftSheetFilters, + sort, + ] as const, + queryFn: () => + searchApi.search(selectedCompanyId!, { + q: trimmedQuery, + scope, + limit: COMPANY_SEARCH_DEFAULT_LIMIT, + ...draftSheetFilters, + ...(sort !== "relevance" ? { sort } : {}), + }), + enabled: queryEnabled && sheetOpen, + placeholderData: (previousData) => previousData, + }); + // 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); + if (!data || !displayQuery) return; + const next = pushRecentSearch(selectedCompanyId, displayQuery); setRecentSearches(next); - }, [data, trimmedQuery, selectedCompanyId]); + }, [data, displayQuery, selectedCompanyId]); // Identifier shortcut: when q matches PAP-123 and the API returns an exact identifier match, redirect to it. useEffect(() => { @@ -241,7 +460,8 @@ export function Search() { setCommittedQuery(""); inputRef.current?.focus(); if (typeof window !== "undefined") { - const next = buildSearchUrl(window.location.href, "", scope); + setUrlFilters({}); + const next = buildSearchUrl(window.location.href, "", scope, {}); window.history.replaceState(window.history.state, "", next); } }, [scope]); @@ -264,8 +484,10 @@ export function Search() { return () => window.removeEventListener("keydown", handler); }, [focusInput]); - const counts = data?.countsByType ?? { issue: 0, artifact: 0, agent: 0, project: 0 }; + const counts = data?.countsByType ?? { issue: 0, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 }; const totalResults = data?.results.length ?? 0; + const allMatchTotal = data ? totalMatchCount(counts) : 0; + const previewTotal = previewData ? totalMatchCount(previewData.countsByType) : null; const tabItems = useMemo(() => { function pill(value: number) { @@ -279,26 +501,46 @@ export function Search() { const issuesTotal = counts.issue ?? 0; return COMPANY_SEARCH_SCOPES.map((value) => { let count: number | null = null; - if (value === "all") count = (counts.issue ?? 0) + (counts.artifact ?? 0) + (counts.agent ?? 0) + (counts.project ?? 0); - else if (value === "issues") count = issuesTotal; + if (value === "all") { + count = (counts.issue ?? 0) + + (counts.comment ?? 0) + + (counts.document ?? 0) + + (counts.artifact ?? 0) + + (counts.agent ?? 0) + + (counts.project ?? 0); + } else if (value === "issues") count = issuesTotal; + else if (value === "comments") count = counts.comment ?? 0; + else if (value === "documents") count = counts.document ?? 0; else if (value === "artifacts") count = counts.artifact ?? 0; else if (value === "agents") count = counts.agent ?? 0; else if (value === "projects") count = counts.project ?? 0; + // Issue-only filters don't constrain agents/projects, so show a dash there + // rather than an unfiltered count that would misrepresent the result set. + const dashOut = filtersActive && (value === "agents" || value === "projects"); return { value, label: ( {SCOPE_LABELS[value as CompanySearchScope]} - {count !== null ? pill(count) : null} + {dashOut ? ( + + ) : count !== null ? ( + pill(count) + ) : null} ), } satisfies PageTabItem; }); - }, [counts, data]); + }, [counts, data, filtersActive]); const subgroups = useMemo(() => buildSubgroups(data?.results ?? []), [data?.results]); - const showInitialState = !trimmedQuery; + const operatorPills = useMemo(() => searchFilterPills(draftFilters, parserContext), [draftFilters, parserContext]); + const operatorSuggestions = useMemo( + () => (inputFocused ? searchOperatorSuggestions(draftQuery, 4) : []), + [draftQuery, inputFocused], + ); + const showInitialState = !displayQuery && !hasSearchFilters(activeFilters); const isLoading = queryEnabled && isFetching && !data; const hasResults = !!data && totalResults > 0; const isEmpty = !!data && !isFetching && totalResults === 0; @@ -307,15 +549,30 @@ export function Search() { const apiMessage = data?.results === undefined && data ? null : null; void apiMessage; + // Zero-results recovery (wireframe screen 4) is only meaningful when active + // filters are what emptied the page; the backend signals that via `zeroResults`. + const zeroResultsSlot: ReactNode = data?.zeroResults ? ( + + ) : null; + function navigateIssuesFallback() { - navigate(`/issues?q=${encodeURIComponent(trimmedQuery)}`); + const fallbackQuery = trimmedQuery || displayQuery; + navigate(fallbackQuery ? `/issues?q=${encodeURIComponent(fallbackQuery)}` : "/issues"); } function handleRecentClick(value: string) { setDraftQuery(value); setCommittedQuery(value); if (typeof window !== "undefined") { - const next = buildSearchUrl(window.location.href, value, scope); + setUrlFilters({}); + const next = buildSearchUrl(window.location.href, value, scope, {}); window.history.replaceState(window.history.state, "", next); } } @@ -325,6 +582,8 @@ export function Search() { handleScopeChange("all"); } + const searchDisplayLabel = displayQuery || operatorPills.map((pill) => pill.label).join(" "); + return (
@@ -336,6 +595,8 @@ export function Search() { autoFocus value={draftQuery} onChange={(event) => setDraftQuery(event.currentTarget.value)} + onFocus={() => setInputFocused(true)} + onBlur={() => setInputFocused(false)} onKeyDown={(event) => { if (event.key === "Escape") { if (draftQuery.length > 0) { @@ -367,6 +628,43 @@ export function Search() { ⌘K
+
+ {operatorPills.length > 0 ? ( +
+ {operatorPills.map((pill) => ( + + {pill.label} + + ))} +
+ ) : null} + {operatorSuggestions.length > 0 ? ( +
+ {operatorSuggestions.map((suggestion) => ( + + ))} +
+ ) : ( + + Try status:todo,{" "} + assignee:me,{" "} + or updated:>7d. + + )} +
@@ -374,6 +672,33 @@ export function Search() { + {!showInitialState ? ( +
+ {isMobile ? ( +
+ setSheetOpen(true)} /> +
+ +
+
+ ) : ( + + )} + +
+ ) : null} + {COMPANY_SEARCH_SCOPES.map((scopeValue) => ( openNewIssue({ title: trimmedQuery })} + openNewIssue={() => openNewIssue({ title: searchDisplayLabel })} refetch={() => void refetch()} recentSearches={recentSearches} onRecentClick={handleRecentClick} subgroups={subgroups} totalResults={totalResults} + allMatchTotal={allMatchTotal} + activeFilterCount={activeFilterCount} + sortLabel={SORT_LABELS[sort]} + zeroResultsSlot={zeroResultsSlot} isFetching={isFetching && !!data} agentsById={agentsById} /> @@ -405,6 +734,20 @@ export function Search() { ))}
+ + {isMobile ? ( + + ) : null} ); } @@ -426,6 +769,10 @@ interface SearchTabContentProps { onRecentClick: (query: string) => void; subgroups: Array<{ key: SubGroupKey; results: CompanySearchResult[] }>; totalResults: number; + allMatchTotal: number; + activeFilterCount: number; + sortLabel: string; + zeroResultsSlot: ReactNode; isFetching: boolean; agentsById: ReadonlyMap>; } @@ -447,6 +794,10 @@ function SearchTabContent({ onRecentClick, subgroups, totalResults, + allMatchTotal, + activeFilterCount, + sortLabel, + zeroResultsSlot, isFetching, agentsById, }: SearchTabContentProps) { @@ -545,6 +896,9 @@ function SearchTabContent({ } if (isEmpty) { + // Filters emptied the page → recovery UI (screen 4). Plain zero-results keeps + // the tips card below. + if (zeroResultsSlot) return zeroResultsSlot; return (
@@ -584,7 +938,15 @@ function SearchTabContent({
- {totalResults === 1 ? "1 result" : `${totalResults} results`} · sorted by relevance + {allMatchTotal > totalResults + ? `${totalResults} of ${allMatchTotal} results` + : totalResults === 1 + ? "1 result" + : `${totalResults} results`} + {` · sorted by ${sortLabel}`} + {activeFilterCount > 0 + ? ` · ${activeFilterCount} ${activeFilterCount === 1 ? "filter" : "filters"} active` + : ""} {isFetching ? Updating… : null}
diff --git a/ui/storybook/stories/search.stories.tsx b/ui/storybook/stories/search.stories.tsx index f926a863c7..605c152685 100644 --- a/ui/storybook/stories/search.stories.tsx +++ b/ui/storybook/stories/search.stories.tsx @@ -1,11 +1,21 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { CompanySearchResult, CompanySearchResponse } from "@paperclipai/shared"; +import type { + CompanySearchFilterOptionCounts, + CompanySearchResult, + CompanySearchResponse, + CompanySearchZeroResults, +} 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 { SearchFilterBar, type SearchFilterDataProps } from "@/components/search/SearchFilterBar"; +import { SearchFilterChips } from "@/components/search/SearchFilterChips"; +import { ZeroResultsRecovery } from "@/components/search/ZeroResultsRecovery"; +import type { FilterChipLookups, SearchFilters } from "@/lib/search-filters"; +import { SEARCH_OPERATOR_QUICK_FILTERS, searchOperatorSuggestions } from "@/lib/search-query-parser"; import { Tabs } from "@/components/ui/tabs"; import { Bot, @@ -189,13 +199,26 @@ const fixtureResponse: CompanySearchResponse = { scope: "all", limit: 20, offset: 0, + sort: "relevance", results: [...fixtureResults, ...fixtureAgents, ...fixtureProjects], countsByType: { issue: fixtureResults.length, + comment: 0, + document: 0, artifact: 0, agent: fixtureAgents.length, project: fixtureProjects.length, }, + filterOptionCounts: { + status: {}, + priority: {}, + assigneeAgentId: {}, + assigneeUserId: {}, + projectId: {}, + labelId: {}, + updatedWithin: {}, + }, + zeroResults: null, hasMore: false, }; @@ -388,6 +411,39 @@ function SearchPagePreview({ ); } +function SearchOperatorInputPreview() { + const suggestions = searchOperatorSuggestions("auth sta", 4); + return ( +
+
+ + +
+
+
+ + status:blocked + + + updated:>7d + +
+
+ {suggestions.map((suggestion) => ( + + {suggestion.token} + {suggestion.description} + + ))} +
+
+
+ ); +} + function CommandPaletteWithSearchAll({ query, emptyResults = false, @@ -424,6 +480,15 @@ function CommandPaletteWithSearchAll({ + + {SEARCH_OPERATOR_QUICK_FILTERS.map((chip) => ( + + + {chip} + + ))} + + @@ -495,6 +560,53 @@ function CommandPaletteWithSearchAll({ ); } +const noop = () => {}; + +const searchFilterCounts: CompanySearchFilterOptionCounts = { + status: { in_progress: 4, todo: 3, backlog: 2, in_review: 1, blocked: 1, done: 8 }, + priority: { critical: 1, high: 3, medium: 5, low: 2 }, + assigneeAgentId: storybookAgents[0]?.id ? { [storybookAgents[0].id]: 4 } : {}, + assigneeUserId: {}, + projectId: storybookProjects[0]?.id ? { [storybookProjects[0].id]: 6 } : {}, + labelId: { "label-infra": 3 }, + updatedWithin: { "24h": 2, "7d": 5, "30d": 9, "90d": 11 }, +}; + +const searchFilterData: SearchFilterDataProps = { + counts: searchFilterCounts, + agents: storybookAgents.map((agent) => ({ id: agent.id, name: agent.name })), + projects: storybookProjects.map((project) => ({ id: project.id, name: project.name })), + labels: [ + { id: "label-infra", name: "infra", color: "#a78bfa" }, + { id: "label-auth", name: "auth", color: "#34d399" }, + ], + currentUserId: "user-1", +}; + +const activeSearchFilters: SearchFilters = { + status: ["in_progress", "todo"], + priority: ["high"], + projectId: storybookProjects[0]?.id, + updatedWithin: "7d", +}; + +const searchFilterLookups: FilterChipLookups = { + agentName: (id) => storybookAgents.find((agent) => agent.id === id)?.name, + userName: () => "Me", + projectName: (id) => storybookProjects.find((project) => project.id === id)?.name, + labelName: (id) => searchFilterData.labels.find((label) => label.id === id)?.name, + currentUserId: "user-1", +}; + +const zeroResultsFixture: CompanySearchZeroResults = { + unfilteredTotal: 42, + loosenSuggestions: [ + { filter: "status", values: ["in_progress", "todo"], resultCount: 30, additionalCount: 30 }, + { filter: "priority", values: ["high"], resultCount: 12, additionalCount: 12 }, + { filter: "updatedWithin", values: ["7d"], resultCount: 6, additionalCount: 6 }, + ], +}; + function SearchStories() { return (
@@ -516,6 +628,14 @@ function SearchStories() { +
+
+
/search · screen 3
+

Typed operators, pills & autocomplete

+
+ +
+
/search
@@ -537,7 +657,49 @@ function SearchStories() {
/search

No results state

- + +
+ +
+
+
/search · screen 1
+

Filter bar, active chips & honest meta

+
+
+ + +
+ 8 of 42 results · sorted by Relevance · 4 filters active +
+
+
+ +
+
+
/search · screen 4
+

Zero-results recovery

+
+
+ +