diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1a6f01ddfbd..e7949453709 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -542,11 +542,14 @@ export type { ProjectWorkspace, CompanySearchHighlight, CompanySearchArtifactSummary, + CompanySearchFolderSummary, CompanySearchIssueSummary, CompanySearchResponse, CompanySearchResult, CompanySearchResultType, + CompanySearchRoutineSummary, CompanySearchScope, + CompanySearchSkillSummary, CompanySearchSnippet, ExecutionWorkspace, ExecutionWorkspaceSummary, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 9931c9899fc..c40f9d807ef 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -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"; diff --git a/packages/shared/src/types/search.ts b/packages/shared/src/types/search.ts index 3b453cfe51b..fe655a7d406 100644 --- a/packages/shared/src/types/search.ts +++ b/packages/shared/src/types/search.ts @@ -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; } diff --git a/server/src/__tests__/company-search-rate-limit-routes.test.ts b/server/src/__tests__/company-search-rate-limit-routes.test.ts index 092a2c7bf11..a7756b1e7ea 100644 --- a/server/src/__tests__/company-search-rate-limit-routes.test.ts +++ b/server/src/__tests__/company-search-rate-limit-routes.test.ts @@ -13,7 +13,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, }; } diff --git a/server/src/__tests__/company-search-service.test.ts b/server/src/__tests__/company-search-service.test.ts index 940f4c0c921..0203f6f0dac 100644 --- a/server/src/__tests__/company-search-service.test.ts +++ b/server/src/__tests__/company-search-service.test.ts @@ -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 = {}) { + 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 = {}) { + 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 = {}) { + 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, { diff --git a/server/src/services/company-search.ts b/server/src/services/company-search.ts index 6c2d9479e92..3f49a7500a0 100644 --- a/server/src/services/company-search.ts +++ b/server/src/services/company-search.ts @@ -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 = { issue: 0, artifact: 0, agent: 0, project: 0 }; + const counts: Record = { + 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; commentMatch: SQL; @@ -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): 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 = []; + 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 = { issue: 0, artifact: 0, agent: 0, project: 0 }; + const emptyCounts: Record = { + 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 ?? ""); diff --git a/ui/src/components/CommandPalette.test.tsx b/ui/src/components/CommandPalette.test.tsx index 3c4db323708..c903f300e97 100644 --- a/ui/src/components/CommandPalette.test.tsx +++ b/ui/src/components/CommandPalette.test.tsx @@ -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(, 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(, container); diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 384e522bb12..b9e26bb65f1 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -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 ( { @@ -263,6 +286,50 @@ export function CommandPalette() { )} + {showRoutines && ( + <> + + {quickRoutines.map((result) => ( + go(result.href)} + data-testid="command-routine-match" + > + + {result.routine!.title} + + {result.routine!.folder?.path ?? "Unfiled"} + + + ))} + + + + )} + + {showSkills && ( + <> + + {quickSkills.map((result) => ( + go(result.href)} + data-testid="command-skill-match" + > + + {result.skill!.name} + + {result.skill!.folder?.path ?? "Unfiled"} + + + ))} + + + + )} + { diff --git a/ui/src/components/search/SearchResultRow.tsx b/ui/src/components/search/SearchResultRow.tsx index 408427e5b31..a4e8b54643d 100644 --- a/ui/src/components/search/SearchResultRow.tsx +++ b/ui/src/components/search/SearchResultRow.tsx @@ -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 = { 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 ( + + +
+
+ {routine.title} + + {routine.folder?.path ?? "Unfiled"} + +
+ {(result.snippets.length > 0 ? result.snippets : result.sourceLabel ? [{ + field: "folder", + label: "Folder", + text: result.sourceLabel, + highlights: [], + }] : []).slice(0, 2).map((snippet, index) => ( + + ))} +
+ + ); + } + + if (result.type === "skill") { + const skill = result.skill; + if (!skill) return null; + return ( + + +
+
+ {skill.name} + + {skill.folder?.path ?? "Unfiled"} + +
+ {(result.snippets.length > 0 ? result.snippets : result.sourceLabel ? [{ + field: "folder", + label: "Folder", + text: result.sourceLabel, + highlights: [], + }] : []).slice(0, 2).map((snippet, index) => ( + + ))} +
+ + ); + } + if (result.type === "artifact") { const artifact = result.artifact; if (!artifact) return null; diff --git a/ui/src/pages/Search.test.tsx b/ui/src/pages/Search.test.tsx index b271583530b..9dea9a94486 100644 --- a/ui/src/pages/Search.test.tsx +++ b/ui/src/pages/Search.test.tsx @@ -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: [], }); diff --git a/ui/src/pages/Search.tsx b/ui/src/pages/Search.tsx index 0d39a26add9..c3287f89030 100644 --- a/ui/src/pages/Search.tsx +++ b/ui/src/pages/Search.tsx @@ -38,11 +38,13 @@ const SCOPE_LABELS: Record = { 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 = { issues: "Tasks", @@ -51,12 +53,16 @@ const SUBGROUP_LABELS: Record = { 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(() => { @@ -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({

Type to search company memory.

- 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.

{recentSearches.length > 0 ? ( diff --git a/ui/storybook/stories/search.stories.tsx b/ui/storybook/stories/search.stories.tsx index f926a863c74..3730186da7a 100644 --- a/ui/storybook/stories/search.stories.tsx +++ b/ui/storybook/stories/search.stories.tsx @@ -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: }, { value: "issues", label: }, @@ -219,6 +223,8 @@ function ScopeTabsPreview({ { value: "artifacts", label: }, { value: "agents", label: }, { value: "projects", label: }, + { value: "routines", label: }, + { value: "skills", label: }, ]; return ( @@ -537,7 +543,7 @@ function SearchStories() {
/search

No results state

- +