feat(skills): curated GitHub catalog redesign (#3016)

* feat(skills): remove ClawHub catalog integration

Drop the ClawHub registry as a skills catalog source across web server,
shared UI, VS Code, docs, and locales. The catalog now serves git-based
sources only: the curated Anthropic repo and user-defined repositories.
Also removes the now-unused adm-zip dependency.

* feat(skills): redesign catalog around curated GitHub repositories

Replace the single-source dropdown with a card grid of curated GitHub
repositories (Anthropic, OpenAI, Cursor pstack/skills, Matt Pocock) plus
user-defined sources. Source cards show skill counts, GitHub stars, and
last-updated time; a global search covers all loaded sources.

Server: curated sources gain GitHub repo metadata (stars, pushed_at)
fetched best-effort with a 3-hour in-memory and on-disk cache; scans
run through a concurrency-limited, deduplicated cache with 3-hour TTL
persisted across restarts. Refresh still bypasses the cache.

Shared UI: source cards, global search with clear button, per-skill
GitHub links, install/installed states. VS Code curated list updated
to match. All new copy translated across 12 locales.

* fix(skills): address catalog review findings

- GitHub metadata fetch timeout drops to 1.5s (under the catalog
  client's 3s deadline) and failed lookups cache briefly (5 min) so
  repeated catalog loads do not re-hit a failing API.
- Disk cache files are written with owner-only permissions (0o600);
  rename preserves the mode.
- loadSource deduplicates concurrent in-flight requests per source and
  the shared isLoadingSource flag now clears only when the last active
  source load finishes.
This commit is contained in:
Bohdan Triapitsyn
2026-08-20 01:40:10 +03:00
committed by GitHub
parent 90d8868bfc
commit 1ed3f1f575
45 changed files with 1143 additions and 1286 deletions
@@ -127,24 +127,13 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
directoryOverride?: string | null;
conflictDecisions?: Record<string, ConflictDecision>;
}) => {
// Build selection with clawdhub metadata if present
const selection: { skillDir: string; clawdhub?: { slug: string; version: string } } = {
skillDir: request.skillDir,
};
if (item?.clawdhub) {
selection.clawdhub = {
slug: item.clawdhub.slug,
version: item.clawdhub.version,
};
}
const result = await installSkills({
source: request.source,
subpath: request.subpath,
gitIdentityId: item?.gitIdentityId,
scope: request.scope,
targetSource: request.targetSource,
selections: [selection],
selections: [{ skillDir: request.skillDir }],
conflictPolicy: 'prompt',
conflictDecisions: request.conflictDecisions,
}, { directory: request.directoryOverride ?? null });
@@ -4,11 +4,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import {
SettingsSection,
SETTINGS_SELECT_SIZE,
SETTINGS_SELECT_TRIGGER_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import {
Dialog,
@@ -18,24 +14,16 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Icon } from "@/components/icon/Icon";
import { Icon } from '@/components/icon/Icon';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useShallow } from 'zustand/react/shallow';
import { cn } from '@/lib/utils';
import type { SkillsCatalogItem } from '@/lib/api/types';
import type { SkillsCatalogItem, SkillsCatalogSource } from '@/lib/api/types';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { updateDesktopSettings } from '@/lib/persistence';
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { AddCatalogDialog } from './AddCatalogDialog';
import { InstallSkillDialog } from './InstallSkillDialog';
@@ -48,6 +36,71 @@ interface SkillsCatalogPageProps {
showModeTabs?: boolean;
}
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const getRepoUrl = (source: string): string | null => {
const trimmed = source.trim();
if (!GITHUB_REPO_PATTERN.test(trimmed)) {
return null;
}
return `https://github.com/${trimmed}`;
};
const getSkillUrl = (item: SkillsCatalogItem): string | null => {
const repoUrl = getRepoUrl(item.repoSource);
if (!repoUrl) {
return null;
}
const skillPath = [item.repoSubpath, item.skillDir].filter(Boolean).join('/');
return skillPath ? `${repoUrl}/tree/HEAD/${skillPath}` : repoUrl;
};
let cachedStarsFormatter: { locale: string; formatter: Intl.NumberFormat } | null = null;
const formatStars = (stars: number): string => {
const locale = getCurrentIntlLocale();
if (!cachedStarsFormatter || cachedStarsFormatter.locale !== locale) {
cachedStarsFormatter = { locale, formatter: new Intl.NumberFormat(locale, { notation: 'compact' }) };
}
return cachedStarsFormatter.formatter.format(stars);
};
type RelativeTimeKey =
| 'common.relative.justNow'
| 'common.relative.minutesAgoShort'
| 'common.relative.hoursAgoShort'
| 'common.relative.daysAgoShort'
| 'common.relative.weeksAgoShort'
| 'common.relative.yearsAgoShort';
const formatRelativeShort = (isoDate: string): { key: RelativeTimeKey; count: number } | null => {
const timestamp = Date.parse(isoDate);
if (Number.isNaN(timestamp)) {
return null;
}
const diffMs = Date.now() - timestamp;
if (diffMs < 60_000) {
return { key: 'common.relative.justNow', count: 0 };
}
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 60) {
return { key: 'common.relative.minutesAgoShort', count: minutes };
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return { key: 'common.relative.hoursAgoShort', count: hours };
}
const days = Math.floor(hours / 24);
if (days < 7) {
return { key: 'common.relative.daysAgoShort', count: days };
}
const weeks = Math.floor(days / 7);
if (weeks < 52) {
return { key: 'common.relative.weeksAgoShort', count: weeks };
}
return { key: 'common.relative.yearsAgoShort', count: Math.floor(days / 365) };
};
const loadSettings = async (): Promise<DesktopSettings | null> => {
try {
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
@@ -71,6 +124,67 @@ const loadSettings = async (): Promise<DesktopSettings | null> => {
}
};
const SourceCard: React.FC<{
source: SkillsCatalogSource;
isActive: boolean;
isLoading: boolean;
skillsCount: number | null;
onSelect: () => void;
t: ReturnType<typeof useI18n>['t'];
}> = ({ source, isActive, isLoading, skillsCount, onSelect, t }) => {
const stars = source.stars ?? null;
const updated = source.repoUpdatedAt ? formatRelativeShort(source.repoUpdatedAt) : null;
return (
<button
type="button"
onClick={onSelect}
aria-pressed={isActive}
className={cn(
'w-full min-h-24 text-left rounded-lg border bg-[var(--surface-elevated)] p-3.5 flex gap-3 items-start transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive
? 'border-primary'
: 'border-[var(--surface-subtle)] hover:border-[var(--interactive-border-hover)]'
)}
>
<span className="min-w-0 flex-1 block">
<span className="flex items-center gap-2">
<span className="typography-ui-label font-medium text-foreground truncate">{source.label}</span>
{isLoading ? (
<Icon name="refresh" className="h-3 w-3 animate-spin text-muted-foreground shrink-0" />
) : (
skillsCount !== null && (
<span className="typography-micro text-muted-foreground shrink-0">
{t('settings.skills.catalog.page.source.skillsCount', { count: skillsCount })}
</span>
)
)}
</span>
<span className="typography-micro font-mono text-muted-foreground block mt-0.5 truncate">{source.source}</span>
<span className="flex items-center gap-3 mt-1">
{stars !== null && (
<span
className="typography-micro text-muted-foreground flex items-center gap-1"
title={t('settings.skills.catalog.page.source.stars', { count: stars })}
>
<Icon name="star" className="h-3 w-3" />
{formatStars(stars)}
</span>
)}
{updated && (
<span className="typography-micro text-muted-foreground">
{updated.key === 'common.relative.justNow'
? t(updated.key)
: t('settings.skills.catalog.page.source.updated', { time: t(updated.key, { count: updated.count }) })}
</span>
)}
</span>
</span>
</button>
);
};
export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onModeChange, showModeTabs = true }) => {
const { t } = useI18n();
const {
@@ -80,12 +194,9 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
setSelectedSource,
loadCatalog,
loadSource,
loadMoreClawdHub,
isLoadingCatalog,
isLoadingSource,
isLoadingMore,
loadedSourceIds,
clawdhubHasMoreBySource,
lastCatalogError,
} = useSkillsCatalogStore(useShallow((s) => ({
sources: s.sources,
@@ -94,12 +205,9 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
setSelectedSource: s.setSelectedSource,
loadCatalog: s.loadCatalog,
loadSource: s.loadSource,
loadMoreClawdHub: s.loadMoreClawdHub,
isLoadingCatalog: s.isLoadingCatalog,
isLoadingSource: s.isLoadingSource,
isLoadingMore: s.isLoadingMore,
loadedSourceIds: s.loadedSourceIds,
clawdhubHasMoreBySource: s.clawdhubHasMoreBySource,
lastCatalogError: s.lastCatalogError,
})));
@@ -109,43 +217,72 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
const [installItem, setInstallItem] = React.useState<SkillsCatalogItem | null>(null);
const [isRemovingCatalog, setIsRemovingCatalog] = React.useState(false);
const [isRemoveCatalogDialogOpen, setIsRemoveCatalogDialogOpen] = React.useState(false);
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
React.useEffect(() => {
void loadCatalog();
}, [loadCatalog]);
// Load every source in the background so global search covers all of them.
React.useEffect(() => {
if (!selectedSourceId) {
const unloaded = sources.filter((src) => !loadedSourceIds[src.id]);
if (unloaded.length === 0) {
return;
}
if (!loadedSourceIds[selectedSourceId]) {
void loadSource(selectedSourceId);
let cancelled = false;
const loadRest = async () => {
for (const src of unloaded) {
if (cancelled) {
return;
}
await loadSource(src.id);
}
};
void loadRest();
return () => {
cancelled = true;
};
}, [sources, loadedSourceIds, loadSource]);
React.useEffect(() => {
if (!selectedSourceId || loadedSourceIds[selectedSourceId]) {
return;
}
void loadSource(selectedSourceId);
}, [selectedSourceId, loadedSourceIds, loadSource]);
const items = React.useMemo(() => {
if (!selectedSourceId) return [];
return itemsBySource[selectedSourceId] || [];
}, [itemsBySource, selectedSourceId]);
React.useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
searchInputRef.current?.focus();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
const isSearching = search.trim().length > 0;
const filtered = React.useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return items;
return items.filter((item) => {
const name = item.skillName.toLowerCase();
const desc = (item.description || '').toLowerCase();
const fm = (item.frontmatterName || '').toLowerCase();
return name.includes(q) || desc.includes(q) || fm.includes(q);
});
}, [items, search]);
const matches = (item: SkillsCatalogItem) =>
item.skillName.toLowerCase().includes(q)
|| (item.description || '').toLowerCase().includes(q)
|| (item.frontmatterName || '').toLowerCase().includes(q);
if (isSearching) {
return sources.flatMap((src) => (itemsBySource[src.id] || []).filter(matches));
}
if (!selectedSourceId) {
return [];
}
return itemsBySource[selectedSourceId] || [];
}, [sources, itemsBySource, selectedSourceId, search, isSearching]);
const selectedSource = React.useMemo(() => sources.find((s) => s.id === selectedSourceId) || null, [sources, selectedSourceId]);
const isCustomSource = Boolean(selectedSourceId && selectedSourceId.startsWith('custom:'));
const isClawdHubSource = selectedSource?.source === 'clawdhub:registry' || selectedSource?.sourceType === 'clawdhub';
const hasMoreClawdHub = Boolean(
selectedSourceId && (clawdhubHasMoreBySource[selectedSourceId] ?? true)
);
const removeSelectedCatalog = async () => {
if (!selectedSourceId || !isCustomSource) {
@@ -165,6 +302,17 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
}
};
const listTitle = isSearching
? t('settings.skills.catalog.page.list.searchTitle')
: (selectedSource?.label ?? '');
// The selected source has no items yet and a load is in flight — show the
// loading state instead of a stale list from the previously selected source.
const isSelectedSourceLoading = !isSearching
&& selectedSourceId !== null
&& !loadedSourceIds[selectedSourceId]
&& (isLoadingSource || isLoadingCatalog);
return (
<>
<SettingsPageLayout
@@ -190,90 +338,74 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
</div>
)}
<p className="typography-meta text-muted-foreground mb-4">
{t('settings.skills.catalog.page.subtitle')}
</p>
<div data-settings-item="skills.catalog.search" className="mb-5">
<div className="relative max-w-md">
<Icon name="search" className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
ref={searchInputRef}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('settings.skills.catalog.page.searchAllPlaceholder')}
className={cn('h-8 pl-8 w-full', search && 'pr-8')}
/>
{search && (
<button
type="button"
onClick={() => {
setSearch('');
searchInputRef.current?.focus();
}}
className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center h-4 w-4 rounded text-muted-foreground hover:text-foreground transition-colors"
title={t('settings.skills.catalog.page.search.clear')}
>
<Icon name="close" className="h-3 w-3" />
</button>
)}
</div>
</div>
<SettingsSection
title={t('settings.skills.catalog.page.section.sourceRepository')}
title={t('settings.skills.catalog.page.section.sources')}
divider={false}
settingsItem="skills.catalog.source"
contentClassName="space-y-0"
>
<div className="flex flex-wrap items-center gap-2 py-1.5">
<Select
value={selectedSourceId || ''}
onValueChange={(v) => setSelectedSource(v)}
>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_TRIGGER_CLASS, 'w-fit')}>
<SelectValue placeholder={t('settings.skills.catalog.page.field.selectSourcePlaceholder')}>
{selectedSource?.label}
</SelectValue>
</SelectTrigger>
<SelectContent align="start">
{sources.map((src) => (
<SelectItem key={src.id} value={src.id}>
{src.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 py-1.5">
{sources.map((src) => (
<SourceCard
key={src.id}
source={src}
isActive={src.id === selectedSourceId}
isLoading={isLoadingSource && !loadedSourceIds[src.id]}
skillsCount={loadedSourceIds[src.id] ? (itemsBySource[src.id] || []).length : null}
onSelect={() => setSelectedSource(src.id)}
t={t}
/>
))}
<Button
variant="outline"
size="xs"
className="!font-normal h-6 w-6 px-0"
onClick={() => {
if (selectedSourceId) {
void loadSource(selectedSourceId, { refresh: true });
} else {
void loadCatalog({ refresh: true });
}
}}
disabled={isLoadingCatalog || isLoadingSource}
title={t('settings.skills.catalog.page.actions.refreshTitle')}
>
<Icon name="refresh" className={cn("h-3.5 w-3.5", (isLoadingCatalog || isLoadingSource) && "animate-spin")} />
</Button>
{isCustomSource && (
<Button
variant="ghost"
size="xs"
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
onClick={() => setIsRemoveCatalogDialogOpen(true)}
disabled={isRemovingCatalog}
title={t('settings.skills.catalog.page.actions.removeCatalogTitle')}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</Button>
)}
<Button
data-settings-item="skills.catalog.add-catalog"
size="xs"
className="!font-normal gap-1"
onClick={() => setAddCatalogOpen(true)}
>
<Icon name="add" className="h-3.5 w-3.5" /> {t('settings.skills.catalog.page.actions.addCatalog')}
</Button>
</div>
<div data-settings-item="skills.catalog.search" className="py-1.5">
<div className="relative">
<Icon name="search" className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('settings.skills.catalog.shared.field.searchSkillsPlaceholder')}
className="h-7 pl-8 w-full sm:w-64"
/>
</div>
<span className="typography-meta text-muted-foreground mt-1 block">
{isLoadingCatalog
? t('settings.skills.catalog.page.loading.catalog')
: t('settings.skills.catalog.page.foundCount', { count: filtered.length })}
<button
type="button"
data-settings-item="skills.catalog.add-catalog"
onClick={() => setAddCatalogOpen(true)}
className="min-h-24 text-left rounded-lg border border-dashed border-[var(--surface-subtle)] hover:border-[var(--interactive-border-hover)] hover:bg-[var(--surface-muted)] p-3.5 flex gap-3 items-start transition-colors"
>
<span className="flex items-center justify-center rounded-md bg-transparent text-muted-foreground w-8 h-8 shrink-0">
<Icon name="add" className="h-4 w-4" />
</span>
</div>
<span className="min-w-0">
<span className="typography-ui-label text-muted-foreground block">
{t('settings.skills.catalog.page.source.addOwnTitle')}
</span>
<span className="typography-micro text-muted-foreground/70 block mt-0.5">
{t('settings.skills.catalog.page.source.addOwnDescription')}
</span>
</span>
</button>
</div>
</SettingsSection>
{lastCatalogError && (
@@ -286,21 +418,63 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
)}
<SettingsSection>
{filtered.length === 0 && !isLoadingSource ? (
<div className="py-8 text-center text-muted-foreground">
<p className="typography-body">{t('settings.skills.catalog.page.empty.noSkillsTitle')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.page.empty.noSkillsDescription')}</p>
</div>
) : isLoadingSource ? (
<div className="flex items-center justify-between gap-2 pb-2">
<div className="flex items-center gap-2 min-w-0">
<span className="typography-micro font-medium uppercase tracking-wide text-muted-foreground truncate">
{listTitle}
</span>
<span className="typography-micro text-muted-foreground/70 shrink-0">
{t('settings.skills.catalog.page.foundCount', { count: filtered.length })}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="xs"
className="!font-normal h-6 w-6 px-0"
onClick={() => {
if (selectedSourceId && !isSearching) {
void loadSource(selectedSourceId, { refresh: true });
} else {
void loadCatalog({ refresh: true });
}
}}
disabled={isLoadingCatalog || isLoadingSource}
title={t('settings.skills.catalog.page.actions.refreshTitle')}
>
<Icon name="refresh" className={cn('h-3.5 w-3.5', (isLoadingCatalog || isLoadingSource) && 'animate-spin')} />
</Button>
{isCustomSource && !isSearching && (
<Button
variant="ghost"
size="xs"
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
onClick={() => setIsRemoveCatalogDialogOpen(true)}
disabled={isRemovingCatalog}
title={t('settings.skills.catalog.page.actions.removeCatalogTitle')}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
{isSelectedSourceLoading || (isLoadingSource && filtered.length === 0) ? (
<div className="py-8 text-center text-muted-foreground">
<Icon name="refresh" className="mx-auto mb-3 h-5 w-5 animate-spin opacity-50" />
<p className="typography-meta">{t('settings.skills.catalog.page.loading.skills')}</p>
</div>
) : filtered.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
<p className="typography-body">{t('settings.skills.catalog.page.empty.noSkillsTitle')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.page.empty.noSkillsDescription')}</p>
</div>
) : (
<div className="divide-y divide-[var(--surface-subtle)]">
{filtered.map((item) => {
const installed = item.installed?.isInstalled;
const installedScope = item.installed?.scope;
const skillUrl = getSkillUrl(item);
return (
<div key={`${item.sourceId}:${item.skillDir}`} className="py-2">
@@ -326,24 +500,28 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
<div className="typography-meta text-muted-foreground/50 mt-0.5 italic">{t('settings.skills.catalog.shared.noDescription')}</div>
)}
{item.clawdhub && (
<div className="typography-micro text-muted-foreground mt-1.5 flex items-center gap-3">
{item.clawdhub.owner && (
<span>{t('settings.skills.catalog.page.byOwnerPrefix')} <span className="font-medium text-foreground/80">{item.clawdhub.owner}</span></span>
)}
<span className="flex items-center gap-1">
<Icon name="download" className="h-3 w-3" />
{item.clawdhub.downloads?.toLocaleString() ?? 0}
</span>
{(item.clawdhub.stars ?? 0) > 0 && (
<span className="flex items-center gap-1">
<Icon name="star" className="h-3 w-3" />
{item.clawdhub.stars}
</span>
)}
<span className="bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">v{item.clawdhub.version}</span>
</div>
)}
<div className="typography-micro text-muted-foreground/80 mt-1 flex items-center gap-2 min-w-0">
{skillUrl ? (
<a
href={skillUrl}
target="_blank"
rel="noreferrer"
className="font-mono hover:underline truncate inline-flex items-center gap-1"
title={t('settings.skills.catalog.page.skill.viewOnGithub')}
>
<Icon name="github" className="h-3 w-3 shrink-0" />
{item.repoSource}
</a>
) : (
<span className="font-mono truncate">{item.repoSource}</span>
)}
{item.skillDir && (
<>
<span className="opacity-40">·</span>
<span className="truncate">{item.skillDir}</span>
</>
)}
</div>
{item.warnings?.length ? (
<div className="typography-micro text-[var(--status-warning)] mt-1.5 bg-[var(--status-warning)]/10 px-2 py-1 rounded w-fit">
@@ -352,37 +530,43 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
) : null}
</div>
<Button
variant="outline"
size="xs"
className="!font-normal shrink-0"
disabled={!item.installable}
onClick={() => {
setInstallItem(item);
setInstallDialogOpen(true);
}}
>
{t('settings.skills.catalog.shared.actions.install')}
</Button>
<div className="flex items-center gap-1.5 shrink-0">
{skillUrl && (
<Button
variant="ghost"
size="xs"
className="!font-normal h-6 w-6 px-0"
onClick={() => window.open(skillUrl, '_blank', 'noreferrer')}
title={t('settings.skills.catalog.page.skill.viewOnGithub')}
>
<Icon name="external-link" className="h-3.5 w-3.5" />
</Button>
)}
{installed ? (
<span className="text-[var(--status-success)] flex items-center justify-center w-7 h-7" title={t('settings.skills.catalog.page.badge.installed', { scope: installedScope || '' })}>
<Icon name="check" className="h-4 w-4" />
</span>
) : (
<Button
variant="outline"
size="xs"
className="!font-normal"
disabled={!item.installable}
onClick={() => {
setInstallItem(item);
setInstallDialogOpen(true);
}}
>
{t('settings.skills.catalog.shared.actions.install')}
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{isClawdHubSource && hasMoreClawdHub && !isLoadingSource && filtered.length > 0 && (
<div className="flex justify-center mt-2">
<Button
variant="outline"
size="xs"
className="!font-normal"
onClick={() => void loadMoreClawdHub()}
disabled={isLoadingMore}
>
{isLoadingMore ? t('settings.skills.catalog.page.loading.more') : t('settings.skills.catalog.page.actions.loadMoreSkills')}
</Button>
</div>
)}
</SettingsSection>
</SettingsPageLayout>
+5 -22
View File
@@ -1247,7 +1247,7 @@ export type RuntimeAPISelector<TValue> = (apis: RuntimeAPIs) => TValue;
type SkillsCatalogSourceId = string;
type SkillsCatalogSourceType = 'github' | 'clawdhub';
type SkillsCatalogSourceType = 'github';
export interface SkillsCatalogSource {
id: SkillsCatalogSourceId;
@@ -1256,6 +1256,10 @@ export interface SkillsCatalogSource {
source: string;
defaultSubpath?: string;
sourceType?: SkillsCatalogSourceType;
/** GitHub repository star count (null when unavailable) */
stars?: number | null;
/** GitHub repository last-push timestamp, ISO (null when unavailable) */
repoUpdatedAt?: string | null;
}
interface SkillsCatalogItemInstalledBadge {
@@ -1264,18 +1268,6 @@ interface SkillsCatalogItemInstalledBadge {
source?: 'opencode' | 'agents' | 'claude';
}
interface ClawdHubSkillMetadata {
slug: string;
version: string;
displayName?: string;
owner?: string;
downloads?: number;
stars?: number;
versionsCount?: number;
createdAt?: number;
updatedAt?: number;
}
export interface SkillsCatalogItem {
sourceId: SkillsCatalogSourceId;
repoSource: string;
@@ -1288,22 +1280,18 @@ export interface SkillsCatalogItem {
installable: boolean;
warnings?: string[];
installed?: SkillsCatalogItemInstalledBadge;
/** ClawdHub-specific metadata (present only for ClawdHub sources) */
clawdhub?: ClawdHubSkillMetadata;
}
export interface SkillsCatalogResponse {
ok: boolean;
sources?: SkillsCatalogSource[];
itemsBySource?: Record<SkillsCatalogSourceId, SkillsCatalogItem[]>;
pageInfoBySource?: Record<SkillsCatalogSourceId, { nextCursor?: string | null }>;
error?: { kind: string; message: string };
}
export interface SkillsCatalogSourceResponse {
ok: boolean;
items?: SkillsCatalogItem[];
nextCursor?: string | null;
error?: { kind: string; message: string };
}
@@ -1328,11 +1316,6 @@ export interface SkillsRepoScanResponse {
interface SkillsInstallSelection {
skillDir: string;
/** ClawdHub-specific metadata for installation */
clawdhub?: {
slug: string;
version: string;
};
}
export interface SkillsInstallRequest {
@@ -845,16 +845,26 @@ export const settingsDict = {
'settings.skills.catalog.page.mode.manual': 'Manuell',
'settings.skills.catalog.page.mode.external': 'Extern',
'settings.skills.catalog.page.title': 'Fähigkeitskatalog',
'settings.skills.catalog.page.subtitle': 'Installiere fertige Skills aus kuratierten Repositories oder füge eine eigene Quelle hinzu.',
'settings.skills.catalog.page.section.sources': 'Quellen',
'settings.skills.catalog.page.searchAllPlaceholder': 'Skills in allen Quellen suchen…',
'settings.skills.catalog.page.search.clear': 'Suche löschen',
'settings.skills.catalog.page.source.skillsCount': 'Skills: {count}',
'settings.skills.catalog.page.source.stars': 'Sterne: {count}',
'settings.skills.catalog.page.source.updated': 'Aktualisiert {time}',
'settings.skills.catalog.page.source.addOwnTitle': 'Eigene Quelle hinzufügen',
'settings.skills.catalog.page.source.addOwnDescription': 'Beliebiges Git-Repository mit Skills',
'settings.skills.catalog.page.source.viewRepo': 'Repository auf GitHub öffnen',
'settings.skills.catalog.page.skill.viewOnGithub': 'Skill auf GitHub ansehen',
'settings.skills.catalog.page.list.searchTitle': 'Suchergebnisse',
'settings.skills.catalog.page.section.sourceRepository': 'Quell-Repository',
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Quelle auswählen',
'settings.skills.catalog.page.actions.refreshTitle': 'Aktualisieren',
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Katalog entfernen',
'settings.skills.catalog.page.actions.addCatalog': 'Katalog hinzufügen',
'settings.skills.catalog.page.actions.removeCatalog': 'Katalog entfernen',
'settings.skills.catalog.page.actions.loadMoreSkills': 'Weitere Fähigkeiten laden',
'settings.skills.catalog.page.loading.catalog': 'Wird geladen...',
'settings.skills.catalog.page.loading.skills': 'Fähigkeiten werden geladen...',
'settings.skills.catalog.page.loading.more': 'Wird geladen...',
'settings.skills.catalog.page.foundCount': '{count} Fähigkeit(en) gefunden',
'settings.skills.catalog.page.error.catalogTitle': 'Katalogfehler',
'settings.skills.catalog.page.empty.noSkillsTitle': 'Keine Fähigkeiten gefunden',
@@ -862,7 +872,6 @@ export const settingsDict = {
'settings.skills.catalog.page.badge.installed': 'installiert ({scope})',
'settings.skills.catalog.page.badge.notInstallable': 'nicht installierbar',
'settings.skills.catalog.page.badge.unknown': 'unbekannt',
'settings.skills.catalog.page.byOwnerPrefix': 'von',
'settings.skills.catalog.page.removeDialog.title': 'Katalog entfernen',
'settings.skills.catalog.page.removeDialog.description': 'Sind Sie sicher, dass Sie diesen Katalog entfernen möchten?',
'settings.openchamber.passkeys.title': 'Passkeys',
@@ -897,16 +897,26 @@ export const settingsDict = {
'settings.skills.catalog.page.mode.manual': 'Manual',
'settings.skills.catalog.page.mode.external': 'External',
'settings.skills.catalog.page.title': 'Skills Catalog',
'settings.skills.catalog.page.subtitle': 'Install ready-made skills from curated repositories, or add your own source.',
'settings.skills.catalog.page.section.sources': 'Sources',
'settings.skills.catalog.page.searchAllPlaceholder': 'Search skills across all sources…',
'settings.skills.catalog.page.search.clear': 'Clear search',
'settings.skills.catalog.page.source.skillsCount': '{count} skills',
'settings.skills.catalog.page.source.stars': '{count} stars',
'settings.skills.catalog.page.source.updated': 'Updated {time}',
'settings.skills.catalog.page.source.addOwnTitle': 'Add your own source',
'settings.skills.catalog.page.source.addOwnDescription': 'Any Git repository with skills',
'settings.skills.catalog.page.source.viewRepo': 'Open repository on GitHub',
'settings.skills.catalog.page.skill.viewOnGithub': 'View skill on GitHub',
'settings.skills.catalog.page.list.searchTitle': 'Search results',
'settings.skills.catalog.page.section.sourceRepository': 'Source Repository',
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Select source',
'settings.skills.catalog.page.actions.refreshTitle': 'Refresh',
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Remove Catalog',
'settings.skills.catalog.page.actions.addCatalog': 'Add Catalog',
'settings.skills.catalog.page.actions.removeCatalog': 'Remove Catalog',
'settings.skills.catalog.page.actions.loadMoreSkills': 'Load More Skills',
'settings.skills.catalog.page.loading.catalog': 'Loading...',
'settings.skills.catalog.page.loading.skills': 'Loading skills...',
'settings.skills.catalog.page.loading.more': 'Loading...',
'settings.skills.catalog.page.foundCount': '{count} skill(s) found',
'settings.skills.catalog.page.error.catalogTitle': 'Catalog error',
'settings.skills.catalog.page.empty.noSkillsTitle': 'No skills found',
@@ -914,7 +924,6 @@ export const settingsDict = {
'settings.skills.catalog.page.badge.installed': 'installed ({scope})',
'settings.skills.catalog.page.badge.notInstallable': 'not installable',
'settings.skills.catalog.page.badge.unknown': 'unknown',
'settings.skills.catalog.page.byOwnerPrefix': 'by',
'settings.skills.catalog.page.removeDialog.title': 'Remove Catalog',
'settings.skills.catalog.page.removeDialog.description': 'Are you sure you want to remove this catalog?',
'settings.openchamber.passkeys.title': 'Passkeys',
@@ -865,16 +865,26 @@ export const settingsDict = {
"settings.skills.catalog.page.mode.manual": "Manual",
"settings.skills.catalog.page.mode.external": "Externo",
"settings.skills.catalog.page.title": "Catálogo de habilidades",
'settings.skills.catalog.page.subtitle': 'Instala skills listos desde repositorios curados o añade tu propia fuente.',
'settings.skills.catalog.page.section.sources': 'Fuentes',
'settings.skills.catalog.page.searchAllPlaceholder': 'Buscar skills en todas las fuentes…',
'settings.skills.catalog.page.search.clear': 'Borrar búsqueda',
'settings.skills.catalog.page.source.skillsCount': 'Skills: {count}',
'settings.skills.catalog.page.source.stars': 'Estrellas: {count}',
'settings.skills.catalog.page.source.updated': 'Actualizado {time}',
'settings.skills.catalog.page.source.addOwnTitle': 'Añadir tu propia fuente',
'settings.skills.catalog.page.source.addOwnDescription': 'Cualquier repositorio Git con skills',
'settings.skills.catalog.page.source.viewRepo': 'Abrir repositorio en GitHub',
'settings.skills.catalog.page.skill.viewOnGithub': 'Ver skill en GitHub',
'settings.skills.catalog.page.list.searchTitle': 'Resultados de búsqueda',
"settings.skills.catalog.page.section.sourceRepository": "Repositorio de origen",
"settings.skills.catalog.page.field.selectSourcePlaceholder": "Seleccionar origen",
"settings.skills.catalog.page.actions.refreshTitle": "Actualizar",
"settings.skills.catalog.page.actions.removeCatalogTitle": "Eliminar catálogo",
"settings.skills.catalog.page.actions.addCatalog": "Añadir catálogo",
"settings.skills.catalog.page.actions.removeCatalog": "Eliminar catálogo",
"settings.skills.catalog.page.actions.loadMoreSkills": "Cargar más habilidades",
"settings.skills.catalog.page.loading.catalog": "Cargando...",
"settings.skills.catalog.page.loading.skills": "Cargando habilidades...",
"settings.skills.catalog.page.loading.more": "Cargando...",
"settings.skills.catalog.page.foundCount": "{count} habilidad(es) encontrada(s)",
"settings.skills.catalog.page.error.catalogTitle": "Error del catálogo",
"settings.skills.catalog.page.empty.noSkillsTitle": "No se encontraron habilidades",
@@ -882,7 +892,6 @@ export const settingsDict = {
"settings.skills.catalog.page.badge.installed": "instalado ({scope})",
"settings.skills.catalog.page.badge.notInstallable": "no instalable",
"settings.skills.catalog.page.badge.unknown": "desconocido",
"settings.skills.catalog.page.byOwnerPrefix": "por",
"settings.skills.catalog.page.removeDialog.title": "Eliminar catálogo",
"settings.skills.catalog.page.removeDialog.description": "¿Estás seguro de que quieres eliminar este catálogo?",
"settings.openchamber.passkeys.title": "Claves de paso",
@@ -783,16 +783,26 @@ export const settingsDict = {
'settings.skills.catalog.page.mode.manual': 'Manuel',
'settings.skills.catalog.page.mode.external': 'Externe',
'settings.skills.catalog.page.title': 'Catalogue de skills',
'settings.skills.catalog.page.subtitle': "Installez des skills prêts à l'emploi depuis des dépôts curatés ou ajoutez votre propre source.",
'settings.skills.catalog.page.section.sources': 'Sources',
'settings.skills.catalog.page.searchAllPlaceholder': 'Rechercher des skills dans toutes les sources…',
'settings.skills.catalog.page.search.clear': 'Effacer la recherche',
'settings.skills.catalog.page.source.skillsCount': 'Skills : {count}',
'settings.skills.catalog.page.source.stars': 'Étoiles : {count}',
'settings.skills.catalog.page.source.updated': 'Mis à jour {time}',
'settings.skills.catalog.page.source.addOwnTitle': 'Ajouter votre propre source',
'settings.skills.catalog.page.source.addOwnDescription': "N'importe quel dépôt Git avec des skills",
'settings.skills.catalog.page.source.viewRepo': 'Ouvrir le dépôt sur GitHub',
'settings.skills.catalog.page.skill.viewOnGithub': 'Voir le skill sur GitHub',
'settings.skills.catalog.page.list.searchTitle': 'Résultats de recherche',
'settings.skills.catalog.page.section.sourceRepository': 'Dépôt source',
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Sélectionnez la source',
'settings.skills.catalog.page.actions.refreshTitle': 'Rafraîchir',
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Supprimer le catalogue',
'settings.skills.catalog.page.actions.addCatalog': 'Ajouter un catalogue',
'settings.skills.catalog.page.actions.removeCatalog': 'Supprimer le catalogue',
'settings.skills.catalog.page.actions.loadMoreSkills': 'Charger plus de skills',
'settings.skills.catalog.page.loading.catalog': 'Chargement...',
'settings.skills.catalog.page.loading.skills': 'Chargement des skills...',
'settings.skills.catalog.page.loading.more': 'Chargement...',
'settings.skills.catalog.page.foundCount': '{count} skill(s) trouvé(s)',
'settings.skills.catalog.page.error.catalogTitle': 'Erreur de catalogue',
'settings.skills.catalog.page.empty.noSkillsTitle': 'Aucun skill trouvé',
@@ -800,7 +810,6 @@ export const settingsDict = {
'settings.skills.catalog.page.badge.installed': 'installé ({scope})',
'settings.skills.catalog.page.badge.notInstallable': 'non installable',
'settings.skills.catalog.page.badge.unknown': 'inconnu',
'settings.skills.catalog.page.byOwnerPrefix': 'par',
'settings.skills.catalog.page.removeDialog.title': 'Supprimer le catalogue',
'settings.skills.catalog.page.removeDialog.description': 'Êtes-vous sûr de vouloir supprimer ce catalogue ?',
'settings.openchamber.passkeys.title': 'Mots-clés',
@@ -898,16 +898,26 @@ export const settingsDict = {
'settings.skills.catalog.page.mode.manual': '手動',
'settings.skills.catalog.page.mode.external': '外部',
'settings.skills.catalog.page.title': 'スキルカタログ',
'settings.skills.catalog.page.subtitle': 'キュレーションされたリポジトリからすぐ使えるスキルをインストール、または独自のソースを追加。',
'settings.skills.catalog.page.section.sources': 'ソース',
'settings.skills.catalog.page.searchAllPlaceholder': 'すべてのソースのスキルを検索…',
'settings.skills.catalog.page.search.clear': '検索をクリア',
'settings.skills.catalog.page.source.skillsCount': 'スキル数: {count}',
'settings.skills.catalog.page.source.stars': 'スター: {count}',
'settings.skills.catalog.page.source.updated': '更新: {time}',
'settings.skills.catalog.page.source.addOwnTitle': '独自のソースを追加',
'settings.skills.catalog.page.source.addOwnDescription': 'スキルを含む任意の Git リポジトリ',
'settings.skills.catalog.page.source.viewRepo': 'GitHub でリポジトリを開く',
'settings.skills.catalog.page.skill.viewOnGithub': 'GitHub でスキルを表示',
'settings.skills.catalog.page.list.searchTitle': '検索結果',
'settings.skills.catalog.page.section.sourceRepository': 'ソースリポジトリ',
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'ソースを選択',
'settings.skills.catalog.page.actions.refreshTitle': '更新',
'settings.skills.catalog.page.actions.removeCatalogTitle': 'カタログを削除',
'settings.skills.catalog.page.actions.addCatalog': 'カタログを追加',
'settings.skills.catalog.page.actions.removeCatalog': 'カタログを削除',
'settings.skills.catalog.page.actions.loadMoreSkills': 'さらに Skill を読み込む',
'settings.skills.catalog.page.loading.catalog': '読み込み中...',
'settings.skills.catalog.page.loading.skills': 'Skill を読み込み中...',
'settings.skills.catalog.page.loading.more': '読み込み中...',
'settings.skills.catalog.page.foundCount': '{count} 個の Skill が見つかりました',
'settings.skills.catalog.page.error.catalogTitle': 'カタログエラー',
'settings.skills.catalog.page.empty.noSkillsTitle': 'Skill が見つかりません',
@@ -915,7 +925,6 @@ export const settingsDict = {
'settings.skills.catalog.page.badge.installed': 'インストール済み ({scope})',
'settings.skills.catalog.page.badge.notInstallable': 'インストール不可',
'settings.skills.catalog.page.badge.unknown': '不明',
'settings.skills.catalog.page.byOwnerPrefix': '提供',
'settings.skills.catalog.page.removeDialog.title': 'カタログを削除',
'settings.skills.catalog.page.removeDialog.description': 'このカタログを削除してもよろしいですか?',
'settings.openchamber.passkeys.title': 'パスキー',
@@ -865,16 +865,26 @@ export const settingsDict = {
'settings.skills.catalog.page.mode.manual': '수동',
'settings.skills.catalog.page.mode.external': 'External',
'settings.skills.catalog.page.title': '스킬 카탈로그',
'settings.skills.catalog.page.subtitle': '선별된 저장소에서 바로 사용 가능한 스킬을 설치하거나 직접 소스를 추가하세요.',
'settings.skills.catalog.page.section.sources': '소스',
'settings.skills.catalog.page.searchAllPlaceholder': '모든 소스에서 스킬 검색…',
'settings.skills.catalog.page.search.clear': '검색 지우기',
'settings.skills.catalog.page.source.skillsCount': '스킬: {count}개',
'settings.skills.catalog.page.source.stars': '스타: {count}',
'settings.skills.catalog.page.source.updated': '업데이트: {time}',
'settings.skills.catalog.page.source.addOwnTitle': '직접 소스 추가',
'settings.skills.catalog.page.source.addOwnDescription': '스킬이 있는 아무 Git 저장소',
'settings.skills.catalog.page.source.viewRepo': 'GitHub에서 저장소 열기',
'settings.skills.catalog.page.skill.viewOnGithub': 'GitHub에서 스킬 보기',
'settings.skills.catalog.page.list.searchTitle': '검색 결과',
'settings.skills.catalog.page.section.sourceRepository': '카탈로그 저장소',
'settings.skills.catalog.page.field.selectSourcePlaceholder': '저장소 선택',
'settings.skills.catalog.page.actions.refreshTitle': '새로고침',
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Catalog 제거',
'settings.skills.catalog.page.actions.addCatalog': 'Catalog 추가',
'settings.skills.catalog.page.actions.removeCatalog': 'Catalog 제거',
'settings.skills.catalog.page.actions.loadMoreSkills': '스킬 더 불러오기',
'settings.skills.catalog.page.loading.catalog': '로딩 중...',
'settings.skills.catalog.page.loading.skills': '스킬 불러오는 중...',
'settings.skills.catalog.page.loading.more': '로딩 중...',
'settings.skills.catalog.page.foundCount': '스킬 {count}개 발견',
'settings.skills.catalog.page.error.catalogTitle': 'Catalog 오류',
'settings.skills.catalog.page.empty.noSkillsTitle': '스킬을 찾을 수 없습니다',
@@ -882,7 +892,6 @@ export const settingsDict = {
'settings.skills.catalog.page.badge.installed': '설치됨({scope})',
'settings.skills.catalog.page.badge.notInstallable': '설치할 수 없음',
'settings.skills.catalog.page.badge.unknown': '알 수 없음',
'settings.skills.catalog.page.byOwnerPrefix': '작성자',
'settings.skills.catalog.page.removeDialog.title': 'Catalog 제거',
'settings.skills.catalog.page.removeDialog.description': '이 카탈로그를 제거하시겠습니까?',
'settings.openchamber.passkeys.title': 'Passkeys',
@@ -1825,21 +1825,18 @@ export const settingsDict = {
'settings.skills.catalog.installSkill.toast.installFailed': 'Nie udało się zainstalować umiejętności',
'settings.skills.catalog.installSkill.toast.installed': 'Umiejętność została zainstalowana',
'settings.skills.catalog.page.actions.addCatalog': 'Dodaj katalog',
'settings.skills.catalog.page.actions.loadMoreSkills': 'Załaduj więcej umiejętności',
'settings.skills.catalog.page.actions.refreshTitle': 'Odśwież',
'settings.skills.catalog.page.actions.removeCatalog': 'Usuń katalog',
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Usuń katalog',
'settings.skills.catalog.page.badge.installed': 'zainstalowano ({scope})',
'settings.skills.catalog.page.badge.notInstallable': 'nie można zainstalować',
'settings.skills.catalog.page.badge.unknown': 'nieznane',
'settings.skills.catalog.page.byOwnerPrefix': 'autor:',
'settings.skills.catalog.page.empty.noSkillsDescription': 'Spróbuj innego wyszukiwania lub odśwież katalog',
'settings.skills.catalog.page.empty.noSkillsTitle': 'Nie znaleziono umiejętności',
'settings.skills.catalog.page.error.catalogTitle': 'Błąd katalogu',
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Wybierz źródło',
'settings.skills.catalog.page.foundCount': 'Znaleziono {count} umiejętności',
'settings.skills.catalog.page.loading.catalog': 'Ładowanie...',
'settings.skills.catalog.page.loading.more': 'Ładowanie...',
'settings.skills.catalog.page.loading.skills': 'Ładowanie umiejętności...',
'settings.skills.catalog.page.mode.external': 'Zewnętrzny',
'settings.skills.catalog.page.mode.manual': 'Ręczny',
@@ -1847,6 +1844,18 @@ export const settingsDict = {
'settings.skills.catalog.page.removeDialog.title': 'Usuń katalog',
'settings.skills.catalog.page.section.sourceRepository': 'Repozytorium źródłowe',
'settings.skills.catalog.page.title': 'Katalog umiejętności',
'settings.skills.catalog.page.subtitle': 'Instaluj gotowe umiejętności z kuratorowanych repozytoriów lub dodaj własne źródło.',
'settings.skills.catalog.page.section.sources': 'Źródła',
'settings.skills.catalog.page.searchAllPlaceholder': 'Szukaj umiejętności we wszystkich źródłach…',
'settings.skills.catalog.page.search.clear': 'Wyczyść wyszukiwanie',
'settings.skills.catalog.page.source.skillsCount': 'Umiejętności: {count}',
'settings.skills.catalog.page.source.stars': 'Gwiazdki: {count}',
'settings.skills.catalog.page.source.updated': 'Zaktualizowano {time}',
'settings.skills.catalog.page.source.addOwnTitle': 'Dodaj własne źródło',
'settings.skills.catalog.page.source.addOwnDescription': 'Dowolne repozytorium Git z umiejętnościami',
'settings.skills.catalog.page.source.viewRepo': 'Otwórz repozytorium na GitHubie',
'settings.skills.catalog.page.skill.viewOnGithub': 'Zobacz umiejętność na GitHubie',
'settings.skills.catalog.page.list.searchTitle': 'Wyniki wyszukiwania',
'settings.skills.catalog.shared.actions.install': 'Zainstaluj',
'settings.skills.catalog.shared.actions.installing': 'Instalowanie...',
'settings.skills.catalog.shared.actions.scan': 'Skanuj',
@@ -865,16 +865,26 @@ export const settingsDict = {
"settings.skills.catalog.page.mode.manual": "Manual",
"settings.skills.catalog.page.mode.external": "Externo",
"settings.skills.catalog.page.title": "Catálogo de habilidades",
'settings.skills.catalog.page.subtitle': 'Instale skills prontas de repositórios curados ou adicione sua própria fonte.',
'settings.skills.catalog.page.section.sources': 'Fontes',
'settings.skills.catalog.page.searchAllPlaceholder': 'Pesquisar skills em todas as fontes…',
'settings.skills.catalog.page.search.clear': 'Limpar pesquisa',
'settings.skills.catalog.page.source.skillsCount': 'Skills: {count}',
'settings.skills.catalog.page.source.stars': 'Estrelas: {count}',
'settings.skills.catalog.page.source.updated': 'Atualizado {time}',
'settings.skills.catalog.page.source.addOwnTitle': 'Adicionar sua própria fonte',
'settings.skills.catalog.page.source.addOwnDescription': 'Qualquer repositório Git com skills',
'settings.skills.catalog.page.source.viewRepo': 'Abrir repositório no GitHub',
'settings.skills.catalog.page.skill.viewOnGithub': 'Ver skill no GitHub',
'settings.skills.catalog.page.list.searchTitle': 'Resultados da pesquisa',
"settings.skills.catalog.page.section.sourceRepository": "Repositório de origem",
"settings.skills.catalog.page.field.selectSourcePlaceholder": "Selecionar origem",
"settings.skills.catalog.page.actions.refreshTitle": "Atualizar",
"settings.skills.catalog.page.actions.removeCatalogTitle": "Excluir catálogo",
"settings.skills.catalog.page.actions.addCatalog": "Adicionar catálogo",
"settings.skills.catalog.page.actions.removeCatalog": "Excluir catálogo",
"settings.skills.catalog.page.actions.loadMoreSkills": "Carregar mais habilidades",
"settings.skills.catalog.page.loading.catalog": "Carregando...",
"settings.skills.catalog.page.loading.skills": "Carregando habilidades...",
"settings.skills.catalog.page.loading.more": "Carregando...",
"settings.skills.catalog.page.foundCount": "{count} habilidade(es) encontrada(s)",
"settings.skills.catalog.page.error.catalogTitle": "Erro do catálogo",
"settings.skills.catalog.page.empty.noSkillsTitle": "Nenhuma habilidade encontrada",
@@ -882,7 +892,6 @@ export const settingsDict = {
"settings.skills.catalog.page.badge.installed": "instalado ({scope})",
"settings.skills.catalog.page.badge.notInstallable": "não instalável",
"settings.skills.catalog.page.badge.unknown": "desconhecido",
"settings.skills.catalog.page.byOwnerPrefix": "por",
"settings.skills.catalog.page.removeDialog.title": "Excluir catálogo",
"settings.skills.catalog.page.removeDialog.description": "Tem certeza de que deseja excluir este catálogo?",
"settings.openchamber.passkeys.title": "Chaves de acesso",
@@ -865,16 +865,26 @@ export const settingsDict = {
"settings.skills.catalog.page.mode.manual": "Вручну",
"settings.skills.catalog.page.mode.external": "зовнішній",
"settings.skills.catalog.page.title": "Каталог навичок",
'settings.skills.catalog.page.subtitle': 'Встановлюйте готові скіли з курованих репозиторіїв або додайте власне джерело.',
'settings.skills.catalog.page.section.sources': 'Джерела',
'settings.skills.catalog.page.searchAllPlaceholder': 'Пошук скілів у всіх джерелах…',
'settings.skills.catalog.page.search.clear': 'Очистити пошук',
'settings.skills.catalog.page.source.skillsCount': 'Скілів: {count}',
'settings.skills.catalog.page.source.stars': 'Зірок: {count}',
'settings.skills.catalog.page.source.updated': 'Оновлено {time}',
'settings.skills.catalog.page.source.addOwnTitle': 'Додати власне джерело',
'settings.skills.catalog.page.source.addOwnDescription': 'Будь-який git-репозиторій зі скілами',
'settings.skills.catalog.page.source.viewRepo': 'Відкрити репозиторій на GitHub',
'settings.skills.catalog.page.skill.viewOnGithub': 'Переглянути скіл на GitHub',
'settings.skills.catalog.page.list.searchTitle': 'Результати пошуку',
"settings.skills.catalog.page.section.sourceRepository": "Репозиторій вихідного коду",
"settings.skills.catalog.page.field.selectSourcePlaceholder": "Виберіть джерело",
"settings.skills.catalog.page.actions.refreshTitle": "Оновити",
"settings.skills.catalog.page.actions.removeCatalogTitle": "Видалити каталог",
"settings.skills.catalog.page.actions.addCatalog": "Додати каталог",
"settings.skills.catalog.page.actions.removeCatalog": "Видалити каталог",
"settings.skills.catalog.page.actions.loadMoreSkills": "Завантажити додаткові навички",
"settings.skills.catalog.page.loading.catalog": "Завантаження...",
"settings.skills.catalog.page.loading.skills": "Завантаження навичок...",
"settings.skills.catalog.page.loading.more": "Завантаження...",
"settings.skills.catalog.page.foundCount": "Знайдено навички {count}",
"settings.skills.catalog.page.error.catalogTitle": "Помилка каталогу",
"settings.skills.catalog.page.empty.noSkillsTitle": "Навички не знайдено",
@@ -882,7 +892,6 @@ export const settingsDict = {
"settings.skills.catalog.page.badge.installed": "встановлено ({scope})",
"settings.skills.catalog.page.badge.notInstallable": "не встановлюється",
"settings.skills.catalog.page.badge.unknown": "невідомий",
"settings.skills.catalog.page.byOwnerPrefix": "за",
"settings.skills.catalog.page.removeDialog.title": "Видалити каталог",
"settings.skills.catalog.page.removeDialog.description": "Ви впевнені, що хочете видалити цей каталог?",
"settings.openchamber.passkeys.title": "Ключі доступу",
@@ -865,16 +865,26 @@ export const settingsDict = {
'settings.skills.catalog.page.mode.manual': '手动',
'settings.skills.catalog.page.mode.external': '外部',
'settings.skills.catalog.page.title': '技能目录',
'settings.skills.catalog.page.subtitle': '从精选仓库安装现成技能,或添加你自己的来源。',
'settings.skills.catalog.page.section.sources': '来源',
'settings.skills.catalog.page.searchAllPlaceholder': '在所有来源中搜索技能…',
'settings.skills.catalog.page.search.clear': '清除搜索',
'settings.skills.catalog.page.source.skillsCount': '技能数:{count}',
'settings.skills.catalog.page.source.stars': '星标:{count}',
'settings.skills.catalog.page.source.updated': '更新于 {time}',
'settings.skills.catalog.page.source.addOwnTitle': '添加自己的来源',
'settings.skills.catalog.page.source.addOwnDescription': '任何包含技能的 Git 仓库',
'settings.skills.catalog.page.source.viewRepo': '在 GitHub 上打开仓库',
'settings.skills.catalog.page.skill.viewOnGithub': '在 GitHub 上查看技能',
'settings.skills.catalog.page.list.searchTitle': '搜索结果',
'settings.skills.catalog.page.section.sourceRepository': '来源仓库',
'settings.skills.catalog.page.field.selectSourcePlaceholder': '选择来源',
'settings.skills.catalog.page.actions.refreshTitle': '刷新',
'settings.skills.catalog.page.actions.removeCatalogTitle': '移除目录',
'settings.skills.catalog.page.actions.addCatalog': '添加目录',
'settings.skills.catalog.page.actions.removeCatalog': '移除目录',
'settings.skills.catalog.page.actions.loadMoreSkills': '加载更多技能',
'settings.skills.catalog.page.loading.catalog': '加载中...',
'settings.skills.catalog.page.loading.skills': '正在加载技能...',
'settings.skills.catalog.page.loading.more': '加载中...',
'settings.skills.catalog.page.foundCount': '找到 {count} 个技能',
'settings.skills.catalog.page.error.catalogTitle': '目录错误',
'settings.skills.catalog.page.empty.noSkillsTitle': '未找到技能',
@@ -882,7 +892,6 @@ export const settingsDict = {
'settings.skills.catalog.page.badge.installed': '已安装({scope}',
'settings.skills.catalog.page.badge.notInstallable': '不可安装',
'settings.skills.catalog.page.badge.unknown': '未知',
'settings.skills.catalog.page.byOwnerPrefix': '作者',
'settings.skills.catalog.page.removeDialog.title': '移除目录',
'settings.skills.catalog.page.removeDialog.description': '确定要移除此目录吗?',
'settings.openchamber.passkeys.title': 'Passkeys',
@@ -862,16 +862,26 @@ export const settingsDict = {
'settings.skills.catalog.page.mode.manual': '手動',
'settings.skills.catalog.page.mode.external': '外部',
'settings.skills.catalog.page.title': 'Skills 目錄',
'settings.skills.catalog.page.subtitle': '從精選儲存庫安裝現成技能,或新增你自己的來源。',
'settings.skills.catalog.page.section.sources': '來源',
'settings.skills.catalog.page.searchAllPlaceholder': '在所有來源中搜尋技能…',
'settings.skills.catalog.page.search.clear': '清除搜尋',
'settings.skills.catalog.page.source.skillsCount': '技能數:{count}',
'settings.skills.catalog.page.source.stars': '星標:{count}',
'settings.skills.catalog.page.source.updated': '更新於 {time}',
'settings.skills.catalog.page.source.addOwnTitle': '新增自己的來源',
'settings.skills.catalog.page.source.addOwnDescription': '任何包含技能的 Git 儲存庫',
'settings.skills.catalog.page.source.viewRepo': '在 GitHub 上開啟儲存庫',
'settings.skills.catalog.page.skill.viewOnGithub': '在 GitHub 上檢視技能',
'settings.skills.catalog.page.list.searchTitle': '搜尋結果',
'settings.skills.catalog.page.section.sourceRepository': '來源儲存庫',
'settings.skills.catalog.page.field.selectSourcePlaceholder': '選擇來源',
'settings.skills.catalog.page.actions.refreshTitle': '重新整理',
'settings.skills.catalog.page.actions.removeCatalogTitle': '移除目錄',
'settings.skills.catalog.page.actions.addCatalog': '新增目錄',
'settings.skills.catalog.page.actions.removeCatalog': '移除目錄',
'settings.skills.catalog.page.actions.loadMoreSkills': '載入更多 Skills',
'settings.skills.catalog.page.loading.catalog': '載入中...',
'settings.skills.catalog.page.loading.skills': '正在載入 skills...',
'settings.skills.catalog.page.loading.more': '載入中...',
'settings.skills.catalog.page.foundCount': '找到 {count} 個 skill(s)',
'settings.skills.catalog.page.error.catalogTitle': '目錄錯誤',
'settings.skills.catalog.page.empty.noSkillsTitle': '找不到 skills',
@@ -879,7 +889,6 @@ export const settingsDict = {
'settings.skills.catalog.page.badge.installed': '已安裝({scope}',
'settings.skills.catalog.page.badge.notInstallable': '不可安裝',
'settings.skills.catalog.page.badge.unknown': '未知',
'settings.skills.catalog.page.byOwnerPrefix': '作者',
'settings.skills.catalog.page.removeDialog.title': '移除目錄',
'settings.skills.catalog.page.removeDialog.description': '確定要移除此目錄嗎?',
'settings.openchamber.passkeys.title': 'Passkeys',
@@ -1,49 +0,0 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
mock.module('@/lib/opencode/client', () => ({
opencodeClient: {
getDirectory: () => undefined,
},
}));
mock.module('@/stores/useProjectsStore', () => ({
useProjectsStore: {
getState: () => ({
getActiveProject: () => null,
}),
},
}));
mock.module('@/lib/runtime-fetch', () => ({
runtimeFetch: async () => new Response('{}', { status: 500 }),
}));
mock.module('@/stores/useSkillsStore', () => ({
invalidateSkillsLoadCache: () => undefined,
refreshSkillsAfterOpenCodeRestart: async () => undefined,
useSkillsStore: {
getState: () => ({}),
},
}));
mock.module('@/lib/configUpdate', () => ({
startConfigUpdate: () => undefined,
finishConfigUpdate: () => undefined,
updateConfigUpdateMessage: () => undefined,
}));
const { useSkillsCatalogStore } = await import('./useSkillsCatalogStore');
describe('skills catalog ClawHub label', () => {
beforeEach(() => {
useSkillsCatalogStore.setState({
sources: useSkillsCatalogStore.getState().sources,
});
});
test('fallback sources label ClawHub correctly', () => {
const clawhub = useSkillsCatalogStore.getState().sources.find((source) => source.id === 'clawdhub');
expect(clawhub).toBeDefined();
expect(clawhub?.label).toBe('ClawHub');
});
});
+85 -135
View File
@@ -30,11 +30,27 @@ const FALLBACK_SOURCES: SkillsCatalogSource[] = [
sourceType: 'github',
},
{
id: 'clawdhub',
label: 'ClawHub',
description: 'Community skill registry with vector search',
source: 'clawdhub:registry',
sourceType: 'clawdhub',
id: 'openai',
label: 'OpenAI',
description: "OpenAI's curated skills",
source: 'openai/skills',
defaultSubpath: 'skills/.curated',
sourceType: 'github',
},
{
id: 'cursor',
label: 'Cursor',
description: "Cursor's plugin skills",
source: 'cursor/plugins',
defaultSubpath: 'pstack/skills',
sourceType: 'github',
},
{
id: 'mattpocock',
label: 'Matt Pocock',
description: 'Matt Pocock skills collection',
source: 'mattpocock/skills',
sourceType: 'github',
},
];
@@ -42,6 +58,8 @@ const SKILLS_CATALOG_LOAD_CACHE_TTL_MS = 5000;
const DEFAULT_SKILLS_CATALOG_CACHE_KEY = '__default__';
const skillsCatalogLastLoadedAt = new Map<string, number>();
const skillsCatalogLoadInFlight = new Map<string, Promise<boolean>>();
const sourceLoadInFlight = new Map<string, Promise<boolean>>();
let activeSourceLoads = 0;
const getSkillsCatalogCacheKey = (directory: string | null): string => {
return directory?.trim() || DEFAULT_SKILLS_CATALOG_CACHE_KEY;
@@ -71,13 +89,10 @@ export interface SkillsCatalogState {
sources: SkillsCatalogSource[];
itemsBySource: Record<string, SkillsCatalogItem[]>;
selectedSourceId: string | null;
pageInfoBySource: Record<string, { nextCursor?: string | null }>;
loadedSourceIds: Record<string, boolean>;
clawdhubHasMoreBySource: Record<string, boolean>;
isLoadingCatalog: boolean;
isLoadingSource: boolean;
isLoadingMore: boolean;
isScanning: boolean;
isInstalling: boolean;
@@ -91,7 +106,6 @@ export interface SkillsCatalogState {
loadCatalog: (options?: { refresh?: boolean }) => Promise<boolean>;
loadSource: (sourceId: string, options?: { refresh?: boolean }) => Promise<boolean>;
loadMoreClawdHub: () => Promise<boolean>;
scanRepo: (request: SkillsRepoScanRequest) => Promise<SkillsRepoScanResponse>;
installSkills: (request: SkillsInstallRequest, options?: { directory?: string | null }) => Promise<SkillsInstallResponse>;
}
@@ -102,13 +116,10 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
sources: FALLBACK_SOURCES,
itemsBySource: {},
selectedSourceId: FALLBACK_SOURCES[0]?.id ?? null,
pageInfoBySource: {},
loadedSourceIds: {},
clawdhubHasMoreBySource: {},
isLoadingCatalog: false,
isLoadingSource: false,
isLoadingMore: false,
isScanning: false,
isInstalling: false,
@@ -141,9 +152,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
const previous = {
sources: get().sources,
itemsBySource: get().itemsBySource,
pageInfoBySource: get().pageInfoBySource,
loadedSourceIds: get().loadedSourceIds,
clawdhubHasMoreBySource: get().clawdhubHasMoreBySource,
};
let lastError: SkillsCatalogResponse['error'] | null = null;
@@ -168,9 +177,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
const sources = (payload.sources && payload.sources.length > 0) ? payload.sources : previous.sources;
const itemsBySource = options?.refresh ? {} : (get().itemsBySource || {});
const pageInfoBySource = options?.refresh ? {} : (get().pageInfoBySource || {});
const loadedSourceIds = options?.refresh ? {} : (get().loadedSourceIds || {});
const clawdhubHasMoreBySource = options?.refresh ? {} : (get().clawdhubHasMoreBySource || {});
const currentSelected = get().selectedSourceId;
const selectedSourceId =
(currentSelected && sources.some((s) => s.id === currentSelected))
@@ -180,9 +187,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
set({
sources,
itemsBySource,
pageInfoBySource,
loadedSourceIds,
clawdhubHasMoreBySource,
selectedSourceId,
});
@@ -197,9 +202,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
set({
sources: previous.sources,
itemsBySource: previous.itemsBySource,
pageInfoBySource: previous.pageInfoBySource,
loadedSourceIds: previous.loadedSourceIds,
clawdhubHasMoreBySource: previous.clawdhubHasMoreBySource,
lastCatalogError: lastError || { kind: 'unknown', message: 'Failed to load catalog' },
});
@@ -222,136 +225,83 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
return false;
}
// Deduplicate concurrent loads of the same source: the background
// loader effect can restart while a request for this source is
// already in flight.
if (!options?.refresh) {
const inFlight = sourceLoadInFlight.get(sourceId);
if (inFlight) {
return inFlight;
}
}
activeSourceLoads += 1;
set({ isLoadingSource: true, lastCatalogError: null });
try {
const currentDirectory = getRequestDirectory();
const refresh = options?.refresh ? '&refresh=true' : '';
const queryParams = currentDirectory
? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}`
: `?sourceId=${encodeURIComponent(sourceId)}${refresh}`;
const request = (async () => {
try {
const currentDirectory = getRequestDirectory();
const refresh = options?.refresh ? '&refresh=true' : '';
const queryParams = currentDirectory
? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}`
: `?sourceId=${encodeURIComponent(sourceId)}${refresh}`;
const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null;
const hasItems = Array.isArray((payload as SkillsCatalogSourceResponse | null)?.items);
if (!response.ok || (!payload?.ok && !hasItems)) {
const fallback = await runtimeFetch(`/api/config/skills/catalog${queryParams}`, {
const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const fallbackPayload = (await fallback.json().catch(() => null)) as SkillsCatalogResponse | null;
const fallbackItems = fallbackPayload?.itemsBySource?.[sourceId];
if (fallback.ok && fallbackPayload?.ok && Array.isArray(fallbackItems)) {
set((state) => ({
itemsBySource: { ...state.itemsBySource, [sourceId]: fallbackItems },
pageInfoBySource: { ...state.pageInfoBySource, [sourceId]: { nextCursor: null } },
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
clawdhubHasMoreBySource: { ...state.clawdhubHasMoreBySource, [sourceId]: false },
}));
return true;
const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null;
const hasItems = Array.isArray((payload as SkillsCatalogSourceResponse | null)?.items);
if (!response.ok || (!payload?.ok && !hasItems)) {
const fallback = await runtimeFetch(`/api/config/skills/catalog${queryParams}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const fallbackPayload = (await fallback.json().catch(() => null)) as SkillsCatalogResponse | null;
const fallbackItems = fallbackPayload?.itemsBySource?.[sourceId];
if (fallback.ok && fallbackPayload?.ok && Array.isArray(fallbackItems)) {
set((state) => ({
itemsBySource: { ...state.itemsBySource, [sourceId]: fallbackItems },
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
}));
return true;
}
set({
lastCatalogError: payload?.error || { kind: 'unknown', message: `Failed to load source (${response.status})` },
});
return false;
}
const items = payload?.items || [];
set((state) => ({
itemsBySource: { ...state.itemsBySource, [sourceId]: items },
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
}));
return true;
} catch (error) {
set({
lastCatalogError: payload?.error || { kind: 'unknown', message: `Failed to load source (${response.status})` },
lastCatalogError: { kind: 'unknown', message: error instanceof Error ? error.message : String(error) },
});
return false;
}
const items = payload?.items || [];
const nextCursor = payload?.nextCursor ?? null;
set((state) => ({
itemsBySource: { ...state.itemsBySource, [sourceId]: items },
pageInfoBySource: { ...state.pageInfoBySource, [sourceId]: { nextCursor } },
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
clawdhubHasMoreBySource: {
...state.clawdhubHasMoreBySource,
[sourceId]: items.length > 0,
},
}));
return true;
} catch (error) {
set({
lastCatalogError: { kind: 'unknown', message: error instanceof Error ? error.message : String(error) },
});
return false;
} finally {
set({ isLoadingSource: false });
}
},
loadMoreClawdHub: async () => {
const selectedSourceId = get().selectedSourceId;
if (!selectedSourceId) {
return false;
}
const pageInfo = get().pageInfoBySource[selectedSourceId];
const cursor = pageInfo?.nextCursor || null;
set({ isLoadingMore: true });
try {
const currentDirectory = getRequestDirectory();
const parts = [`sourceId=${encodeURIComponent(selectedSourceId)}`];
if (currentDirectory) {
parts.push(`directory=${encodeURIComponent(currentDirectory)}`);
}
if (cursor) {
parts.push(`cursor=${encodeURIComponent(cursor)}`);
}
const queryParams = `?${parts.join('&')}`;
const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null;
if (!response.ok || !payload?.ok) {
return false;
}
const nextCursor = payload.nextCursor ?? null;
const currentItems = get().itemsBySource[selectedSourceId] || [];
const items = payload.items || [];
const merged = new Map(currentItems.map((item) => [`${item.sourceId}:${item.skillDir}`, item]));
let newCount = 0;
for (const item of items) {
const key = `${item.sourceId}:${item.skillDir}`;
if (!merged.has(key)) {
newCount += 1;
} finally {
activeSourceLoads -= 1;
if (activeSourceLoads === 0) {
set({ isLoadingSource: false });
}
merged.set(key, item);
}
})();
const noMore = items.length === 0 || newCount === 0;
set((state) => ({
itemsBySource: {
...state.itemsBySource,
[selectedSourceId]: Array.from(merged.values()),
},
pageInfoBySource: {
...state.pageInfoBySource,
[selectedSourceId]: { nextCursor },
},
clawdhubHasMoreBySource: {
...state.clawdhubHasMoreBySource,
[selectedSourceId]: !noMore,
},
}));
return true;
} catch {
return false;
sourceLoadInFlight.set(sourceId, request);
try {
return await request;
} finally {
set({ isLoadingMore: false });
if (sourceLoadInFlight.get(sourceId) === request) {
sourceLoadInFlight.delete(sourceId);
}
}
},