Fix routine and skill folder search

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-05 12:31:38 +00:00
1 parent c87d009aab
commit a87183ded0
12 files changed
+718 -25

No files matched your search

+3
View File
@@ -542,11 +542,14 @@ export type {
ProjectWorkspace,
CompanySearchHighlight,
CompanySearchArtifactSummary,
CompanySearchFolderSummary,
CompanySearchIssueSummary,
CompanySearchResponse,
CompanySearchResult,
CompanySearchResultType,
CompanySearchRoutineSummary,
CompanySearchScope,
CompanySearchSkillSummary,
CompanySearchSnippet,
ExecutionWorkspace,
ExecutionWorkspaceSummary,
+3
View File
@@ -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";
+37 -2
View File
@@ -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, {
+199 -4
View File
@@ -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 ?? "");
+120
View File
@@ -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);
+68 -1
View File
@@ -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={() => {
+74 -1
View File
@@ -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;
+87 -5
View File
@@ -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
View File
@@ -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 ? (
+9 -3
View File
@@ -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">