feat: enhance ClawdHub integration with paging and retry
Add paging support when loading ClawdHub skills Retry on API errors and throttle for ClawdHub requests Load source content on source change and show loading indicators
This commit is contained in:
@@ -147,29 +147,6 @@ const permissionConfigToRuleset = (value: unknown): PermissionRule[] => {
|
||||
return rules;
|
||||
};
|
||||
|
||||
const buildPermissionConfigFromRules = (ruleset: PermissionRule[]): AgentConfig['permission'] | undefined => {
|
||||
const normalized = normalizeRuleset(ruleset);
|
||||
if (normalized.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const grouped: Record<string, Record<string, PermissionAction>> = {};
|
||||
for (const rule of normalized) {
|
||||
(grouped[rule.permission] ||= {})[rule.pattern] = rule.action;
|
||||
}
|
||||
|
||||
const result: Record<string, PermissionConfigValue> = {};
|
||||
for (const [permissionName, patterns] of Object.entries(grouped)) {
|
||||
if (Object.keys(patterns).length === 1 && patterns['*']) {
|
||||
result[permissionName] = patterns['*'];
|
||||
} else {
|
||||
result[permissionName] = patterns;
|
||||
}
|
||||
}
|
||||
|
||||
return result as AgentConfig['permission'];
|
||||
};
|
||||
|
||||
const buildPermissionConfigWithGlobal = (
|
||||
globalAction: PermissionAction,
|
||||
ruleset: PermissionRule[],
|
||||
@@ -201,23 +178,6 @@ const buildPermissionConfigWithGlobal = (
|
||||
return result as AgentConfig['permission'];
|
||||
};
|
||||
|
||||
const buildPermissionDiffConfig = (
|
||||
baselineRules: PermissionRule[],
|
||||
currentRules: PermissionRule[],
|
||||
): AgentConfig['permission'] | undefined => {
|
||||
const baselineMap = buildRuleMap(baselineRules);
|
||||
const currentMap = buildRuleMap(currentRules);
|
||||
const changedRules: PermissionRule[] = [];
|
||||
|
||||
for (const [key, rule] of currentMap.entries()) {
|
||||
const baselineRule = baselineMap.get(key);
|
||||
if (!baselineRule || baselineRule.action !== rule.action) {
|
||||
changedRules.push(rule);
|
||||
}
|
||||
}
|
||||
|
||||
return buildPermissionConfigFromRules(changedRules);
|
||||
};
|
||||
|
||||
export const AgentsPage: React.FC = () => {
|
||||
const { selectedAgentName, getAgentByName, createAgent, updateAgent, agents, agentDraft, setAgentDraft } = useAgentsStore();
|
||||
@@ -324,12 +284,6 @@ export const AgentsPage: React.FC = () => {
|
||||
currentRuleMap.get(buildRuleKey(permissionName, '*'))?.action
|
||||
), [currentRuleMap]);
|
||||
|
||||
const getEffectiveWildcardAction = React.useCallback((permissionName: string): PermissionAction => {
|
||||
if (permissionName === '*') {
|
||||
return globalPermission;
|
||||
}
|
||||
return getWildcardOverride(permissionName) ?? globalPermission;
|
||||
}, [getWildcardOverride, globalPermission]);
|
||||
|
||||
const getPatternRules = React.useCallback((permissionName: string): PermissionRule[] => (
|
||||
permissionRules
|
||||
@@ -348,12 +302,6 @@ export const AgentsPage: React.FC = () => {
|
||||
return Array.from(names).sort((a, b) => a.localeCompare(b));
|
||||
}, [knownPermissionNames]);
|
||||
|
||||
const getFallbackDefaultAction = React.useCallback((permissionName: string): PermissionAction => {
|
||||
if (permissionName === 'doom_loop' || permissionName === 'external_directory') {
|
||||
return 'ask';
|
||||
}
|
||||
return 'allow';
|
||||
}, []);
|
||||
|
||||
const getPermissionSummary = React.useCallback((permissionName: string) => {
|
||||
const defaultAction = permissionName === '*'
|
||||
|
||||
@@ -65,7 +65,13 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
selectedSourceId,
|
||||
setSelectedSource,
|
||||
loadCatalog,
|
||||
loadSource,
|
||||
loadMoreClawdHub,
|
||||
isLoadingCatalog,
|
||||
isLoadingSource,
|
||||
isLoadingMore,
|
||||
loadedSourceIds,
|
||||
clawdhubHasMoreBySource,
|
||||
lastCatalogError,
|
||||
} = useSkillsCatalogStore();
|
||||
|
||||
@@ -79,6 +85,15 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
void loadCatalog();
|
||||
}, [loadCatalog]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedSourceId) {
|
||||
return;
|
||||
}
|
||||
if (!loadedSourceIds[selectedSourceId]) {
|
||||
void loadSource(selectedSourceId);
|
||||
}
|
||||
}, [selectedSourceId, loadedSourceIds, loadSource]);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
if (!selectedSourceId) return [];
|
||||
return itemsBySource[selectedSourceId] || [];
|
||||
@@ -98,6 +113,10 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
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) {
|
||||
@@ -167,8 +186,14 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void loadCatalog({ refresh: true })}
|
||||
disabled={isLoadingCatalog}
|
||||
onClick={() => {
|
||||
if (selectedSourceId) {
|
||||
void loadSource(selectedSourceId, { refresh: true });
|
||||
} else {
|
||||
void loadCatalog({ refresh: true });
|
||||
}
|
||||
}}
|
||||
disabled={isLoadingCatalog || isLoadingSource}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiRefreshLine className="h-4 w-4" />
|
||||
@@ -219,81 +244,99 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{filtered.length === 0 ? (
|
||||
{filtered.length === 0 && !isLoadingSource ? (
|
||||
<div className="py-10 text-center text-muted-foreground">
|
||||
<p className="typography-body">No skills found</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Try a different search or refresh the catalog</p>
|
||||
</div>
|
||||
) : isLoadingSource ? (
|
||||
<div className="py-10 text-center text-muted-foreground">
|
||||
<p className="typography-body">Loading skills…</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((item) => {
|
||||
const installed = item.installed?.isInstalled;
|
||||
const installedScope = item.installed?.scope;
|
||||
<>
|
||||
{filtered.map((item) => {
|
||||
const installed = item.installed?.isInstalled;
|
||||
const installedScope = item.installed?.scope;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.sourceId}:${item.skillDir}`}
|
||||
className="rounded-lg border bg-muted/10 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="typography-ui-label truncate">{item.skillName}</div>
|
||||
{installed ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
installed ({installedScope || 'unknown'})
|
||||
</span>
|
||||
) : null}
|
||||
{!item.installable ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
not installable
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{item.description ? (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 line-clamp-2">{item.description}</div>
|
||||
) : (
|
||||
<div className="typography-micro text-muted-foreground mt-0.5">No description provided</div>
|
||||
)}
|
||||
{item.clawdhub ? (
|
||||
<div className="typography-micro text-muted-foreground mt-1 flex items-center gap-3">
|
||||
{item.clawdhub.owner ? (
|
||||
<span>by {item.clawdhub.owner}</span>
|
||||
) : null}
|
||||
<span className="flex items-center gap-1">
|
||||
<RiDownloadLine className="h-3 w-3" />
|
||||
{item.clawdhub.downloads?.toLocaleString() ?? 0}
|
||||
</span>
|
||||
{(item.clawdhub.stars ?? 0) > 0 ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<RiStarLine className="h-3 w-3" />
|
||||
{item.clawdhub.stars}
|
||||
return (
|
||||
<div
|
||||
key={`${item.sourceId}:${item.skillDir}`}
|
||||
className="rounded-lg border bg-muted/10 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="typography-ui-label truncate">{item.skillName}</div>
|
||||
{installed ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
installed ({installedScope || 'unknown'})
|
||||
</span>
|
||||
) : null}
|
||||
{!item.installable ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
not installable
|
||||
</span>
|
||||
) : null}
|
||||
<span>v{item.clawdhub.version}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{item.warnings?.length ? (
|
||||
<div className="typography-micro text-muted-foreground mt-1">{item.warnings.join(' · ')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{item.description ? (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 line-clamp-2">{item.description}</div>
|
||||
) : (
|
||||
<div className="typography-micro text-muted-foreground mt-0.5">No description provided</div>
|
||||
)}
|
||||
{item.clawdhub ? (
|
||||
<div className="typography-micro text-muted-foreground mt-1 flex items-center gap-3">
|
||||
{item.clawdhub.owner ? (
|
||||
<span>by {item.clawdhub.owner}</span>
|
||||
) : null}
|
||||
<span className="flex items-center gap-1">
|
||||
<RiDownloadLine className="h-3 w-3" />
|
||||
{item.clawdhub.downloads?.toLocaleString() ?? 0}
|
||||
</span>
|
||||
{(item.clawdhub.stars ?? 0) > 0 ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<RiStarLine className="h-3 w-3" />
|
||||
{item.clawdhub.stars}
|
||||
</span>
|
||||
) : null}
|
||||
<span>v{item.clawdhub.version}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{item.warnings?.length ? (
|
||||
<div className="typography-micro text-muted-foreground mt-1">{item.warnings.join(' · ')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={!item.installable}
|
||||
onClick={() => {
|
||||
setInstallItem(item);
|
||||
setInstallDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={!item.installable}
|
||||
onClick={() => {
|
||||
setInstallItem(item);
|
||||
setInstallDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isClawdHubSource && hasMoreClawdHub ? (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void loadMoreClawdHub()}
|
||||
disabled={isLoadingMore || isLoadingSource}
|
||||
>
|
||||
{isLoadingMore ? 'Loading…' : 'Load more'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user