fix(desktop): hide community catalog rows that only share a name with an installed skill

Skills install by name, so the installed marker matched any same-named
feed row. That identity only holds for first-party namespaces; community
feeds carry distinct skills reusing popular names (docx, xlsx, pdf), and
every lookalike rendered with the installed checkmark and an ON/disabled
switch, and passed the Installed filter. Such a row is neither the
installed skill nor installable beside it, so the browser now drops it
while official rows keep the existing name match.
This commit is contained in:
kokhlo
2026-09-28 10:42:39 +03:00
committed by brooklyn!
parent 77b727551b
commit 9e63cdc4b8
3 changed files with 62 additions and 6 deletions

View File

@@ -46,6 +46,9 @@ interface CatalogBrowserProps {
isInstalled: (entry: CatalogEntry) => boolean
onInstall: (entry: CatalogEntry) => void
isInstalling?: (entry: CatalogEntry) => boolean
/** Feed rows the caller drops from the list entirely (e.g. a community
* lookalike whose name is taken by an installed skill). */
isSuperseded?: (entry: CatalogEntry) => boolean
installedEntries?: CatalogEntry[]
matchInstalled?: (entry: CatalogEntry) => CatalogEntry | undefined
actions?: ReactNode
@@ -111,6 +114,7 @@ export const CatalogBrowser = memo(function CatalogBrowser({
isInstalled,
onInstall,
isInstalling,
isSuperseded,
installedEntries,
matchInstalled,
actions,
@@ -164,7 +168,8 @@ export const CatalogBrowser = memo(function CatalogBrowser({
useEffect(() => (CATALOG_POINTER_ENABLED && root.current ? trackCatalogPointer(root.current) : undefined), [])
const entries = mergeInstalled(data ?? [], installedEntries ?? [], matchInstalled)
const filtered = sortCatalog(filterCatalog(entries, facets, deferredQuery, isInstalled), sort, kind)
const visible = isSuperseded ? entries.filter(entry => !isSuperseded(entry)) : entries
const filtered = sortCatalog(filterCatalog(visible, facets, deferredQuery, isInstalled), sort, kind)
const discover =
kind === 'plugins' && !facets.categories.length && !facets.tags.length && !deferredQuery && !facets.installedOnly
@@ -176,7 +181,7 @@ export const CatalogBrowser = memo(function CatalogBrowser({
const pageOrder = sections.length ? sections.flatMap(section => section.entries) : filtered
const selected = entries.find(entry => entry.id === selectedId) ?? filtered[0]
const selected = visible.find(entry => entry.id === selectedId) ?? filtered[0]
const related = selected && (!cardView || detailOpen) ? relatedEntries(filtered, selected, 3) : []
const searchFor = (value: string) => {
@@ -263,7 +268,7 @@ export const CatalogBrowser = memo(function CatalogBrowser({
<SelectValue placeholder={c.sortBy} />
</SelectTrigger>
<SelectContent>
{catalogSortOptions(entries, kind, sortLabels).map(({ value, label }) => (
{catalogSortOptions(visible, kind, sortLabels).map(({ value, label }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
@@ -279,7 +284,7 @@ export const CatalogBrowser = memo(function CatalogBrowser({
<div className="@container/catalog flex h-full min-h-0 min-w-0" data-catalog={kind} ref={root}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col @[48rem]/catalog:flex-row">
<CatalogFilters
categories={catalogCategories(entries, kind)}
categories={catalogCategories(visible, kind)}
className="max-h-52 shrink-0 gap-0 pt-3 @[48rem]/catalog:max-h-none @[48rem]/catalog:w-48"
facets={facets}
onCategory={filters.toggleCategory}
@@ -290,8 +295,8 @@ export const CatalogBrowser = memo(function CatalogBrowser({
resultCount={filtered.length}
sidebarActions={actions && <div className="flex flex-wrap items-center gap-1.5">{actions}</div>}
sortControl={sortControl}
sources={catalogSources(entries)}
tags={catalogTags(entries, facets.tags, TAG_LIMIT)}
sources={catalogSources(visible)}
tags={catalogTags(visible, facets.tags, TAG_LIMIT)}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col" data-catalog-results>
{/* Search owns the header; kind-specific setup actions and the view toggle share the end slot. */}

View File

@@ -221,6 +221,18 @@ function ScopedSkillCatalog({
[catalog]
)
// The name guarantee above only holds for first-party namespaces, where a
// shared name is the same skill. Community feeds carry distinct skills that
// reuse popular names, so a community row whose name is taken by an installed
// skill is neither that skill nor installable beside it — hide it instead of
// showing it as installed. Rows already merged into an installed one (their
// id is the installed row's) stay visible.
const isSuperseded = useCallback(
(entry: CatalogEntry) =>
entry.source !== 'official' && !entry.id.startsWith('installed:') && catalog.skillsByName.has(entry.name),
[catalog]
)
const installIdentifier = (entry: CatalogEntry) => catalog.officialFor(entry)?.identifier ?? entry.installIdentifier
const identityPending = hasHubSkills && (hubPending || Boolean(hubError))
@@ -257,6 +269,7 @@ function ScopedSkillCatalog({
installedPending={installedPending || identityPending}
isInstalled={isInstalled}
isInstalling={entry => installing.has(installIdentifier(entry) ?? '')}
isSuperseded={isSuperseded}
kind="skills"
matchInstalled={catalog.matchInstalled}
notice={

View File

@@ -502,4 +502,42 @@ describe('CapabilitiesView toolset management', { timeout: 60_000 }, () => {
expect(vi.mocked(installHubSkill)).toHaveBeenCalledWith('official/gifs/gif-search', expect.anything())
)
})
it('drops community feed rows that only share a name with an installed skill', async () => {
// Installs are name-keyed, so a community lookalike of an installed skill
// can neither be added beside it nor is it the installed skill itself —
// it must not surface as a second "installed" row under the same name.
getSkills.mockResolvedValue([
{
name: 'docx',
description: 'Bundled document tools',
category: 'documents',
enabled: true,
usage: 0,
provenance: 'bundled'
}
])
queryClient.setQueryData(
['public-catalog', 'skills'],
parseCatalog('skills', [
{ name: 'docx', source: 'ClawHub', identifier: 'wordpro' },
{ name: 'gif-search', source: 'ClawHub', identifier: 'gif-raccoon' }
])
)
await act(async () => {
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/capabilities?tab=skills']}>
<CapabilitiesView />
</MemoryRouter>
</QueryClientProvider>
)
})
// The feed settles first; then only the installed row keeps the name,
// while the distinct community skill stays installable.
await screen.findByRole('switch', { name: 'Add gif-search' })
expect(screen.getAllByRole('button', { name: 'docx' })).toHaveLength(1)
})
})