mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
Fix routine and skill folder search
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
c87d009aab
commit
a87183ded0
12 files changed
+718
-25
No files matched your search
@@ -542,11 +542,14 @@ export type {
|
||||
ProjectWorkspace,
|
||||
CompanySearchHighlight,
|
||||
CompanySearchArtifactSummary,
|
||||
CompanySearchFolderSummary,
|
||||
CompanySearchIssueSummary,
|
||||
CompanySearchResponse,
|
||||
CompanySearchResult,
|
||||
CompanySearchResultType,
|
||||
CompanySearchRoutineSummary,
|
||||
CompanySearchScope,
|
||||
CompanySearchSkillSummary,
|
||||
CompanySearchSnippet,
|
||||
ExecutionWorkspace,
|
||||
ExecutionWorkspaceSummary,
|
||||
|
||||
@@ -205,11 +205,14 @@ export type { Project, ProjectBudgetSummary, ProjectCodebase, ProjectCodebaseOri
|
||||
export type {
|
||||
CompanySearchHighlight,
|
||||
CompanySearchArtifactSummary,
|
||||
CompanySearchFolderSummary,
|
||||
CompanySearchIssueSummary,
|
||||
CompanySearchResponse,
|
||||
CompanySearchResult,
|
||||
CompanySearchResultType,
|
||||
CompanySearchRoutineSummary,
|
||||
CompanySearchScope,
|
||||
CompanySearchSkillSummary,
|
||||
CompanySearchSnippet,
|
||||
} from "./search.js";
|
||||
export { COMPANY_SEARCH_SCOPES } from "./search.js";
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { IssuePriority, IssueStatus } from "../constants.js";
|
||||
|
||||
export const COMPANY_SEARCH_SCOPES = ["all", "issues", "comments", "documents", "artifacts", "agents", "projects"] as const;
|
||||
export const COMPANY_SEARCH_SCOPES = [
|
||||
"all",
|
||||
"issues",
|
||||
"comments",
|
||||
"documents",
|
||||
"artifacts",
|
||||
"agents",
|
||||
"projects",
|
||||
"routines",
|
||||
"skills",
|
||||
] as const;
|
||||
export type CompanySearchScope = (typeof COMPANY_SEARCH_SCOPES)[number];
|
||||
|
||||
export type CompanySearchResultType = "issue" | "artifact" | "agent" | "project";
|
||||
export type CompanySearchResultType = "issue" | "artifact" | "agent" | "project" | "routine" | "skill";
|
||||
|
||||
export interface CompanySearchHighlight {
|
||||
start: number;
|
||||
@@ -41,6 +51,29 @@ export interface CompanySearchArtifactSummary {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CompanySearchFolderSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface CompanySearchRoutineSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
folder: CompanySearchFolderSummary | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CompanySearchSkillSummary {
|
||||
id: string;
|
||||
key: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
folder: CompanySearchFolderSummary | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CompanySearchResult {
|
||||
id: string;
|
||||
type: CompanySearchResultType;
|
||||
@@ -53,6 +86,8 @@ export interface CompanySearchResult {
|
||||
snippets: CompanySearchSnippet[];
|
||||
issue?: CompanySearchIssueSummary;
|
||||
artifact?: CompanySearchArtifactSummary;
|
||||
routine?: CompanySearchRoutineSummary;
|
||||
skill?: CompanySearchSkillSummary;
|
||||
updatedAt: string | null;
|
||||
previewImageUrl: string | null;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ function createSearchResponse(query: CompanySearchQuery): CompanySearchResponse
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
results: [],
|
||||
countsByType: { issue: 0, artifact: 0, agent: 0, project: 0 },
|
||||
countsByType: { issue: 0, artifact: 0, agent: 0, project: 0, routine: 0, skill: 0 },
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,12 +4,15 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
companySkills,
|
||||
createDb,
|
||||
documents,
|
||||
folders,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
issues,
|
||||
projects,
|
||||
routines,
|
||||
} from "@paperclipai/db";
|
||||
import { companySearchQuerySchema, COMPANY_SEARCH_MAX_QUERY_LENGTH } from "@paperclipai/shared";
|
||||
import {
|
||||
@@ -72,6 +75,9 @@ describeEmbeddedPostgres("companySearchService", () => {
|
||||
await db.delete(documents);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issues);
|
||||
await db.delete(routines);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(folders);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
@@ -134,6 +140,48 @@ describeEmbeddedPostgres("companySearchService", () => {
|
||||
return id;
|
||||
}
|
||||
|
||||
async function createFolder(companyId: string, values: Partial<typeof folders.$inferInsert> = {}) {
|
||||
const id = values.id ?? randomUUID();
|
||||
await db.insert(folders).values({
|
||||
id,
|
||||
companyId,
|
||||
kind: values.kind ?? "routine",
|
||||
name: values.name ?? "Reporting",
|
||||
color: values.color ?? null,
|
||||
position: values.position ?? 0,
|
||||
...values,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
async function createRoutine(companyId: string, values: Partial<typeof routines.$inferInsert> = {}) {
|
||||
const id = values.id ?? randomUUID();
|
||||
await db.insert(routines).values({
|
||||
id,
|
||||
companyId,
|
||||
title: values.title ?? "Standup digest",
|
||||
description: values.description ?? null,
|
||||
status: values.status ?? "active",
|
||||
...values,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
async function createCompanySkill(companyId: string, values: Partial<typeof companySkills.$inferInsert> = {}) {
|
||||
const id = values.id ?? randomUUID();
|
||||
await db.insert(companySkills).values({
|
||||
id,
|
||||
companyId,
|
||||
key: values.key ?? `paperclip/${id}`,
|
||||
slug: values.slug ?? `skill-${id.slice(0, 8)}`,
|
||||
name: values.name ?? "Research Skill",
|
||||
description: values.description ?? null,
|
||||
markdown: values.markdown ?? "# Research Skill",
|
||||
...values,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
it("ranks exact issue identifiers before weaker title matches", async () => {
|
||||
const companyId = await createCompany();
|
||||
const exactId = await createIssue(companyId, {
|
||||
@@ -238,7 +286,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, artifact: 1, agent: 0, project: 0, routine: 0, skill: 0 });
|
||||
});
|
||||
|
||||
it("does not pass high-offset search fetch windows through to artifact query validation", async () => {
|
||||
@@ -440,10 +488,56 @@ 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, artifact: 0, agent: 3, project: 3, routine: 0, skill: 0 });
|
||||
expect(result.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it("searches routines and skills by folder and returns folder paths", async () => {
|
||||
const companyId = await createCompany();
|
||||
const otherCompanyId = await createCompany("Other Co");
|
||||
const routineFolderId = await createFolder(companyId, { kind: "routine", name: "Reporting" });
|
||||
const skillFolderId = await createFolder(companyId, { kind: "skill", name: "Research" });
|
||||
const routineId = await createRoutine(companyId, {
|
||||
title: "Daily standup digest",
|
||||
description: "Summarizes team activity.",
|
||||
folderId: routineFolderId,
|
||||
});
|
||||
const skillId = await createCompanySkill(companyId, {
|
||||
key: "paperclip/deep-research",
|
||||
slug: "deep-research",
|
||||
name: "Deep Research",
|
||||
description: "Collects evidence for planning.",
|
||||
folderId: skillFolderId,
|
||||
});
|
||||
await createFolder(otherCompanyId, { kind: "routine", name: "Reporting" });
|
||||
await createRoutine(otherCompanyId, {
|
||||
title: "Other company reporting routine",
|
||||
});
|
||||
|
||||
const byName = await svc.search(companyId, companySearchQuerySchema.parse({ q: "standup" }));
|
||||
const routineByName = byName.results.find((item) => item.id === routineId);
|
||||
expect(routineByName).toMatchObject({
|
||||
type: "routine",
|
||||
href: expect.stringContaining(`/routines/${routineId}`),
|
||||
sourceLabel: "Folder: Reporting",
|
||||
routine: expect.objectContaining({
|
||||
folder: expect.objectContaining({ path: "Reporting" }),
|
||||
}),
|
||||
});
|
||||
|
||||
const byFolder = await svc.search(companyId, companySearchQuerySchema.parse({ q: "Research", scope: "skills" }));
|
||||
expect(byFolder.results.map((item) => item.id)).toContain(skillId);
|
||||
expect(byFolder.results.find((item) => item.id === skillId)).toMatchObject({
|
||||
type: "skill",
|
||||
href: expect.stringContaining("/skills/deep-research"),
|
||||
sourceLabel: "Folder: Research",
|
||||
skill: expect.objectContaining({
|
||||
folder: expect.objectContaining({ path: "Research" }),
|
||||
}),
|
||||
});
|
||||
expect(byFolder.countsByType).toMatchObject({ routine: 0, skill: 1 });
|
||||
});
|
||||
|
||||
it("escapes underscore and backslash characters in issue phrase and token patterns", async () => {
|
||||
const companyId = await createCompany();
|
||||
const literalId = await createIssue(companyId, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { and, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, companies, issues, projects } from "@paperclipai/db";
|
||||
import { agents, companies, companySkills, folders, issues, projects, routines } from "@paperclipai/db";
|
||||
import {
|
||||
COMPANY_SEARCH_MAX_LIMIT,
|
||||
COMPANY_SEARCH_MAX_OFFSET,
|
||||
@@ -10,12 +10,15 @@ import {
|
||||
COMPANY_ARTIFACTS_MAX_QUERY_LENGTH,
|
||||
type CompanyArtifact,
|
||||
type CompanySearchArtifactSummary,
|
||||
type CompanySearchFolderSummary,
|
||||
type CompanySearchIssueSummary,
|
||||
type CompanySearchQuery,
|
||||
type CompanySearchResponse,
|
||||
type CompanySearchResult,
|
||||
type CompanySearchResultType,
|
||||
type CompanySearchRoutineSummary,
|
||||
type CompanySearchScope,
|
||||
type CompanySearchSkillSummary,
|
||||
type CompanySearchSnippet,
|
||||
} from "@paperclipai/shared";
|
||||
import { companyArtifactsService } from "./company-artifacts.js";
|
||||
@@ -59,6 +62,11 @@ type SimpleSearchRow = {
|
||||
title: string;
|
||||
description: string | null;
|
||||
role?: string | null;
|
||||
key?: string | null;
|
||||
slug?: string | null;
|
||||
status?: string | null;
|
||||
folderId?: string | null;
|
||||
folderName?: string | null;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
@@ -193,7 +201,14 @@ function matchTerms(normalizedQuery: string, tokens: string[]) {
|
||||
}
|
||||
|
||||
function makeCounts(results: CompanySearchResult[]) {
|
||||
const counts: Record<CompanySearchResultType, number> = { issue: 0, artifact: 0, agent: 0, project: 0 };
|
||||
const counts: Record<CompanySearchResultType, number> = {
|
||||
issue: 0,
|
||||
artifact: 0,
|
||||
agent: 0,
|
||||
project: 0,
|
||||
routine: 0,
|
||||
skill: 0,
|
||||
};
|
||||
for (const result of results) counts[result.type] += 1;
|
||||
return counts;
|
||||
}
|
||||
@@ -214,6 +229,14 @@ function scopeIncludesProjects(scope: CompanySearchScope) {
|
||||
return scope === "all" || scope === "projects";
|
||||
}
|
||||
|
||||
function scopeIncludesRoutines(scope: CompanySearchScope) {
|
||||
return scope === "all" || scope === "routines";
|
||||
}
|
||||
|
||||
function scopeIncludesSkills(scope: CompanySearchScope) {
|
||||
return scope === "all" || scope === "skills";
|
||||
}
|
||||
|
||||
function issueSearchCondition(scope: CompanySearchScope, input: {
|
||||
issueTextMatch: SQL<boolean>;
|
||||
commentMatch: SQL<boolean>;
|
||||
@@ -286,15 +309,113 @@ function issueResult(row: IssueSearchRow, prefix: string, normalizedQuery: strin
|
||||
}
|
||||
|
||||
function scoreSimpleRow(row: SimpleSearchRow, normalizedQuery: string, tokens: string[]) {
|
||||
const haystack = [row.title, row.description, row.role].filter(Boolean).join(" ").toLowerCase();
|
||||
const haystack = [row.title, row.description, row.role, row.key, row.slug, row.folderName]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
let score = haystack.includes(normalizedQuery) ? 90 : 0;
|
||||
for (const token of tokens) {
|
||||
if (haystack.includes(token)) score += 20;
|
||||
}
|
||||
if (row.title.toLowerCase().startsWith(normalizedQuery)) score += 80;
|
||||
if (row.folderName?.toLowerCase().includes(normalizedQuery)) score += 60;
|
||||
return score;
|
||||
}
|
||||
|
||||
function simpleMatchedFields(row: SimpleSearchRow, normalizedQuery: string, tokens: string[]) {
|
||||
const terms = matchTerms(normalizedQuery, tokens);
|
||||
const fieldValues: Array<[string, string | null | undefined]> = [
|
||||
["title", row.title],
|
||||
["description", row.description],
|
||||
["role", row.role],
|
||||
["key", row.key],
|
||||
["slug", row.slug],
|
||||
["folder", row.folderName],
|
||||
];
|
||||
return fieldValues
|
||||
.filter(([, value]) => {
|
||||
const lowered = (value ?? "").toLowerCase();
|
||||
return lowered.length > 0 && terms.some((term) => lowered.includes(term.toLowerCase()));
|
||||
})
|
||||
.map(([field]) => field);
|
||||
}
|
||||
|
||||
function folderSummary(row: Pick<SimpleSearchRow, "folderId" | "folderName">): CompanySearchFolderSummary | null {
|
||||
if (!row.folderId || !row.folderName) return null;
|
||||
return { id: row.folderId, name: row.folderName, path: row.folderName };
|
||||
}
|
||||
|
||||
function entitySourceLabel(folder: CompanySearchFolderSummary | null) {
|
||||
return folder ? `Folder: ${folder.path}` : "Unfiled";
|
||||
}
|
||||
|
||||
function simpleEntitySnippets(row: SimpleSearchRow, normalizedQuery: string, tokens: string[], entityLabel: string) {
|
||||
const terms = matchTerms(normalizedQuery, tokens);
|
||||
const matched = new Set(simpleMatchedFields(row, normalizedQuery, tokens));
|
||||
const candidates: Array<CompanySearchSnippet | null> = [];
|
||||
if (matched.has("folder")) candidates.push(createSnippet("folder", "Folder", row.folderName, terms));
|
||||
if (matched.has("description")) candidates.push(createSnippet("description", entityLabel, row.description, terms));
|
||||
if (matched.has("key")) candidates.push(createSnippet("key", "Key", row.key, terms));
|
||||
if (matched.has("slug")) candidates.push(createSnippet("slug", "Slug", row.slug, terms));
|
||||
if (matched.has("role")) candidates.push(createSnippet("role", entityLabel, row.role, terms));
|
||||
return candidates.filter((snippet): snippet is CompanySearchSnippet => Boolean(snippet)).slice(0, 2);
|
||||
}
|
||||
|
||||
function routineResult(row: SimpleSearchRow, prefix: string, normalizedQuery: string, tokens: string[]): CompanySearchResult {
|
||||
const folder = folderSummary(row);
|
||||
const snippets = simpleEntitySnippets(row, normalizedQuery, tokens, "Routine");
|
||||
const updatedAt = iso(row.updatedAt);
|
||||
const routine: CompanySearchRoutineSummary = {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
status: row.status ?? "active",
|
||||
folder,
|
||||
updatedAt: updatedAt!,
|
||||
};
|
||||
return {
|
||||
id: row.id,
|
||||
type: "routine",
|
||||
score: scoreSimpleRow(row, normalizedQuery, tokens),
|
||||
title: row.title,
|
||||
href: `/${prefix}/routines/${encodeURIComponent(row.id)}`,
|
||||
matchedFields: simpleMatchedFields(row, normalizedQuery, tokens),
|
||||
sourceLabel: entitySourceLabel(folder),
|
||||
snippet: snippets[0]?.text ?? null,
|
||||
snippets,
|
||||
routine,
|
||||
updatedAt,
|
||||
previewImageUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
function skillResult(row: SimpleSearchRow, prefix: string, normalizedQuery: string, tokens: string[]): CompanySearchResult {
|
||||
const folder = folderSummary(row);
|
||||
const snippets = simpleEntitySnippets(row, normalizedQuery, tokens, "Skill");
|
||||
const updatedAt = iso(row.updatedAt);
|
||||
const skill: CompanySearchSkillSummary = {
|
||||
id: row.id,
|
||||
key: row.key ?? row.id,
|
||||
slug: row.slug ?? row.id,
|
||||
name: row.title,
|
||||
folder,
|
||||
updatedAt: updatedAt!,
|
||||
};
|
||||
return {
|
||||
id: row.id,
|
||||
type: "skill",
|
||||
score: scoreSimpleRow(row, normalizedQuery, tokens),
|
||||
title: row.title,
|
||||
href: `/${prefix}/skills/${encodeURIComponent(row.slug || row.key || row.id)}`,
|
||||
matchedFields: simpleMatchedFields(row, normalizedQuery, tokens),
|
||||
sourceLabel: entitySourceLabel(folder),
|
||||
snippet: snippets[0]?.text ?? null,
|
||||
snippets,
|
||||
skill,
|
||||
updatedAt,
|
||||
previewImageUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
function artifactResult(artifact: CompanyArtifact, normalizedQuery: string, tokens: string[]): CompanySearchResult {
|
||||
const terms = matchTerms(normalizedQuery, tokens);
|
||||
const snippet = createSnippet(
|
||||
@@ -358,7 +479,14 @@ export function companySearchService(db: Db) {
|
||||
const scope = query.scope;
|
||||
const limit = query.limit;
|
||||
const offset = query.offset;
|
||||
const emptyCounts: Record<CompanySearchResultType, number> = { issue: 0, artifact: 0, agent: 0, project: 0 };
|
||||
const emptyCounts: Record<CompanySearchResultType, number> = {
|
||||
issue: 0,
|
||||
artifact: 0,
|
||||
agent: 0,
|
||||
project: 0,
|
||||
routine: 0,
|
||||
skill: 0,
|
||||
};
|
||||
if (normalizedQuery.length === 0) {
|
||||
return {
|
||||
query: query.q,
|
||||
@@ -695,6 +823,71 @@ export function companySearchService(db: Db) {
|
||||
.limit(fetchLimit)
|
||||
: [];
|
||||
|
||||
const routineCondition = simpleTextCondition([
|
||||
sql`${routines.title}`,
|
||||
sql`${routines.description}`,
|
||||
sql`${folders.name}`,
|
||||
], containsPattern, tokenArray);
|
||||
const routineRows = scopeIncludesRoutines(scope)
|
||||
? await db
|
||||
.select({
|
||||
id: routines.id,
|
||||
title: routines.title,
|
||||
description: routines.description,
|
||||
status: routines.status,
|
||||
folderId: routines.folderId,
|
||||
folderName: folders.name,
|
||||
updatedAt: routines.updatedAt,
|
||||
})
|
||||
.from(routines)
|
||||
.leftJoin(
|
||||
folders,
|
||||
and(
|
||||
eq(routines.folderId, folders.id),
|
||||
eq(folders.companyId, companyId),
|
||||
eq(folders.kind, "routine"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(routines.companyId, companyId), routineCondition))
|
||||
.orderBy(desc(routines.updatedAt), desc(routines.id))
|
||||
.limit(fetchLimit)
|
||||
: [];
|
||||
|
||||
const skillCondition = simpleTextCondition([
|
||||
sql`${companySkills.name}`,
|
||||
sql`${companySkills.description}`,
|
||||
sql`${companySkills.key}`,
|
||||
sql`${companySkills.slug}`,
|
||||
sql`${companySkills.tagline}`,
|
||||
sql`${companySkills.markdown}`,
|
||||
sql`${folders.name}`,
|
||||
], containsPattern, tokenArray);
|
||||
const skillRows = scopeIncludesSkills(scope)
|
||||
? await db
|
||||
.select({
|
||||
id: companySkills.id,
|
||||
title: companySkills.name,
|
||||
description: companySkills.description,
|
||||
key: companySkills.key,
|
||||
slug: companySkills.slug,
|
||||
folderId: companySkills.folderId,
|
||||
folderName: folders.name,
|
||||
updatedAt: companySkills.updatedAt,
|
||||
})
|
||||
.from(companySkills)
|
||||
.leftJoin(
|
||||
folders,
|
||||
and(
|
||||
eq(companySkills.folderId, folders.id),
|
||||
eq(folders.companyId, companyId),
|
||||
eq(folders.kind, "skill"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(companySkills.companyId, companyId), skillCondition))
|
||||
.orderBy(desc(companySkills.updatedAt), desc(companySkills.id))
|
||||
.limit(fetchLimit)
|
||||
: [];
|
||||
|
||||
const artifactRows = scopeIncludesArtifacts(scope)
|
||||
? await companyArtifactsService(db).list(companyId, {
|
||||
q: normalizedQuery.slice(0, COMPANY_ARTIFACTS_MAX_QUERY_LENGTH),
|
||||
@@ -739,6 +932,8 @@ export function companySearchService(db: Db) {
|
||||
previewImageUrl: null,
|
||||
};
|
||||
}),
|
||||
...(routineRows as SimpleSearchRow[]).map((row) => routineResult(row, prefix, normalizedQuery, tokens)),
|
||||
...(skillRows as SimpleSearchRow[]).map((row) => skillResult(row, prefix, normalizedQuery, tokens)),
|
||||
].sort((left, right) => {
|
||||
if (right.score !== left.score) return right.score - left.score;
|
||||
return (right.updatedAt ?? "").localeCompare(left.updatedAt ?? "");
|
||||
|
||||
@@ -42,6 +42,10 @@ const mockProjectsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSearchApi = vi.hoisted(() => ({
|
||||
search: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
@@ -83,6 +87,10 @@ vi.mock("../api/projects", () => ({
|
||||
projectsApi: mockProjectsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/search", () => ({
|
||||
searchApi: mockSearchApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
@@ -192,6 +200,7 @@ describe("CommandPalette", () => {
|
||||
mockIssuesApi.list.mockReset();
|
||||
mockAgentsApi.list.mockReset();
|
||||
mockProjectsApi.list.mockReset();
|
||||
mockSearchApi.search.mockReset();
|
||||
mockInstanceSettingsApi.getExperimental.mockReset();
|
||||
navigateState.navigate.mockReset();
|
||||
locationState.location.pathname = "/";
|
||||
@@ -200,6 +209,16 @@ describe("CommandPalette", () => {
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockAgentsApi.list.mockResolvedValue([]);
|
||||
mockProjectsApi.list.mockResolvedValue([]);
|
||||
mockSearchApi.search.mockResolvedValue({
|
||||
query: "",
|
||||
normalizedQuery: "",
|
||||
scope: "all",
|
||||
limit: 12,
|
||||
offset: 0,
|
||||
countsByType: { issue: 0, artifact: 0, agent: 0, project: 0, routine: 0, skill: 0 },
|
||||
hasMore: false,
|
||||
results: [],
|
||||
});
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableExperimentalFileViewer: false,
|
||||
});
|
||||
@@ -381,6 +400,107 @@ describe("CommandPalette", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows routine and skill quick matches with folder paths while typing", async () => {
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
const quickSearchResponse = {
|
||||
query: "standup",
|
||||
normalizedQuery: "standup",
|
||||
scope: "all",
|
||||
limit: 12,
|
||||
offset: 0,
|
||||
countsByType: { issue: 0, artifact: 0, agent: 0, project: 0, routine: 1, skill: 1 },
|
||||
hasMore: false,
|
||||
results: [
|
||||
{
|
||||
id: "routine-1",
|
||||
type: "routine",
|
||||
score: 100,
|
||||
title: "Daily standup digest",
|
||||
href: "/PAP/routines/routine-1",
|
||||
matchedFields: ["title"],
|
||||
sourceLabel: "Folder: Reporting",
|
||||
snippet: null,
|
||||
snippets: [],
|
||||
routine: {
|
||||
id: "routine-1",
|
||||
title: "Daily standup digest",
|
||||
status: "active",
|
||||
folder: { id: "folder-1", name: "Reporting", path: "Reporting" },
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
previewImageUrl: null,
|
||||
},
|
||||
{
|
||||
id: "skill-1",
|
||||
type: "skill",
|
||||
score: 90,
|
||||
title: "Standup Writer",
|
||||
href: "/PAP/skills/standup-writer",
|
||||
matchedFields: ["folder"],
|
||||
sourceLabel: "Folder: Operations",
|
||||
snippet: null,
|
||||
snippets: [],
|
||||
skill: {
|
||||
id: "skill-1",
|
||||
key: "paperclip/standup-writer",
|
||||
slug: "standup-writer",
|
||||
name: "Standup Writer",
|
||||
folder: { id: "folder-2", name: "Operations", path: "Operations" },
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
previewImageUrl: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockSearchApi.search.mockResolvedValue(quickSearchResponse);
|
||||
|
||||
const { root } = renderWithQueryClient(<CommandPalette />, container, (queryClient) => {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.companySearch.search("company-1", "standup", "all", 12, 0),
|
||||
quickSearchResponse,
|
||||
);
|
||||
});
|
||||
|
||||
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, "standup");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockSearchApi.search).toHaveBeenCalledWith("company-1", {
|
||||
q: "standup",
|
||||
scope: "all",
|
||||
limit: 12,
|
||||
});
|
||||
expect(container.textContent).toContain("Daily standup digest");
|
||||
expect(container.textContent).toContain("Reporting");
|
||||
expect(container.textContent).toContain("Standup Writer");
|
||||
expect(container.textContent).toContain("Operations");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
container
|
||||
.querySelector('button[data-testid="command-routine-match"]')!
|
||||
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(navigateState.navigate).toHaveBeenCalledWith("/PAP/routines/routine-1");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates to /search when the user clicks the Search-all command", async () => {
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
const { root } = renderWithQueryClient(<CommandPalette />, container);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSidebar } from "../context/SidebarContext";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { searchApi } from "../api/search";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
@@ -31,6 +32,8 @@ import {
|
||||
FileCode2,
|
||||
Plus,
|
||||
Search,
|
||||
Repeat,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { Identity } from "./Identity";
|
||||
import { agentUrl, projectUrl } from "../lib/utils";
|
||||
@@ -126,6 +129,12 @@ export function CommandPalette() {
|
||||
enabled: !!selectedCompanyId && open && searchQuery.length > 0,
|
||||
});
|
||||
|
||||
const { data: quickSearch } = useQuery({
|
||||
queryKey: queryKeys.companySearch.search(selectedCompanyId!, searchQuery, "all", 12, 0),
|
||||
queryFn: () => searchApi.search(selectedCompanyId!, { q: searchQuery, scope: "all", limit: 12 }),
|
||||
enabled: !!selectedCompanyId && open && searchQuery.length > 0,
|
||||
});
|
||||
|
||||
const { data: agents = [] } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
@@ -160,6 +169,14 @@ export function CommandPalette() {
|
||||
() => (searchQuery.length > 0 ? searchedIssues : issues),
|
||||
[issues, searchedIssues, searchQuery],
|
||||
);
|
||||
const quickRoutines = useMemo(
|
||||
() => (quickSearch?.results ?? []).filter((result) => result.type === "routine" && result.routine).slice(0, 5),
|
||||
[quickSearch?.results],
|
||||
);
|
||||
const quickSkills = useMemo(
|
||||
() => (quickSearch?.results ?? []).filter((result) => result.type === "skill" && result.skill).slice(0, 5),
|
||||
[quickSearch?.results],
|
||||
);
|
||||
|
||||
// Client-side typeahead ranking over the already-loaded projects. cmdk ranks
|
||||
// items by their `value` (which defaults to the rendered name) and would bury
|
||||
@@ -184,9 +201,15 @@ export function CommandPalette() {
|
||||
|
||||
const showSearchAll = searchQuery.length > 0;
|
||||
const showPromotedProjects = showSearchAll && matchedProjects.length > 0;
|
||||
const showRoutines = showSearchAll && quickRoutines.length > 0;
|
||||
const showSkills = showSearchAll && quickSkills.length > 0;
|
||||
const taskLimit = showPromotedProjects ? TASK_LIMIT_WITH_PROJECTS : TASK_LIMIT;
|
||||
const showEmptyHint =
|
||||
showSearchAll && visibleIssues.length === 0 && matchedProjects.length === 0;
|
||||
showSearchAll
|
||||
&& visibleIssues.length === 0
|
||||
&& matchedProjects.length === 0
|
||||
&& quickRoutines.length === 0
|
||||
&& quickSkills.length === 0;
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={(v) => {
|
||||
@@ -263,6 +286,50 @@ export function CommandPalette() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{showRoutines && (
|
||||
<>
|
||||
<CommandGroup heading="Routines">
|
||||
{quickRoutines.map((result) => (
|
||||
<CommandItem
|
||||
key={result.id}
|
||||
value={`${searchQuery} ${result.routine!.title} ${result.routine!.folder?.path ?? ""}`}
|
||||
onSelect={() => go(result.href)}
|
||||
data-testid="command-routine-match"
|
||||
>
|
||||
<Repeat className="mr-2 h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 truncate">{result.routine!.title}</span>
|
||||
<span className="ml-2 hidden min-w-0 flex-1 truncate text-xs text-muted-foreground sm:inline">
|
||||
{result.routine!.folder?.path ?? "Unfiled"}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{showSkills && (
|
||||
<>
|
||||
<CommandGroup heading="Skills">
|
||||
{quickSkills.map((result) => (
|
||||
<CommandItem
|
||||
key={result.id}
|
||||
value={`${searchQuery} ${result.skill!.name} ${result.skill!.folder?.path ?? ""}`}
|
||||
onSelect={() => go(result.href)}
|
||||
data-testid="command-skill-match"
|
||||
>
|
||||
<Wrench className="mr-2 h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 truncate">{result.skill!.name}</span>
|
||||
<span className="ml-2 hidden min-w-0 flex-1 truncate text-xs text-muted-foreground sm:inline">
|
||||
{result.skill!.folder?.path ?? "Unfiled"}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<CommandGroup heading="Actions">
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, type ComponentType, type SVGProps } from "react";
|
||||
import { Bot, FileText, Hexagon, MessageSquare, Paperclip, Quote } from "lucide-react";
|
||||
import { Bot, FileText, Folder, Hexagon, MessageSquare, Paperclip, Quote, Repeat, Wrench } from "lucide-react";
|
||||
import type { Agent, CompanySearchResult } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -17,6 +17,7 @@ const SNIPPET_STYLES: Record<string, SnippetStyle> = {
|
||||
document: { Icon: FileText, label: "Doc" },
|
||||
artifact: { Icon: Paperclip, label: "Artifact" },
|
||||
description: { Icon: Quote, label: "Description" },
|
||||
folder: { Icon: Folder, label: "Folder" },
|
||||
};
|
||||
|
||||
function snippetStyle(field: string, fallbackLabel: string): SnippetStyle {
|
||||
@@ -110,6 +111,78 @@ function SearchResultRowImpl({
|
||||
);
|
||||
}
|
||||
|
||||
if (result.type === "routine") {
|
||||
const routine = result.routine;
|
||||
if (!routine) return null;
|
||||
return (
|
||||
<Link
|
||||
to={result.href}
|
||||
className={cn(ROW_BASE, "py-3", isActive && "bg-muted/40", className)}
|
||||
data-result-type="routine"
|
||||
>
|
||||
<Repeat className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2.5 gap-y-1">
|
||||
<span className="truncate text-sm font-medium">{routine.title}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{routine.folder?.path ?? "Unfiled"}
|
||||
</span>
|
||||
</div>
|
||||
{(result.snippets.length > 0 ? result.snippets : result.sourceLabel ? [{
|
||||
field: "folder",
|
||||
label: "Folder",
|
||||
text: result.sourceLabel,
|
||||
highlights: [],
|
||||
}] : []).slice(0, 2).map((snippet, index) => (
|
||||
<SnippetLine
|
||||
key={`${snippet.field}-${index}`}
|
||||
text={snippet.text}
|
||||
highlights={snippet.highlights}
|
||||
field={snippet.field}
|
||||
fallbackLabel={snippet.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.type === "skill") {
|
||||
const skill = result.skill;
|
||||
if (!skill) return null;
|
||||
return (
|
||||
<Link
|
||||
to={result.href}
|
||||
className={cn(ROW_BASE, "py-3", isActive && "bg-muted/40", className)}
|
||||
data-result-type="skill"
|
||||
>
|
||||
<Wrench className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2.5 gap-y-1">
|
||||
<span className="truncate text-sm font-medium">{skill.name}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{skill.folder?.path ?? "Unfiled"}
|
||||
</span>
|
||||
</div>
|
||||
{(result.snippets.length > 0 ? result.snippets : result.sourceLabel ? [{
|
||||
field: "folder",
|
||||
label: "Folder",
|
||||
text: result.sourceLabel,
|
||||
highlights: [],
|
||||
}] : []).slice(0, 2).map((snippet, index) => (
|
||||
<SnippetLine
|
||||
key={`${snippet.field}-${index}`}
|
||||
text={snippet.text}
|
||||
highlights={snippet.highlights}
|
||||
field={snippet.field}
|
||||
fallbackLabel={snippet.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.type === "artifact") {
|
||||
const artifact = result.artifact;
|
||||
if (!artifact) return null;
|
||||
|
||||
@@ -169,7 +169,7 @@ describe("Search page", () => {
|
||||
scope: "all",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
countsByType: { issue: 1, artifact: 0, agent: 0, project: 0 },
|
||||
countsByType: { issue: 1, artifact: 0, agent: 0, project: 0, routine: 0, skill: 0 },
|
||||
hasMore: false,
|
||||
results: [
|
||||
{
|
||||
@@ -239,7 +239,7 @@ describe("Search page", () => {
|
||||
scope: "artifacts",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
countsByType: { issue: 0, artifact: 1, agent: 0, project: 0 },
|
||||
countsByType: { issue: 0, artifact: 1, agent: 0, project: 0, routine: 0, skill: 0 },
|
||||
hasMore: false,
|
||||
results: [
|
||||
{
|
||||
@@ -297,6 +297,88 @@ describe("Search page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders routine and skill search results with folder paths", async () => {
|
||||
searchApiMock.search.mockResolvedValueOnce({
|
||||
query: "research",
|
||||
normalizedQuery: "research",
|
||||
scope: "all",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
countsByType: { issue: 0, artifact: 0, agent: 0, project: 0, routine: 1, skill: 1 },
|
||||
hasMore: false,
|
||||
results: [
|
||||
{
|
||||
id: "routine-1",
|
||||
type: "routine",
|
||||
score: 120,
|
||||
title: "Research digest",
|
||||
href: "/PAP/routines/routine-1",
|
||||
matchedFields: ["title", "folder"],
|
||||
sourceLabel: "Folder: Reporting",
|
||||
snippet: "Reporting",
|
||||
snippets: [
|
||||
{
|
||||
field: "folder",
|
||||
label: "Folder",
|
||||
text: "Reporting",
|
||||
highlights: [],
|
||||
},
|
||||
],
|
||||
routine: {
|
||||
id: "routine-1",
|
||||
title: "Research digest",
|
||||
status: "active",
|
||||
folder: { id: "folder-1", name: "Reporting", path: "Reporting" },
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
previewImageUrl: null,
|
||||
},
|
||||
{
|
||||
id: "skill-1",
|
||||
type: "skill",
|
||||
score: 110,
|
||||
title: "Deep Research",
|
||||
href: "/PAP/skills/deep-research",
|
||||
matchedFields: ["title", "folder"],
|
||||
sourceLabel: "Folder: Research",
|
||||
snippet: "Research",
|
||||
snippets: [
|
||||
{
|
||||
field: "folder",
|
||||
label: "Folder",
|
||||
text: "Research",
|
||||
highlights: [],
|
||||
},
|
||||
],
|
||||
skill: {
|
||||
id: "skill-1",
|
||||
key: "paperclip/deep-research",
|
||||
slug: "deep-research",
|
||||
name: "Deep Research",
|
||||
folder: { id: "folder-2", name: "Research", path: "Research" },
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
previewImageUrl: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { root } = renderSearch("/search?q=research", container);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Research digest");
|
||||
expect(container.textContent).toContain("Deep Research");
|
||||
expect(container.textContent).toContain("Reporting");
|
||||
expect(container.textContent).toContain("Research");
|
||||
});
|
||||
|
||||
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 +386,7 @@ describe("Search page", () => {
|
||||
scope: "all",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
countsByType: { issue: 0, artifact: 0, agent: 0, project: 0 },
|
||||
countsByType: { issue: 0, artifact: 0, agent: 0, project: 0, routine: 0, skill: 0 },
|
||||
hasMore: false,
|
||||
results: [],
|
||||
});
|
||||
@@ -348,7 +430,7 @@ describe("Search page", () => {
|
||||
scope: "all",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
countsByType: { issue: 1, artifact: 0, agent: 0, project: 0 },
|
||||
countsByType: { issue: 1, artifact: 0, agent: 0, project: 0, routine: 0, skill: 0 },
|
||||
hasMore: false,
|
||||
results: [
|
||||
{
|
||||
@@ -402,7 +484,7 @@ describe("Search page", () => {
|
||||
scope: "comments",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
countsByType: { issue: 0, artifact: 0, agent: 0, project: 0 },
|
||||
countsByType: { issue: 0, artifact: 0, agent: 0, project: 0, routine: 0, skill: 0 },
|
||||
hasMore: false,
|
||||
results: [],
|
||||
});
|
||||
|
||||
+21
-6
@@ -38,11 +38,13 @@ const SCOPE_LABELS: Record<CompanySearchScope, string> = {
|
||||
artifacts: "Artifacts",
|
||||
agents: "Agents",
|
||||
projects: "Projects",
|
||||
routines: "Routines",
|
||||
skills: "Skills",
|
||||
};
|
||||
|
||||
type SubGroupKey = "issues" | "comments" | "documents" | "artifacts" | "agents" | "projects";
|
||||
type SubGroupKey = "issues" | "comments" | "documents" | "artifacts" | "agents" | "projects" | "routines" | "skills";
|
||||
|
||||
const SUBGROUP_ORDER: SubGroupKey[] = ["issues", "comments", "documents", "artifacts", "agents", "projects"];
|
||||
const SUBGROUP_ORDER: SubGroupKey[] = ["issues", "comments", "documents", "artifacts", "agents", "projects", "routines", "skills"];
|
||||
|
||||
const SUBGROUP_LABELS: Record<SubGroupKey, string> = {
|
||||
issues: "Tasks",
|
||||
@@ -51,12 +53,16 @@ const SUBGROUP_LABELS: Record<SubGroupKey, string> = {
|
||||
artifacts: "Artifacts",
|
||||
agents: "Agents",
|
||||
projects: "Projects",
|
||||
routines: "Routines",
|
||||
skills: "Skills",
|
||||
};
|
||||
|
||||
function classifyResult(result: CompanySearchResult): SubGroupKey {
|
||||
if (result.type === "artifact") return "artifacts";
|
||||
if (result.type === "agent") return "agents";
|
||||
if (result.type === "project") return "projects";
|
||||
if (result.type === "routine") return "routines";
|
||||
if (result.type === "skill") return "skills";
|
||||
const matched = new Set(result.matchedFields);
|
||||
if (matched.has("title") || matched.has("identifier") || matched.has("description")) return "issues";
|
||||
if (matched.has("comment")) return "comments";
|
||||
@@ -264,7 +270,7 @@ 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, artifact: 0, agent: 0, project: 0, routine: 0, skill: 0 };
|
||||
const totalResults = data?.results.length ?? 0;
|
||||
|
||||
const tabItems = useMemo<PageTabItem[]>(() => {
|
||||
@@ -279,11 +285,20 @@ 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);
|
||||
if (value === "all") {
|
||||
count = (counts.issue ?? 0)
|
||||
+ (counts.artifact ?? 0)
|
||||
+ (counts.agent ?? 0)
|
||||
+ (counts.project ?? 0)
|
||||
+ (counts.routine ?? 0)
|
||||
+ (counts.skill ?? 0);
|
||||
}
|
||||
else if (value === "issues") count = issuesTotal;
|
||||
else if (value === "artifacts") count = counts.artifact ?? 0;
|
||||
else if (value === "agents") count = counts.agent ?? 0;
|
||||
else if (value === "projects") count = counts.project ?? 0;
|
||||
else if (value === "routines") count = counts.routine ?? 0;
|
||||
else if (value === "skills") count = counts.skill ?? 0;
|
||||
return {
|
||||
value,
|
||||
label: (
|
||||
@@ -346,7 +361,7 @@ export function Search() {
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="Search tasks, comments, documents, artifacts, agents, projects…"
|
||||
placeholder="Search tasks, comments, documents, artifacts, agents, projects, routines, skills…"
|
||||
aria-label="Search query"
|
||||
className="h-10 pl-9 pr-20 text-sm"
|
||||
/>
|
||||
@@ -456,7 +471,7 @@ function SearchTabContent({
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Type to search company memory.</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Tasks, comments, plan documents, artifacts, agents, projects — same surface, ranked by relevance.
|
||||
Tasks, comments, plan documents, artifacts, agents, projects, routines, and skills — same surface, ranked by relevance.
|
||||
</p>
|
||||
</div>
|
||||
{recentSearches.length > 0 ? (
|
||||
|
||||
@@ -195,6 +195,8 @@ const fixtureResponse: CompanySearchResponse = {
|
||||
artifact: 0,
|
||||
agent: fixtureAgents.length,
|
||||
project: fixtureProjects.length,
|
||||
routine: 0,
|
||||
skill: 0,
|
||||
},
|
||||
hasMore: false,
|
||||
};
|
||||
@@ -203,14 +205,16 @@ function ScopeTabsPreview({
|
||||
active,
|
||||
response,
|
||||
}: {
|
||||
active: "all" | "issues" | "comments" | "documents" | "artifacts" | "agents" | "projects";
|
||||
active: "all" | "issues" | "comments" | "documents" | "artifacts" | "agents" | "projects" | "routines" | "skills";
|
||||
response: CompanySearchResponse;
|
||||
}) {
|
||||
const total =
|
||||
(response.countsByType.issue ?? 0) +
|
||||
(response.countsByType.artifact ?? 0) +
|
||||
(response.countsByType.agent ?? 0) +
|
||||
(response.countsByType.project ?? 0);
|
||||
(response.countsByType.project ?? 0) +
|
||||
(response.countsByType.routine ?? 0) +
|
||||
(response.countsByType.skill ?? 0);
|
||||
const items: PageTabItem[] = [
|
||||
{ value: "all", label: <ScopeTabLabel label="All" count={total} /> },
|
||||
{ value: "issues", label: <ScopeTabLabel label="Issues" count={response.countsByType.issue} /> },
|
||||
@@ -219,6 +223,8 @@ function ScopeTabsPreview({
|
||||
{ value: "artifacts", label: <ScopeTabLabel label="Artifacts" count={response.countsByType.artifact} /> },
|
||||
{ value: "agents", label: <ScopeTabLabel label="Agents" count={response.countsByType.agent} /> },
|
||||
{ value: "projects", label: <ScopeTabLabel label="Projects" count={response.countsByType.project} /> },
|
||||
{ value: "routines", label: <ScopeTabLabel label="Routines" count={response.countsByType.routine} /> },
|
||||
{ value: "skills", label: <ScopeTabLabel label="Skills" count={response.countsByType.skill} /> },
|
||||
];
|
||||
return (
|
||||
<Tabs value={active}>
|
||||
@@ -537,7 +543,7 @@ function SearchStories() {
|
||||
<div className="paperclip-story__label">/search</div>
|
||||
<h2 className="mt-1 text-lg font-semibold">No results state</h2>
|
||||
</div>
|
||||
<SearchPagePreview response={{ ...fixtureResponse, results: [], countsByType: { issue: 0, artifact: 0, agent: 0, project: 0 } }} state="empty" query="ghostbuster" />
|
||||
<SearchPagePreview response={{ ...fixtureResponse, results: [], countsByType: { issue: 0, artifact: 0, agent: 0, project: 0, routine: 0, skill: 0 } }} state="empty" query="ghostbuster" />
|
||||
</section>
|
||||
|
||||
<section className="paperclip-story__frame overflow-hidden p-4">
|
||||
|
||||
Reference in new issue
Block a user