fix search

This commit is contained in:
Shadow
2026-01-23 15:24:26 -06:00
parent 31e9a57678
commit 5d9a89a885
9 changed files with 167 additions and 288 deletions
+2 -1
View File
@@ -61,7 +61,7 @@
},
"packages/clawdhub": {
"name": "clawdhub",
"version": "0.2.1",
"version": "0.3.0",
"bin": {
"clawdhub": "bin/clawdhub.js",
},
@@ -76,6 +76,7 @@
"ora": "^9.0.0",
"p-retry": "^7.1.1",
"semver": "^7.7.3",
"undici": "^7.16.0",
},
"devDependencies": {
"@types/node": "^25.0.9",
+23 -9
View File
@@ -9,6 +9,7 @@ type HydratedEntry = {
embeddingId: Id<'skillEmbeddings'>
skill: Doc<'skills'> | null
version: Doc<'skillVersions'> | null
ownerHandle: string | null
}
type SearchResult = HydratedEntry & { score: number }
@@ -86,18 +87,31 @@ export const searchSkills: ReturnType<typeof action> = action({
export const hydrateResults = internalQuery({
args: { embeddingIds: v.array(v.id('skillEmbeddings')) },
handler: async (ctx, args): Promise<HydratedEntry[]> => {
const entries: HydratedEntry[] = []
const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
for (const embeddingId of args.embeddingIds) {
const embedding = await ctx.db.get(embeddingId)
if (!embedding) continue
const skill = await ctx.db.get(embedding.skillId)
if (skill?.softDeletedAt) continue
const version = await ctx.db.get(embedding.versionId)
entries.push({ embeddingId, skill, version })
const getOwnerHandle = (ownerUserId: Id<'users'>) => {
const cached = ownerHandleCache.get(ownerUserId)
if (cached) return cached
const handlePromise = ctx.db.get(ownerUserId).then((owner) => owner?.handle ?? owner?._id ?? null)
ownerHandleCache.set(ownerUserId, handlePromise)
return handlePromise
}
return entries
const entries = await Promise.all(
args.embeddingIds.map(async (embeddingId) => {
const embedding = await ctx.db.get(embeddingId)
if (!embedding) return null
const skill = await ctx.db.get(embedding.skillId)
if (skill?.softDeletedAt) return null
const [version, ownerHandle] = await Promise.all([
ctx.db.get(embedding.versionId),
getOwnerHandle(skill.ownerUserId),
])
return { embeddingId, skill, version, ownerHandle }
}),
)
return entries.filter((entry): entry is HydratedEntry => entry !== null)
},
})
+40 -27
View File
@@ -25,6 +25,39 @@ const MAX_PUBLIC_LIST_LIMIT = 200
const MAX_LIST_BULK_LIMIT = 200
const MAX_LIST_TAKE = 1000
async function resolveOwnerHandle(ctx: QueryCtx, ownerUserId: Id<'users'>) {
const owner = await ctx.db.get(ownerUserId)
return owner?.handle ?? owner?._id ?? null
}
type PublicSkillEntry = {
skill: Doc<'skills'>
latestVersion: Doc<'skillVersions'> | null
ownerHandle: string | null
}
async function buildPublicSkillEntries(ctx: QueryCtx, skills: Doc<'skills'>[]) {
const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
const getOwnerHandle = (ownerUserId: Id<'users'>) => {
const cached = ownerHandleCache.get(ownerUserId)
if (cached) return cached
const handlePromise = resolveOwnerHandle(ctx, ownerUserId)
ownerHandleCache.set(ownerUserId, handlePromise)
return handlePromise
}
return Promise.all(
skills.map(async (skill) => {
const [latestVersion, ownerHandle] = await Promise.all([
skill.latestVersionId ? ctx.db.get(skill.latestVersionId) : null,
getOwnerHandle(skill.ownerUserId),
])
return { skill, latestVersion, ownerHandle }
}),
) satisfies Promise<PublicSkillEntry[]>
}
export const getBySlug = query({
args: { slug: v.string() },
handler: async (ctx, args) => {
@@ -178,35 +211,24 @@ export const listPublicPage = query({
.order('desc')
.paginate({ cursor: args.cursor ?? null, numItems: limit })
const items: Array<{
skill: Doc<'skills'>
latestVersion: Doc<'skillVersions'> | null
}> = []
for (const skill of page) {
if (skill.softDeletedAt) continue
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
items.push({ skill, latestVersion })
}
const skills = page.filter((skill) => !skill.softDeletedAt)
const items = await buildPublicSkillEntries(ctx, skills)
return { items, nextCursor: isDone ? null : continueCursor }
}
if (sort === 'trending') {
const entries = await getTrendingEntries(ctx, limit)
const items: Array<{
skill: Doc<'skills'>
latestVersion: Doc<'skillVersions'> | null
}> = []
const skills: Doc<'skills'>[] = []
for (const entry of entries) {
const skill = await ctx.db.get(entry.skillId)
if (!skill || skill.softDeletedAt) continue
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
items.push({ skill, latestVersion })
if (items.length >= limit) break
skills.push(skill)
if (skills.length >= limit) break
}
const items = await buildPublicSkillEntries(ctx, skills)
return { items, nextCursor: null }
}
@@ -218,16 +240,7 @@ export const listPublicPage = query({
.take(Math.min(limit * 5, MAX_LIST_TAKE))
const filtered = page.filter((skill) => !skill.softDeletedAt).slice(0, limit)
const items: Array<{
skill: Doc<'skills'>
latestVersion: Doc<'skillVersions'> | null
}> = []
for (const skill of filtered) {
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
items.push({ skill, latestVersion })
}
const items = await buildPublicSkillEntries(ctx, filtered)
return { items, nextCursor: null }
},
})
+2 -1
View File
@@ -28,7 +28,8 @@
"mime": "^4.1.0",
"ora": "^9.0.0",
"p-retry": "^7.1.1",
"semver": "^7.7.3"
"semver": "^7.7.3",
"undici": "^7.16.0"
},
"devDependencies": {
"@types/node": "^25.0.9",
+4 -6
View File
@@ -8,7 +8,7 @@ vi.mock('@tanstack/react-router', () => ({
import { Route } from '../routes/search'
describe('search route', () => {
it('redirects to home with search mode enabled', () => {
it('redirects to the skills index', () => {
const beforeLoad = Route.__config.beforeLoad as (args: {
search: { q?: string; highlighted?: boolean }
}) => void
@@ -22,18 +22,17 @@ describe('search route', () => {
expect(thrown).toEqual({
redirect: {
to: '/',
to: '/skills',
search: {
q: 'crab',
highlighted: true,
search: true,
},
replace: true,
},
})
})
it('redirects to home with search flag even without query', () => {
it('redirects to the skills index without query', () => {
const beforeLoad = Route.__config.beforeLoad as (args: {
search: { q?: string; highlighted?: boolean }
}) => void
@@ -47,11 +46,10 @@ describe('search route', () => {
expect(thrown).toEqual({
redirect: {
to: '/',
to: '/skills',
search: {
q: undefined,
highlighted: undefined,
search: true,
},
replace: true,
},
+5 -2
View File
@@ -8,11 +8,14 @@ type SkillCardProps = {
chip?: string
summaryFallback: string
meta: ReactNode
href?: string
}
export function SkillCard({ skill, badge, chip, summaryFallback, meta }: SkillCardProps) {
export function SkillCard({ skill, badge, chip, summaryFallback, meta, href }: SkillCardProps) {
const link = href ?? `/skills/${skill.slug}`
return (
<Link to="/skills/$slug" params={{ slug: skill.slug }} className="card skill-card">
<Link to={link} className="card skill-card">
{badge || chip ? (
<div className="skill-card-tags">
{badge ? <div className="tag">{badge}</div> : null}
+59 -221
View File
@@ -29,86 +29,13 @@ function Home() {
}
function SkillsHome() {
const navigate = Route.useNavigate()
const search = Route.useSearch()
const searchSkills = useAction(api.search.searchSkills)
const highlighted =
(useQuery(api.skills.list, { batch: 'highlighted', limit: 6 }) as Doc<'skills'>[]) ?? []
const latest = (useQuery(api.skills.list, { limit: 12 }) as Doc<'skills'>[]) ?? []
const [query, setQuery] = useState(search.q ?? '')
const [highlightedOnly, setHighlightedOnly] = useState(search.highlighted ?? false)
const [results, setResults] = useState<
Array<{ skill: Doc<'skills'>; version: Doc<'skillVersions'> | null; score: number }>
>([])
const [isSearching, setIsSearching] = useState(false)
const [searchMode, setSearchMode] = useState(
Boolean(search.q || search.highlighted || search.search),
)
const searchRequest = useRef(0)
const inputRef = useRef<HTMLInputElement | null>(null)
const trimmedQuery = useMemo(() => query.trim(), [query])
const hasQuery = trimmedQuery.length > 0
useEffect(() => {
setQuery(search.q ?? '')
setHighlightedOnly(search.highlighted ?? false)
if (search.q || search.highlighted || search.search) {
setSearchMode(true)
} else {
setSearchMode(false)
}
}, [search.highlighted, search.q, search.search])
useEffect(() => {
void navigate({
search: () => ({
q: trimmedQuery || undefined,
highlighted: highlightedOnly ? true : undefined,
search: searchMode && !trimmedQuery && !highlightedOnly ? true : undefined,
}),
replace: true,
})
}, [highlightedOnly, navigate, searchMode, trimmedQuery])
useEffect(() => {
if (searchMode && inputRef.current) {
inputRef.current.focus()
}
}, [searchMode])
useEffect(() => {
if (!trimmedQuery) {
setResults([])
setIsSearching(false)
return
}
searchRequest.current += 1
const requestId = searchRequest.current
setIsSearching(true)
const handle = window.setTimeout(() => {
void (async () => {
try {
const data = (await searchSkills({ query: trimmedQuery, highlightedOnly })) as Array<{
skill: Doc<'skills'>
version: Doc<'skillVersions'> | null
score: number
}>
if (requestId === searchRequest.current) {
setResults(data)
}
} finally {
if (requestId === searchRequest.current) {
setIsSearching(false)
}
}
})()
}, 220)
return () => window.clearTimeout(handle)
}, [highlightedOnly, searchSkills, trimmedQuery])
return (
<main>
<section className={`hero${searchMode ? ' search-mode' : ''}`}>
<section className="hero">
<div className="hero-inner">
<div className="hero-copy fade-up" data-delay="1">
<span className="hero-badge">Lobster-light. Agent-right.</span>
@@ -121,162 +48,73 @@ function SkillsHome() {
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
Publish a skill
</Link>
<Link
to="/"
search={{ q: undefined, highlighted: undefined, search: true }}
className="btn"
>
Explore search
<Link to="/skills" className="btn">
Browse skills
</Link>
</div>
</div>
<div className="hero-card hero-search-card fade-up" data-delay="2">
<form
className="search-bar"
onSubmit={(event) => {
event.preventDefault()
if (!searchMode) setSearchMode(true)
inputRef.current?.focus()
}}
>
<span className="mono">/</span>
<input
ref={inputRef}
className="search-input"
placeholder="Search skills, tags, or capabilities"
value={query}
onChange={(event) => setQuery(event.target.value)}
onFocus={() => setSearchMode(true)}
onKeyDown={(event) => {
if (event.key === 'Escape' && !trimmedQuery) {
setSearchMode(false)
inputRef.current?.blur()
}
}}
/>
<button
className="search-filter-button"
type="button"
aria-pressed={highlightedOnly}
onClick={() => {
setHighlightedOnly((value) => !value)
setSearchMode(true)
}}
>
Highlighted
</button>
</form>
{!searchMode ? (
<div className="hero-install" style={{ marginTop: 18 }}>
<div className="stat">Search skills. Versioned, rollback-ready.</div>
<InstallSwitcher exampleSlug="sonoscli" />
</div>
) : null}
<div className="hero-install" style={{ marginTop: 18 }}>
<div className="stat">Search skills. Versioned, rollback-ready.</div>
<InstallSwitcher exampleSlug="sonoscli" />
</div>
</div>
</div>
</section>
{searchMode ? (
<section className="section">
<h2 className="section-title">Search results</h2>
<p className="section-subtitle">
{isSearching ? 'Searching now.' : 'Instant results as you type.'}
</p>
<div className="grid">
{!hasQuery ? (
<div className="card">Start typing to search.</div>
) : results.length === 0 ? (
<div className="card">No results yet. Try a different prompt.</div>
) : (
results.map((result) => (
<Link
key={result.skill._id}
to="/skills/$slug"
params={{ slug: result.skill.slug }}
className="card"
>
<div className="tag">Score {(result.score ?? 0).toFixed(2)}</div>
<h3 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
{result.skill.displayName}
</h3>
<p className="section-subtitle" style={{ margin: 0 }}>
{result.skill.summary ?? 'Skill pack'}
</p>
{result.skill.batch === 'highlighted' ? (
<div className="tag">Highlighted</div>
) : null}
</Link>
))
)}
</div>
</section>
) : (
<>
<section className="section">
<h2 className="section-title">Highlighted batch</h2>
<p className="section-subtitle">Curated signal highlighted for quick trust.</p>
<div className="grid">
{highlighted.length === 0 ? (
<div className="card">No highlighted skills yet.</div>
) : (
highlighted.map((skill) => (
<SkillCard
key={skill._id}
skill={skill}
badge="Highlighted"
summaryFallback="A fresh skill bundle."
meta={
<div className="stat">
{skill.stats.stars} · {skill.stats.downloads} · {' '}
{skill.stats.installsAllTime ?? 0}
</div>
}
/>
))
)}
</div>
</section>
<section className="section">
<h2 className="section-title">Highlighted batch</h2>
<p className="section-subtitle">Curated signal highlighted for quick trust.</p>
<div className="grid">
{highlighted.length === 0 ? (
<div className="card">No highlighted skills yet.</div>
) : (
highlighted.map((skill) => (
<SkillCard
key={skill._id}
skill={skill}
badge="Highlighted"
summaryFallback="A fresh skill bundle."
meta={
<div className="stat">
{skill.stats.stars} · {skill.stats.downloads} · {' '}
{skill.stats.installsAllTime ?? 0}
</div>
}
/>
))
)}
</div>
</section>
<section className="section">
<h2 className="section-title">Latest drops</h2>
<p className="section-subtitle">Newest uploads across the registry.</p>
<div className="grid">
{latest.length === 0 ? (
<div className="card">No skills yet. Be the first.</div>
) : (
latest.map((skill) => (
<SkillCard
key={skill._id}
skill={skill}
summaryFallback="Agent-ready skill pack."
meta={
<div className="stat">
{skill.stats.versions} versions · {skill.stats.downloads} · {' '}
{skill.stats.installsAllTime ?? 0}
</div>
}
/>
))
)}
</div>
<div className="section-cta">
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
highlighted: undefined,
view: undefined,
}}
className="btn"
>
See all skills
</Link>
</div>
</section>
</>
)}
<section className="section">
<h2 className="section-title">Latest drops</h2>
<p className="section-subtitle">Newest uploads across the registry.</p>
<div className="grid">
{latest.length === 0 ? (
<div className="card">No skills yet. Be the first.</div>
) : (
latest.map((skill) => (
<SkillCard
key={skill._id}
skill={skill}
summaryFallback="Agent-ready skill pack."
meta={
<div className="stat">
{skill.stats.versions} versions · {skill.stats.downloads} · {' '}
{skill.stats.installsAllTime ?? 0}
</div>
}
/>
))
)}
</div>
<div className="section-cta">
<Link to="/skills" className="btn">
See all skills
</Link>
</div>
</section>
</main>
)
}
+1 -2
View File
@@ -7,11 +7,10 @@ export const Route = createFileRoute('/search')({
}),
beforeLoad: ({ search }) => {
throw redirect({
to: '/',
to: '/skills',
search: {
q: search.q || undefined,
highlighted: search.highlighted || undefined,
search: true,
},
replace: true,
})
+31 -19
View File
@@ -21,13 +21,34 @@ function parseDir(value: unknown, sort: SortKey): SortDir {
return sort === 'name' ? 'asc' : 'desc'
}
type SkillListEntry = {
skill: Doc<'skills'>
latestVersion: Doc<'skillVersions'> | null
ownerHandle?: string | null
}
type SkillSearchEntry = {
skill: Doc<'skills'>
version: Doc<'skillVersions'> | null
score: number
ownerHandle?: string | null
}
function buildSkillHref(skill: Doc<'skills'>, ownerHandle?: string | null) {
const owner = ownerHandle ?? 'unknown'
return `/${encodeURIComponent(owner)}/${encodeURIComponent(skill.slug)}`
}
export const Route = createFileRoute('/skills/')({
validateSearch: (search) => {
return {
q: typeof search.q === 'string' && search.q.trim() ? search.q : undefined,
sort: typeof search.sort === 'string' ? parseSort(search.sort) : undefined,
dir: search.dir === 'asc' || search.dir === 'desc' ? search.dir : undefined,
highlighted: search.highlighted === '1' || search.highlighted === 'true' ? true : undefined,
highlighted:
search.highlighted === '1' || search.highlighted === 'true' || search.highlighted === true
? true
: undefined,
view: search.view === 'cards' || search.view === 'list' ? search.view : undefined,
}
},
@@ -43,14 +64,10 @@ export function SkillsIndex() {
const highlightedOnly = search.highlighted ?? false
const [query, setQuery] = useState(search.q ?? '')
const searchSkills = useAction(api.search.searchSkills)
const [pages, setPages] = useState<
Array<{ skill: Doc<'skills'>; latestVersion: Doc<'skillVersions'> | null }>
>([])
const [pages, setPages] = useState<Array<SkillListEntry>>([])
const [cursor, setCursor] = useState<string | null>(null)
const [nextCursor, setNextCursor] = useState<string | null>(null)
const [searchResults, setSearchResults] = useState<
Array<{ skill: Doc<'skills'>; version: Doc<'skillVersions'> | null; score: number }>
>([])
const [searchResults, setSearchResults] = useState<Array<SkillSearchEntry>>([])
const [searchLimit, setSearchLimit] = useState(pageSize)
const [isSearching, setIsSearching] = useState(false)
const searchRequest = useRef(0)
@@ -65,7 +82,7 @@ export function SkillsIndex() {
hasQuery ? 'skip' : { cursor: cursor ?? undefined, limit: pageSize },
) as
| {
items: Array<{ skill: Doc<'skills'>; latestVersion: Doc<'skillVersions'> | null }>
items: Array<SkillListEntry>
nextCursor: string | null
}
| undefined
@@ -110,11 +127,7 @@ export function SkillsIndex() {
query: trimmedQuery,
highlightedOnly,
limit: searchLimit,
})) as Array<{
skill: Doc<'skills'>
version: Doc<'skillVersions'> | null
score: number
}>
})) as Array<SkillSearchEntry>
if (requestId === searchRequest.current) {
setSearchResults(data)
}
@@ -133,6 +146,7 @@ export function SkillsIndex() {
return searchResults.map((entry) => ({
skill: entry.skill,
latestVersion: entry.version,
ownerHandle: entry.ownerHandle ?? null,
}))
}
return pages
@@ -322,10 +336,12 @@ export function SkillsIndex() {
{sorted.map((entry) => {
const skill = entry.skill
const isPlugin = Boolean(entry.latestVersion?.parsed?.clawdis?.nix?.plugin)
const skillHref = buildSkillHref(skill, entry.ownerHandle)
return (
<SkillCard
key={skill._id}
skill={skill}
href={skillHref}
badge={skill.batch === 'highlighted' ? 'Highlighted' : undefined}
chip={isPlugin ? 'Plugin bundle (nix)' : undefined}
summaryFallback="Agent-ready skill pack."
@@ -344,13 +360,9 @@ export function SkillsIndex() {
{sorted.map((entry) => {
const skill = entry.skill
const isPlugin = Boolean(entry.latestVersion?.parsed?.clawdis?.nix?.plugin)
const skillHref = buildSkillHref(skill, entry.ownerHandle)
return (
<Link
key={skill._id}
className="skills-row"
to="/skills/$slug"
params={{ slug: skill.slug }}
>
<Link key={skill._id} className="skills-row" to={skillHref}>
<div className="skills-row-main">
<div className="skills-row-title">
<span>{skill.displayName}</span>