feat: move topic discovery into search and category browse (#2732)

* feat: search categories and topics

* fix: remove topic filters from browse sidebars

* feat: show top topics for selected categories

* fix(deps): resolve static audit advisories

* fix(search): scope skill recall by category

* fix(search): keep scoped recall within Convex limits

* fix(search): avoid Convex pagination fan-out
This commit is contained in:
Jason (Json)
2026-06-18 17:31:13 -06:00
committed by GitHub
parent 8c4a9ce9a4
commit 769c620800
20 changed files with 1841 additions and 380 deletions
+2 -2
View File
@@ -150,7 +150,7 @@
},
"overrides": {
"ast-v8-to-istanbul": "1.0.4",
"dompurify": "3.4.10",
"dompurify": "3.4.11",
"esbuild": "0.28.1",
"next": "16.2.6",
"postcss": "8.5.12",
@@ -1158,7 +1158,7 @@
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"dompurify": ["dompurify@3.4.10", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w=="],
"dompurify": ["dompurify@3.4.11", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
+2
View File
@@ -12,6 +12,7 @@ import type * as appMeta from "../appMeta.js";
import type * as auth from "../auth.js";
import type * as catalogClassification from "../catalogClassification.js";
import type * as catalogClassificationNode from "../catalogClassificationNode.js";
import type * as catalogTopics from "../catalogTopics.js";
import type * as cliDeviceAuth from "../cliDeviceAuth.js";
import type * as crons from "../crons.js";
import type * as depRegistryScan from "../depRegistryScan.js";
@@ -161,6 +162,7 @@ declare const fullApi: ApiFromModules<{
auth: typeof auth;
catalogClassification: typeof catalogClassification;
catalogClassificationNode: typeof catalogClassificationNode;
catalogTopics: typeof catalogTopics;
cliDeviceAuth: typeof cliDeviceAuth;
crons: typeof crons;
depRegistryScan: typeof depRegistryScan;
+210
View File
@@ -0,0 +1,210 @@
/* @vitest-environment node */
import { describe, expect, it, vi } from "vitest";
import { listTopByCategory, rankTopCatalogTopics } from "./catalogTopics";
type WrappedHandler<TArgs, TResult> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
};
const listTopByCategoryHandler = (
listTopByCategory as unknown as WrappedHandler<
{ kind: "skill" | "plugin"; category: string },
string[]
>
)._handler;
function makeQueryCtx(rowsByTable: Record<string, Array<Record<string, unknown>>>) {
const indexNames: string[] = [];
const filters: Array<{ field: string; value: unknown }> = [];
return {
indexNames,
filters,
db: {
query: vi.fn((table: string) => ({
withIndex: vi.fn(
(
indexName: string,
build: (query: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
indexNames.push(indexName);
const query = {
eq: (field: string, value: unknown) => {
filters.push({ field, value });
return query;
},
};
build(query);
return {
order: vi.fn(() => ({
take: vi.fn(async (limit: number) => (rowsByTable[table] ?? []).slice(0, limit)),
})),
};
},
),
})),
},
};
}
function makeSkillDigest(overrides: Record<string, unknown> = {}) {
return {
skillId: "skills:demo",
slug: "demo",
displayName: "Demo",
ownerUserId: "users:owner",
forkOf: undefined,
tags: {},
badges: {},
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
moderationStatus: "active",
createdAt: 1,
updatedAt: 2,
...overrides,
};
}
function makePluginCategoryDigest(overrides: Record<string, unknown> = {}) {
return {
packageId: "packages:demo",
name: "demo",
normalizedName: "demo",
displayName: "Demo",
family: "code-plugin",
channel: "community",
isOfficial: false,
ownerUserId: "users:owner",
pluginCategory: "runtime",
scanStatus: "clean",
createdAt: 1,
updatedAt: 2,
...overrides,
};
}
describe("catalog topic ranking", () => {
it("returns the five most frequent normalized topics and excludes the selected category", () => {
expect(
rankTopCatalogTopics(
[
{ topics: ["TypeScript", "Development", "Docker"] },
{ topics: ["typescript", "GitHub", "Debugging"] },
{ topics: ["docker", "typescript", "Coding"] },
{ topics: ["Automation", "GitHub"] },
],
"development",
),
).toEqual(["typescript", "docker", "github", "debugging", "coding"]);
});
});
describe("listTopByCategory", () => {
it("returns top public skill topics from the selected category", async () => {
const ctx = makeQueryCtx({
skillSearchDigest: [
makeSkillDigest({
categories: ["development"],
topics: ["TypeScript", "Docker"],
}),
makeSkillDigest({
skillId: "skills:second",
slug: "second",
categories: ["development"],
topics: ["typescript", "GitHub"],
}),
makeSkillDigest({
skillId: "skills:other",
slug: "other",
categories: ["productivity"],
topics: ["notes"],
}),
makeSkillDigest({
skillId: "skills:blocked",
slug: "blocked",
categories: ["development"],
topics: ["malware"],
moderationStatus: "rejected",
}),
],
});
await expect(
listTopByCategoryHandler(ctx, { kind: "skill", category: "development" }),
).resolves.toEqual(["typescript", "docker", "github"]);
expect(ctx.indexNames).toEqual(["by_active_recommended_score"]);
expect(ctx.filters).toContainEqual({ field: "softDeletedAt", value: undefined });
});
it("collects a category sample beyond the first global topic sample", async () => {
const globallyHigherRanked = Array.from({ length: 240 }, (_, index) =>
makeSkillDigest({
skillId: `skills:global-${index}`,
slug: `global-${index}`,
categories: ["productivity"],
topics: ["notes"],
}),
);
const ctx = makeQueryCtx({
skillSearchDigest: [
...globallyHigherRanked,
makeSkillDigest({
skillId: "skills:development",
slug: "development",
categories: ["development"],
topics: ["TypeScript", "Docker"],
}),
],
});
await expect(
listTopByCategoryHandler(ctx, { kind: "skill", category: "development" }),
).resolves.toEqual(["typescript", "docker"]);
});
it("uses the plugin category index and excludes private or blocked plugins", async () => {
const ctx = makeQueryCtx({
packagePluginCategorySearchDigest: [
makePluginCategoryDigest({ topics: ["Docker", "TypeScript"] }),
makePluginCategoryDigest({
packageId: "packages:second",
name: "second",
normalizedName: "second",
topics: ["docker", "GitHub"],
}),
makePluginCategoryDigest({
packageId: "packages:private",
name: "private",
normalizedName: "private",
topics: ["secret"],
channel: "private",
}),
makePluginCategoryDigest({
packageId: "packages:blocked",
name: "blocked",
normalizedName: "blocked",
topics: ["malware"],
scanStatus: "malicious",
}),
],
});
await expect(
listTopByCategoryHandler(ctx, { kind: "plugin", category: "runtime" }),
).resolves.toEqual(["docker", "typescript", "github"]);
expect(ctx.indexNames).toEqual(["by_active_category_installs"]);
expect(ctx.filters).toContainEqual({ field: "pluginCategory", value: "runtime" });
});
it("rejects categories that do not belong to the requested catalog kind", async () => {
const ctx = makeQueryCtx({});
await expect(
listTopByCategoryHandler(ctx, { kind: "skill", category: "runtime" }),
).resolves.toEqual([]);
await expect(
listTopByCategoryHandler(ctx, { kind: "plugin", category: "development" }),
).resolves.toEqual([]);
expect(ctx.db.query).not.toHaveBeenCalled();
});
});
+93
View File
@@ -0,0 +1,93 @@
import {
getCatalogTopicSlugs,
isPluginCategorySlug,
isSkillCategorySlug,
resolveStoredSkillCategories,
} from "clawhub-schema";
import { v } from "convex/values";
import type { Doc } from "./_generated/dataModel";
import type { QueryCtx } from "./_generated/server";
import { query } from "./functions";
import { isPublicPluginDoc, isPublicSkillDoc } from "./lib/globalStats";
const TOP_CATEGORY_TOPIC_LIMIT = 5;
const TOP_CATEGORY_TOPIC_SAMPLE_LIMIT = 240;
const TOP_SKILL_CATEGORY_TOPIC_SCAN_LIMIT = TOP_CATEGORY_TOPIC_SAMPLE_LIMIT * 10;
type CatalogTopicSource = {
topics?: readonly string[] | null;
};
export function rankTopCatalogTopics(
sources: readonly CatalogTopicSource[],
selectedCategory: string,
limit = TOP_CATEGORY_TOPIC_LIMIT,
) {
const counts = new Map<string, { count: number; firstSeen: number }>();
let firstSeen = 0;
for (const source of sources) {
for (const topic of getCatalogTopicSlugs(source.topics)) {
if (topic === selectedCategory) continue;
const existing = counts.get(topic);
if (existing) {
existing.count += 1;
} else {
counts.set(topic, { count: 1, firstSeen });
firstSeen += 1;
}
}
}
return [...counts.entries()]
.sort(
([leftTopic, left], [rightTopic, right]) =>
right.count - left.count ||
left.firstSeen - right.firstSeen ||
leftTopic.localeCompare(rightTopic),
)
.slice(0, Math.max(0, limit))
.map(([topic]) => topic);
}
async function listTopSkillTopics(ctx: QueryCtx, category: string) {
if (!isSkillCategorySlug(category)) return [];
const digests = await ctx.db
.query("skillSearchDigest")
.withIndex("by_active_recommended_score", (q) => q.eq("softDeletedAt", undefined))
.order("desc")
.take(TOP_SKILL_CATEGORY_TOPIC_SCAN_LIMIT);
const matching: Doc<"skillSearchDigest">[] = [];
// Skill categories are multi-valued, so collect a bounded category sample from the ranked digest.
for (const digest of digests) {
if (!isPublicSkillDoc(digest)) continue;
if (!resolveStoredSkillCategories(digest).includes(category)) continue;
matching.push(digest);
if (matching.length >= TOP_CATEGORY_TOPIC_SAMPLE_LIMIT) break;
}
return rankTopCatalogTopics(matching, category);
}
async function listTopPluginTopics(ctx: QueryCtx, category: string) {
if (!isPluginCategorySlug(category)) return [];
const digests: Doc<"packagePluginCategorySearchDigest">[] = await ctx.db
.query("packagePluginCategorySearchDigest")
.withIndex("by_active_category_installs", (q) =>
q.eq("softDeletedAt", undefined).eq("pluginCategory", category),
)
.order("desc")
.take(TOP_CATEGORY_TOPIC_SAMPLE_LIMIT);
return rankTopCatalogTopics(digests.filter(isPublicPluginDoc), category);
}
export const listTopByCategory = query({
args: {
kind: v.union(v.literal("skill"), v.literal("plugin")),
category: v.string(),
},
handler: async (ctx, args) => {
return args.kind === "skill"
? await listTopSkillTopics(ctx, args.category)
: await listTopPluginTopics(ctx, args.category);
},
});
+194 -13
View File
@@ -1425,7 +1425,57 @@ function makeDigestCtx(options: {
}
tableNames.push(table);
return {
withIndex: (indexName: string) => withIndex(table, indexName),
withIndex: (
indexName: string,
builder?: (q: {
eq: (field: string, value: unknown) => unknown;
gte: (field: string, value: string) => unknown;
lt: (field: string, value: string) => unknown;
}) => unknown,
) => {
if (table !== "packageTopicSearchDigest" || indexName !== "by_active_topic_updated") {
return withIndex(table, indexName);
}
let exactTopic = "";
let lowerBound = "";
let upperBound = "";
const queryBuilder = {
eq: (field: string, value: unknown) => {
if (field === "topic" && typeof value === "string") exactTopic = value;
return queryBuilder;
},
gte: (field: string, value: string) => {
if (field === "topic") lowerBound = value;
return queryBuilder;
},
lt: (field: string, value: string) => {
if (field === "topic") upperBound = value;
return queryBuilder;
},
};
builder?.(queryBuilder);
const baseQuery = withIndex(table, indexName);
return {
...baseQuery,
order: () => {
const ordered = baseQuery.order();
return {
...ordered,
take: async (limit: number) => {
take(limit);
const rows = rowsByTable.get(table) ?? [];
return rows
.filter((row) => {
const rowTopic = typeof row.topic === "string" ? row.topic : "";
if (exactTopic) return rowTopic === exactTopic;
return rowTopic >= lowerBound && rowTopic < upperBound;
})
.slice(0, limit);
},
};
},
};
},
};
}),
},
@@ -3283,11 +3333,124 @@ describe("packages public queries", () => {
expect(result.map((entry) => entry.package.name)).toEqual(["needle-plugin"]);
expect(paginate).not.toHaveBeenCalled();
expect(take).toHaveBeenCalledTimes(2);
expect(take).toHaveBeenCalledTimes(3);
expect(take).toHaveBeenCalledWith(20);
expect(take).toHaveBeenCalledWith(50);
});
it("recalls plugins whose category matches the query", async () => {
const { ctx, tableNames } = makeDigestCtx({
categoryPages: [
{
page: [
makeDigest("focused-helper", {
displayName: "Focused Helper",
summary: "Keeps projects tidy.",
pluginCategory: "runtime",
pluginCategoryTags: ["runtime"],
}),
],
isDone: true,
continueCursor: "",
},
],
});
const result = await searchPublicHandler(ctx, {
query: "runtime",
family: "code-plugin",
limit: 10,
});
expect(result.map((entry) => entry.package.name)).toEqual(["focused-helper"]);
expect(tableNames).toContain("packagePluginCategorySearchDigest");
});
it("does not use the fallback other category as search evidence", async () => {
const { ctx, tableNames } = makeDigestCtx({
categoryPages: [
{
page: [
makeDigest("focused-helper", {
displayName: "Focused Helper",
summary: "Keeps projects tidy.",
pluginCategory: "other",
pluginCategoryTags: ["other"],
}),
],
isDone: true,
continueCursor: "",
},
],
});
const result = await searchPublicHandler(ctx, {
query: "other",
family: "code-plugin",
limit: 10,
});
expect(result).toEqual([]);
expect(tableNames).not.toContain("packagePluginCategorySearchDigest");
});
it("uses partial author topics as plugin search evidence", async () => {
const { ctx } = makeDigestCtx({
topicPages: [
{
page: [
makeDigest("focused-helper", {
displayName: "Focused Helper",
summary: "Keeps projects tidy.",
topic: "gpu-development",
topics: ["GPU development"],
}),
],
isDone: true,
continueCursor: "",
},
],
});
const result = await searchPublicHandler(ctx, {
query: "gpu",
family: "code-plugin",
limit: 10,
});
expect(result.map((entry) => entry.package.name)).toEqual(["focused-helper"]);
});
it("prioritizes exact plugin topics ahead of bounded prefix recall", async () => {
const prefixTopics = Array.from({ length: 100 }, (_, index) =>
makeDigest(`react-prefix-${index}`, {
topic: `react-${index}`,
topics: [`React ${index}`],
}),
);
const exactTopic = makeDigest("react-exact", {
topic: "react",
topics: ["React"],
});
const { ctx } = makeDigestCtx({
topicPages: [
{
page: [...prefixTopics, exactTopic],
isDone: true,
continueCursor: "",
},
],
});
const result = await searchPublicHandler(ctx, {
query: "react",
family: "code-plugin",
limit: 1,
});
expect(result.map((entry) => entry.package.name)).toEqual(["react-exact"]);
});
it("does not let official status make unrelated packages eligible for search", async () => {
const { ctx } = makeDigestCtx({
pages: [
@@ -3496,9 +3659,15 @@ describe("packages public queries", () => {
expect(result.map((entry) => entry.package.name)).toEqual(["tools-demo"]);
expect(new Set(tableNames)).toEqual(
new Set(["packageSearchDigest", "packageTopicSearchDigest"]),
new Set([
"packageSearchDigest",
"packageTopicSearchDigest",
"packagePluginCategorySearchDigest",
]),
);
expect(new Set(indexNames)).toEqual(
new Set(["by_active_topic_updated", "by_active_category_updated", "by_active_updated"]),
);
expect(new Set(indexNames)).toEqual(new Set(["by_active_topic_updated", "by_active_updated"]));
});
it("uses plugin category digests for category-filtered listings", async () => {
@@ -4234,7 +4403,7 @@ describe("packages public queries", () => {
expect(result).toEqual([]);
expect(paginate).not.toHaveBeenCalled();
expect(take).toHaveBeenCalledTimes(2);
expect(take).toHaveBeenCalledTimes(3);
expect(take).toHaveBeenCalledWith(20);
expect(take).toHaveBeenCalledWith(50);
});
@@ -4260,7 +4429,7 @@ describe("packages public queries", () => {
});
expect(result.map((entry) => entry.package.name)).toEqual(["demo-plugin"]);
expect(take).toHaveBeenCalledTimes(2);
expect(take).toHaveBeenCalledTimes(3);
expect(ctx.db.query).toHaveBeenCalledWith("packageSearchDigest");
});
@@ -4287,7 +4456,7 @@ describe("packages public queries", () => {
});
expect(result.map((entry) => entry.package.name)).toEqual(["runtime-demo"]);
expect(take).toHaveBeenCalledTimes(2);
expect(take).toHaveBeenCalledTimes(3);
expect(ctx.db.query).toHaveBeenCalledWith("packageSearchDigest");
});
@@ -4312,7 +4481,7 @@ describe("packages public queries", () => {
});
expect(result.map((entry) => entry.package.name)).toEqual(["demo-prefix"]);
expect(take).toHaveBeenCalledTimes(2);
expect(take).toHaveBeenCalledTimes(3);
expect(ctx.db.query).toHaveBeenCalledWith("packageSearchDigest");
});
@@ -4367,7 +4536,7 @@ describe("packages public queries", () => {
});
expect(result.map((entry) => entry.package.name)).toEqual(["demo-alpha", "demo-beta"]);
expect(take).toHaveBeenCalledTimes(2);
expect(take).toHaveBeenCalledTimes(3);
expect(take).toHaveBeenCalledWith(20);
expect(take).toHaveBeenCalledWith(50);
});
@@ -4465,7 +4634,7 @@ describe("packages public queries", () => {
expect(result).toEqual([]);
expect(paginate).not.toHaveBeenCalled();
expect(take).toHaveBeenCalledTimes(2);
expect(take).toHaveBeenCalledTimes(3);
expect(take).toHaveBeenCalledWith(20);
expect(take).toHaveBeenCalledWith(200);
});
@@ -4488,7 +4657,11 @@ describe("packages public queries", () => {
});
expect(result.map((entry) => entry.package.name)).toEqual(["official-demo"]);
expect(indexNames).toEqual(["by_active_topic_updated", "by_active_official_updated"]);
expect(indexNames).toEqual([
"by_active_topic_updated",
"by_active_topic_updated",
"by_active_official_updated",
]);
});
it("uses the channel index for no-family channel search filters", async () => {
@@ -4509,7 +4682,11 @@ describe("packages public queries", () => {
});
expect(result.map((entry) => entry.package.name)).toEqual(["community-demo"]);
expect(indexNames).toEqual(["by_active_topic_updated", "by_active_channel_updated"]);
expect(indexNames).toEqual([
"by_active_topic_updated",
"by_active_topic_updated",
"by_active_channel_updated",
]);
});
it("uses the combined channel and official index when both filters are set", async () => {
@@ -4536,7 +4713,11 @@ describe("packages public queries", () => {
});
expect(result.map((entry) => entry.package.name)).toEqual(["official-community-demo"]);
expect(indexNames).toEqual(["by_active_topic_updated", "by_active_channel_official_updated"]);
expect(indexNames).toEqual([
"by_active_topic_updated",
"by_active_topic_updated",
"by_active_channel_official_updated",
]);
});
it("blocks anonymous reads of private packages", async () => {
+50 -4
View File
@@ -3,6 +3,7 @@ import {
derivePluginCategoryTags,
getCatalogTopicSlugs,
getPackageScopeOwnerMismatch,
INTERNAL_UNCATEGORIZED_CATEGORY,
isPluginCategorySlug,
normalizeCatalogTopic,
normalizeCatalogTopics,
@@ -1536,10 +1537,23 @@ function packageSearchMatch(
setMatch(1, 35);
}
const topicQuery = normalizeCatalogTopic(queryText);
if (topicQuery && getCatalogTopicSlugs(digest.topics).includes(topicQuery)) {
const taxonomyQuery = normalizeCatalogTopic(queryText);
const categories = (digest.pluginCategoryTags ?? []).filter(
(category) => category !== INTERNAL_UNCATEGORIZED_CATEGORY,
);
const topicSlugs = getCatalogTopicSlugs(digest.topics);
if (taxonomyQuery && (categories.includes(taxonomyQuery) || topicSlugs.includes(taxonomyQuery))) {
setMatch(2, 25);
}
if (
matchesExploratoryTokenPrefixes(
queryTokens,
[...categories, ...(digest.topics ?? [])],
EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH,
)
) {
setMatch(2, 20);
}
if (
matchesExploratoryTokenPrefixes(
@@ -1594,9 +1608,13 @@ async function resolveDirectPackageSearchDigests(
): Promise<PackageDigestLike[]> {
const normalizedQuery = maybeNormalizePackageQuery(queryText);
const topicQuery = normalizeCatalogTopic(queryText);
const categoryQuery =
topicQuery !== INTERNAL_UNCATEGORIZED_CATEGORY && isPluginCategorySlug(topicQuery)
? topicQuery
: undefined;
const queryTokens = tokenize(queryText).filter((token) => token.length > 1);
const runtimePrefix = queryTokens.length === 1 ? queryTokens[0] : queryText;
const [nameDigests, runtimeDigests, topicDigests] = await Promise.all([
const [nameDigests, runtimeDigests, exactTopicDigests, categoryDigests] = await Promise.all([
normalizedQuery
? ctx.db
.query("packageSearchDigest")
@@ -1628,8 +1646,36 @@ async function resolveDirectPackageSearchDigests(
.order("desc")
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
: Promise.resolve([]),
categoryQuery
? ctx.db
.query("packagePluginCategorySearchDigest")
.withIndex("by_active_category_updated", (q) =>
q.eq("softDeletedAt", undefined).eq("pluginCategory", categoryQuery),
)
.order("desc")
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
: Promise.resolve([]),
]);
return [...nameDigests, ...runtimeDigests, ...topicDigests].filter(
const prefixTopicDigests =
topicQuery && exactTopicDigests.length < MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES
? await ctx.db
.query("packageTopicSearchDigest")
.withIndex("by_active_topic_updated", (q) =>
q
.eq("softDeletedAt", undefined)
.gte("topic", topicQuery)
.lt("topic", prefixUpperBound(topicQuery)),
)
.order("desc")
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES - exactTopicDigests.length)
: [];
return [
...nameDigests,
...runtimeDigests,
...exactTopicDigests,
...prefixTopicDigests,
...categoryDigests,
].filter(
(digest, index, all) =>
all.findIndex((candidate) => candidate?.packageId === digest?.packageId) === index,
) as PackageDigestLike[];
+528 -35
View File
@@ -165,6 +165,89 @@ describe("search helpers", () => {
);
});
it("passes normalized selected categories through every skill recall path", async () => {
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
const development = {
embeddingId: "skillEmbeddings:development",
skill: makePublicSkill({
id: "skills:development",
slug: "development-helper",
displayName: "Development Helper",
categories: ["development"],
}),
version: null,
ownerHandle: "owner",
owner: null,
};
const automation = {
embeddingId: "skillEmbeddings:automation",
skill: makePublicSkill({
id: "skills:automation",
slug: "automation-helper",
displayName: "Automation Helper",
categories: ["automation"],
}),
version: null,
ownerHandle: "owner",
owner: null,
};
const runQuery = vi
.fn()
.mockResolvedValueOnce(null) // getExactSkillSlugMatch
.mockResolvedValueOnce([development, automation]) // directPrefixSkillMatches
.mockResolvedValueOnce([development, automation]) // hydrateResults
.mockResolvedValueOnce([development, automation]); // lexicalFallbackSkills
const result = await searchSkillsHandler(
{
vectorSearch: vi.fn().mockResolvedValue([
{ _id: "skillEmbeddings:development", _score: 0.8 },
{ _id: "skillEmbeddings:automation", _score: 0.9 },
]),
runQuery,
},
{ query: "helper", categorySlug: "Development", limit: 10 },
);
expect(result.map((entry) => entry.skill.slug)).toEqual(["development-helper"]);
for (const [, args] of runQuery.mock.calls) {
expect(args).toEqual(expect.objectContaining({ categorySlug: "development" }));
}
});
it("uses stored categories as skill search evidence", async () => {
generateEmbeddingMock.mockRejectedValueOnce(new Error("API unavailable"));
const fallback = [
{
skill: makePublicSkill({
id: "skills:category-match",
slug: "focused-helper",
displayName: "Focused Helper",
summary: "Keeps projects tidy.",
categories: ["development"],
}),
version: null,
ownerHandle: "steipete",
owner: null,
},
];
const runQuery = vi
.fn()
.mockResolvedValueOnce(null) // getExactSkillSlugMatch
.mockResolvedValueOnce([]) // directPrefixSkillMatches
.mockResolvedValueOnce(fallback); // lexicalFallbackSkills
const result = await searchSkillsHandler(
{
vectorSearch: vi.fn(),
runQuery,
},
{ query: "dev", limit: 10 },
);
expect(result.map((entry) => entry.skill.slug)).toEqual(["focused-helper"]);
});
it("uses normalized prefix matches so lowercase name queries do not depend on vector recall", async () => {
const scienceClawSkills = [
"ScienceClaw: Query (Dry Run)",
@@ -265,6 +348,49 @@ describe("search helpers", () => {
);
});
it("recalls author topics by normalized prefix through the indexed topic digest", async () => {
const skill = makeSkillDoc({
id: "skills:gpu-helper",
slug: "accelerated-helper",
displayName: "Accelerated Helper",
topics: ["GPU development"],
});
const ctx = makeDirectPrefixCtx([skill]);
const result = await directPrefixSkillMatchesHandler(ctx, {
query: "gpu",
limit: 10,
});
expect(result.map((entry) => entry.skill.slug)).toEqual(["accelerated-helper"]);
expect(ctx.usedIndexes).toEqual(
expect.arrayContaining(["by_active_topic_updated", "by_skill"]),
);
});
it("prioritizes exact author-topic recall ahead of prefix expansion", async () => {
const prefixSkill = makeSkillDoc({
id: "skills:react-native-helper",
slug: "mobile-helper",
displayName: "Mobile Helper",
topics: ["React Native"],
});
const exactSkill = makeSkillDoc({
id: "skills:react-helper",
slug: "web-helper",
displayName: "Web Helper",
topics: ["React"],
});
const ctx = makeDirectPrefixCtx([prefixSkill, exactSkill]);
const result = await directPrefixSkillMatchesHandler(ctx, {
query: "react",
limit: 10,
});
expect(result.map((entry) => entry.skill.slug)).toEqual(["web-helper", "mobile-helper"]);
});
it("recalls text matches from the selected topic digest", async () => {
const skill = makeSkillDoc({
id: "skills:calendar-helper",
@@ -287,6 +413,120 @@ describe("search helpers", () => {
);
});
it("continues topic recall past globally capped rows before category filtering", async () => {
const distractors = Array.from({ length: 100 }, (_, index) =>
makeSkillDoc({
id: `skills:scheduling-${index}`,
slug: `temporal-${index}`,
displayName: `Temporal ${index}`,
summary: "Coordinates events.",
categories: ["automation"],
topics: ["scheduling"],
}),
);
const development = makeSkillDoc({
id: "skills:calendar-helper",
slug: "temporal-helper",
displayName: "Temporal Helper",
summary: "Coordinates calendar events.",
categories: ["development"],
topics: ["scheduling"],
});
const ctx = makeDirectPrefixCtx([...distractors, development]);
const result = await directPrefixSkillMatchesHandler(ctx, {
query: "calendar",
categorySlug: "development",
topic: "scheduling",
limit: 10,
});
expect(result.map((entry) => entry.skill.slug)).toEqual(["temporal-helper"]);
});
it("filters direct prefix matches by the selected category", async () => {
const development = makeSkillDoc({
id: "skills:development-helper",
slug: "development-helper",
displayName: "Development Helper",
categories: ["development"],
});
const automation = makeSkillDoc({
id: "skills:automation-helper",
slug: "automation-helper",
displayName: "Automation Helper",
categories: ["automation"],
});
const ctx = makeDirectPrefixCtx([development, automation]);
const result = await directPrefixSkillMatchesHandler(ctx, {
query: "helper",
categorySlug: "development",
limit: 10,
});
expect(result.map((entry) => entry.skill.slug)).toEqual(["development-helper"]);
});
it("continues direct recall past globally capped matches for the selected category", async () => {
const distractors = Array.from({ length: 150 }, (_, index) =>
makeSkillDoc({
id: `skills:automation-${index}`,
slug: `helper-automation-${index}`,
displayName: `Helper Automation ${index}`,
categories: ["automation"],
}),
);
const development = makeSkillDoc({
id: "skills:development-helper",
slug: "helper-development",
displayName: "Helper Development",
categories: ["development"],
});
const ctx = makeDirectPrefixCtx([...distractors, development]);
const result = await directPrefixSkillMatchesHandler(ctx, {
query: "helper",
categorySlug: "development",
limit: 10,
});
expect(result.map((entry) => entry.skill.slug)).toEqual(["helper-development"]);
expect(ctx.paginateCalls).toBe(0);
expect(Math.max(...ctx.takeLimits)).toBeLessThanOrEqual(250);
expect(ctx.takeLimits.reduce((total, limit) => total + limit, 0)).toBeLessThanOrEqual(2_500);
});
it("does not let unhighlighted scoped matches consume featured recall", async () => {
const distractors = Array.from({ length: 150 }, (_, index) =>
makeSkillDoc({
id: `skills:development-${index}`,
slug: `helper-development-${index}`,
displayName: `Helper Development ${index}`,
categories: ["development"],
}),
);
const highlighted = {
...makeSkillDoc({
id: "skills:highlighted-development",
slug: "helper-highlighted-development",
displayName: "Helper Highlighted Development",
categories: ["development"],
}),
badges: { highlighted: { byUserId: "users:mod", at: 1 } },
};
const ctx = makeDirectPrefixCtx([...distractors, highlighted]);
const result = await directPrefixSkillMatchesHandler(ctx, {
query: "helper",
categorySlug: "development",
highlightedOnly: true,
limit: 10,
});
expect(result.map((entry) => entry.skill.slug)).toEqual(["helper-highlighted-development"]);
});
it("does not return suspicious skills via full-text search when nonSuspiciousOnly is set", async () => {
// Even though the full-text search would token-match the suspicious
// skill, the filterField `isSuspicious=false` plus the post-hydration
@@ -584,6 +824,59 @@ describe("search helpers", () => {
expect(result.map((entry) => entry.skill._id)).toEqual(["skills:alice-demo"]);
});
it("filters duplicate exact slug matches by category", async () => {
const ctx = makeLexicalCtx({
exactSlugSkills: [
makeSkillDoc({
id: "skills:development-demo",
slug: "demo",
displayName: "Development Demo",
ownerPublisherId: "publishers:development",
categories: ["development"],
}),
makeSkillDoc({
id: "skills:automation-demo",
slug: "demo",
displayName: "Automation Demo",
ownerPublisherId: "publishers:automation",
categories: ["automation"],
}),
],
recentSkills: [],
});
const result = await getExactSkillSlugMatchHandler(ctx, {
slug: "demo",
categorySlug: "development",
});
expect(result.map((entry) => entry.skill._id)).toEqual(["skills:development-demo"]);
});
it("preserves resolved inferred categories on exact slug results", async () => {
const ctx = makeLexicalCtx({
exactSlugSkills: [
makeSkillDoc({
id: "skills:development-demo",
slug: "demo",
displayName: "Development Demo",
inferredCategories: ["development"],
inferredFromVersionId: "skillVersions:1",
}),
],
recentSkills: [],
});
const result = await getExactSkillSlugMatchHandler(ctx, {
slug: "demo",
categorySlug: "development",
});
const [entry] = result;
if (!entry) throw new Error("Expected an exact slug result");
expect((entry.skill as { categories?: string[] }).categories).toEqual(["development"]);
});
it("includes duplicate exact slug matches from by_slug when recent scan is empty", async () => {
const ctx = makeLexicalCtx({
exactSlugSkills: [
@@ -646,6 +939,100 @@ describe("search helpers", () => {
expect(result.map((entry) => entry.skill._id)).toEqual(["skills:alice-demo"]);
});
it("filters lexical fallback matches by the selected category", async () => {
const ctx = makeLexicalCtx({
exactSlugSkill: null,
recentSkills: [
makeSkillDoc({
id: "skills:development",
slug: "development-helper",
displayName: "Development Helper",
categories: ["development"],
}),
makeSkillDoc({
id: "skills:automation",
slug: "automation-helper",
displayName: "Automation Helper",
categories: ["automation"],
}),
],
});
const result = await lexicalFallbackSkillsHandler(ctx, {
query: "helper",
queryTokens: ["helper"],
categorySlug: "development",
limit: 10,
});
expect(result.map((entry) => entry.skill.slug)).toEqual(["development-helper"]);
});
it("continues fallback recall past global rows for the selected category", async () => {
const distractors = Array.from({ length: 25 }, (_, index) =>
makeSkillDoc({
id: `skills:automation-${index}`,
slug: `automation-${index}`,
displayName: `Automation ${index}`,
summary: "Helper workflow",
categories: ["automation"],
}),
);
const development = makeSkillDoc({
id: "skills:development",
slug: "development-tool",
displayName: "Development Tool",
summary: "Helper workflow",
categories: ["development"],
});
const ctx = makeLexicalCtx({
exactSlugSkill: null,
recentSkills: [...distractors, development],
});
const result = await lexicalFallbackSkillsHandler(ctx, {
query: "helper",
queryTokens: ["helper"],
categorySlug: "development",
limit: 10,
});
expect(result.map((entry) => entry.skill.slug)).toEqual(["development-tool"]);
expect(ctx.paginateCalls).toBe(0);
});
it("does not let unrelated scoped rows consume fallback recall", async () => {
const distractors = Array.from({ length: 25 }, (_, index) =>
makeSkillDoc({
id: `skills:development-${index}`,
slug: `development-${index}`,
displayName: `Development ${index}`,
summary: "Unrelated workflow",
categories: ["development"],
}),
);
const target = makeSkillDoc({
id: "skills:development-target",
slug: "development-target",
displayName: "Development Target",
summary: "Helper workflow",
categories: ["development"],
});
const ctx = makeLexicalCtx({
exactSlugSkill: null,
recentSkills: [...distractors, target],
});
const result = await lexicalFallbackSkillsHandler(ctx, {
query: "helper",
queryTokens: ["helper"],
categorySlug: "development",
limit: 10,
});
expect(result.map((entry) => entry.skill.slug)).toEqual(["development-target"]);
});
it("dedupes overlap and enforces rank + limit across vector and fallback", async () => {
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
const vectorEntries = [
@@ -1137,6 +1524,40 @@ describe("search helpers", () => {
expect(result).toHaveLength(0);
});
it("filters vector results by the selected category", async () => {
const result = await hydrateResultsHandler(
{
db: {
get: vi.fn(async (id: string) => {
if (id === "skillEmbeddings:1") {
return {
_id: "skillEmbeddings:1",
skillId: "skills:1",
versionId: "skillVersions:1",
};
}
if (id === "skills:1") {
return makeSkillDoc({
id: "skills:1",
slug: "automation-helper",
displayName: "Automation Helper",
categories: ["automation"],
});
}
if (id === "users:owner") return { _id: "users:owner", handle: "owner" };
return null;
}),
query: vi.fn(() => ({
withIndex: () => ({ unique: vi.fn().mockResolvedValue(null) }),
})),
},
},
{ embeddingIds: ["skillEmbeddings:1"], categorySlug: "development" },
);
expect(result).toHaveLength(0);
});
it("excludes soft-deleted skills from vector search results (#29)", async () => {
const result = await hydrateResultsHandler(
{
@@ -1771,6 +2192,7 @@ function makePublicSkill(params: {
ownerPublisherId?: string;
installsAllTime?: number;
stars?: number;
categories?: string[];
topics?: string[];
}) {
return {
@@ -1785,6 +2207,7 @@ function makePublicSkill(params: {
forkOf: undefined,
latestVersionId: "skillVersions:1",
tags: {},
categories: params.categories,
topics: params.topics,
badges: {},
stats: {
@@ -1809,7 +2232,10 @@ function makeSkillDoc(params: {
moderationFlags?: string[];
moderationReason?: string;
softDeletedAt?: number;
categories?: string[];
topics?: string[];
inferredCategories?: string[];
inferredFromVersionId?: string;
}) {
return {
...makePublicSkill(params),
@@ -1818,9 +2244,25 @@ function makeSkillDoc(params: {
moderationFlags: params.moderationFlags ?? [],
moderationReason: params.moderationReason,
softDeletedAt: params.softDeletedAt as number | undefined,
inferredCategories: params.inferredCategories,
inferredFromVersionId: params.inferredFromVersionId,
};
}
function makePaginatedRows<T>(rows: T[], onPaginate?: () => void) {
return vi.fn(async ({ cursor, numItems }: { cursor: string | null; numItems: number }) => {
onPaginate?.();
const start = cursor ? Number(cursor) : 0;
const page = rows.slice(start, start + numItems);
const next = start + page.length;
return {
page,
isDone: next >= rows.length,
continueCursor: String(next),
};
});
}
function makeLexicalCtx(params: {
exactSlugSkill?: ReturnType<typeof makeSkillDoc> | null;
exactSlugSkills?: Array<ReturnType<typeof makeSkillDoc>>;
@@ -1843,9 +2285,13 @@ function makeLexicalCtx(params: {
const digestByCreated = toDigestRows(params.recentByCreated ?? []);
const usedIndexes: string[] = [];
const takeLimits: number[] = [];
let paginateCalls = 0;
return {
usedIndexes,
takeLimits,
get paginateCalls() {
return paginateCalls;
},
db: {
query: vi.fn((table: string) => {
if (table === "skills") {
@@ -1878,6 +2324,9 @@ function makeLexicalCtx(params: {
takeLimits.push(limit);
return Promise.resolve(digestByUpdated);
}),
paginate: makePaginatedRows(digestByUpdated, () => {
paginateCalls += 1;
}),
}),
};
}
@@ -1888,6 +2337,9 @@ function makeLexicalCtx(params: {
takeLimits.push(limit);
return Promise.resolve(digestByCreated);
}),
paginate: makePaginatedRows(digestByCreated, () => {
paginateCalls += 1;
}),
}),
};
}
@@ -1942,35 +2394,62 @@ function makeDirectPrefixCtx(skills: Array<ReturnType<typeof makeSkillDoc>>) {
}));
const usedIndexes: string[] = [];
const usedSearchIndexes: string[] = [];
const takeLimits: number[] = [];
let paginateCalls = 0;
return {
usedIndexes,
usedSearchIndexes,
takeLimits,
get paginateCalls() {
return paginateCalls;
},
db: {
query: vi.fn((table: string) => {
if (table === "skillTopicSearchDigest") {
return {
withIndex: (
index: string,
builder: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
builder: (q: {
eq: (field: string, value: unknown) => unknown;
gte: (field: string, value: unknown) => unknown;
lt: (field: string, value: unknown) => unknown;
}) => unknown,
) => {
usedIndexes.push(index);
let topic = "";
let topicPrefix = "";
const q = {
eq: (field: string, value: unknown) => {
if (field === "topic") topic = String(value);
return q;
},
gte: (field: string, value: unknown) => {
if (field === "topic") topicPrefix = String(value);
return q;
},
lt: () => q,
};
builder(q);
const rows = digestRows
.filter((digest) =>
digest.topics?.some((value) => {
const topicSlug = tokenize(value).join("-");
return topic ? topicSlug === topic : topicSlug.startsWith(topicPrefix);
}),
)
.map((digest) => ({
skillId: digest.skillId,
topic: topic || topicPrefix,
}));
return {
order: () => ({
take: vi.fn(async () =>
digestRows
.filter((digest) =>
digest.topics?.some((value) => tokenize(value).join("-") === topic),
)
.map((digest) => ({ skillId: digest.skillId, topic })),
),
take: vi.fn(async (limit: number) => {
takeLimits.push(limit);
return rows.slice(0, limit);
}),
paginate: makePaginatedRows(rows, () => {
paginateCalls += 1;
}),
}),
};
},
@@ -2002,17 +2481,24 @@ function makeDirectPrefixCtx(skills: Array<ReturnType<typeof makeSkillDoc>>) {
),
};
}
const rows = digestRows.filter((digest) => {
const field = index.includes("first_token")
? index.includes("slug")
? "normalizedSlugFirstToken"
: "normalizedDisplayNameFirstToken"
: index.includes("slug")
? "normalizedSlug"
: "normalizedDisplayName";
const prefix = range[field] ?? "";
return (digest[field] ?? "").startsWith(prefix);
});
return {
take: vi.fn(async () => {
const field = index.includes("first_token")
? index.includes("slug")
? "normalizedSlugFirstToken"
: "normalizedDisplayNameFirstToken"
: index.includes("slug")
? "normalizedSlug"
: "normalizedDisplayName";
const prefix = range[field] ?? "";
return digestRows.filter((digest) => (digest[field] ?? "").startsWith(prefix));
take: vi.fn(async (limit: number) => {
takeLimits.push(limit);
return rows.slice(0, limit);
}),
paginate: makePaginatedRows(rows, () => {
paginateCalls += 1;
}),
};
},
@@ -2045,24 +2531,31 @@ function makeDirectPrefixCtx(skills: Array<ReturnType<typeof makeSkillDoc>>) {
},
};
builder(q);
return {
take: vi.fn(async () => {
const queryTokens = new Set(tokensOf(searchQuery));
if (queryTokens.size === 0) return [];
return digestRows.filter((digest) => {
for (const filter of filters) {
if ((digest as Record<string, unknown>)[filter.field] !== filter.value) {
return false;
const queryTokens = new Set(tokensOf(searchQuery));
const rows =
queryTokens.size === 0
? []
: digestRows.filter((digest) => {
for (const filter of filters) {
if ((digest as Record<string, unknown>)[filter.field] !== filter.value) {
return false;
}
}
}
const fieldValue =
(digest as unknown as Record<string, string | undefined>)[searchField] ?? "";
const fieldTokens = new Set(tokensOf(fieldValue));
for (const token of queryTokens) {
if (fieldTokens.has(token)) return true;
}
return false;
});
const fieldValue =
(digest as unknown as Record<string, string | undefined>)[searchField] ?? "";
const fieldTokens = new Set(tokensOf(fieldValue));
for (const token of queryTokens) {
if (fieldTokens.has(token)) return true;
}
return false;
});
return {
take: vi.fn(async (limit: number) => {
takeLimits.push(limit);
return rows.slice(0, limit);
}),
paginate: makePaginatedRows(rows, () => {
paginateCalls += 1;
}),
};
},
+372 -187
View File
@@ -1,4 +1,11 @@
import { getCatalogTopicSlugs, normalizeCatalogTopic } from "clawhub-schema";
import {
getCatalogTopicSlugs,
INTERNAL_UNCATEGORIZED_CATEGORY,
isSkillCategorySlug,
normalizeCatalogTopic,
resolveStoredSkillCategories,
type SkillCategorySlug,
} from "clawhub-schema";
import { v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
@@ -82,6 +89,9 @@ const MIN_STABLE_SEARCH_RECALL_LIMIT = 100;
const MAX_DIRECT_SKILL_SEARCH_CANDIDATES = 100;
const MAX_DIRECT_SKILL_FULL_TEXT_CANDIDATES = 40;
const MAX_DIRECT_SKILL_TOPIC_CANDIDATES = 100;
// Scoped direct recall fans out across up to nine indexed reads in one query.
// Keep each source small enough that the aggregate stays below Convex read limits.
const MAX_FILTERED_DIRECT_SKILL_SCAN_CANDIDATES = 250;
const MIN_VECTOR_SEARCH_CANDIDATES = 50;
const MAX_VECTOR_SEARCH_CANDIDATES = 128;
const MAX_EXACT_SLUG_MATCHES = 25;
@@ -146,7 +156,7 @@ function scoreSkillResult(
function classifySkillMatch(
query: string,
queryTokens: string[],
skill: Pick<HydratableSkill, "displayName" | "slug" | "summary" | "topics">,
skill: Pick<HydratableSkill, "displayName" | "slug" | "summary" | "categories" | "topics">,
): SearchMatch | null {
const needle = query.toLowerCase();
const normalizedSlugQuery = queryTokens.join("-");
@@ -170,8 +180,21 @@ function classifySkillMatch(
if (matchesAllTokens(queryTokens, [...slugTokens, ...displayTokens], (a, b) => a.startsWith(b))) {
return { rankTier: 1 };
}
const topicQuery = normalizeCatalogTopic(query);
if (topicQuery && getCatalogTopicSlugs(skill.topics).includes(topicQuery)) {
const taxonomyQuery = normalizeCatalogTopic(query);
const categories = (skill.categories ?? []).filter(
(category) => category !== INTERNAL_UNCATEGORIZED_CATEGORY,
);
const topicSlugs = getCatalogTopicSlugs(skill.topics);
if (taxonomyQuery && (categories.includes(taxonomyQuery) || topicSlugs.includes(taxonomyQuery))) {
return { rankTier: 2 };
}
if (
matchesExploratoryTokenPrefixes(
queryTokens,
[...categories, ...(skill.topics ?? [])],
EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH,
)
) {
return { rankTier: 2 };
}
if (
@@ -206,6 +229,49 @@ function matchesCatalogTopic(skill: Pick<HydratableSkill, "topics">, topic: stri
return !topic || getCatalogTopicSlugs(skill.topics).includes(topic);
}
function normalizeSkillCategoryFilter(categorySlug: string | undefined) {
if (categorySlug === undefined) return undefined;
const normalized = categorySlug.trim().toLowerCase();
return isSkillCategorySlug(normalized) ? normalized : null;
}
function matchesCatalogFilters(
skill: Parameters<typeof resolveStoredSkillCategories>[0] & Pick<HydratableSkill, "topics">,
categorySlug: SkillCategorySlug | undefined,
topic: string | undefined,
) {
return (
(!categorySlug || resolveStoredSkillCategories(skill).includes(categorySlug)) &&
matchesCatalogTopic(skill, topic)
);
}
function toPublicSearchSkill(skill: HydratableSkill) {
return toPublicSkill({
...skill,
categories: resolveStoredSkillCategories(skill),
});
}
type SkillDigestCandidateQuery = {
take: (limit: number) => Promise<Doc<"skillSearchDigest">[]>;
};
type SkillDigestCandidateQueryFactory = () => SkillDigestCandidateQuery;
async function collectFilteredSkillDigestCandidates(
createQuery: SkillDigestCandidateQueryFactory,
opts: {
limit: number;
scanLimit: number;
matches: (digest: Doc<"skillSearchDigest">) => boolean;
},
) {
// Convex permits only one paginated read per query function. Use one bounded
// take so the several recall indexes can be searched in the same transaction.
const candidates = await createQuery().take(opts.scanLimit);
return candidates.filter(opts.matches).slice(0, opts.limit);
}
function isSlugLikeQuery(query: string) {
// Lenient shape check used by the read path: pattern + upper length cap only.
// The min-length floor and reserved-word blocklist are intentionally omitted
@@ -224,11 +290,14 @@ export const searchSkills: ReturnType<typeof action> = action({
limit: v.optional(v.number()),
highlightedOnly: v.optional(v.boolean()),
nonSuspiciousOnly: v.optional(v.boolean()),
categorySlug: v.optional(v.string()),
topic: v.optional(v.string()),
},
handler: async (ctx, args): Promise<PublicSearchResult[]> => {
const query = args.query.trim();
if (!query) return [];
const categorySlug = normalizeSkillCategoryFilter(args.categorySlug);
if (categorySlug === null) return [];
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
if (args.topic !== undefined && !topic) return [];
const queryTokens = tokenize(query);
@@ -237,6 +306,7 @@ export const searchSkills: ReturnType<typeof action> = action({
? ((await ctx.runQuery(internal.search.getExactSkillSlugMatch, {
slug: query.toLowerCase(),
nonSuspiciousOnly: args.nonSuspiciousOnly,
categorySlug,
topic,
})) as SkillSearchEntry[] | SkillSearchEntry | null)
: [];
@@ -251,6 +321,7 @@ export const searchSkills: ReturnType<typeof action> = action({
query,
highlightedOnly: args.highlightedOnly,
nonSuspiciousOnly: args.nonSuspiciousOnly,
categorySlug,
topic,
})) as SkillSearchEntry[];
let vector: number[] | null;
@@ -295,6 +366,7 @@ export const searchSkills: ReturnType<typeof action> = action({
const newEntries = (await ctx.runQuery(internal.search.hydrateResults, {
embeddingIds: newEmbeddingIds,
nonSuspiciousOnly: args.nonSuspiciousOnly,
categorySlug,
topic,
})) as SkillSearchEntry[];
hydrated = [...hydrated, ...newEntries];
@@ -321,6 +393,7 @@ export const searchSkills: ReturnType<typeof action> = action({
entry.skill.displayName,
entry.skill.slug,
entry.skill.summary,
...(entry.skill.categories ?? []),
...(entry.skill.topics ?? []),
]),
);
@@ -354,10 +427,11 @@ export const searchSkills: ReturnType<typeof action> = action({
highlightedOnly: args.highlightedOnly,
nonSuspiciousOnly: args.nonSuspiciousOnly,
skipExactSlugLookup: true,
categorySlug,
topic,
})) as SkillSearchEntry[]);
const mergedMatches = mergeUniqueBySkillId(primaryMatches, fallbackMatches).filter((entry) =>
matchesCatalogTopic(entry.skill, topic),
matchesCatalogFilters(entry.skill, categorySlug, topic),
);
const rankedMatches = mergedMatches
@@ -399,9 +473,12 @@ export const getExactSkillSlugMatch = internalQuery({
args: {
slug: v.string(),
nonSuspiciousOnly: v.optional(v.boolean()),
categorySlug: v.optional(v.string()),
topic: v.optional(v.string()),
},
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
const categorySlug = normalizeSkillCategoryFilter(args.categorySlug);
if (categorySlug === null) return [];
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
if (args.topic !== undefined && !topic) return [];
const skills = await ctx.db
@@ -414,10 +491,10 @@ export const getExactSkillSlugMatch = internalQuery({
skills.map(async (skill) => {
if (skill.softDeletedAt) return null;
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) return null;
if (!matchesCatalogTopic(skill, topic)) return null;
if (!matchesCatalogFilters(skill, categorySlug, topic)) return null;
const resolved = await getOwnerInfo(skill.ownerUserId, skill.ownerPublisherId);
const publicSkill = toPublicSkill(skill);
const publicSkill = toPublicSearchSkill(skill);
if (!publicSkill || !resolved.owner) return null;
const entry: SkillSearchEntry = {
@@ -439,9 +516,12 @@ export const directPrefixSkillMatches = internalQuery({
query: v.string(),
highlightedOnly: v.optional(v.boolean()),
nonSuspiciousOnly: v.optional(v.boolean()),
categorySlug: v.optional(v.string()),
topic: v.optional(v.string()),
},
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
const categorySlug = normalizeSkillCategoryFilter(args.categorySlug);
if (categorySlug === null) return [];
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
if (args.topic !== undefined && !topic) return [];
const normalizedQuery = normalizeSkillSearchText(args.query);
@@ -449,28 +529,85 @@ export const directPrefixSkillMatches = internalQuery({
const firstToken = getFirstSearchToken(args.query);
const queryTokens = tokenize(args.query);
const topicQuery = normalizeCatalogTopic(args.query);
const recallTopics = [
const exactRecallTopics = [
...new Set([topicQuery, topic].filter((value): value is string => !!value)),
];
const loadTopicRows = (recallTopic: string) =>
args.nonSuspiciousOnly
? ctx.db
.query("skillTopicSearchDigest")
.withIndex("by_nonsuspicious_topic_updated", (q) =>
q.eq("softDeletedAt", undefined).eq("isSuspicious", false).eq("topic", recallTopic),
)
.order("desc")
.take(MAX_DIRECT_SKILL_TOPIC_CANDIDATES)
: ctx.db
.query("skillTopicSearchDigest")
.withIndex("by_active_topic_updated", (q) =>
q.eq("softDeletedAt", undefined).eq("topic", recallTopic),
)
.order("desc")
.take(MAX_DIRECT_SKILL_TOPIC_CANDIDATES);
const passesAllQueryTokens = (digest: Doc<"skillSearchDigest">) =>
queryTokens.length === 0 ||
matchesExactTokens(queryTokens, [
digest.displayName,
digest.slug,
digest.summary,
...(digest.categories ?? []),
...(digest.topics ?? []),
]);
const matchesDirectRecallFilters = (digest: Doc<"skillSearchDigest">) =>
(!args.highlightedOnly || isSkillHighlighted(digestToHydratableSkill(digest))) &&
passesAllQueryTokens(digest) &&
matchesCatalogFilters(digest, categorySlug, topic);
const needsExpandedRecall = Boolean(
categorySlug || topic || args.highlightedOnly || queryTokens.length > 1,
);
const directScanLimit = (candidateLimit: number) =>
needsExpandedRecall ? MAX_FILTERED_DIRECT_SKILL_SCAN_CANDIDATES : candidateLimit;
const loadTopicDigests = async (recallTopic: string, usePrefix: boolean, limit: number) => {
const createQuery = () =>
args.nonSuspiciousOnly
? ctx.db
.query("skillTopicSearchDigest")
.withIndex("by_nonsuspicious_topic_updated", (q) =>
usePrefix
? q
.eq("softDeletedAt", undefined)
.eq("isSuspicious", false)
.gte("topic", recallTopic)
.lt("topic", prefixUpperBound(recallTopic))
: q
.eq("softDeletedAt", undefined)
.eq("isSuspicious", false)
.eq("topic", recallTopic),
)
.order("desc")
: ctx.db
.query("skillTopicSearchDigest")
.withIndex("by_active_topic_updated", (q) =>
usePrefix
? q
.eq("softDeletedAt", undefined)
.gte("topic", recallTopic)
.lt("topic", prefixUpperBound(recallTopic))
: q.eq("softDeletedAt", undefined).eq("topic", recallTopic),
)
.order("desc");
const scanLimit = needsExpandedRecall ? MAX_FILTERED_DIRECT_SKILL_SCAN_CANDIDATES : limit;
const rows = await createQuery().take(scanLimit);
const digests = await Promise.all(
rows.map((row) =>
ctx.db
.query("skillSearchDigest")
.withIndex("by_skill", (q) => q.eq("skillId", row.skillId))
.unique(),
),
);
return digests
.filter(
(digest): digest is Doc<"skillSearchDigest"> =>
digest !== null && matchesDirectRecallFilters(digest),
)
.slice(0, limit);
};
const upperBound = prefixUpperBound(normalizedQuery);
const firstTokenUpperBound = firstToken ? prefixUpperBound(firstToken) : null;
const collectDirectCandidates = (
createQuery: SkillDigestCandidateQueryFactory,
limit: number,
) =>
collectFilteredSkillDigestCandidates(createQuery, {
limit,
scanLimit: directScanLimit(limit),
matches: matchesDirectRecallFilters,
});
const [
slugDigests,
displayNameDigests,
@@ -478,153 +615,164 @@ export const directPrefixSkillMatches = internalQuery({
displayNameFirstTokenDigests,
ftDisplayNameDigests,
ftSlugDigests,
topicRowPages,
exactTopicDigestPages,
] = await Promise.all([
args.nonSuspiciousOnly
? ctx.db
.query("skillSearchDigest")
.withIndex("by_nonsuspicious_normalized_slug", (q) =>
q
.eq("softDeletedAt", undefined)
.eq("isSuspicious", false)
.gte("normalizedSlug", normalizedQuery)
.lt("normalizedSlug", upperBound),
)
.take(MAX_DIRECT_SKILL_SEARCH_CANDIDATES)
: ctx.db
.query("skillSearchDigest")
.withIndex("by_active_normalized_slug", (q) =>
q
.eq("softDeletedAt", undefined)
.gte("normalizedSlug", normalizedQuery)
.lt("normalizedSlug", upperBound),
)
.take(MAX_DIRECT_SKILL_SEARCH_CANDIDATES),
args.nonSuspiciousOnly
? ctx.db
.query("skillSearchDigest")
.withIndex("by_nonsuspicious_normalized_display_name", (q) =>
q
.eq("softDeletedAt", undefined)
.eq("isSuspicious", false)
.gte("normalizedDisplayName", normalizedQuery)
.lt("normalizedDisplayName", upperBound),
)
.take(MAX_DIRECT_SKILL_SEARCH_CANDIDATES)
: ctx.db
.query("skillSearchDigest")
.withIndex("by_active_normalized_display_name", (q) =>
q
.eq("softDeletedAt", undefined)
.gte("normalizedDisplayName", normalizedQuery)
.lt("normalizedDisplayName", upperBound),
)
.take(MAX_DIRECT_SKILL_SEARCH_CANDIDATES),
collectDirectCandidates(
() =>
args.nonSuspiciousOnly
? ctx.db
.query("skillSearchDigest")
.withIndex("by_nonsuspicious_normalized_slug", (q) =>
q
.eq("softDeletedAt", undefined)
.eq("isSuspicious", false)
.gte("normalizedSlug", normalizedQuery)
.lt("normalizedSlug", upperBound),
)
: ctx.db
.query("skillSearchDigest")
.withIndex("by_active_normalized_slug", (q) =>
q
.eq("softDeletedAt", undefined)
.gte("normalizedSlug", normalizedQuery)
.lt("normalizedSlug", upperBound),
),
MAX_DIRECT_SKILL_SEARCH_CANDIDATES,
),
collectDirectCandidates(
() =>
args.nonSuspiciousOnly
? ctx.db
.query("skillSearchDigest")
.withIndex("by_nonsuspicious_normalized_display_name", (q) =>
q
.eq("softDeletedAt", undefined)
.eq("isSuspicious", false)
.gte("normalizedDisplayName", normalizedQuery)
.lt("normalizedDisplayName", upperBound),
)
: ctx.db
.query("skillSearchDigest")
.withIndex("by_active_normalized_display_name", (q) =>
q
.eq("softDeletedAt", undefined)
.gte("normalizedDisplayName", normalizedQuery)
.lt("normalizedDisplayName", upperBound),
),
MAX_DIRECT_SKILL_SEARCH_CANDIDATES,
),
firstTokenUpperBound
? args.nonSuspiciousOnly
? ctx.db
.query("skillSearchDigest")
.withIndex("by_nonsuspicious_normalized_slug_first_token", (q) =>
q
.eq("softDeletedAt", undefined)
.eq("isSuspicious", false)
.gte("normalizedSlugFirstToken", firstToken)
.lt("normalizedSlugFirstToken", firstTokenUpperBound),
)
.take(MAX_DIRECT_SKILL_SEARCH_CANDIDATES)
: ctx.db
.query("skillSearchDigest")
.withIndex("by_active_normalized_slug_first_token", (q) =>
q
.eq("softDeletedAt", undefined)
.gte("normalizedSlugFirstToken", firstToken)
.lt("normalizedSlugFirstToken", firstTokenUpperBound),
)
.take(MAX_DIRECT_SKILL_SEARCH_CANDIDATES)
? collectDirectCandidates(
() =>
args.nonSuspiciousOnly
? ctx.db
.query("skillSearchDigest")
.withIndex("by_nonsuspicious_normalized_slug_first_token", (q) =>
q
.eq("softDeletedAt", undefined)
.eq("isSuspicious", false)
.gte("normalizedSlugFirstToken", firstToken)
.lt("normalizedSlugFirstToken", firstTokenUpperBound),
)
: ctx.db
.query("skillSearchDigest")
.withIndex("by_active_normalized_slug_first_token", (q) =>
q
.eq("softDeletedAt", undefined)
.gte("normalizedSlugFirstToken", firstToken)
.lt("normalizedSlugFirstToken", firstTokenUpperBound),
),
MAX_DIRECT_SKILL_SEARCH_CANDIDATES,
)
: Promise.resolve([]),
firstTokenUpperBound
? args.nonSuspiciousOnly
? ctx.db
.query("skillSearchDigest")
.withIndex("by_nonsuspicious_normalized_display_name_first_token", (q) =>
q
.eq("softDeletedAt", undefined)
.eq("isSuspicious", false)
.gte("normalizedDisplayNameFirstToken", firstToken)
.lt("normalizedDisplayNameFirstToken", firstTokenUpperBound),
)
.take(MAX_DIRECT_SKILL_SEARCH_CANDIDATES)
: ctx.db
.query("skillSearchDigest")
.withIndex("by_active_normalized_display_name_first_token", (q) =>
q
.eq("softDeletedAt", undefined)
.gte("normalizedDisplayNameFirstToken", firstToken)
.lt("normalizedDisplayNameFirstToken", firstTokenUpperBound),
)
.take(MAX_DIRECT_SKILL_SEARCH_CANDIDATES)
? collectDirectCandidates(
() =>
args.nonSuspiciousOnly
? ctx.db
.query("skillSearchDigest")
.withIndex("by_nonsuspicious_normalized_display_name_first_token", (q) =>
q
.eq("softDeletedAt", undefined)
.eq("isSuspicious", false)
.gte("normalizedDisplayNameFirstToken", firstToken)
.lt("normalizedDisplayNameFirstToken", firstTokenUpperBound),
)
: ctx.db
.query("skillSearchDigest")
.withIndex("by_active_normalized_display_name_first_token", (q) =>
q
.eq("softDeletedAt", undefined)
.gte("normalizedDisplayNameFirstToken", firstToken)
.lt("normalizedDisplayNameFirstToken", firstTokenUpperBound),
),
MAX_DIRECT_SKILL_SEARCH_CANDIDATES,
)
: Promise.resolve([]),
// Full-text search on displayName — matches any token at any position.
// Resolves Bug (non-first-token undiscoverable) by leveraging the
// Convex inverted index added in `search_by_display_name`.
args.nonSuspiciousOnly
? ctx.db
.query("skillSearchDigest")
.withSearchIndex("search_by_display_name", (q) =>
q
.search("displayName", args.query)
.eq("softDeletedAt", undefined)
.eq("isSuspicious", false),
)
.take(MAX_DIRECT_SKILL_FULL_TEXT_CANDIDATES)
: ctx.db
.query("skillSearchDigest")
.withSearchIndex("search_by_display_name", (q) =>
q.search("displayName", args.query).eq("softDeletedAt", undefined),
)
.take(MAX_DIRECT_SKILL_FULL_TEXT_CANDIDATES),
collectDirectCandidates(
() =>
args.nonSuspiciousOnly
? ctx.db
.query("skillSearchDigest")
.withSearchIndex("search_by_display_name", (q) =>
q
.search("displayName", args.query)
.eq("softDeletedAt", undefined)
.eq("isSuspicious", false),
)
: ctx.db
.query("skillSearchDigest")
.withSearchIndex("search_by_display_name", (q) =>
q.search("displayName", args.query).eq("softDeletedAt", undefined),
),
MAX_DIRECT_SKILL_FULL_TEXT_CANDIDATES,
),
// Full-text search on slug — same rationale, covers slug middle/tail tokens
// (e.g. "yijian" or "vision" inside "baidu-yijian-vision").
args.nonSuspiciousOnly
? ctx.db
.query("skillSearchDigest")
.withSearchIndex("search_by_slug", (q) =>
q.search("slug", args.query).eq("softDeletedAt", undefined).eq("isSuspicious", false),
)
.take(MAX_DIRECT_SKILL_FULL_TEXT_CANDIDATES)
: ctx.db
.query("skillSearchDigest")
.withSearchIndex("search_by_slug", (q) =>
q.search("slug", args.query).eq("softDeletedAt", undefined),
)
.take(MAX_DIRECT_SKILL_FULL_TEXT_CANDIDATES),
Promise.all(recallTopics.map(loadTopicRows)),
]);
const topicRows = topicRowPages.flat();
const topicDigests = (
await Promise.all(
topicRows.map((row) =>
ctx.db
.query("skillSearchDigest")
.withIndex("by_skill", (q) => q.eq("skillId", row.skillId))
.unique(),
collectDirectCandidates(
() =>
args.nonSuspiciousOnly
? ctx.db
.query("skillSearchDigest")
.withSearchIndex("search_by_slug", (q) =>
q
.search("slug", args.query)
.eq("softDeletedAt", undefined)
.eq("isSuspicious", false),
)
: ctx.db
.query("skillSearchDigest")
.withSearchIndex("search_by_slug", (q) =>
q.search("slug", args.query).eq("softDeletedAt", undefined),
),
MAX_DIRECT_SKILL_FULL_TEXT_CANDIDATES,
),
Promise.all(
exactRecallTopics.map((recallTopic) =>
loadTopicDigests(recallTopic, false, MAX_DIRECT_SKILL_TOPIC_CANDIDATES),
),
)
).filter((digest): digest is Doc<"skillSearchDigest"> => digest !== null);
// Mirrors the `matchesExactTokens` filter the vector path applies on
// hydrated results, so every recall path shares one literal-match
// contract. For single-token queries this gate is a no-op against the
// existing prefix paths, since any prefix match also implies a token
// match.
const passesAllQueryTokens = (digest: Doc<"skillSearchDigest">) =>
queryTokens.length === 0 ||
matchesExactTokens(queryTokens, [
digest.displayName,
digest.slug,
digest.summary,
...(digest.topics ?? []),
]);
),
]);
const queryExactTopicDigests = topicQuery
? (exactTopicDigestPages[exactRecallTopics.indexOf(topicQuery)] ?? [])
: [];
const prefixTopicDigests =
topicQuery && queryExactTopicDigests.length < MAX_DIRECT_SKILL_TOPIC_CANDIDATES
? await loadTopicDigests(
topicQuery,
true,
MAX_DIRECT_SKILL_TOPIC_CANDIDATES - queryExactTopicDigests.length,
)
: [];
const topicDigests = [...exactTopicDigestPages.flat(), ...prefixTopicDigests]
.flat()
.filter(
(digest, index, all) =>
all.findIndex((candidate) => candidate.skillId === digest.skillId) === index,
);
const digests = [
...slugDigests,
...displayNameDigests,
@@ -647,12 +795,12 @@ export const directPrefixSkillMatches = internalQuery({
const skill = digestToHydratableSkill(digest);
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) return null;
if (args.highlightedOnly && !isSkillHighlighted(skill)) return null;
if (!matchesCatalogTopic(skill, topic)) return null;
if (!matchesCatalogFilters(skill, categorySlug, topic)) return null;
const preResolved = digestToOwnerInfo(digest);
const resolved = preResolved?.owner
? preResolved
: await getOwnerInfo(skill.ownerUserId, skill.ownerPublisherId);
const publicSkill = toPublicSkill(skill);
const publicSkill = toPublicSearchSkill(skill);
if (!publicSkill || !resolved.owner) return null;
return {
skill: publicSkill,
@@ -671,9 +819,12 @@ export const hydrateResults = internalQuery({
args: {
embeddingIds: v.array(v.id("skillEmbeddings")),
nonSuspiciousOnly: v.optional(v.boolean()),
categorySlug: v.optional(v.string()),
topic: v.optional(v.string()),
},
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
const categorySlug = normalizeSkillCategoryFilter(args.categorySlug);
if (categorySlug === null) return [];
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
if (args.topic !== undefined && !topic) return [];
// Only used as fallback when digest doesn't have owner data.
@@ -701,14 +852,14 @@ export const hydrateResults = internalQuery({
: await ctx.db.get(skillId);
if (!skill || skill.softDeletedAt) return null;
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) return null;
if (!matchesCatalogTopic(skill, topic)) return null;
if (!matchesCatalogFilters(skill, categorySlug, topic)) return null;
// Use pre-resolved owner from digest to avoid reading the users table.
// Fall back to live lookup when digest owner is null (deactivated/deleted user).
const preResolved = digest ? digestToOwnerInfo(digest) : null;
const resolved = preResolved?.owner
? preResolved
: await getOwnerInfo(skill.ownerUserId, skill.ownerPublisherId);
const publicSkill = toPublicSkill(skill);
const publicSkill = toPublicSearchSkill(skill);
if (!publicSkill || !resolved.owner) return null;
return {
embeddingId,
@@ -732,9 +883,12 @@ export const lexicalFallbackSkills = internalQuery({
highlightedOnly: v.optional(v.boolean()),
nonSuspiciousOnly: v.optional(v.boolean()),
skipExactSlugLookup: v.optional(v.boolean()),
categorySlug: v.optional(v.string()),
topic: v.optional(v.string()),
},
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
const categorySlug = normalizeSkillCategoryFilter(args.categorySlug);
if (categorySlug === null) return [];
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
if (args.topic !== undefined && !topic) return [];
const limit = Math.min(Math.max(args.limit ?? 200, 10), FALLBACK_SCAN_LIMIT);
@@ -762,7 +916,7 @@ export const lexicalFallbackSkills = internalQuery({
if (
!exactSlugSkill.softDeletedAt &&
(!args.nonSuspiciousOnly || !isSkillSuspicious(exactSlugSkill)) &&
matchesCatalogTopic(exactSlugSkill, topic)
matchesCatalogFilters(exactSlugSkill, categorySlug, topic)
) {
seenSkillIds.add(exactSlugSkill._id);
candidates.push(exactSlugSkill);
@@ -773,28 +927,58 @@ export const lexicalFallbackSkills = internalQuery({
// Scan recent active digests (~800 bytes each) instead of full skill docs (~3-5KB).
// Use updatedAt and createdAt windows so newly published skills are visible even
// when they are not in the most recently updated slice.
const recentByUpdatedQuery = args.nonSuspiciousOnly
? ctx.db
.query("skillSearchDigest")
.withIndex("by_nonsuspicious_updated", (q) =>
q.eq("softDeletedAt", undefined).eq("isSuspicious", false),
)
: ctx.db
.query("skillSearchDigest")
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined));
const recentByCreatedQuery = args.nonSuspiciousOnly
? ctx.db
.query("skillSearchDigest")
.withIndex("by_nonsuspicious_created", (q) =>
q.eq("softDeletedAt", undefined).eq("isSuspicious", false),
)
: ctx.db
.query("skillSearchDigest")
.withIndex("by_active_created", (q) => q.eq("softDeletedAt", undefined));
const createRecentByUpdatedQuery = () =>
args.nonSuspiciousOnly
? ctx.db
.query("skillSearchDigest")
.withIndex("by_nonsuspicious_updated", (q) =>
q.eq("softDeletedAt", undefined).eq("isSuspicious", false),
)
.order("desc")
: ctx.db
.query("skillSearchDigest")
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
.order("desc");
const createRecentByCreatedQuery = () =>
args.nonSuspiciousOnly
? ctx.db
.query("skillSearchDigest")
.withIndex("by_nonsuspicious_created", (q) =>
q.eq("softDeletedAt", undefined).eq("isSuspicious", false),
)
.order("desc")
: ctx.db
.query("skillSearchDigest")
.withIndex("by_active_created", (q) => q.eq("softDeletedAt", undefined))
.order("desc");
const filteredScanLimit =
categorySlug || topic || args.highlightedOnly ? FALLBACK_SCAN_LIMIT : scanLimit;
const matchesFallbackRecallFilters = (digest: Doc<"skillSearchDigest">) => {
const skill = digestToHydratableSkill(digest);
return (
(!args.highlightedOnly || isSkillHighlighted(skill)) &&
matchesCatalogFilters(skill, categorySlug, topic) &&
matchesExactTokens(args.queryTokens, [
skill.displayName,
skill.slug,
skill.summary,
...(skill.categories ?? []),
...(skill.topics ?? []),
])
);
};
const [recentByUpdated, recentByCreated] = await Promise.all([
recentByUpdatedQuery.order("desc").take(scanLimit),
recentByCreatedQuery.order("desc").take(scanLimit),
collectFilteredSkillDigestCandidates(createRecentByUpdatedQuery, {
limit: scanLimit,
scanLimit: filteredScanLimit,
matches: matchesFallbackRecallFilters,
}),
collectFilteredSkillDigestCandidates(createRecentByCreatedQuery, {
limit: scanLimit,
scanLimit: filteredScanLimit,
matches: matchesFallbackRecallFilters,
}),
]);
const addDigestCandidates = (digests: typeof recentByUpdated) => {
@@ -802,7 +986,7 @@ export const lexicalFallbackSkills = internalQuery({
if (seenSkillIds.has(digest.skillId)) continue;
const skill = digestToHydratableSkill(digest);
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) continue;
if (!matchesCatalogTopic(skill, topic)) continue;
if (!matchesCatalogFilters(skill, categorySlug, topic)) continue;
seenSkillIds.add(digest.skillId);
candidates.push(skill);
// Pre-resolve owner from digest to avoid users table reads.
@@ -818,6 +1002,7 @@ export const lexicalFallbackSkills = internalQuery({
skill.displayName,
skill.slug,
skill.summary,
...(skill.categories ?? []),
...(skill.topics ?? []),
]),
);
@@ -832,7 +1017,7 @@ export const lexicalFallbackSkills = internalQuery({
const resolved = preResolved?.owner
? preResolved
: await getOwnerInfo(skill.ownerUserId, skill.ownerPublisherId);
const publicSkill = toPublicSkill(skill);
const publicSkill = toPublicSearchSkill(skill);
if (!publicSkill || !resolved.owner) return null;
return {
skill: publicSkill,
+58
View File
@@ -233,6 +233,7 @@ function makeTopicCtx(
field,
value,
eq: () => ({ eq: () => ({}), lt: () => ({}) }),
gte: () => ({ lt: () => ({}) }),
lt: () => ({}),
};
},
@@ -246,6 +247,7 @@ function makeTopicCtx(
field,
value,
eq: () => ({ eq: () => ({}), lt: () => ({}) }),
gte: () => ({ lt: () => ({}) }),
lt: () => ({}),
}),
});
@@ -806,6 +808,30 @@ describe("skills package catalog queries", () => {
expect(result[0]?.score).toBeGreaterThan(0);
});
it("uses stored categories as skill package search evidence", async () => {
const result = await searchPackageCatalogPublicHandler(
makeCtx([
{
page: [
makeDigest("focused-helper", {
displayName: "Focused Helper",
summary: "Keeps projects tidy.",
categories: ["development"],
}),
],
isDone: true,
continueCursor: "",
},
]),
{
query: "dev",
limit: 5,
},
);
expect(result.map((entry) => entry.package.name)).toEqual(["focused-helper"]);
});
it("matches author topics in unfiltered skill package search", async () => {
const topicSkill = makeDigest("render-helper", {
displayName: "Render Helper",
@@ -841,6 +867,38 @@ describe("skills package catalog queries", () => {
expect(indexNames).toContain("by_active_topic_updated");
});
it("uses partial author topics as skill package search evidence", async () => {
const topicSkill = makeDigest("focused-helper", {
displayName: "Focused Helper",
summary: "Keeps projects tidy.",
topics: ["GPU development"],
});
const result = await searchPackageCatalogPublicHandler(
makeTopicCtx(
[
{
page: [
{
skillId: topicSkill.skillId,
topic: "gpu-development",
updatedAt: topicSkill.updatedAt,
},
],
isDone: true,
continueCursor: "",
},
],
[topicSkill],
),
{
query: "gpu",
limit: 1,
},
);
expect(result.map((entry) => entry.package.name)).toEqual(["focused-helper"]);
});
it("normalizes and filters skill package catalog search topics", async () => {
const indexNames: string[] = [];
const calendarSkill = makeDigest("calendar-demo", { topics: ["calendar"] });
+38 -3
View File
@@ -1,6 +1,7 @@
import { getAuthUserId } from "@convex-dev/auth/server";
import {
getCatalogTopicSlugs,
INTERNAL_UNCATEGORIZED_CATEGORY,
isSkillCategorySlug,
normalizeCatalogTopic,
normalizeCatalogTopics,
@@ -6363,10 +6364,23 @@ function skillCatalogSearchMatch(
setMatch(1, 35);
}
const topicQuery = normalizeCatalogTopic(queryText);
if (topicQuery && getCatalogTopicSlugs(digest.topics).includes(topicQuery)) {
const taxonomyQuery = normalizeCatalogTopic(queryText);
const categories = (digest.categories ?? []).filter(
(category) => category !== INTERNAL_UNCATEGORIZED_CATEGORY,
);
const topicSlugs = getCatalogTopicSlugs(digest.topics);
if (taxonomyQuery && (categories.includes(taxonomyQuery) || topicSlugs.includes(taxonomyQuery))) {
setMatch(2, 25);
}
if (
matchesExploratoryTokenPrefixes(
queryTokens,
[...categories, ...(digest.topics ?? [])],
EXPLORATORY_SKILL_CATALOG_SEARCH_MIN_TOKEN_LENGTH,
)
) {
setMatch(2, 20);
}
if (
matchesExploratoryTokenPrefixes(
@@ -6538,6 +6552,10 @@ export const hasMissingPackageCatalogRecommendationScoresInternal = internalQuer
const EXPLORATORY_SKILL_CATALOG_SEARCH_MIN_TOKEN_LENGTH = 3;
function skillCatalogPrefixUpperBound(value: string) {
return `${value}\uffff`;
}
type SkillPackageCatalogSearchArgs = {
query: string;
limit?: number;
@@ -6580,13 +6598,30 @@ async function searchPackageCatalogImpl(ctx: QueryCtx, args: SkillPackageCatalog
if (!topic && matches.length < targetCount) {
const directTopic = normalizeCatalogTopic(queryText);
if (directTopic) {
const topicDigests = await ctx.db
const exactTopicDigests = await ctx.db
.query("skillTopicSearchDigest")
.withIndex("by_active_topic_updated", (q) =>
q.eq("softDeletedAt", undefined).eq("topic", directTopic),
)
.order("desc")
.take(MAX_DIRECT_SKILL_CATALOG_SEARCH_CANDIDATES);
const prefixTopicDigests =
exactTopicDigests.length < MAX_DIRECT_SKILL_CATALOG_SEARCH_CANDIDATES
? await ctx.db
.query("skillTopicSearchDigest")
.withIndex("by_active_topic_updated", (q) =>
q
.eq("softDeletedAt", undefined)
.gte("topic", directTopic)
.lt("topic", skillCatalogPrefixUpperBound(directTopic)),
)
.order("desc")
.take(MAX_DIRECT_SKILL_CATALOG_SEARCH_CANDIDATES - exactTopicDigests.length)
: [];
const topicDigests = [...exactTopicDigests, ...prefixTopicDigests].filter(
(digest, index, all) =>
all.findIndex((candidate) => candidate.skillId === digest.skillId) === index,
);
for (const topicDigest of topicDigests) {
const digest = await ctx.db
.query("skillSearchDigest")
+1 -1
View File
@@ -154,7 +154,7 @@
},
"overrides": {
"ast-v8-to-istanbul": "1.0.4",
"dompurify": "3.4.10",
"dompurify": "3.4.11",
"esbuild": "0.28.1",
"next": "16.2.6",
"postcss": "8.5.12",
+5 -1
View File
@@ -30,7 +30,11 @@
- Authors may supply up to five topics through CLI or UI publish and edit surfaces.
- Stored topics preserve author-facing labels. Lookup uses normalized topic slugs.
- Reserved platform trust labels such as `official`, `featured`, and `verified` are rejected.
- Topics are separate from release tags and are available to browse and exact-topic global search.
- Topics are separate from release tags and remain available to search.
- Browse sidebars do not enumerate the global topic space because it is open-ended and
author-facing labels may use different casing. Selecting a category reveals at most five
normalized top-topic chips from a bounded sample of that category's highest-ranked public items.
Exact normalized topic browse links remain supported.
- Authors can edit categories and topics from skill and plugin settings.
- Settings expose Generate as an explicit category action. Clearing categories saves `other`.
- Backports and non-latest plugin releases do not replace current topics.
+10 -2
View File
@@ -3,19 +3,27 @@
ClawHub search is a retrieval surface, not a browse fallback. A package, plugin, or skill can appear as a search match only when the query has evidence against that item:
- exact, prefix, or substring match in a navigational field such as name, slug, display name, normalized package name, or runtime id;
- token-prefix match in exploratory fields such as summary or capability tags, using a minimum query-token length for every query token to avoid short-query noise.
- exact or token-prefix match in taxonomy fields such as categories and author topics;
- token-prefix match in exploratory fields such as summary, using a minimum query-token length for every query token to avoid short-query noise.
Trust and business signals are not relevance signals. `official`, verification tier, security status, downloads, stars, installs, highlighting, and recency may break ties between already eligible matches or appear as filters/badges, but they must not make an otherwise unrelated item eligible for search.
Generic fallback categories such as `other` are browse groupings, not search evidence.
Search ranking should be lexicographic before it is numeric:
1. exact full field match in name, slug, normalized package name, or runtime id;
2. lexical field match in name, slug, normalized package name, display name, or runtime id;
3. capability or tag match;
3. category or topic match;
4. summary match;
Numeric scores, trust state, popularity, and recency may order results inside those broad tiers, but must not move a weaker tier above a stronger tier.
The same contract applies across `/search`, the header typeahead, package/plugin catalog search, and skill-as-package catalog search.
Explicit browse filters such as category and topic must be applied during backend recall before
result limits. Client-side filtering may remain as a defensive display check, but it must not be the
only category or topic filter because limited global results can under-fill scoped search. Recall may
stop at an explicit safety scan budget, but the result limit applies after scoped matches are found.
Search result counts in the web UI should describe what is known from the current request. Do not label a page-size-limited result length as a total corpus count. Prefer `N+`, "shown", or no count unless an indexed/materialized total is available.
+46 -5
View File
@@ -74,6 +74,9 @@ vi.mock("convex/react", () => ({
vi.mock("../../convex/_generated/api", () => ({
api: {
catalogTopics: {
listTopByCategory: "catalogTopics:listTopByCategory",
},
packages: {
countPublicPlugins: "packages:countPublicPlugins",
},
@@ -666,7 +669,7 @@ describe("plugins route", () => {
expect(screen.queryByText("321")).toBeNull();
});
it("keeps an active topic facet visible when it has no results", async () => {
it("does not render an active topic in the sidebar when it has no results", async () => {
searchMock = { topic: "postgres" };
loaderDataMock = {
items: [],
@@ -679,10 +682,48 @@ describe("plugins route", () => {
render(<Component />);
expect(screen.getByRole("radio", { name: "postgres" }).getAttribute("aria-checked")).toBe(
"true",
);
expect(screen.getByRole("radio", { name: "All topics" })).toBeTruthy();
expect(screen.queryByRole("radio", { name: "postgres" })).toBeNull();
expect(screen.queryByRole("radio", { name: "All topics" })).toBeNull();
});
it("shows category topic chips and filters plugins by the selected topic", async () => {
searchMock = { category: "runtime" };
loaderDataMock = {
items: [],
nextCursor: null,
rateLimited: false,
retryAfterSeconds: null,
};
convexReactMocks.useQuery.mockImplementation((_reference, args) => {
if (
args &&
typeof args === "object" &&
"kind" in args &&
(args as { kind?: string }).kind === "plugin"
) {
return ["docker", "typescript", "github", "debugging", "coding"];
}
return null;
});
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.getAllByRole("button", { name: /^#/ })).toHaveLength(5);
fireEvent.click(screen.getByRole("button", { name: "#docker" }));
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
replace?: boolean;
};
expect(lastCall.search({ category: "runtime" })).toEqual({
category: "runtime",
cursor: undefined,
family: undefined,
topic: "docker",
});
expect(lastCall.replace).toBe(true);
});
it("renders a label-only title without positive count data and switches to grid view", async () => {
+96 -10
View File
@@ -309,6 +309,25 @@ describe("SkillsIndex", () => {
});
});
it("passes the selected category to backend skill search", async () => {
searchMock = { q: "helper", category: "development" };
const actionFn = vi.fn().mockResolvedValue([]);
convexReactMocks.useAction.mockReturnValue(actionFn);
vi.useFakeTimers();
render(<SkillsIndex />);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(actionFn).toHaveBeenCalledWith({
query: "helper",
highlightedOnly: false,
categorySlug: "development",
limit: 25,
});
});
it("keeps recommended as the visible default search sort", async () => {
searchMock = { q: "notion" };
const actionFn = vi.fn().mockResolvedValue([]);
@@ -614,7 +633,7 @@ describe("SkillsIndex", () => {
expect(screen.queryByText(/\d+ loaded/)).toBeNull();
});
it("passes author topics to browse filtering and renders the active topic facet", async () => {
it("passes author topics to browse filtering without rendering topic navigation", async () => {
searchMock = { topic: "google-calendar" };
convexHttpMock.query.mockResolvedValue({
page: [
@@ -634,13 +653,82 @@ describe("SkillsIndex", () => {
topic: "google-calendar",
}),
);
expect(
screen.getByRole("radio", { name: "google-calendar" }).getAttribute("aria-checked"),
).toBe("true");
expect(screen.getAllByText("google-calendar")).toHaveLength(2);
expect(screen.queryByRole("radio", { name: "google-calendar" })).toBeNull();
expect(screen.queryByRole("radio", { name: "All topics" })).toBeNull();
});
it("keeps an active topic facet visible when it has no results", async () => {
it("shows the top five topics beneath the selected category and filters by chip", async () => {
searchMock = { category: "development" };
convexReactMocks.useQuery.mockImplementation((_reference, args) => {
if (
args &&
typeof args === "object" &&
"kind" in args &&
(args as { kind?: string }).kind === "skill"
) {
return ["typescript", "docker", "github", "debugging", "coding"];
}
return null;
});
render(<SkillsIndex />);
await act(async () => {});
const category = screen.getByRole("radio", { name: "Development" });
const firstTopic = screen.getByRole("button", { name: "#typescript" });
expect(
Boolean(category.compareDocumentPosition(firstTopic) & Node.DOCUMENT_POSITION_FOLLOWING),
).toBe(true);
expect(screen.getAllByRole("button", { name: /^#/ })).toHaveLength(5);
fireEvent.click(screen.getByRole("button", { name: "#docker" }));
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
replace?: boolean;
};
expect(lastCall.search({ category: "development" })).toEqual({
category: "development",
topic: "docker",
featured: undefined,
highlighted: undefined,
});
expect(lastCall.replace).toBe(true);
});
it("clears the active category topic when its chip is selected again", async () => {
searchMock = { category: "development", topic: "docker" };
convexReactMocks.useQuery.mockImplementation((_reference, args) => {
if (
args &&
typeof args === "object" &&
"kind" in args &&
(args as { kind?: string }).kind === "skill"
) {
return ["docker"];
}
return null;
});
render(<SkillsIndex />);
await act(async () => {});
const topic = screen.getByRole("button", { name: "#docker" });
expect(topic.getAttribute("aria-pressed")).toBe("true");
fireEvent.click(topic);
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
};
expect(lastCall.search({ category: "development", topic: "docker" })).toEqual({
category: "development",
topic: undefined,
featured: undefined,
highlighted: undefined,
});
});
it("does not render an active topic in the sidebar when it has no results", async () => {
searchMock = { topic: "google-calendar" };
convexHttpMock.query.mockResolvedValue({
page: [],
@@ -651,10 +739,8 @@ describe("SkillsIndex", () => {
render(<SkillsIndex />);
await act(async () => {});
expect(
screen.getByRole("radio", { name: "google-calendar" }).getAttribute("aria-checked"),
).toBe("true");
expect(screen.getByRole("radio", { name: "All topics" })).toBeTruthy();
expect(screen.queryByRole("radio", { name: "google-calendar" })).toBeNull();
expect(screen.queryByRole("radio", { name: "All topics" })).toBeNull();
});
it("preserves backend official-first ordering on category pages", async () => {
+43 -15
View File
@@ -51,6 +51,9 @@ type BrowseSidebarProps = {
categories?: BrowseCategory[];
activeCategory?: string;
onCategoryChange?: (slug: string | undefined) => void;
categoryTopics?: string[];
activeTopic?: string;
onTopicChange?: (topic: string | undefined) => void;
sortOptions?: SortOption[];
activeSort?: string;
onSortChange?: (value: string) => void;
@@ -91,6 +94,9 @@ export function BrowseSidebar({
categories,
activeCategory,
onCategoryChange,
categoryTopics = [],
activeTopic,
onTopicChange,
sortOptions,
activeSort,
onSortChange,
@@ -171,21 +177,43 @@ export function BrowseSidebar({
>
All
</button>
{categories.map((cat) => (
<button
key={cat.slug}
className={`sidebar-option${activeCategory === cat.slug ? " is-active" : ""}`}
type="button"
role="radio"
aria-checked={activeCategory === cat.slug}
onClick={() => onCategoryChange(cat.slug)}
>
<span className="sidebar-option-icon" aria-hidden="true">
{getCategoryIcon(cat.icon)}
</span>
{cat.label}
</button>
))}
{categories.map((cat) => {
const isActive = activeCategory === cat.slug;
return (
<div key={cat.slug} className="sidebar-category">
<button
className={`sidebar-option${isActive ? " is-active" : ""}`}
type="button"
role="radio"
aria-checked={isActive}
onClick={() => onCategoryChange(cat.slug)}
>
<span className="sidebar-option-icon" aria-hidden="true">
{getCategoryIcon(cat.icon)}
</span>
{cat.label}
</button>
{isActive && categoryTopics.length > 0 && onTopicChange ? (
<div className="sidebar-category-topics" aria-label={`${cat.label} top topics`}>
{categoryTopics.slice(0, 5).map((topic) => {
const isActiveTopic = activeTopic === topic;
return (
<button
key={topic}
className={`sidebar-topic-chip${isActiveTopic ? " is-active" : ""}`}
type="button"
aria-pressed={isActiveTopic}
onClick={() => onTopicChange(isActiveTopic ? undefined : topic)}
>
#{topic}
</button>
);
})}
</div>
) : null}
</div>
);
})}
</fieldset>
) : null}
+13 -43
View File
@@ -393,6 +393,15 @@ function PluginsIndex() {
]);
const activeCategory = search.category;
const categoryTopics = useQuery(
api.catalogTopics.listTopByCategory,
activeCategory
? {
kind: "plugin",
category: activeCategory,
}
: "skip",
);
const activeSort: PluginSort =
search.sort === "relevance" || search.sort === "newest" || search.sort === "name"
@@ -401,30 +410,6 @@ function PluginsIndex() {
const visibleItems = useMemo(() => {
return hasQuery ? sortPluginSearchItems(items, activeSort) : items;
}, [activeSort, hasQuery, items]);
const availableTopics = useMemo(() => {
const topics = new Map<string, { label: string; count: number }>();
for (const item of items) {
for (const label of item.topics ?? []) {
const slug = normalizeCatalogTopic(label);
if (!slug) continue;
const current = topics.get(slug);
topics.set(slug, { label: current?.label ?? label, count: (current?.count ?? 0) + 1 });
}
}
const visibleTopics = [...topics.entries()]
.sort((a, b) => b[1].count - a[1].count || a[1].label.localeCompare(b[1].label))
.slice(0, 8)
.map(([slug, value]) => ({ slug, label: value.label }));
const activeTopic = search.topic ? normalizeCatalogTopic(search.topic) : undefined;
if (!activeTopic || visibleTopics.some((topic) => topic.slug === activeTopic)) {
return visibleTopics;
}
return [
{ slug: activeTopic, label: topics.get(activeTopic)?.label ?? activeTopic },
...visibleTopics,
].slice(0, 8);
}, [items, search.topic]);
const handleFilterToggle = (key: string) => {
if (key === "official") {
void navigate({
@@ -483,6 +468,7 @@ function PluginsIndex() {
search: (prev: PluginSearchState) => ({
...prev,
cursor: undefined,
family: undefined,
topic,
}),
replace: true,
@@ -617,30 +603,14 @@ function PluginsIndex() {
categories={PLUGIN_CATEGORIES}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
categoryTopics={categoryTopics ?? []}
activeTopic={search.topic}
onTopicChange={handleTopicChange}
sortOptions={PLUGIN_SORT_OPTIONS}
activeSort={activeSort}
onSortChange={handleSortChange}
filters={[{ key: "official", label: "Official only", active: search.official ?? false }]}
onFilterToggle={handleFilterToggle}
radioGroups={
availableTopics.length
? [
{
title: "Topics",
ariaLabel: "Topic filter",
activeValue: search.topic,
onChange: handleTopicChange,
options: [
{ value: undefined, label: "All topics" },
...availableTopics.map((topic) => ({
value: topic.slug,
label: topic.label,
})),
],
},
]
: []
}
/>
<div className="browse-results">
{isLoading ? (
+11 -40
View File
@@ -90,7 +90,7 @@ export function useSkillsBrowseModel({
const listSort = toListSort(sort);
const dir = sort === "relevance" ? "desc" : parseDir(search.dir, sort);
const searchKey = hasQuery
? `${trimmedQuery}::${featuredOnly ? "1" : "0"}::${activeTopic ?? ""}`
? `${trimmedQuery}::${featuredOnly ? "1" : "0"}::${activeCategory?.slug ?? ""}::${activeTopic ?? ""}`
: "";
// One-shot paginated fetches (no reactive subscription)
@@ -215,6 +215,7 @@ export function useSkillsBrowseModel({
const data = (await searchSkills({
query: trimmedQuery,
highlightedOnly: featuredOnly,
categorySlug: activeCategory?.slug,
topic: activeTopic,
limit: searchLimit,
})) as Array<SkillSearchEntry>;
@@ -229,7 +230,15 @@ export function useSkillsBrowseModel({
})();
}, 220);
return () => window.clearTimeout(handle);
}, [activeTopic, hasQuery, featuredOnly, searchLimit, searchSkills, trimmedQuery]);
}, [
activeCategory?.slug,
activeTopic,
hasQuery,
featuredOnly,
searchLimit,
searchSkills,
trimmedQuery,
]);
const baseItems = useMemo(() => {
if (hasQuery) {
@@ -296,29 +305,6 @@ export function useSkillsBrowseModel({
return results;
}, [activeCategory, activeTopic, baseItems, dir, hasQuery, sort]);
const availableTopics = useMemo(() => {
const topics = new Map<string, { label: string; count: number }>();
for (const entry of baseItems) {
for (const label of entry.skill.topics ?? []) {
const slug = normalizeCatalogTopic(label);
if (!slug) continue;
const current = topics.get(slug);
topics.set(slug, { label: current?.label ?? label, count: (current?.count ?? 0) + 1 });
}
}
const visibleTopics = [...topics.entries()]
.sort((a, b) => b[1].count - a[1].count || a[1].label.localeCompare(b[1].label))
.slice(0, 8)
.map(([slug, value]) => ({ slug, label: value.label }));
if (!activeTopic || visibleTopics.some((topic) => topic.slug === activeTopic)) {
return visibleTopics;
}
return [
{ slug: activeTopic, label: topics.get(activeTopic)?.label ?? activeTopic },
...visibleTopics,
].slice(0, 8);
}, [activeTopic, baseItems]);
const isLoadingSkills = hasQuery ? isSearching && searchResults.length === 0 : isLoadingList;
const canLoadMore = hasQuery
? !isSearching && searchResults.length === searchLimit && searchResults.length > 0
@@ -485,24 +471,10 @@ export function useSkillsBrowseModel({
const activeFilters: string[] = [];
if (featuredOnly) activeFilters.push("featured");
const onTopicChange = useCallback(
(value: string | undefined) => {
void navigate({
search: (prev) => ({
...prev,
topic: value,
}),
replace: true,
});
},
[navigate],
);
return {
activeFilters,
activeCategory: activeCategory?.slug,
activeTopic,
availableTopics,
canAutoLoad,
canLoadMore,
dir,
@@ -519,7 +491,6 @@ export function useSkillsBrowseModel({
onToggleDir,
onToggleFeatured,
onToggleView,
onTopicChange,
query,
sort,
sorted,
+27 -19
View File
@@ -77,6 +77,15 @@ export function SkillsIndex() {
: model.sort;
const hasActiveFilters = model.hasQuery || Boolean(model.activeCategory) || model.featuredOnly;
const totalSkillsCount = useQuery(api.skills.countPublicSkills, {});
const categoryTopics = useQuery(
api.catalogTopics.listTopByCategory,
model.activeCategory
? {
kind: "skill",
category: model.activeCategory,
}
: "skip",
);
const formattedCount = !hasActiveFilters ? formatBrowseCount(totalSkillsCount) : null;
const handleSortChange = useCallback(
@@ -133,6 +142,21 @@ export function SkillsIndex() {
[navigate],
);
const handleTopicChange = useCallback(
(topic: string | undefined) => {
void navigate({
search: (prev: SkillsSearchState) => ({
...prev,
topic,
featured: undefined,
highlighted: undefined,
}),
replace: true,
});
},
[navigate],
);
return (
<main className="browse-page">
<div className="browse-page-header">
@@ -196,28 +220,12 @@ export function SkillsIndex() {
categories={SKILL_CATEGORIES}
activeCategory={model.activeCategory}
onCategoryChange={handleCategoryChange}
categoryTopics={categoryTopics ?? []}
activeTopic={model.activeTopic}
onTopicChange={handleTopicChange}
sortOptions={SKILLS_SORT_OPTIONS}
activeSort={activeSort}
onSortChange={handleSortChange}
radioGroups={
model.availableTopics.length
? [
{
title: "Topics",
ariaLabel: "Topic filter",
activeValue: model.activeTopic,
onChange: model.onTopicChange,
options: [
{ value: undefined, label: "All topics" },
...model.availableTopics.map((topic) => ({
value: topic.slug,
label: topic.label,
})),
],
},
]
: []
}
/>
<div className="browse-results">
<SkillsResults
+42
View File
@@ -11956,6 +11956,48 @@ a.agentic-risk-finding-title:focus-visible {
opacity: 1;
}
.sidebar-category {
display: flex;
flex-direction: column;
min-width: 0;
}
.sidebar-category-topics {
display: flex;
flex-wrap: wrap;
gap: 5px;
padding: 1px 0 8px 23px;
}
.sidebar-topic-chip {
all: unset;
box-sizing: border-box;
max-width: 100%;
padding: 3px 6px;
border: 1px solid color-mix(in srgb, var(--line) 84%, transparent);
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--surface-muted) 72%, transparent);
color: var(--ink-soft);
cursor: pointer;
font-family: var(--font-mono);
font-size: 0.66rem;
font-weight: 550;
line-height: 1.25;
overflow-wrap: anywhere;
}
.sidebar-topic-chip:hover {
border-color: color-mix(in srgb, var(--ink-soft) 40%, var(--line));
color: var(--ink);
}
.sidebar-topic-chip.is-active {
border-color: color-mix(in srgb, var(--accent) 55%, var(--line));
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent);
font-weight: 650;
}
.sidebar-checkbox {
display: flex;
align-items: center;