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:
@@ -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. */}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user