Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion convex/packages.public.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { getAuthUserId } from "@convex-dev/auth/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MAX_PUBLISH_FILE_BYTES } from "./lib/publishLimits";
import {
backfillLatestPackageScanStatusInternal,
backfillPackageReleaseScansInternal,
Expand Down Expand Up @@ -43,7 +44,6 @@ import {
searchForViewerInternal,
searchPublic,
} from "./packages";
import { MAX_PUBLISH_FILE_BYTES } from "./lib/publishLimits";

vi.mock("@convex-dev/auth/server", () => ({
getAuthUserId: vi.fn(),
Expand Down
5 changes: 5 additions & 0 deletions src/__tests__/skill-detail-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ vi.mock("@tanstack/react-router", () => ({
Link: ({ children }: { children: ReactNode }) => children,
useNavigate: () => navigateMock,
useRouter: () => ({ invalidate: routerInvalidateMock }),
useRouterState: ({
select,
}: {
select: (state: { location: { searchStr: string } }) => string;
}) => select({ location: { searchStr: "" } }),
}));

vi.mock("@convex-dev/auth/react", () => ({
Expand Down
53 changes: 50 additions & 3 deletions src/components/SkillDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useAuthActions } from "@convex-dev/auth/react";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { useAction, useMutation, useQuery } from "convex/react";
import { ArrowLeft, TriangleAlert, Upload } from "lucide-react";
Expand Down Expand Up @@ -32,6 +32,7 @@ import {
import { SkillHeader } from "./SkillHeader";
import { buildSkillInstallTabs } from "./SkillInstallCard";
import { SkillOwnershipPanel } from "./SkillOwnershipPanel";
import { SkillPublishSuccessDialog } from "./SkillPublishSuccessDialog";
import { SkillRelatedSection, type RelatedSkillEntry } from "./SkillRelatedSection";
import { SkillReportDialog } from "./SkillReportDialog";
import { Alert, AlertDescription } from "./ui/alert";
Expand All @@ -44,6 +45,8 @@ type SkillDetailPageProps = {
redirectToCanonical?: boolean;
initialData?: SkillPageInitialData | null;
mode?: "detail" | "settings";
showPostPublishSuccess?: boolean;
onDismissPostPublish?: () => void;
};

type SkillFile = Doc<"skillVersions">["files"][number];
Expand All @@ -70,6 +73,15 @@ function tabFromHash(hash: string): DetailTab {
return "readme";
}

function isPostPublishSearchValue(value: unknown) {
const normalized = typeof value === "string" ? value.trim().replace(/^"|"$/g, "") : value;
return normalized === "1" || normalized === "true" || normalized === 1 || normalized === true;
}

function hasPostPublishSearch(searchStr: string) {
return isPostPublishSearchValue(new URLSearchParams(searchStr).get("published"));
}

function formatReportError(error: unknown) {
if (error && typeof error === "object" && "data" in error) {
const data = (error as { data?: unknown }).data;
Expand Down Expand Up @@ -167,9 +179,12 @@ export function SkillDetailPage({
redirectToCanonical,
initialData,
mode = "detail",
showPostPublishSuccess = false,
onDismissPostPublish,
}: SkillDetailPageProps) {
const navigate = useNavigate();
const router = useRouter();
const searchStr = useRouterState({ select: (state) => state.location.searchStr });
const { isAuthenticated, me } = useAuthStatus();
const { signIn } = useAuthActions();
const initialResult = initialData?.result ?? undefined;
Expand Down Expand Up @@ -206,6 +221,7 @@ export function SkillDetailPage({
const [reportReason, setReportReason] = useState("");
const [reportError, setReportError] = useState<string | null>(null);
const [isSubmittingReport, setIsSubmittingReport] = useState(false);
const [hasClientPostPublishSearch, setHasClientPostPublishSearch] = useState(false);
const [optimisticStar, setOptimisticStar] = useState<{
skillId: Id<"skills">;
starred: boolean;
Expand Down Expand Up @@ -253,6 +269,14 @@ export function SkillDetailPage({
const activeOptimisticStar =
optimisticStar && skill && optimisticStar.skillId === skill._id ? optimisticStar : null;
const effectiveIsStarred = activeOptimisticStar?.starred ?? isStarred;

useEffect(() => {
const browserSearch = typeof window === "undefined" ? "" : window.location.search;
setHasClientPostPublishSearch(
hasPostPublishSearch(searchStr) || hasPostPublishSearch(browserSearch),
);
}, [searchStr]);

const displayedSkill = useMemo(() => {
if (!skill || !activeOptimisticStar) return skill;
const currentStars = skill.stats.stars ?? 0;
Expand Down Expand Up @@ -672,10 +696,13 @@ export function SkillDetailPage({
onSaveSummary={canAccessSettings ? submitSummary : null}
/>
) : null;
const detailHref = buildSkillHref(ownerHandle, owner?._id ?? null, skill.slug);
const showPublishSuccessDialog =
mode === "detail" &&
(showPostPublishSuccess || hasClientPostPublishSearch) &&
Boolean(onDismissPostPublish);

if (mode === "settings") {
const detailHref = buildSkillHref(ownerHandle, owner?._id ?? null, skill.slug);

return (
<main className="section detail-page-section">
<DetailPageShell className="skill-settings-page">
Expand Down Expand Up @@ -821,6 +848,26 @@ export function SkillDetailPage({
onCancel={closeReportDialog}
onSubmit={() => void submitReport()}
/>
<SkillPublishSuccessDialog
isOpen={showPublishSuccessDialog}
displayName={skill.displayName}
skillPath={detailHref}
skillIcon={skill.icon ?? null}
publisher={
owner
? {
displayName: owner.displayName,
handle: owner.handle ?? ownerHandle,
image: owner.image,
kind: owner.kind,
}
: ownerHandle
? { handle: ownerHandle }
: null
}
categoryLabel={relatedCategory?.label ?? null}
onDismiss={onDismissPostPublish ?? (() => undefined)}
/>
</main>
);
}
123 changes: 123 additions & 0 deletions src/components/SkillPublishSuccessDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/* @vitest-environment jsdom */

import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
OPENCLAW_SKILLS_DISCORD_URL,
SkillPublishSuccessDialog,
} from "./SkillPublishSuccessDialog";

const writeTextMock = vi.fn();

function renderDialog(overrides: Partial<Parameters<typeof SkillPublishSuccessDialog>[0]> = {}) {
return render(
<SkillPublishSuccessDialog
isOpen
displayName="Agent Helper"
skillPath="/vyctor/agent-helper"
skillIcon="lucide:Plug"
publisher={{ displayName: "Vyctor", handle: "vyctor", kind: "user" }}
categoryLabel="Developer tools"
onDismiss={vi.fn()}
{...overrides}
/>,
);
}

describe("SkillPublishSuccessDialog", () => {
beforeEach(() => {
vi.unstubAllEnvs();
writeTextMock.mockReset();
writeTextMock.mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: writeTextMock,
},
});
Object.defineProperty(navigator, "share", {
configurable: true,
value: undefined,
});
});

it("offers Discord, Twitter, and the published skill link", () => {
renderDialog();

expect(screen.getByRole("heading", { name: /It's alive!/i })).toBeTruthy();
expect(screen.getAllByText("Agent Helper").length).toBeGreaterThan(0);
expect(screen.getByText("Vyctor")).toBeTruthy();
expect(screen.queryByText("v1.0.0")).toBeNull();
expect(screen.getByText("Developer tools")).toBeTruthy();
expect(screen.getByText("#skills")).toBeTruthy();
expect(screen.getByText("Friends of the Crustacean 🦞🤝")).toBeTruthy();

const discordLink = screen.getByRole("link", { name: /Share on Discord/i });
expect(discordLink.getAttribute("href")).toBe(OPENCLAW_SKILLS_DISCORD_URL);

const xLink = screen.getByRole("link", { name: /Share on Twitter/i });
const xHref = xLink.getAttribute("href") ?? "";
expect(xHref).toContain("https://twitter.com/intent/tweet?");
const xParams = new URL(xHref).searchParams;
expect(xParams.get("text")).toBe(
"Agent Helper is now live on ClawHub 🦞 Check it out: https://clawhub.ai/vyctor/agent-helper",
);
expect(xParams.get("url")).toBeNull();
});

it("moves focus into the dialog without highlighting a secondary action", async () => {
renderDialog();

const dialog = screen.getByRole("dialog");
await waitFor(() => {
expect(document.activeElement).toBe(dialog);
});
expect(screen.getByRole("button", { name: /Copy skill link/i })).not.toBe(
document.activeElement,
);
});

it.each(["http://127.0.0.1:3030", "http://localhost:3030", "http://[::1]:3030"])(
"shares the public ClawHub URL instead of local dev origin %s",
(localOrigin) => {
vi.stubEnv("VITE_SITE_URL", localOrigin);

renderDialog();

const skillLink = screen.getByRole("link", { name: "clawhub.ai/vyctor/agent-helper" });
expect(skillLink.getAttribute("href")).toBe("https://clawhub.ai/vyctor/agent-helper");
},
);

it("copies the skill link from the inline copy action", async () => {
renderDialog();

fireEvent.click(screen.getByRole("button", { name: /Copy skill link/i }));

await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith(expect.stringContaining("/vyctor/agent-helper"));
});
expect(screen.getByText("Copied")).toBeTruthy();
});

it("copies a ready Discord message before opening the Discord channel", async () => {
renderDialog();

fireEvent.click(screen.getByRole("link", { name: /Share on Discord/i }));

await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith(
"I just published Agent Helper on ClawHub: https://clawhub.ai/vyctor/agent-helper",
);
});
});

it("dismisses from the View skill action", () => {
const onDismiss = vi.fn();
renderDialog({ onDismiss });

fireEvent.click(screen.getByRole("button", { name: /View skill/i }));

expect(onDismiss).toHaveBeenCalledTimes(1);
});
});
Loading
Loading