{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)
+ })
})