feat(claude-code): two-step add-marketplace flow with skill confirmation

Add Marketplace used to submit and close the modal immediately, giving
no visibility into what actually got imported (worse now that sync can
take a few seconds for larger repos). It's now two steps: fill in the
repo, click Next, and the modal stays open showing an importing state
while the sync runs, then a scrollable list of every skill that was
actually loaded (or the error, with a way to go back and retry) before
you dismiss it.

Verified live: registering a 53-skill repo through the new flow shows
the full skill list with visibility badges before Done is clicked.
This commit is contained in:
Krrish Dholakia 2026-07-10 21:42:04 -07:00
parent fcd285facd
commit 62547d67f0
2 changed files with 142 additions and 41 deletions

View file

@ -1,7 +1,9 @@
import React, { useState } from "react";
import { Modal, Form, Input, Button } from "antd";
import { registerClaudeCodeMarketplace } from "@/components/networking";
import { Modal, Form, Input, Button, Spin, List, Tag } from "antd";
import { CheckCircleFilled, CloseCircleFilled, ExclamationCircleFilled } from "@ant-design/icons";
import { registerClaudeCodeMarketplace, getClaudeCodePluginsList } from "@/components/networking";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { MarketplaceSource, PluginListItem } from "@/components/claude_code_plugins/types";
interface AddMarketplaceFormProps {
visible: boolean;
@ -15,13 +17,30 @@ interface AddMarketplaceFormValues {
name?: string;
}
type Step = "form" | "importing" | "result";
const AddMarketplaceForm: React.FC<AddMarketplaceFormProps> = ({ visible, onClose, accessToken, onSuccess }) => {
const [form] = Form.useForm();
const [isSubmitting, setIsSubmitting] = useState(false);
const [step, setStep] = useState<Step>("form");
const [marketplace, setMarketplace] = useState<MarketplaceSource | null>(null);
const [loadedSkills, setLoadedSkills] = useState<PluginListItem[]>([]);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const resetAndClose = () => {
form.resetFields();
setStep("form");
setMarketplace(null);
setLoadedSkills([]);
setErrorMessage(null);
onClose();
};
const handleCancel = () => {
form.resetFields();
onClose();
// Once import has started, the marketplace already exists server-side
// regardless of whether this dialog stays open - closing early shouldn't
// look like nothing happened, so still refresh the lists behind it.
if (step !== "form") onSuccess();
resetAndClose();
};
const handleSubmit = async (values: AddMarketplaceFormValues) => {
@ -30,56 +49,137 @@ const AddMarketplaceForm: React.FC<AddMarketplaceFormProps> = ({ visible, onClos
return;
}
setIsSubmitting(true);
setStep("importing");
setErrorMessage(null);
try {
await registerClaudeCodeMarketplace(accessToken, {
const response = await registerClaudeCodeMarketplace(accessToken, {
source: values.source.trim(),
...(values.name?.trim() ? { name: values.name.trim() } : {}),
});
NotificationsManager.success("Marketplace imported successfully");
form.resetFields();
setMarketplace(response.marketplace);
const skillsResponse = await getClaudeCodePluginsList(accessToken, false);
const prefix = `${response.marketplace.name}--`;
setLoadedSkills(skillsResponse.plugins.filter((p: PluginListItem) => p.name.startsWith(prefix)));
onSuccess();
onClose();
setStep("result");
} catch (error) {
console.error("Error registering marketplace:", error);
const reason = error instanceof Error && error.message ? error.message : "Failed to import marketplace";
NotificationsManager.error(`Failed to import marketplace: ${reason}`);
} finally {
setIsSubmitting(false);
setErrorMessage(error instanceof Error && error.message ? error.message : "Failed to import marketplace");
setStep("result");
}
};
return (
<Modal title="Add Marketplace" open={visible} onCancel={handleCancel} footer={null} width={520} className="top-8">
<Form form={form} layout="vertical" onFinish={handleSubmit} className="mt-4">
<Form.Item
label="Repository"
name="source"
rules={[{ required: true, message: "Please enter a repository (org/repo) or URL" }]}
tooltip="A GitHub org/repo (e.g. anthropics/claude-code-marketplace) or a full git URL"
>
<Input placeholder="org/repo or https://github.com/org/repo" className="rounded-lg" />
</Form.Item>
const renderForm = () => (
<Form form={form} layout="vertical" onFinish={handleSubmit} className="mt-4">
<Form.Item
label="Repository"
name="source"
rules={[{ required: true, message: "Please enter a repository (org/repo) or URL" }]}
tooltip="A GitHub org/repo (e.g. anthropics/claude-code-marketplace) or a full git URL"
>
<Input placeholder="org/repo or https://github.com/org/repo" className="rounded-lg" />
</Form.Item>
<Form.Item
label="Name (Optional)"
name="name"
tooltip="Marketplace identifier used to namespace its skills. Defaults to the repository name"
>
<Input placeholder="my-marketplace" className="rounded-lg" />
</Form.Item>
<Form.Item
label="Name (Optional)"
name="name"
tooltip="Marketplace identifier used to namespace its skills. Defaults to the repository name"
>
<Input placeholder="my-marketplace" className="rounded-lg" />
</Form.Item>
<Form.Item className="mb-0 mt-6">
<div className="flex justify-end gap-2">
<Button onClick={handleCancel} disabled={isSubmitting}>
Cancel
</Button>
<Button type="primary" htmlType="submit" loading={isSubmitting}>
{isSubmitting ? "Importing..." : "Add Marketplace"}
<Form.Item className="mb-0 mt-6">
<div className="flex justify-end gap-2">
<Button onClick={handleCancel}>Cancel</Button>
<Button type="primary" htmlType="submit">
Next
</Button>
</div>
</Form.Item>
</Form>
);
const renderImporting = () => (
<div className="flex flex-col items-center justify-center gap-4 py-12">
<Spin size="large" />
<p className="text-gray-600">Importing marketplace and loading its skills</p>
<p className="text-xs text-gray-400">This can take a few seconds for repositories with many skills.</p>
</div>
);
const renderResult = () => {
if (errorMessage) {
return (
<div className="mt-4">
<div className="flex items-start gap-2 rounded-lg bg-red-50 p-4">
<CloseCircleFilled className="mt-0.5 text-red-500" />
<div>
<p className="font-medium text-red-700">Import failed</p>
<p className="text-sm text-red-600">{errorMessage}</p>
</div>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button onClick={resetAndClose}>Close</Button>
<Button type="primary" onClick={() => setStep("form")}>
Back
</Button>
</div>
</Form.Item>
</Form>
</div>
);
}
const skippedCount = marketplace?.skipped_count ?? 0;
return (
<div className="mt-4">
<div className="flex items-start gap-2 rounded-lg bg-green-50 p-4">
<CheckCircleFilled className="mt-0.5 text-green-500" />
<div>
<p className="font-medium text-green-700">
{loadedSkills.length} skill{loadedSkills.length === 1 ? "" : "s"} loaded from &quot;{marketplace?.name}
&quot;
</p>
{skippedCount > 0 && (
<p className="mt-1 flex items-center gap-1 text-sm text-amber-600">
<ExclamationCircleFilled />
{skippedCount} skill{skippedCount === 1 ? "" : "s"} could not be fetched after retries. Use
&quot;Sync now&quot; on the marketplace to try again.
</p>
)}
</div>
</div>
<div className="mt-4 max-h-80 overflow-y-auto rounded-lg border border-gray-200">
<List
size="small"
dataSource={loadedSkills}
renderItem={(skill) => (
<List.Item className="px-4">
<div className="flex w-full items-center justify-between gap-2">
<span className="font-mono text-sm">{skill.name}</span>
<Tag color={skill.enabled ? "green" : "default"}>{skill.enabled ? "public" : "private"}</Tag>
</div>
</List.Item>
)}
/>
</div>
<div className="mt-6 flex justify-end">
<Button type="primary" onClick={resetAndClose}>
Done
</Button>
</div>
</div>
);
};
return (
<Modal title="Add Marketplace" open={visible} onCancel={handleCancel} footer={null} width={560} className="top-8">
{step === "form" && renderForm()}
{step === "importing" && renderImporting()}
{step === "result" && renderResult()}
</Modal>
);
};

View file

@ -102,6 +102,7 @@ export interface MarketplaceSource {
sync_error?: string;
last_synced_at?: string;
plugin_count: number;
skipped_count: number;
created_at: string;
updated_at: string;
}