feat: add plugin submission success modal (#3141)

Add the missing plugin submission success state, align plugin and skill success icons with the muted marketplace treatment, and harden pending-publish and public URL fallback behavior.

Validated with real full-stack browser proof, focused tests, maintainer review, and all required checks green. Vercel remains the expected contributor authorization failure.

Co-authored-by: Nancy <nancymxgao@gmail.com>
Co-authored-by: vyctorbrzezowski <krzyszchweski@gmail.com>
This commit is contained in:
Nancy
2026-07-17 23:08:26 -03:00
committed by GitHub
co-authored by vyctorbrzezowski
parent db3b3fe920
commit aaa73625ed
11 changed files with 805 additions and 51 deletions
+307 -15
View File
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { DocsLinks } from "clawhub-schema";
import { getFunctionName } from "convex/server";
import { createElement } from "react";
@@ -33,6 +33,7 @@ vi.mock("sonner", () => ({
const generateUploadUrl = vi.fn();
const publishRelease = vi.fn();
const fetchMock = vi.fn();
const writeTextMock = vi.fn();
const useAuthStatusMock = vi.fn();
const useQueryMock = vi.fn();
const useSearchMock = vi.fn();
@@ -78,6 +79,38 @@ function makeCodePluginPackageJson(overrides: Record<string, unknown>) {
});
}
function uploadCodePluginPackage(
packageJsonOverrides: Record<string, unknown>,
directory = "demo-plugin",
) {
const packageJson = withRelativePath(
new File([makeCodePluginPackageJson(packageJsonOverrides)], "package.json", {
type: "application/json",
}),
`${directory}/package.json`,
);
const manifest = withRelativePath(
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
`${directory}/openclaw.plugin.json`,
);
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest] } });
}
function makeVintageAyuMembership(
overrides: { kind?: "user" | "org"; role?: "owner" | "publisher" } = {},
) {
return {
publisher: {
_id: "publishers:vintageayu",
handle: "vintageayu",
displayName: "VintageAyu",
kind: overrides.kind ?? "user",
image: "/clawd-logo.png",
},
role: overrides.role ?? "owner",
};
}
function getFileInput() {
const input = document.querySelector('input[type="file"]');
if (!(input instanceof HTMLInputElement)) throw new Error("Missing file input");
@@ -102,6 +135,7 @@ describe("plugins publish route", () => {
generateUploadUrl.mockReset();
publishRelease.mockReset();
fetchMock.mockReset();
writeTextMock.mockReset();
useAuthStatusMock.mockReset();
useQueryMock.mockReset();
useSearchMock.mockReset();
@@ -125,18 +159,7 @@ describe("plugins publish route", () => {
if (args === "skip") return undefined;
const name = fn ? getFunctionName(fn as Parameters<typeof getFunctionName>[0]) : "";
if (name !== "publishers:listMine") return null;
return [
{
publisher: {
_id: "publishers:vintageayu",
handle: "vintageayu",
displayName: "VintageAyu",
kind: "user",
image: "/clawd-logo.png",
},
role: "owner",
},
];
return [makeVintageAyuMembership()];
});
generateUploadUrl.mockResolvedValue("https://upload.local");
publishRelease.mockResolvedValue({ ok: true, packageId: "pkg:1", releaseId: "rel:1" });
@@ -151,6 +174,12 @@ describe("plugins publish route", () => {
configurable: true,
writable: true,
});
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: writeTextMock.mockResolvedValue(undefined),
},
});
});
afterEach(() => {
@@ -319,6 +348,163 @@ describe("plugins publish route", () => {
});
});
it("shows the submitted plugin modal with plugin identity and canonical actions", async () => {
useSearchMock.mockReturnValue({
ownerHandle: "@VintageAyu",
name: undefined,
displayName: undefined,
family: undefined,
nextVersion: undefined,
sourceRepo: undefined,
});
renderPublishRoute();
uploadCodePluginPackage({
name: "demo-plugin",
displayName: "Demo Plugin",
version: "1.2.3",
repository: "https://github.com/openclaw/demo-plugin.git",
});
await waitFor(() => {
expect(screen.getByDisplayValue("demo-plugin")).toBeTruthy();
});
fireEvent.change(screen.getByPlaceholderText("Full commit SHA"), {
target: { value: "abc123" },
});
fireEvent.click(screen.getByRole("button", { name: "Publish plugin" }));
expect(await screen.findByRole("heading", { name: "Plugin submitted" })).toBeTruthy();
const submittedDialog = within(screen.getByRole("dialog"));
expect(submittedDialog.getByText("Your plugin is under review")).toBeTruthy();
expect(submittedDialog.getByText("Demo Plugin")).toBeTruthy();
expect(submittedDialog.getByText("VintageAyu")).toBeTruthy();
expect(submittedDialog.getByText("@vintageayu")).toBeTruthy();
const pluginLink = screen.getByRole("link", {
name: "clawhub.ai/vintageayu/plugins/demo-plugin",
});
expect(pluginLink.getAttribute("href")).toBe(
"https://clawhub.ai/vintageayu/plugins/demo-plugin",
);
fireEvent.click(screen.getByRole("button", { name: "Copy plugin link" }));
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith(
"https://clawhub.ai/vintageayu/plugins/demo-plugin",
);
});
const viewPlugin = screen.getByRole("link", { name: "View plugin" });
expect(viewPlugin.getAttribute("href")).toBe("/vintageayu/plugins/demo-plugin");
expect(publishRelease).toHaveBeenCalledWith({
payload: expect.objectContaining({ ownerHandle: "vintageayu" }),
});
expect(screen.queryByRole("link", { name: /Share on Discord/i })).toBeNull();
expect(screen.queryByRole("link", { name: /Share on Twitter/i })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await waitFor(() => {
expect(screen.queryByRole("heading", { name: "Plugin submitted" })).toBeNull();
});
});
it("keeps publish disabled until a publisher identity resolves", async () => {
useQueryMock.mockImplementation((fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
const name = fn ? getFunctionName(fn as Parameters<typeof getFunctionName>[0]) : "";
if (name === "publishers:listMine") return undefined;
return null;
});
renderPublishRoute();
uploadCodePluginPackage({ name: "demo-plugin", version: "1.0.0" });
expect(await screen.findByText("Loading publishing identities…")).toBeTruthy();
expect(
screen.getByRole("button", { name: "Publish plugin" }).getAttribute("disabled"),
).not.toBeNull();
expect(publishRelease).not.toHaveBeenCalled();
});
it("keeps publish disabled when the requested publisher is not available", async () => {
useSearchMock.mockReturnValue({
ownerHandle: "not-a-member",
name: undefined,
displayName: undefined,
family: undefined,
nextVersion: undefined,
sourceRepo: undefined,
});
renderPublishRoute();
uploadCodePluginPackage({ name: "demo-plugin", version: "1.0.0" });
await waitFor(() => {
expect(screen.getByPlaceholderText("Full commit SHA").getAttribute("disabled")).toBeNull();
});
fireEvent.change(screen.getByPlaceholderText("Full commit SHA"), {
target: { value: "abc123" },
});
expect(screen.getByText("Select an available publisher to publish.")).toBeTruthy();
expect(
screen.getByRole("button", { name: "Publish plugin" }).getAttribute("disabled"),
).not.toBeNull();
expect(publishRelease).not.toHaveBeenCalled();
});
it("skips the package-page lookup for backend-reserved package names", async () => {
let getByNameCalls = 0;
useQueryMock.mockImplementation((fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
const name = fn ? getFunctionName(fn as Parameters<typeof getFunctionName>[0]) : "";
if (name === "packages:getByName") {
getByNameCalls += 1;
throw new Error("Reserved package names must not be queried");
}
if (name === "publishers:listMine") {
return [makeVintageAyuMembership()];
}
return null;
});
renderPublishRoute();
uploadCodePluginPackage({ name: "publish", version: "1.0.0" }, "publish");
expect(await screen.findByDisplayValue("publish")).toBeTruthy();
expect(getByNameCalls).toBe(0);
});
it("keeps an existing-plugin publish disabled until its context resolves", () => {
useSearchMock.mockReturnValue({
ownerHandle: "vintageayu",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
nextVersion: "1.2.4",
sourceRepo: "openclaw/demo-plugin",
});
useQueryMock.mockImplementation((fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
const name = fn ? getFunctionName(fn as Parameters<typeof getFunctionName>[0]) : "";
if (name === "packages:getManageContext") return undefined;
if (name === "publishers:listMine") {
return [makeVintageAyuMembership()];
}
return null;
});
renderPublishRoute();
expect(screen.getByText("Loading plugin details…")).toBeTruthy();
expect(
screen.getByRole("button", { name: "Publish plugin" }).getAttribute("disabled"),
).not.toBeNull();
expect(publishRelease).not.toHaveBeenCalled();
});
it("prefills and preserves catalog metadata when publishing a new plugin version", async () => {
useSearchMock.mockReturnValue({
ownerHandle: "vintageayu",
@@ -343,7 +529,9 @@ describe("plugins publish route", () => {
suggestedCategories: [],
};
}
if (name === "publishers:listMine") return [];
if (name === "publishers:listMine") {
return [makeVintageAyuMembership()];
}
return null;
});
@@ -418,7 +606,9 @@ describe("plugins publish route", () => {
suggestedCategories: [],
};
}
if (name === "publishers:listMine") return [];
if (name === "publishers:listMine") {
return [makeVintageAyuMembership()];
}
return null;
});
@@ -884,6 +1074,108 @@ describe("plugins publish route", () => {
});
expect(screen.queryByText(/Running TruffleHog and ClawScan/i)).toBeNull();
expect(screen.queryByText("Publishing release...")).toBeNull();
expect(screen.queryByRole("heading", { name: "Plugin submitted" })).toBeNull();
});
it("shows the submitted modal for a staged version of an existing plugin", async () => {
useSearchMock.mockReturnValue({
ownerHandle: "vintageayu",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
nextVersion: "1.2.4",
sourceRepo: "openclaw/demo-plugin",
});
useQueryMock.mockImplementation((fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
const name = fn ? getFunctionName(fn as Parameters<typeof getFunctionName>[0]) : "";
if (name === "packages:getManageContext") {
return {
package: { name: "demo-plugin", displayName: "Demo Plugin" },
latestRelease: { version: "1.2.3" },
suggestedCategories: [],
};
}
if (name === "publishers:listMine") {
return [makeVintageAyuMembership()];
}
return null;
});
publishRelease.mockResolvedValueOnce({
ok: true,
status: "pending",
attemptId: "publishAttempts:2",
packageName: "demo-plugin",
version: "1.2.4",
});
renderPublishRoute();
uploadCodePluginPackage({ name: "demo-plugin", version: "1.2.4" });
await waitFor(() => {
expect(screen.getByPlaceholderText("Full commit SHA").getAttribute("disabled")).toBeNull();
});
fireEvent.change(screen.getByPlaceholderText("Full commit SHA"), {
target: { value: "abc123" },
});
fireEvent.click(screen.getByRole("button", { name: "Publish plugin" }));
expect(await screen.findByRole("heading", { name: "Plugin submitted" })).toBeTruthy();
expect(screen.getByRole("link", { name: "View plugin" }).getAttribute("href")).toBe(
"/vintageayu/plugins/demo-plugin",
);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.getByText("Publish received. Security checks are running.")).toBeTruthy();
expect(
screen.getByRole("button", { name: "Publish plugin" }).getAttribute("disabled"),
).not.toBeNull();
});
it("shows a canonical submitted modal for an existing plugin uploaded from the generic route", async () => {
useQueryMock.mockImplementation((fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
const name = fn ? getFunctionName(fn as Parameters<typeof getFunctionName>[0]) : "";
if (name === "packages:getByName") {
return {
package: { name: "demo-plugin", displayName: "Demo Plugin" },
latestRelease: { version: "1.2.3" },
owner: { handle: "vintageayu" },
};
}
if (name === "publishers:listMine") {
return [makeVintageAyuMembership({ kind: "org", role: "publisher" })];
}
return null;
});
publishRelease.mockResolvedValueOnce({
ok: true,
status: "pending",
attemptId: "publishAttempts:3",
packageName: "demo-plugin",
version: "1.2.4",
});
renderPublishRoute();
uploadCodePluginPackage({
name: "Demo-Plugin",
displayName: "Demo Plugin",
version: "1.2.4",
repository: "https://github.com/openclaw/demo-plugin.git",
});
await waitFor(() => {
expect(screen.getByPlaceholderText("Full commit SHA").getAttribute("disabled")).toBeNull();
});
fireEvent.change(screen.getByPlaceholderText("Full commit SHA"), {
target: { value: "abc123" },
});
fireEvent.click(screen.getByRole("button", { name: "Publish plugin" }));
expect(await screen.findByRole("heading", { name: "Plugin submitted" })).toBeTruthy();
expect(screen.getByRole("link", { name: "View plugin" }).getAttribute("href")).toBe(
"/vintageayu/plugins/demo-plugin",
);
expect(
screen.getByRole("link", { name: "clawhub.ai/vintageayu/plugins/demo-plugin" }),
).toBeTruthy();
});
it("warns when README references relative image paths but no source repo/commit is set", async () => {
+10
View File
@@ -71,4 +71,14 @@ describe("MarketplaceIcon", () => {
expect(glyph?.classList.contains("lucide-package")).toBe(true);
expect(glyph?.classList.contains("lucide-slash")).toBe(false);
});
it("exposes a muted treatment for neutral marketplace contexts", () => {
const { container } = render(
<MarketplaceIcon kind="plugin" label="Muted Plugin" tone="muted" />,
);
expect(
container.querySelector(".marketplace-icon")?.classList.contains("marketplace-icon-muted"),
).toBe(true);
});
});
+8 -4
View File
@@ -20,6 +20,7 @@ type MarketplaceIconProps = {
summary?: string | null;
} | null;
size?: "xs" | "sm" | "md";
tone?: "default" | "muted";
};
const TONES = [
@@ -42,6 +43,7 @@ export function MarketplaceIcon({
categorySlug,
skill,
size = "sm",
tone = "default",
}: MarketplaceIconProps) {
const [failedImageUrl, setFailedImageUrl] = useState<string | null>(null);
useEffect(() => {
@@ -56,16 +58,18 @@ export function MarketplaceIcon({
: kind === "plugin" && pluginCategory
? (getCategoryIconComponent(pluginCategory.icon) ?? MARKETPLACE_KIND_ICONS.plugin)
: MARKETPLACE_KIND_ICONS[kind];
const tone = hashTone(label);
const hashedTone = hashTone(label);
const visibleImageUrl = imageUrl && failedImageUrl !== imageUrl ? imageUrl : null;
return (
<span
className={`marketplace-icon marketplace-icon-${kind} marketplace-icon-${size}`}
className={`marketplace-icon marketplace-icon-${kind} marketplace-icon-${size}${
tone === "muted" ? " marketplace-icon-muted" : ""
}`}
style={
{
"--marketplace-icon-accent": tone.accent,
"--marketplace-icon-wash": tone.wash,
"--marketplace-icon-accent": hashedTone.accent,
"--marketplace-icon-wash": hashedTone.wash,
} as CSSProperties
}
aria-hidden="true"
@@ -0,0 +1,99 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PluginPublishSubmittedDialog } from "./PluginPublishSubmittedDialog";
const writeTextMock = vi.fn();
function renderDialog(overrides: Partial<Parameters<typeof PluginPublishSubmittedDialog>[0]> = {}) {
return render(
<PluginPublishSubmittedDialog
isOpen
plugin={{
name: "Demo Plugin",
path: "/vintageayu/plugins/demo-plugin",
publisher: { displayName: "VintageAyu", handle: "vintageayu" },
}}
onDismiss={vi.fn()}
{...overrides}
/>,
);
}
describe("PluginPublishSubmittedDialog", () => {
beforeEach(() => {
vi.unstubAllEnvs();
writeTextMock.mockReset();
writeTextMock.mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: writeTextMock },
});
});
it("moves focus into the dialog instead of a secondary action", async () => {
renderDialog();
const dialog = screen.getByRole("dialog");
await waitFor(() => {
expect(document.activeElement).toBe(dialog);
});
expect(screen.getByRole("button", { name: "Copy plugin link" })).not.toBe(
document.activeElement,
);
expect(document.querySelector(".marketplace-icon-muted")).toBeTruthy();
});
it.each(["http://127.0.0.1:3030", "http://localhost:3030", "http://[::1]:3030"])(
"uses the public ClawHub URL instead of local dev origin %s",
(localOrigin) => {
vi.stubEnv("VITE_SITE_URL", localOrigin);
renderDialog();
const pluginLink = screen.getByRole("link", {
name: "clawhub.ai/vintageayu/plugins/demo-plugin",
});
expect(pluginLink.getAttribute("href")).toBe(
"https://clawhub.ai/vintageayu/plugins/demo-plugin",
);
},
);
it("copies the canonical plugin link and confirms success", async () => {
renderDialog();
fireEvent.click(screen.getByRole("button", { name: "Copy plugin link" }));
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith(
"https://clawhub.ai/vintageayu/plugins/demo-plugin",
);
});
expect(await screen.findByRole("button", { name: "Copied plugin link" })).toBeTruthy();
expect(screen.getByText("Copied")).toBeTruthy();
});
it("shows clipboard failures and allows retry", async () => {
writeTextMock.mockRejectedValueOnce(new Error("Clipboard unavailable"));
renderDialog();
fireEvent.click(screen.getByRole("button", { name: "Copy plugin link" }));
expect(await screen.findByRole("button", { name: "Plugin link copy failed" })).toBeTruthy();
expect(screen.getByText("Copy failed")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Plugin link copy failed" }));
expect(await screen.findByRole("button", { name: "Copied plugin link" })).toBeTruthy();
});
it("dismisses from the dialog close control", () => {
const onDismiss = vi.fn();
renderDialog({ onDismiss });
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,212 @@
import { ArrowRight, Check, Code2, Copy, FileText, Package, Wrench } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { getPublicClawHubSiteUrl } from "../lib/site";
import { copyText } from "./InstallCopyButton";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { Button } from "./ui/button";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./ui/dialog";
type CopyState = "idle" | "copied" | "failed";
export type SubmittedPlugin = {
name: string;
path: string;
publisher: {
displayName?: string | null;
handle?: string | null;
} | null;
};
type PluginPublishSubmittedDialogProps = {
isOpen: boolean;
plugin: SubmittedPlugin;
onDismiss: () => void;
};
function buildAbsolutePluginUrl(pluginPath: string) {
return new URL(pluginPath, getPublicClawHubSiteUrl()).toString();
}
export function PluginPublishSubmittedDialog({
isOpen,
plugin,
onDismiss,
}: PluginPublishSubmittedDialogProps) {
const { name: pluginName, path: pluginPath, publisher } = plugin;
const [copyState, setCopyState] = useState<CopyState>("idle");
const [dismissed, setDismissed] = useState(false);
const hasDismissedRef = useRef(false);
const dialogContentRef = useRef<HTMLDivElement | null>(null);
const pluginUrl = useMemo(() => buildAbsolutePluginUrl(pluginPath), [pluginPath]);
const compactPluginUrl = useMemo(() => pluginUrl.replace(/^https?:\/\//, ""), [pluginUrl]);
const publisherDisplayName = publisher?.displayName?.trim() || null;
const publisherHandle = publisher?.handle?.trim() || null;
const publisherLabel = publisherDisplayName || (publisherHandle ? `@${publisherHandle}` : null);
const copyButtonLabel =
copyState === "copied"
? "Copied plugin link"
: copyState === "failed"
? "Plugin link copy failed"
: "Copy plugin link";
const copyButtonText =
copyState === "copied" ? "Copied" : copyState === "failed" ? "Copy failed" : "Copy link";
const CopyButtonIcon = copyState === "copied" ? Check : Copy;
useEffect(() => {
if (isOpen) {
setCopyState("idle");
setDismissed(false);
hasDismissedRef.current = false;
}
}, [isOpen]);
function dismiss() {
if (hasDismissedRef.current) return;
hasDismissedRef.current = true;
setDismissed(true);
onDismiss();
}
async function copyPluginLink() {
try {
const didCopy = await copyText(pluginUrl);
setCopyState(didCopy ? "copied" : "failed");
} catch {
setCopyState("failed");
}
}
return (
<Dialog
open={isOpen && !dismissed}
onOpenChange={(open) => {
if (!open) dismiss();
}}
>
<DialogContent
ref={dialogContentRef}
tabIndex={-1}
onOpenAutoFocus={(event) => {
event.preventDefault();
dialogContentRef.current?.focus({ preventScroll: true });
}}
onEscapeKeyDown={dismiss}
onInteractOutside={dismiss}
className="[--publish-accent:var(--oc-status-success-fg)] [display:block] w-[min(calc(100vw-2rem),620px)] overflow-hidden rounded-[var(--oc-radius-surface)] border-[color:var(--oc-border-subtle)] bg-[color:var(--oc-bg-elevated)] p-0 shadow-[var(--oc-shadow-lg)] focus:outline-none sm:p-0"
style={{ display: "block" }}
>
<div className="relative w-full overflow-hidden">
<div
className="pointer-events-none absolute inset-x-[9%] top-0 z-20 h-px bg-[linear-gradient(90deg,transparent_0%,color-mix(in_srgb,var(--publish-accent)_28%,transparent)_20%,color-mix(in_srgb,var(--publish-accent)_72%,transparent)_50%,color-mix(in_srgb,var(--publish-accent)_28%,transparent)_80%,transparent_100%)]"
aria-hidden="true"
/>
<div
className="pointer-events-none absolute inset-x-0 top-0 h-44 bg-[radial-gradient(70%_72%_at_50%_0%,color-mix(in_srgb,var(--publish-accent)_11%,transparent)_0%,color-mix(in_srgb,var(--publish-accent)_5%,transparent)_42%,transparent_82%)]"
aria-hidden="true"
/>
<div className="relative flex flex-col gap-4 p-5 pt-6 pb-5 sm:p-7 sm:pt-8 sm:pb-6">
<div className="relative overflow-hidden rounded-[var(--radius-md)] px-6 py-5 text-center sm:px-10">
<div
className="pointer-events-none absolute inset-0 hidden overflow-hidden rounded-[inherit] text-[color:var(--ink-soft)] sm:block"
aria-hidden="true"
>
<FileText
className="absolute left-[9%] top-[22%] h-5 w-5 -rotate-12 opacity-18"
strokeWidth={1.8}
/>
<Code2
className="absolute left-[16%] bottom-[20%] h-5 w-5 rotate-8 opacity-14"
strokeWidth={1.8}
/>
<Wrench
className="absolute right-[10%] top-[23%] h-5 w-5 rotate-12 opacity-16"
strokeWidth={1.8}
/>
<Package
className="absolute right-[16%] bottom-[20%] h-5 w-5 -rotate-10 opacity-14"
strokeWidth={1.8}
/>
</div>
<div className="relative">
<DialogTitle className="inline-flex items-center justify-center gap-2 text-[1.35rem] leading-tight sm:text-[1.45rem]">
Plugin submitted
</DialogTitle>
<DialogDescription className="mx-auto mt-1.5 max-w-[31rem] text-[0.8125rem] leading-[1.4] [text-wrap:balance]">
Your plugin is under review
</DialogDescription>
</div>
</div>
<div>
<div className="relative z-10 rounded-t-[var(--radius-md)] rounded-b-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:color-mix(in_srgb,var(--surface-muted)_78%,var(--surface))] px-3.5 pb-3.5 pt-3">
<div className="flex min-w-0 items-center gap-2.5">
<MarketplaceIcon kind="plugin" label={pluginName} tone="muted" />
<div className="min-w-0 flex-1">
<p className="truncate text-[0.8125rem] font-bold text-[color:var(--ink)]">
{pluginName}
</p>
{publisherLabel ? (
<div className="mt-1.5 flex min-w-0 items-center gap-1.5 text-xs text-[color:var(--ink-soft)]">
<span className="min-w-0 truncate font-medium">{publisherLabel}</span>
{publisherDisplayName && publisherHandle ? (
<>
<span
className="shrink-0 leading-none text-[color:color-mix(in_srgb,var(--ink-soft)_66%,var(--surface))]"
aria-hidden="true"
>
·
</span>
<span className="shrink-0 text-[color:color-mix(in_srgb,var(--ink-soft)_82%,var(--surface))]">
@{publisherHandle}
</span>
</>
) : null}
</div>
) : null}
</div>
</div>
</div>
<div className="-mt-2 flex min-w-0 items-center gap-2 rounded-b-[var(--oc-radius-surface)] border border-t-0 border-[color:color-mix(in_srgb,var(--oc-border-subtle)_74%,transparent)] bg-[color:var(--oc-bg-surface)] px-3.5 pb-2 pt-4">
<div
className="h-2 w-2 shrink-0 rounded-full bg-[color:color-mix(in_srgb,var(--publish-accent)_42%,transparent)]"
aria-hidden="true"
/>
<a
href={pluginUrl}
target="_blank"
rel="noreferrer"
className="block min-w-0 flex-1 truncate text-xs font-medium text-[color:color-mix(in_srgb,var(--ink-soft)_68%,var(--surface))] !no-underline hover:!no-underline hover:text-[color:var(--ink-soft)]"
>
{compactPluginUrl}
</a>
<button
type="button"
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-[var(--radius-sm)] px-2 text-xs font-semibold text-[color:var(--ink-soft)] transition hover:bg-[color:var(--surface-muted)] hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--publish-accent)]/30"
aria-label={copyButtonLabel}
onClick={() => void copyPluginLink()}
>
<CopyButtonIcon className="h-3.5 w-3.5" aria-hidden="true" />
<span aria-live="polite">{copyButtonText}</span>
</button>
</div>
</div>
<div className="mt-2 flex justify-end">
<Button
asChild
className="min-h-0 border-transparent bg-transparent p-0 text-[color:var(--ink-soft)] hover:not-disabled:border-transparent hover:not-disabled:bg-transparent hover:not-disabled:text-[color:var(--ink)]"
>
<a href={pluginPath}>
View plugin
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</a>
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -56,6 +56,7 @@ describe("SkillPublishSuccessDialog", () => {
expect(screen.getByText("Developer tools")).toBeTruthy();
expect(screen.getByText("#skills")).toBeTruthy();
expect(screen.getByText("Friends of the Crustacean 🦞🤝")).toBeTruthy();
expect(document.querySelector(".marketplace-icon-muted")).toBeTruthy();
const discordLink = screen.getByRole("link", { name: /Share on Discord/i });
expect(discordLink.getAttribute("href")).toBe(OPENCLAW_SKILLS_DISCORD_URL);
+2 -15
View File
@@ -10,7 +10,7 @@ import {
} from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { getClawHubSiteUrl } from "../lib/site";
import { getPublicClawHubSiteUrl } from "../lib/site";
import { cn } from "../lib/utils";
import { copyText } from "./InstallCopyButton";
import { MarketplaceIcon } from "./MarketplaceIcon";
@@ -19,9 +19,6 @@ import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./ui/dial
export const OPENCLAW_SKILLS_DISCORD_URL =
"https://discord.com/channels/1456350064065904867/1456891440897724637";
const PUBLIC_CLAWHUB_SITE_URL = "https://clawhub.ai";
const LOCAL_SHARE_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"]);
type CopyState = "idle" | "copied" | "failed";
type SkillPublishSuccessDialogProps = {
@@ -48,17 +45,6 @@ type SkillPublishSuccessDialogProps = {
onDismiss: () => void;
};
function getPublicClawHubSiteUrl() {
const configured = getClawHubSiteUrl();
try {
const hostname = new URL(configured).hostname;
if (LOCAL_SHARE_HOSTS.has(hostname)) return PUBLIC_CLAWHUB_SITE_URL;
} catch {
return PUBLIC_CLAWHUB_SITE_URL;
}
return configured;
}
function buildAbsoluteSkillUrl(skillPath: string) {
return new URL(skillPath, getPublicClawHubSiteUrl()).toString();
}
@@ -199,6 +185,7 @@ export function SkillPublishSuccessDialog({
label={displayName}
icon={skill?.icon}
skill={skill}
tone="muted"
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center justify-between gap-3">
+13 -1
View File
@@ -1,7 +1,7 @@
/* @vitest-environment node */
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getClawHubSiteUrl, normalizeClawHubSiteOrigin } from "./site";
import { getClawHubSiteUrl, getPublicClawHubSiteUrl, normalizeClawHubSiteOrigin } from "./site";
function withServerEnv<T>(values: Record<string, string | undefined>, run: () => T): T {
const previous = new Map<string, string | undefined>();
@@ -62,4 +62,16 @@ describe("site helpers", () => {
expect(getClawHubSiteUrl()).toBe("https://clawhub.ai");
});
});
it("keeps shareable URLs public during local development", () => {
withServerEnv({ VITE_SITE_URL: "http://localhost:3030" }, () => {
expect(getPublicClawHubSiteUrl()).toBe("https://clawhub.ai");
});
withServerEnv({ VITE_SITE_URL: "https://example.com" }, () => {
expect(getPublicClawHubSiteUrl()).toBe("https://example.com");
});
withServerEnv({ VITE_SITE_URL: "file:///tmp/clawhub" }, () => {
expect(getPublicClawHubSiteUrl()).toBe("https://clawhub.ai");
});
});
});
+12
View File
@@ -2,6 +2,7 @@ import { getRuntimeEnv } from "./runtimeEnv";
const DEFAULT_CLAWHUB_SITE_URL = "https://clawhub.ai";
const LEGACY_CLAWDHUB_HOSTS = new Set(["clawdhub.com", "www.clawdhub.com", "auth.clawdhub.com"]);
const LOCAL_SITE_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"]);
export const SITE_NAME = "ClawHub";
export const SITE_DESCRIPTION = "ClawHub — a fast skill registry for agents, with vector search.";
@@ -22,3 +23,14 @@ export function normalizeClawHubSiteOrigin(value?: string | null) {
export function getClawHubSiteUrl() {
return normalizeClawHubSiteOrigin(getRuntimeEnv("VITE_SITE_URL")) ?? DEFAULT_CLAWHUB_SITE_URL;
}
export function getPublicClawHubSiteUrl() {
const configured = getClawHubSiteUrl();
try {
return LOCAL_SITE_HOSTS.has(new URL(configured).hostname)
? DEFAULT_CLAWHUB_SITE_URL
: configured;
} catch {
return DEFAULT_CLAWHUB_SITE_URL;
}
}
+132 -16
View File
@@ -18,6 +18,10 @@ import {
PackageSourceChooser,
type PackagePickSource,
} from "../../components/PackageSourceChooser";
import {
PluginPublishSubmittedDialog,
type SubmittedPlugin,
} from "../../components/PluginPublishSubmittedDialog";
import {
PublisherOwnerSelect,
type PublisherOwnerMembership,
@@ -41,6 +45,7 @@ import {
normalizePackageUploadFiles,
} from "../../lib/packageUpload";
import { derivePluginPrefill, listPrefilledFields } from "../../lib/pluginPublishPrefill";
import { buildPluginDetailHref, displayPluginPackageName } from "../../lib/pluginRoutes";
import { buildReadmeAssetBaseUrl } from "../../lib/readmeAssetBaseUrl";
import { expandFilesWithReport } from "../../lib/uploadFiles";
import { useAuthStatus } from "../../lib/useAuthStatus";
@@ -70,6 +75,29 @@ const apiRefs = api as unknown as {
const SHOW_CLAWPACK_ONBOARDING_BANNER = false;
const PLUGIN_PUBLISHING_GUIDE_URL = "https://docs.openclaw.ai/clawhub/publishing#plugins";
function normalizePublisherHandle(handle: string) {
return handle.trim().replace(/^@+/, "").toLowerCase();
}
function findPublisherMembership(
publishers: PublisherOwnerMembership[] | undefined,
handle: string,
) {
const normalizedHandle = normalizePublisherHandle(handle);
if (!normalizedHandle) return null;
return (
publishers?.find(
(membership) => normalizePublisherHandle(membership.publisher.handle) === normalizedHandle,
) ?? null
);
}
function normalizePluginPackageName(name: string) {
const normalized = name.trim().toLowerCase();
if (normalized === "publish") return null;
return /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(normalized) ? normalized : null;
}
function findReadmeFile(files: File[]): File | null {
// Match the same lookup the publish backend uses (readme.md / readme.mdx)
// by going through the shared upload-path normalizer so we see the exact
@@ -118,6 +146,24 @@ type ParsedInspectorPublishError = {
findings: Array<{ code: string; message: string }>;
};
type PluginPublishResult = {
isPending: boolean;
hasPluginPage: boolean;
packageName: string | null;
};
function parsePluginPublishResult(result: unknown): PluginPublishResult {
if (result === null || typeof result !== "object") {
return { isPending: false, hasPluginPage: false, packageName: null };
}
return {
isPending: "status" in result && result.status === "pending",
hasPluginPage: "packageId" in result && typeof result.packageId === "string",
packageName:
"packageName" in result && typeof result.packageName === "string" ? result.packageName : null,
};
}
const PLUGIN_INSPECTOR_BLOCKED_PREFIX = "Plugin Inspector blocked publish:";
function parsePluginInspectorPublishError(message: string): ParsedInspectorPublishError | null {
@@ -231,8 +277,19 @@ export function PublishPluginRoute() {
const [status, setStatus] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [submittedPlugin, setSubmittedPlugin] = useState<SubmittedPlugin | null>(null);
const showChangelogField = Boolean(search.name);
const canonicalDraftName = useMemo(() => normalizePluginPackageName(name), [name]);
const existingPluginPage = useQuery(
api.packages.getByName,
me && canonicalDraftName ? { name: canonicalDraftName } : "skip",
);
const selectedPublisher = useMemo(() => {
return findPublisherMembership(publishers, ownerHandle)?.publisher ?? null;
}, [ownerHandle, publishers]);
const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files]);
const normalizedPaths = useMemo(
() => normalizePackageUploadFiles(files).map((entry) => entry.path),
@@ -260,8 +317,10 @@ export function PublishPluginRoute() {
const isNewPluginPublishEmpty = files.length === 0 && !search.name;
const metadataDisabled = isMetadataLocked || isSubmitting;
const ownerScopeError = useMemo(() => {
return getPackageScopeOwnerMismatch(name, ownerHandle)?.message ?? null;
}, [name, ownerHandle]);
return (
getPackageScopeOwnerMismatch(name, selectedPublisher?.handle ?? ownerHandle)?.message ?? null
);
}, [name, ownerHandle, selectedPublisher]);
const submitBlockers = useMemo(() => {
if (isMetadataLocked) return [];
const blockers: string[] = [];
@@ -277,8 +336,18 @@ export function PublishPluginRoute() {
Boolean(validationError) || Boolean(ownerScopeError) || codePluginFieldIssues.length > 0;
const hasPublished =
status?.startsWith("Published.") || status?.startsWith("Publish received.") || false;
const isPublisherLoading = me !== null && me !== undefined && publishers === undefined;
const isExistingContextLoading = Boolean(me && search.name && existing === undefined);
const isPluginPageLookupLoading = Boolean(
me && files.length > 0 && canonicalDraftName && existingPluginPage === undefined,
);
const isPublishDisabled =
!isAuthenticated ||
isPublisherLoading ||
isExistingContextLoading ||
isPluginPageLookupLoading ||
!ownerHandle ||
!selectedPublisher ||
isMetadataLocked ||
hasPackageBlocker ||
submitBlockers.length > 0 ||
@@ -287,6 +356,11 @@ export function PublishPluginRoute() {
const publishBlockerSummary = useMemo(() => {
if (isSubmitting) return null;
if (!isAuthenticated) return "Sign in to publish.";
if (isPublisherLoading) return "Loading publishing identities…";
if (isExistingContextLoading) return "Loading plugin details…";
if (isPluginPageLookupLoading) return "Checking plugin page…";
if (!ownerHandle) return "Select a publisher to publish.";
if (!selectedPublisher) return "Select an available publisher to publish.";
if (isMetadataLocked) return "Complete plugin files to publish.";
if (validationError) return `Fix: ${validationError}`;
if (ownerScopeError) return `Fix: ${ownerScopeError}`;
@@ -302,9 +376,14 @@ export function PublishPluginRoute() {
}, [
codePluginFieldIssues,
isAuthenticated,
isExistingContextLoading,
isMetadataLocked,
isPluginPageLookupLoading,
isPublisherLoading,
isSubmitting,
ownerHandle,
ownerScopeError,
selectedPublisher,
submitBlockers,
validationError,
]);
@@ -360,6 +439,7 @@ export function PublishPluginRoute() {
setIgnoredPaths(nextIgnoredPaths);
setError(null);
setStatus(null);
setSubmittedPlugin(null);
setReadmeAssetReport(await scanReadmeRelativeAssets(filtered.files));
const prefill = await derivePluginPrefill(normalized);
setDetectedPrefillFields(listPrefilledFields(prefill));
@@ -389,14 +469,16 @@ export function PublishPluginRoute() {
setReadmeAssetReport(EMPTY_README_ASSET_REPORT);
setError(null);
setStatus(null);
setSubmittedPlugin(null);
};
useEffect(() => {
if (ownerHandle) return;
const personal =
publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
if (personal?.publisher.handle) {
setOwnerHandle(personal.publisher.handle);
const matchingMembership = ownerHandle
? findPublisherMembership(publishers, ownerHandle)
: (publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0]);
const canonicalHandle = matchingMembership?.publisher.handle;
if (canonicalHandle && ownerHandle !== canonicalHandle) {
setOwnerHandle(canonicalHandle);
}
}, [ownerHandle, publishers]);
@@ -812,6 +894,10 @@ export function PublishPluginRoute() {
toast.error(validationError);
return;
}
if (!selectedPublisher) {
toast.error("Select an available publisher to publish.");
return;
}
if (ownerScopeError) {
toast.error(ownerScopeError);
return;
@@ -833,7 +919,7 @@ export function PublishPluginRoute() {
payload: {
name: name.trim(),
displayName: displayName.trim() || undefined,
ownerHandle: ownerHandle || undefined,
ownerHandle: selectedPublisher.handle,
family,
version: version.trim(),
changelog: changelog.trim(),
@@ -872,19 +958,42 @@ export function PublishPluginRoute() {
files: uploaded,
},
});
if (
result &&
typeof result === "object" &&
"status" in result &&
result.status === "pending"
) {
setStatus(null);
void navigate({ to: "/dashboard" });
const publishResult = parsePluginPublishResult(result);
const submittedPackageName =
publishResult.packageName ?? canonicalDraftName;
const existingPluginHasPage = Boolean(
submittedPackageName &&
(existingPluginPage?.package?.name === submittedPackageName ||
existing?.package?.name === submittedPackageName),
);
const hasSubmittedPluginPage =
publishResult.hasPluginPage || existingPluginHasPage;
if (publishResult.isPending) {
if (hasSubmittedPluginPage) {
setStatus("Publish received. Security checks are running.");
} else {
setStatus(null);
void navigate({ to: "/dashboard" });
}
} else {
setStatus(
"Published. Pending security checks and verification before public listing.",
);
}
if (hasSubmittedPluginPage) {
setSubmittedPlugin({
name:
displayName.trim() ||
displayPluginPackageName(submittedPackageName ?? name.trim()),
path: buildPluginDetailHref(submittedPackageName ?? name.trim(), {
ownerHandle: selectedPublisher.handle,
}),
publisher: {
displayName: selectedPublisher.displayName,
handle: selectedPublisher.handle,
},
});
}
} catch (publishError) {
const message = formatPublishError(publishError);
setError(message);
@@ -908,6 +1017,13 @@ export function PublishPluginRoute() {
</div>
) : null}
</Container>
{submittedPlugin ? (
<PluginPublishSubmittedDialog
isOpen
plugin={submittedPlugin}
onDismiss={() => setSubmittedPlugin(null)}
/>
) : null}
</main>
);
}
+9
View File
@@ -3910,6 +3910,15 @@ code {
box-shadow: 0 4px 12px color-mix(in srgb, var(--marketplace-icon-accent) 20%, transparent);
}
.marketplace-icon-muted,
.marketplace-icon-muted:hover {
background: color-mix(in srgb, var(--ink) 4%, transparent);
border-color: color-mix(in srgb, var(--ink) 7%, transparent);
color: var(--ink-soft);
transform: none;
box-shadow: none;
}
.marketplace-icon-xs {
width: 26px;
height: 26px;