feat(search): filters, sorting, operators & command-palette parity (#9327)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Company search is the primary way operators find issues, comments,
documents, artifacts, agents, and projects across a busy company
> - Search previously supported only a bare text query: no way to narrow
by status/assignee/project/label/date, no sort control, no typed
operators, and weak relevance/snippets meant hunting through noise
> - As companies accumulate tens of thousands of items, unfiltered
single-sort search stops scaling for day-to-day operator workflows
> - This pull request adds a full filtering model (filter bar, chips,
mobile sheet, URL state), sort modes, typed query operators (`status:`,
`assignee:`, `type:`, …) with command-palette parity,
relevance/snippet/deep-link improvements, zero-results recovery, and the
supporting shared validators, backend service work, and DB indexes
> - The benefit is that operators can go from a vague query to the exact
item in a couple of keystrokes, on desktop and mobile, with shareable
filtered-search URLs

## Linked Issues or Issue Description

No existing public GitHub issue; describing the underlying feature
request inline (per feature_request template):

- **Problem:** Company search accepted only a plain text query. Users
could not filter results by status, assignee, project, label, or
recency; could not change result ordering; and got no guidance when
filters emptied the result set.
- **Desired solution:** Structured search filters (UI controls + typed
query operators + URL parameters), selectable sort modes, better
relevance and snippets with exact deep links, and parity between the
search page and the command palette.
- **Alternatives considered:** Client-side filtering of unfiltered
results (does not scale past the fetch limit); a separate "advanced
search" page (splits the surface and duplicates state handling).

Related (not duplicate) PRs found while searching: #4848 (issue search
query planning), #8235 (search rate limiting).

## What Changed

- **Shared contract:** new search filter/sort/count/zero-results types
and validators in `packages/shared` (`validators/search.ts`, types
index).
- **Backend:** `server/src/services/company-search.ts` supports issue
filters, sort modes, per-filter option counts, snippets, artifact
visibility, and zero-results loosen suggestions; single-statement match
replaces per-scope scans and predicates are trigram-index compatible
(~3.7s → ~350ms on a live 14.8k-hit corpus).
- **DB:** migration `0142_company_search_sort_indexes.sql` adds the
supporting indexes.
- **Search page (`ui/src/pages/Search.tsx`):** filter bar, removable
chips, mobile filter sheet with result-count preview, sort menu, URL
round-tripping, zero-results recovery UI.
- **Query operators (`ui/src/lib/search-query-parser.ts`):** typed
operators parsed into filters, operator autocomplete, filter pills.
- **Command palette:** operator-aware parsing and full-search handoff.
- **Stale-operator fix (latest commit):** typed operator filters are no
longer folded into persistent URL-filter state, so deleting a token
(e.g. removing `status:blocked` from the input) actually removes the
filter from subsequent requests; filter-control edits materialize
control state and strip typed tokens so a removed chip cannot resurrect
from the input.

## Verification

- `cd ui && npx vitest run src/pages/Search.test.tsx` — 19 tests
including two new red→green regressions for the stale-operator paths
(both fail on the previous commit, pass now).
- `cd ui && npx vitest run src/components/CommandPalette.test.tsx` and
`cd server && npx vitest run
src/services/company-search-service.test.ts` — operator parity and
backend filter/sort/count coverage.
- `cd ui && npx tsc --noEmit` — clean.
- Manual: open `/search`, type `auth status:blocked`, confirm the status
filter applies; delete `status:blocked`, confirm results are unfiltered
again; drive the same filters from the filter bar/chips/mobile sheet and
confirm the URL round-trips (reload/back/forward preserves state).
- Full end-to-end QA pass (9/9 acceptance checks) against the wireframes
on desktop (1280px) and mobile (390px) with a live API and browser
automation.

## Risks

- Additive migration (indexes only, no data rewrites) — safe to roll
forward; index creation cost is paid once at migrate time.
- Search request shape gains optional parameters only; old clients keep
working.
- Behavioral shift: filter-control edits now strip typed operator tokens
from the query text (their values persist as filter state) — deliberate,
so removed filters stay removed.
- Ranking changes alter result ordering for existing queries; covered by
service tests and the QA pass.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Fable 5 (`claude-fable-5`, Anthropic, extended thinking + tool
use) — stale-operator-filter fix, regression tests, PR preparation.
- GPT-5 Codex (`codex_local` adapter) and Claude Opus 4.6
(`claude-opus-4-6`) — earlier implementation phases (backend contract,
filter UI, operators, ranking) under agent orchestration.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
— pre-existing branch name retained to avoid closing/reopening the PR
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes (no
user-facing docs affected)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending re-run on latest commit)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending re-review of the stale-filter fix)
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dotta 2026-07-09 19:32:58 -05:00 committed by GitHub
parent cec0fc249a
commit 606aa4f266
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 4423 additions and 401 deletions

View File

@ -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");

View File

@ -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
}
]
}

View File

@ -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")),

View File

@ -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,

View File

@ -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,

View File

@ -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<Record<IssueStatus, number>>;
priority: Partial<Record<IssuePriority, number>>;
assigneeAgentId: Record<string, number>;
assigneeUserId: Record<string, number>;
projectId: Record<string, number>;
labelId: Record<string, number>;
updatedWithin: Partial<Record<CompanySearchUpdatedWithinOption, number>>;
}
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<CompanySearchResultType, number>;
countsByType: Record<CompanySearchCountType, number>;
filterOptionCounts: CompanySearchFilterOptionCounts;
zeroResults: CompanySearchZeroResults | null;
hasMore: boolean;
}

View File

@ -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<T extends string>(
value: unknown,
ctx: z.RefinementCtx,
field: string,
allowed: readonly T[],
): T[] {
const allowedSet = new Set<string>(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<typeof companySearchQuerySchema>;

View File

@ -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();
});
});

View File

@ -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<typeof labels.$inferInsert> = {}) {
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);
});

View File

@ -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));

View File

@ -315,7 +315,11 @@ function buildArtifactGroups(input: {
export function companyArtifactsService(db: Db, storage?: StorageService) {
return {
list: async (companyId: string, rawQuery: Partial<CompanyArtifactsQuery> = {}): Promise<CompanyArtifactsResponse> => {
list: async (
companyId: string,
rawQuery: Partial<CompanyArtifactsQuery> = {},
options: { issueConditions?: SQL[] } = {},
): Promise<CompanyArtifactsResponse> => {
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<string>();
@ -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<Date>`${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

File diff suppressed because it is too large Load Diff

View File

@ -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<CompanySearchResponse>(
`/companies/${companyId}/search${qs ? `?${qs}` : ""}`,

View File

@ -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 }) => <span>{name}</span>,
}));
@ -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(<CommandPalette />, 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(<CommandPalette />, 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();
});
});
});

View File

@ -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<SearchQueryParserContext>(() => ({
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 ? <CommandSeparator /> : null}
<CommandGroup heading="Quick filters">
{SEARCH_OPERATOR_QUICK_FILTERS.map((chip) => (
<CommandItem
key={chip}
value={`quick-filter ${chip}`}
onSelect={() => setQuery((current) => current.trim() ? `${current.trim()} ${chip}` : chip)}
data-testid="command-filter-chip"
>
<Search className="mr-2 h-4 w-4" />
<span className="font-mono text-xs">{chip}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
{showPromotedProjects && (
<>
<CommandGroup heading="Projects">

View File

@ -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<string, number> | 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: <StatusIcon status={value} />,
count: count(counts?.status as Record<string, number> | undefined, value),
}));
const priority: FilterMenuOption[] = ISSUE_PRIORITIES.map((value) => ({
value,
label: humanize(value),
icon: <PriorityIcon priority={value} />,
count: count(counts?.priority as Record<string, number> | undefined, value),
}));
const assignee: FilterMenuOption[] = [];
if (currentUserId) {
assignee.push({
value: "me",
label: "Me",
icon: <User className="h-3.5 w-3.5 text-muted-foreground" />,
count: count(counts?.assigneeUserId, currentUserId),
searchText: "me mine",
});
}
assignee.push({
value: "none",
label: "Unassigned",
icon: <UserX className="h-3.5 w-3.5 text-muted-foreground" />,
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<string, number> | 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 (
<div className="flex flex-wrap items-center gap-1.5" data-testid="search-filter-bar">
<SearchFilterMenu
label="Status"
multi
options={options.status}
selected={filters.status ?? []}
onToggle={(value) => toggleMulti("status", value)}
onClear={() => onChange({ ...filters, status: [] })}
presets={[{ label: "Open items", values: OPEN_STATUS_PRESET }]}
/>
<SearchFilterMenu
label="Assignee"
options={options.assignee}
selected={selectedAssignee ? [selectedAssignee] : []}
onSelect={(value) => onChange(applyAssigneeToken(filters, value, data.currentUserId))}
searchable
searchPlaceholder="Search assignees…"
emptyMessage="No assignees"
/>
<SearchFilterMenu
label="Project"
options={options.project}
selected={filters.projectId ? [filters.projectId] : []}
onSelect={(value) => onChange({ ...filters, projectId: value })}
searchable
searchPlaceholder="Search projects…"
emptyMessage="No projects"
/>
<SearchFilterMenu
label="Label"
options={options.label}
selected={filters.labelId ? [filters.labelId] : []}
onSelect={(value) => onChange({ ...filters, labelId: value })}
searchable
searchPlaceholder="Search labels…"
emptyMessage="No labels"
/>
<SearchFilterMenu
label="Priority"
multi
options={options.priority}
selected={filters.priority ?? []}
onToggle={(value) => toggleMulti("priority", value)}
onClear={() => onChange({ ...filters, priority: [] })}
/>
<SearchFilterMenu
label="Updated"
options={options.updated}
selected={filters.updatedWithin ? [filters.updatedWithin] : []}
onSelect={(value) => onChange({ ...filters, updatedWithin: value })}
/>
<div className="ml-auto">
<SearchSortMenu value={sort} onChange={onSortChange} />
</div>
</div>
);
}

View File

@ -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 (
<div className="flex flex-wrap items-center gap-1.5" data-testid="search-filter-chips">
{chips.map((chip) => (
<Badge key={chip.id} variant="secondary" className="gap-1 pr-1 font-normal">
<span className="truncate">{chip.label}</span>
<button
type="button"
className="rounded-full p-0.5 hover:bg-background/60"
onClick={() => onChange(chip.remove(filters))}
aria-label={`Remove filter ${chip.label}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
<button
type="button"
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
onClick={onClearAll}
>
Clear all
</button>
</div>
);
}

View File

@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-8 gap-1 text-xs font-normal",
active && "border-primary/60 text-foreground",
triggerClassName,
)}
aria-label={`Filter by ${label}`}
>
<span className="truncate">{summarizeTrigger(label, selected, options)}</span>
{active ? (
<span className="ml-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-(length:--text-nano) font-semibold tabular-nums text-primary-foreground">
{selected.length}
</span>
) : (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
)}
</Button>
</PopoverTrigger>
<PopoverContent align={align} className={cn("w-64 p-0", contentClassName)}>
<div className="flex items-center justify-between px-3 py-2">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
{props.multi && active ? (
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => props.onClear()}
>
Clear
</button>
) : null}
</div>
{props.multi && props.presets && props.presets.length > 0 ? (
<div className="flex flex-wrap gap-1 px-3 pb-2">
{props.presets.map((preset) => {
const isActive =
preset.values.length === selected.length &&
preset.values.every((value) => selected.includes(value));
return (
<button
key={preset.label}
type="button"
className={cn(
"rounded-full border px-2 py-0.5 text-(length:--text-micro) transition-colors",
isActive
? "border-primary bg-primary text-primary-foreground"
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground",
)}
onClick={() => {
// Replace selection with the preset (toggle off when already exact).
for (const value of options.map((option) => option.value)) {
const wantSelected = !isActive && preset.values.includes(value);
const currentlySelected = selected.includes(value);
if (wantSelected !== currentlySelected) props.onToggle(value);
}
}}
>
{preset.label}
</button>
);
})}
</div>
) : null}
{searchable ? (
<div className="px-3 pb-2">
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={searchPlaceholder}
className="h-8 pl-7 text-xs"
/>
</div>
</div>
) : null}
<div className="max-h-72 overflow-y-auto overscroll-contain border-t border-border py-1">
{visibleOptions.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">{emptyMessage}</div>
) : (
visibleOptions.map((option) => {
const isSelected = selected.includes(option.value);
return (
<button
key={option.value}
type="button"
className="flex w-full cursor-pointer items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => handleOptionClick(option.value)}
>
{props.multi ? (
<Checkbox checked={isSelected} tabIndex={-1} className="pointer-events-none" />
) : (
<span className="flex h-4 w-4 items-center justify-center">
{isSelected ? <Check className="h-3.5 w-3.5 text-primary" /> : null}
</span>
)}
{option.icon ? <span className="flex h-4 w-4 items-center justify-center">{option.icon}</span> : null}
{option.swatch ? (
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: option.swatch }}
aria-hidden
/>
) : null}
<span className="min-w-0 flex-1 truncate text-sm">{option.label}</span>
{typeof option.count === "number" ? (
<span className="ml-1 text-xs tabular-nums text-muted-foreground">{option.count}</span>
) : null}
</button>
);
})
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@ -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 (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">{title}</div>
<div className="flex flex-wrap gap-1.5">
{options.map((option) => {
const isActive = selected.includes(option.value);
return (
<button
key={option.value}
type="button"
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors",
isActive
? "border-primary bg-primary text-primary-foreground"
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground",
)}
onClick={() => onToggle(option.value)}
>
{option.swatch ? (
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: option.swatch }} aria-hidden />
) : null}
<span>{option.label}</span>
{typeof option.count === "number" ? (
<span className={cn("tabular-nums", isActive ? "opacity-80" : "text-muted-foreground/70")}>
{option.count}
</span>
) : null}
</button>
);
})}
</div>
</div>
);
}
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<SearchFilters>(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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="max-h-(--sz-85vh) gap-0 rounded-t-xl p-0" data-testid="search-filter-sheet">
<SheetHeader className="flex-row items-center justify-between border-b border-border">
<SheetTitle className="text-base">Filters</SheetTitle>
<button
type="button"
className={cn("text-xs text-muted-foreground hover:text-foreground", activeCount === 0 && "invisible")}
onClick={() => update({})}
>
Clear all
</button>
</SheetHeader>
<div className="flex-1 space-y-4 overflow-y-auto p-4">
<ChipToggleGroup
title="Status"
options={options.status}
selected={draft.status ?? []}
onToggle={(value) => toggleMulti("status", value)}
/>
<ChipToggleGroup
title="Priority"
options={options.priority}
selected={draft.priority ?? []}
onToggle={(value) => toggleMulti("priority", value)}
/>
<ChipToggleGroup
title="Assignee"
options={options.assignee}
selected={selectedAssignee ? [selectedAssignee] : []}
onToggle={toggleAssignee}
/>
<ChipToggleGroup
title="Project"
options={options.project}
selected={draft.projectId ? [draft.projectId] : []}
onToggle={(value) => toggleSingle("projectId", value)}
/>
<ChipToggleGroup
title="Label"
options={options.label}
selected={draft.labelId ? [draft.labelId] : []}
onToggle={(value) => toggleSingle("labelId", value)}
/>
<ChipToggleGroup
title="Updated"
options={options.updated}
selected={draft.updatedWithin ? [draft.updatedWithin] : []}
onToggle={(value) => toggleSingle("updatedWithin", value)}
/>
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">Sort by</div>
<div className="flex flex-wrap gap-1.5">
{COMPANY_SEARCH_SORTS.map((value) => (
<button
key={value}
type="button"
className={cn(
"rounded-full border px-2.5 py-1 text-xs transition-colors",
value === sort
? "border-primary bg-primary text-primary-foreground"
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground",
)}
onClick={() => onSortChange(value)}
>
{SORT_LABELS[value]}
</button>
))}
</div>
</div>
</div>
<SheetFooter className="flex-row gap-2 border-t border-border">
<SheetClose asChild>
<Button variant="outline" className="flex-1">
Cancel
</Button>
</SheetClose>
<Button
className="flex-1"
onClick={() => {
onApply(draft);
onOpenChange(false);
}}
>
{applyLabel}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
/** The compact "Filters · n" trigger button shown on mobile. */
export function SearchFilterSheetTrigger({
activeCount,
onClick,
}: {
activeCount: number;
onClick: () => void;
}) {
return (
<Button variant="outline" size="sm" className="h-8 gap-1.5 text-xs font-normal" onClick={onClick}>
<SlidersHorizontal className="h-3.5 w-3.5" />
Filters
{activeCount > 0 ? (
<span className="ml-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-(length:--text-nano) font-semibold tabular-nums text-primary-foreground">
{activeCount}
</span>
) : null}
</Button>
);
}

View File

@ -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
/>
<span className="sr-only">{label}: </span>
<span
className="shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 text-(length:--text-nano) font-medium uppercase tracking-wide text-muted-foreground"
>
{label}
</span>
<HighlightedText
text={text}
highlights={highlights}

View File

@ -0,0 +1,43 @@
import { ArrowUpDown, Check } from "lucide-react";
import { COMPANY_SEARCH_SORTS, type CompanySearchSort } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SORT_LABELS } from "@/lib/search-filters";
import { cn } from "@/lib/utils";
export function SearchSortMenu({
value,
onChange,
}: {
value: CompanySearchSort;
onChange: (next: CompanySearchSort) => void;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs font-normal" aria-label="Sort results">
<ArrowUpDown className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
<span className="hidden sm:inline text-muted-foreground">Sort:</span>
<span>{SORT_LABELS[value]}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="text-xs text-muted-foreground">Sort by</DropdownMenuLabel>
<DropdownMenuSeparator />
{COMPANY_SEARCH_SORTS.map((sort) => (
<DropdownMenuItem key={sort} onSelect={() => onChange(sort)} className="gap-2 text-sm">
<Check className={cn("h-3.5 w-3.5", sort === value ? "opacity-100 text-primary" : "opacity-0")} />
{SORT_LABELS[sort]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -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 (
<div
className="mx-auto flex w-full max-w-xl flex-col items-center gap-4 px-4 py-12 text-center"
data-testid="search-zero-results-recovery"
>
<FilterX className="h-10 w-10 text-muted-foreground" aria-hidden />
<div className="space-y-1">
<div className="text-base font-semibold">No results with these filters</div>
<p className="text-sm text-muted-foreground">
{unfilteredTotal === 1 ? "1 result matches" : `${unfilteredTotal} results match`}
{query ? <> &ldquo;{query}&rdquo;</> : null}, but your{" "}
{activeCount === 1 ? "active filter hides" : `${activeCount} active filters hide`} all of them.
</p>
</div>
{suggestions.length > 0 ? (
<div className="flex w-full flex-col gap-1.5">
<div className="text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
Loosen a filter
</div>
{suggestions.map((suggestion) => (
<button
key={`${suggestion.filter}:${suggestion.values.join(",")}`}
type="button"
className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2 text-left text-sm hover:border-foreground/30 hover:bg-accent/40"
onClick={() => onChange(clearFilterDimension(filters, suggestion.filter))}
>
<span className="min-w-0 truncate">
Remove{" "}
<span className="font-medium">
{describeLoosenSuggestion(suggestion.filter, suggestion.values, lookups)}
</span>
</span>
<span className="shrink-0 tabular-nums text-emerald-600 dark:text-emerald-400">
+{suggestion.additionalCount} {suggestion.additionalCount === 1 ? "result" : "results"}
</span>
</button>
))}
</div>
) : null}
<Button onClick={onClearAll} variant="default" size="sm">
<RotateCcw className="mr-1.5 h-4 w-4" />
Clear all filters
</Button>
</div>
);
}

View File

@ -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<CompanySearchSort, string> = {
relevance: "Relevance",
updated: "Recently updated",
created: "Newest created",
priority: "Priority",
};
export const UPDATED_WITHIN_LABELS: Record<string, string> = {
"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<string>(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:<id>" → assigneeAgentId
// "user:<id>" → 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;
}

View File

@ -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");
});
});

View File

@ -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<T extends { id: string; name: string; urlKey?: string | null }>(
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<T extends string>(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<T extends string>(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<T extends { id: string; name: string }>(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";
}

View File

@ -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<typeof import("react-router-dom")>("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("Couldnt 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<HTMLButtonElement>('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<string, unknown> = {}) {
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<HTMLButtonElement>('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();
});
});
});

View File

@ -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<Record<CompanySearchCountType, number>>): 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<CompanySearchScope>(urlScope);
const [sort, setSort] = useState<CompanySearchSort>(urlSort);
const [sheetOpen, setSheetOpen] = useState(false);
const [draftSheetFilters, setDraftSheetFilters] = useState<ParsedSearchQuery["filters"]>({});
const inputRef = useRef<HTMLInputElement | null>(null);
const [inputFocused, setInputFocused] = useState(false);
const lastUrlSyncRef = useRef<string>("");
const lastIdentifierRedirectRef = useRef<string>("");
const [recentSearches, setRecentSearches] = useState<string[]>([]);
@ -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<SearchQueryParserContext>(() => ({
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<CompanySearchResponse>({
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<ReadonlyMap<string, Pick<Agent, "id" | "name">>>(() => {
const map = new Map<string, Pick<Agent, "id" | "name">>();
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<FilterChipLookups>(
() => ({
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<SearchFilterDataProps>(
() => ({
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<CompanySearchResponse>({
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<PageTabItem[]>(() => {
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: (
<span className="flex items-center">
{SCOPE_LABELS[value as CompanySearchScope]}
{count !== null ? pill(count) : null}
{dashOut ? (
<span className="ml-1.5 text-(length:--text-nano) text-muted-foreground"></span>
) : count !== null ? (
pill(count)
) : null}
</span>
),
} 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 ? (
<ZeroResultsRecovery
query={displayQuery || trimmedQuery}
filters={activeFilters}
zeroResults={data.zeroResults}
lookups={filterLookups}
onChange={handleFiltersChange}
onClearAll={handleClearAllFilters}
/>
) : 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 (
<div className="flex h-full min-h-0 flex-col" data-page="search">
<div className="border-b border-border px-4 py-3 sm:px-6">
@ -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
</kbd>
</div>
<div className="mt-2 flex min-h-6 flex-wrap items-center gap-1.5 text-(length:--text-micro) text-muted-foreground">
{operatorPills.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5" data-testid="search-operator-pills">
{operatorPills.map((pill) => (
<Badge key={`${pill.key}:${pill.value}`} variant="outline" className="px-1.5 py-0 text-(length:--text-micro) font-normal normal-case">
{pill.label}
</Badge>
))}
</div>
) : null}
{operatorSuggestions.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5" data-testid="search-operator-suggestions">
{operatorSuggestions.map((suggestion) => (
<button
key={suggestion.token}
type="button"
aria-label={`Insert operator ${suggestion.token}`}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setDraftQuery(applySearchOperatorSuggestion(draftQuery, suggestion.token));
inputRef.current?.focus();
}}
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 hover:bg-accent/60"
>
<span className="font-mono text-(length:--text-micro)">{suggestion.token}</span>
<span className="hidden text-(length:--text-micro) sm:inline">{suggestion.description}</span>
</button>
))}
</div>
) : (
<span className="truncate">
Try <code className="rounded bg-muted px-1 py-0.5 text-(length:--text-micro)">status:todo</code>,{" "}
<code className="rounded bg-muted px-1 py-0.5 text-(length:--text-micro)">assignee:me</code>,{" "}
or <code className="rounded bg-muted px-1 py-0.5 text-(length:--text-micro)">updated:&gt;7d</code>.
</span>
)}
</div>
</div>
<Tabs value={scope} onValueChange={handleScopeChange} className="flex h-full min-h-0 flex-col">
@ -374,6 +672,33 @@ export function Search() {
<PageTabBar items={tabItems} value={scope} onValueChange={handleScopeChange} align="start" />
</div>
{!showInitialState ? (
<div className="flex flex-col gap-2 border-b border-border px-2 py-2 sm:px-4" data-testid="search-filters">
{isMobile ? (
<div className="flex items-center gap-2">
<SearchFilterSheetTrigger activeCount={activeFilterCount} onClick={() => setSheetOpen(true)} />
<div className="ml-auto">
<SearchSortMenu value={sort} onChange={handleSortChange} />
</div>
</div>
) : (
<SearchFilterBar
filters={activeFilters}
onChange={handleFiltersChange}
sort={sort}
onSortChange={handleSortChange}
data={filterData}
/>
)}
<SearchFilterChips
filters={activeFilters}
lookups={filterLookups}
onChange={handleFiltersChange}
onClearAll={handleClearAllFilters}
/>
</div>
) : null}
{COMPANY_SEARCH_SCOPES.map((scopeValue) => (
<TabsContent
key={scopeValue}
@ -388,16 +713,20 @@ export function Search() {
hasError={hasError}
apiError={apiError}
isEmpty={isEmpty}
trimmedQuery={trimmedQuery}
trimmedQuery={searchDisplayLabel}
scope={scope}
showAllScope={showAllScope}
navigateIssuesFallback={navigateIssuesFallback}
openNewIssue={() => 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() {
</TabsContent>
))}
</Tabs>
{isMobile ? (
<SearchFilterSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
filters={activeFilters}
onApply={handleFiltersChange}
onDraftChange={setDraftSheetFilters}
previewTotal={previewTotal}
data={filterData}
sort={sort}
onSortChange={handleSortChange}
/>
) : null}
</div>
);
}
@ -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<string, Pick<Agent, "id" | "name">>;
}
@ -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 (
<div className="mx-auto flex w-full max-w-xl flex-col items-center justify-center gap-3 px-4 py-12 text-center">
<FileQuestion className="h-10 w-10 text-muted-foreground" aria-hidden />
@ -584,7 +938,15 @@ function SearchTabContent({
<div className="flex w-full max-w-(--sz-960px) flex-col px-2 sm:px-4" data-testid="search-results">
<div className="flex items-center justify-between py-2 text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">
<span>
{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`
: ""}
</span>
{isFetching ? <span aria-live="polite" className="normal-case tracking-normal">Updating</span> : null}
</div>

View File

@ -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 (
<div className="border-t border-border bg-background p-4">
<div className="relative">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input value="auth status:blocked updated:>7d" readOnly className="h-10 pl-9 pr-4 text-sm" />
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-(length:--text-micro) text-muted-foreground">
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="outline" className="px-1.5 py-0 text-(length:--text-micro) font-normal normal-case">
status:blocked
</Badge>
<Badge variant="outline" className="px-1.5 py-0 text-(length:--text-micro) font-normal normal-case">
updated:&gt;7d
</Badge>
</div>
<div className="flex flex-wrap items-center gap-1.5">
{suggestions.map((suggestion) => (
<span
key={suggestion.token}
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5"
>
<span className="font-mono text-(length:--text-micro)">{suggestion.token}</span>
<span className="hidden text-(length:--text-micro) sm:inline">{suggestion.description}</span>
</span>
))}
</div>
</div>
</div>
);
}
function CommandPaletteWithSearchAll({
query,
emptyResults = false,
@ -424,6 +480,15 @@ function CommandPaletteWithSearchAll({
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Quick filters">
{SEARCH_OPERATOR_QUICK_FILTERS.map((chip) => (
<CommandItem key={chip} value={`quick-filter ${chip}`}>
<SearchIcon className="mr-2 h-4 w-4" />
<span className="font-mono text-xs">{chip}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Actions">
<CommandItem>
<SquarePen className="mr-2 h-4 w-4" />
@ -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 (
<div className="paperclip-story">
@ -516,6 +628,14 @@ function SearchStories() {
<SearchPagePreview response={fixtureResponse} state="results" query="auth flake" />
</section>
<section className="paperclip-story__frame overflow-hidden">
<div className="paperclip-story__title-block">
<div className="paperclip-story__label">/search · screen 3</div>
<h2 className="mt-1 text-lg font-semibold">Typed operators, pills &amp; autocomplete</h2>
</div>
<SearchOperatorInputPreview />
</section>
<section className="paperclip-story__frame overflow-hidden">
<div className="paperclip-story__title-block">
<div className="paperclip-story__label">/search</div>
@ -537,7 +657,49 @@ function SearchStories() {
<div className="paperclip-story__label">/search</div>
<h2 className="mt-1 text-lg font-semibold">No results state</h2>
</div>
<SearchPagePreview response={{ ...fixtureResponse, results: [], countsByType: { issue: 0, artifact: 0, agent: 0, project: 0 } }} state="empty" query="ghostbuster" />
<SearchPagePreview response={{ ...fixtureResponse, results: [], countsByType: { issue: 0, comment: 0, document: 0, artifact: 0, agent: 0, project: 0 } }} state="empty" query="ghostbuster" />
</section>
<section className="paperclip-story__frame overflow-hidden">
<div className="paperclip-story__title-block">
<div className="paperclip-story__label">/search · screen 1</div>
<h2 className="mt-1 text-lg font-semibold">Filter bar, active chips &amp; honest meta</h2>
</div>
<div className="flex flex-col gap-2 border-t border-border bg-background p-4">
<SearchFilterBar
filters={activeSearchFilters}
onChange={noop}
sort="relevance"
onSortChange={noop}
data={searchFilterData}
/>
<SearchFilterChips
filters={activeSearchFilters}
lookups={searchFilterLookups}
onChange={noop}
onClearAll={noop}
/>
<div className="py-1 text-[11px] uppercase tracking-wide text-muted-foreground">
8 of 42 results · sorted by Relevance · 4 filters active
</div>
</div>
</section>
<section className="paperclip-story__frame overflow-hidden">
<div className="paperclip-story__title-block">
<div className="paperclip-story__label">/search · screen 4</div>
<h2 className="mt-1 text-lg font-semibold">Zero-results recovery</h2>
</div>
<div className="border-t border-border bg-background">
<ZeroResultsRecovery
query="auth flake"
filters={activeSearchFilters}
zeroResults={zeroResultsFixture}
lookups={searchFilterLookups}
onChange={noop}
onClearAll={noop}
/>
</div>
</section>
<section className="paperclip-story__frame overflow-hidden p-4">