diff --git a/convex/lib/skillPublish.ts b/convex/lib/skillPublish.ts index 33fd5086..80f8c6ba 100644 --- a/convex/lib/skillPublish.ts +++ b/convex/lib/skillPublish.ts @@ -49,7 +49,7 @@ const QUALITY_WINDOW_MS = 24 * 60 * 60 * 1000; const QUALITY_ACTIVITY_LIMIT = 60; const PLATFORM_SKILL_LICENSE = "MIT-0" as const; const SECURITY_SCAN_ENQUEUE_BACKUP_DELAY_MS = 15_000; -const MAX_PUBLISH_SUMMARY_LENGTH = 200; +const MAX_PUBLISH_SUMMARY_LENGTH = 300; type FingerprintFile = { path: string; sha256: string }; type SafePublishFile = PublishVersionArgs["files"][number] & { path: string }; diff --git a/src/__tests__/skills-publish-route.test.tsx b/src/__tests__/skills-publish-route.test.tsx index b1e20b69..6d334648 100644 --- a/src/__tests__/skills-publish-route.test.tsx +++ b/src/__tests__/skills-publish-route.test.tsx @@ -182,6 +182,136 @@ describe("Upload route", () => { }); }); + it("prefills short summary from SKILL.md description metadata", async () => { + render(); + + const file = new File( + ["---\nname: plain-skill\ndescription: Automate recurring workflows.\n---\n# Plain Skill"], + "SKILL.md", + { type: "text/markdown" }, + ); + fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [file] } }); + + await waitFor(() => { + expect((screen.getByLabelText("Short summary") as HTMLTextAreaElement).value).toBe( + "Automate recurring workflows.", + ); + expect(screen.getByText(/^Make it discoverable!$/i)).toBeTruthy(); + expect(screen.getByText(/Imported from your SKILL\.md/i)).toBeTruthy(); + expect(screen.getByText(/where people decide whether to try your skill/i)).toBeTruthy(); + expect(screen.getByText(/For better discovery/i)).toBeTruthy(); + expect(screen.getByText(/Say what it does/i)).toBeTruthy(); + expect(screen.getByText(/Edits here only affect ClawHub/i)).toBeTruthy(); + }); + }); + + it("truncates long SKILL.md descriptions when prefilling short summary", async () => { + render(); + + const longDescription = "a".repeat(350); + const file = new File( + [`---\nname: long-skill\ndescription: ${longDescription}\n---\n# Long Skill`], + "SKILL.md", + { type: "text/markdown" }, + ); + fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [file] } }); + + await waitFor(() => { + const summary = screen.getByLabelText("Short summary") as HTMLTextAreaElement; + expect(summary.value).toHaveLength(300); + expect(summary.value).toBe("a".repeat(300)); + }); + }); + + it("hides the short summary recommendation when dismissed and keeps prefilled text", async () => { + render(); + + const file = new File( + ["---\nname: plain-skill\ndescription: Automate recurring workflows.\n---\n# Plain Skill"], + "SKILL.md", + { type: "text/markdown" }, + ); + fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [file] } }); + + await waitFor(() => { + expect(screen.getByText(/^Make it discoverable!$/i)).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Dismiss summary recommendation" })); + + expect(screen.queryByText(/^Make it discoverable!$/i)).toBeNull(); + expect((screen.getByLabelText("Short summary") as HTMLTextAreaElement).value).toBe( + "Automate recurring workflows.", + ); + }); + + it("keeps manual short summary edits when SKILL.md is replaced", async () => { + render(); + + const firstFile = new File(["---\ndescription: First description.\n---\n# First"], "SKILL.md", { + type: "text/markdown", + }); + fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [firstFile] } }); + + await waitFor(() => { + expect((screen.getByLabelText("Short summary") as HTMLTextAreaElement).value).toBe( + "First description.", + ); + }); + + fireEvent.change(screen.getByLabelText("Short summary"), { + target: { value: "Custom summary." }, + }); + + expect(screen.queryByText(/^Make it discoverable!$/i)).toBeNull(); + + const secondFile = new File( + ["---\ndescription: Second description.\n---\n# Second"], + "SKILL.md", + { type: "text/markdown" }, + ); + fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [secondFile] } }); + + await waitFor(() => { + expect((screen.getByLabelText("Short summary") as HTMLTextAreaElement).value).toBe( + "Custom summary.", + ); + }); + }); + + it("prefills short summary again after a new upload following manual edits", async () => { + render(); + + const firstFile = new File(["---\ndescription: First description.\n---\n# First"], "SKILL.md", { + type: "text/markdown", + }); + fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [firstFile] } }); + + await waitFor(() => { + expect((screen.getByLabelText("Short summary") as HTMLTextAreaElement).value).toBe( + "First description.", + ); + }); + + fireEvent.change(screen.getByLabelText("Short summary"), { + target: { value: "Custom summary." }, + }); + + const secondFile = new File( + ["---\ndescription: Second description.\n---\n# Second"], + "SKILL.md", + { type: "text/markdown" }, + ); + fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [secondFile] } }); + + await waitFor(() => { + expect((screen.getByLabelText("Short summary") as HTMLTextAreaElement).value).toBe( + "Second description.", + ); + expect(screen.getByText(/^Make it discoverable!$/i)).toBeTruthy(); + }); + }); + it("sends explicit empty metadata arrays when categories and topics are cleared on republish", async () => { useSearchMock.mockReturnValue({ updateSlug: "categorized-skill" }); useQueryMock.mockImplementation((fn: unknown, args: unknown) => { diff --git a/src/components/SkillShortSummaryField.tsx b/src/components/SkillShortSummaryField.tsx index 9b00ab34..12d33876 100644 --- a/src/components/SkillShortSummaryField.tsx +++ b/src/components/SkillShortSummaryField.tsx @@ -1,12 +1,19 @@ +import { Info, Lightbulb, X } from "lucide-react"; +import { useLayoutEffect, useRef, useState } from "react"; +import { cn } from "../lib/utils"; import { Label } from "./ui/label"; import { Textarea } from "./ui/textarea"; -export const SKILL_PUBLISH_SUMMARY_MAX_LENGTH = 200; +export const SKILL_PUBLISH_SUMMARY_MAX_LENGTH = 300; + +const BANNER_TEXTAREA_GAP_PX = 24; type SkillShortSummaryFieldProps = { id: string; value: string; disabled?: boolean; + recommendation?: boolean; + onDismissRecommendation?: () => void; onChange: (value: string) => void; }; @@ -14,25 +21,116 @@ export function SkillShortSummaryField({ id, value, disabled, + recommendation, + onDismissRecommendation, onChange, }: SkillShortSummaryFieldProps) { + const bannerRef = useRef(null); + const [bannerHeight, setBannerHeight] = useState(0); + + useLayoutEffect(() => { + let disconnect: (() => void) | undefined; + + if (!recommendation) { + setBannerHeight(0); + } else { + const node = bannerRef.current; + if (node) { + const updateHeight = () => { + setBannerHeight(node.getBoundingClientRect().height); + }; + updateHeight(); + if (typeof ResizeObserver !== "undefined") { + const observer = new ResizeObserver(updateHeight); + observer.observe(node); + disconnect = () => observer.disconnect(); + } + } + } + + return () => { + disconnect?.(); + }; + }, [recommendation]); + + const textareaPaddingBottom = + recommendation && bannerHeight > 0 ? bannerHeight + BANNER_TEXTAREA_GAP_PX : undefined; + return ( -
+
-

- Short description shown in cards, search, and previews. -

-