diff --git a/apps/desktop/src/app/capabilities/catalog/catalog-browser.tsx b/apps/desktop/src/app/capabilities/catalog/catalog-browser.tsx index fd5124c3ba..70c36f562b 100644 --- a/apps/desktop/src/app/capabilities/catalog/catalog-browser.tsx +++ b/apps/desktop/src/app/capabilities/catalog/catalog-browser.tsx @@ -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({ - {catalogSortOptions(entries, kind, sortLabels).map(({ value, label }) => ( + {catalogSortOptions(visible, kind, sortLabels).map(({ value, label }) => ( {label} @@ -279,7 +284,7 @@ export const CatalogBrowser = memo(function CatalogBrowser({
{actions}
} sortControl={sortControl} - sources={catalogSources(entries)} - tags={catalogTags(entries, facets.tags, TAG_LIMIT)} + sources={catalogSources(visible)} + tags={catalogTags(visible, facets.tags, TAG_LIMIT)} />
{/* Search owns the header; kind-specific setup actions and the view toggle share the end slot. */} diff --git a/apps/desktop/src/app/capabilities/catalog/skill-catalog.tsx b/apps/desktop/src/app/capabilities/catalog/skill-catalog.tsx index c18255f74a..befefc6f0c 100644 --- a/apps/desktop/src/app/capabilities/catalog/skill-catalog.tsx +++ b/apps/desktop/src/app/capabilities/catalog/skill-catalog.tsx @@ -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={ diff --git a/apps/desktop/src/app/capabilities/index.test.tsx b/apps/desktop/src/app/capabilities/index.test.tsx index ccd11dbc80..bcc56565bd 100644 --- a/apps/desktop/src/app/capabilities/index.test.tsx +++ b/apps/desktop/src/app/capabilities/index.test.tsx @@ -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( + + + + + + ) + }) + + // 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) + }) })