mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
feat(publish): prefill short summary from SKILL.md with discovery banner
Auto-populate the publish form short summary from SKILL.md frontmatter description (metadata or top-level), with a dismissible in-field banner that nudges authors toward discovery-friendly copy. Reset prefill state on re-upload, measure banner height for textarea padding, and raise the summary limit to 300 characters.
This commit is contained in:
committed by
Patrick Erichsen
parent
556b6729dc
commit
5b1be27f4a
@@ -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 };
|
||||
|
||||
@@ -182,6 +182,136 @@ describe("Upload route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("prefills short summary from SKILL.md description metadata", async () => {
|
||||
render(<Upload />);
|
||||
|
||||
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(<Upload />);
|
||||
|
||||
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(<Upload />);
|
||||
|
||||
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(<Upload />);
|
||||
|
||||
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(<Upload />);
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="flex flex-col gap-2 col-span-full">
|
||||
<div className="flex flex-col gap-3 col-span-full">
|
||||
<Label htmlFor={id}>Short summary</Label>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
Short description shown in cards, search, and previews.
|
||||
</p>
|
||||
<Textarea
|
||||
id={id}
|
||||
aria-label="Short summary"
|
||||
rows={3}
|
||||
value={value}
|
||||
maxLength={SKILL_PUBLISH_SUMMARY_MAX_LENGTH}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder="Enter a brief description..."
|
||||
/>
|
||||
<span className="text-xs text-[color:var(--ink-soft)]">
|
||||
<div
|
||||
className={cn(
|
||||
"relative",
|
||||
recommendation &&
|
||||
"overflow-hidden rounded-[var(--radius-sm)] border border-input-border transition-all duration-[180ms] ease-out focus-within:border-input-focus-border focus-within:shadow-[0_0_0_3px_var(--input-focus-ring)]",
|
||||
)}
|
||||
>
|
||||
<Textarea
|
||||
id={id}
|
||||
aria-label="Short summary"
|
||||
rows={3}
|
||||
value={value}
|
||||
maxLength={SKILL_PUBLISH_SUMMARY_MAX_LENGTH}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder="Enter a brief description..."
|
||||
className={cn(
|
||||
recommendation &&
|
||||
"rounded-none border-0 shadow-none focus:border-transparent focus:shadow-none",
|
||||
)}
|
||||
style={
|
||||
textareaPaddingBottom !== undefined
|
||||
? { paddingBottom: textareaPaddingBottom }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{recommendation ? (
|
||||
<div
|
||||
ref={bannerRef}
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 border-t border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3.5 py-2.5"
|
||||
role="note"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5 font-semibold leading-none text-[#0099ff]">
|
||||
<Lightbulb className="size-3.5 shrink-0" aria-hidden />
|
||||
<span>Make it discoverable!</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss summary recommendation"
|
||||
disabled={disabled}
|
||||
className="pointer-events-auto flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--radius-sm)] text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={onDismissRecommendation}
|
||||
>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-[color:var(--ink-soft)]">
|
||||
Imported from your SKILL.md. Descriptions there are often written for agents —
|
||||
technical and trigger-focused. On ClawHub, this appears in cards and search, where
|
||||
people decide whether to try your skill.
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 text-xs leading-snug sm:flex-row sm:flex-wrap sm:items-center sm:gap-x-2 sm:gap-y-1">
|
||||
<span className="font-semibold text-[color:var(--ink)]">For better discovery</span>
|
||||
<span
|
||||
className="hidden h-3 w-px shrink-0 bg-[color:var(--line)] sm:block"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="font-medium text-[color:var(--ink)]">
|
||||
Say what it does · Name who it's for · Keep it short and jargon-light
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 flex items-start gap-1.5 border-t border-[color:var(--line)]/50 pt-2 text-xs leading-snug text-[color:var(--ink-soft)]">
|
||||
<Info className="mt-0.5 size-3 shrink-0" aria-hidden />
|
||||
<span>Edits here only affect ClawHub, not your SKILL.md.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="self-end text-xs text-[color:var(--ink-soft)]">
|
||||
{value.trim().length}/{SKILL_PUBLISH_SUMMARY_MAX_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractSkillFrontmatterDescription,
|
||||
truncateSkillPublishSummary,
|
||||
} from "./skillFrontmatter";
|
||||
|
||||
describe("extractSkillFrontmatterDescription", () => {
|
||||
it("reads a top-level description field", () => {
|
||||
const content = `---
|
||||
name: demo
|
||||
description: Automate recurring workflows.
|
||||
---
|
||||
# Demo`;
|
||||
|
||||
expect(extractSkillFrontmatterDescription(content)).toBe("Automate recurring workflows.");
|
||||
});
|
||||
|
||||
it("prefers metadata.description when present", () => {
|
||||
const content = `---
|
||||
name: demo
|
||||
description: Legacy description.
|
||||
metadata:
|
||||
description: Use this skill when the user needs CSV analysis.
|
||||
---
|
||||
# Demo`;
|
||||
|
||||
expect(extractSkillFrontmatterDescription(content)).toBe(
|
||||
"Use this skill when the user needs CSV analysis.",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined when no description is present", () => {
|
||||
expect(extractSkillFrontmatterDescription("# Demo")).toBeUndefined();
|
||||
expect(extractSkillFrontmatterDescription("---\nname: demo\n---\n# Demo")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateSkillPublishSummary", () => {
|
||||
it("keeps short values unchanged", () => {
|
||||
expect(truncateSkillPublishSummary("Short summary", 300)).toBe("Short summary");
|
||||
});
|
||||
|
||||
it("truncates long values to the publish summary limit", () => {
|
||||
const longDescription = "a".repeat(350);
|
||||
expect(truncateSkillPublishSummary(longDescription, 300)).toHaveLength(300);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,12 @@ function parseMetadata(value: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
export function truncateSkillPublishSummary(value: string, maxLength: number) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length <= maxLength) return trimmed;
|
||||
return trimmed.slice(0, maxLength).trimEnd();
|
||||
}
|
||||
|
||||
export function extractSkillFrontmatterDescription(content: string) {
|
||||
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
if (!normalized.startsWith("---")) return undefined;
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
CircleX,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
Info,
|
||||
Lock,
|
||||
Upload as UploadIcon,
|
||||
X,
|
||||
@@ -48,7 +47,10 @@ import { Textarea } from "../../components/ui/textarea";
|
||||
import { UploadDropzoneDecor } from "../../components/UploadDropzoneDecor";
|
||||
import { VersionInput } from "../../components/VersionInput";
|
||||
import { setPostPublishFlash } from "../../lib/postPublishFlash";
|
||||
import { extractSkillFrontmatterDescription } from "../../lib/skillFrontmatter";
|
||||
import {
|
||||
extractSkillFrontmatterDescription,
|
||||
truncateSkillPublishSummary,
|
||||
} from "../../lib/skillFrontmatter";
|
||||
import { getPublicSlugCollision } from "../../lib/slugCollision";
|
||||
import { expandDroppedItems, expandFilesWithReport } from "../../lib/uploadFiles";
|
||||
import { useAuthStatus } from "../../lib/useAuthStatus";
|
||||
@@ -132,7 +134,6 @@ export function Upload() {
|
||||
tags: false,
|
||||
license: false,
|
||||
});
|
||||
const [metadataPrefillNote, setMetadataPrefillNote] = useState<string | null>(null);
|
||||
const [version, setVersion] = useState("1.0.0");
|
||||
const [tags, setTags] = useState("latest");
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
@@ -144,6 +145,7 @@ export function Upload() {
|
||||
const categoriesTouchedRef = useRef(false);
|
||||
const topicsTouchedRef = useRef(false);
|
||||
const summaryTouchedRef = useRef(false);
|
||||
const [summaryRecommendationDismissed, setSummaryRecommendationDismissed] = useState(false);
|
||||
const [changelogStatus, setChangelogStatus] = useState<"idle" | "loading" | "ready" | "error">(
|
||||
"idle",
|
||||
);
|
||||
@@ -346,7 +348,12 @@ export function Upload() {
|
||||
useEffect(() => {
|
||||
if (summaryTouchedRef.current) return;
|
||||
if (!uploadedSkillSummary) return;
|
||||
setSummary((current) => (current === uploadedSkillSummary ? current : uploadedSkillSummary));
|
||||
const nextSummary = truncateSkillPublishSummary(
|
||||
uploadedSkillSummary,
|
||||
SKILL_PUBLISH_SUMMARY_MAX_LENGTH,
|
||||
);
|
||||
if (!nextSummary) return;
|
||||
setSummary((current) => (current === nextSummary ? current : nextSummary));
|
||||
}, [uploadedSkillSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -609,12 +616,17 @@ export function Upload() {
|
||||
);
|
||||
}
|
||||
|
||||
function resetSummaryPrefillState() {
|
||||
summaryTouchedRef.current = false;
|
||||
setSummaryRecommendationDismissed(false);
|
||||
}
|
||||
|
||||
async function applyExpandedFiles(selected: File[]) {
|
||||
const report = await expandFilesWithReport(selected);
|
||||
resetSummaryPrefillState();
|
||||
setFiles(report.files);
|
||||
setIgnoredLocalMetadataPaths(report.ignoredLocalMetadataPaths);
|
||||
setPendingFileRemovalIndex(null);
|
||||
setMetadataPrefillNote(null);
|
||||
resetFileInput();
|
||||
|
||||
if (updateSlug) return;
|
||||
@@ -624,17 +636,11 @@ export function Upload() {
|
||||
|
||||
const nextSlug = slugFromFolderName(folderName);
|
||||
const nextDisplayName = displayNameFromFolderName(folderName);
|
||||
const prefilled: string[] = [];
|
||||
if (nextSlug && !dirtyFields.slug && !trimmedSlug) {
|
||||
setSlug(nextSlug);
|
||||
prefilled.push("slug");
|
||||
}
|
||||
if (nextDisplayName && !dirtyFields.displayName && !trimmedName) {
|
||||
setDisplayName(nextDisplayName);
|
||||
prefilled.push("display name");
|
||||
}
|
||||
if (prefilled.length > 0) {
|
||||
setMetadataPrefillNote(`Suggested ${prefilled.join(" and ")} from the selected folder.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,10 +657,10 @@ export function Upload() {
|
||||
}
|
||||
|
||||
function clearSelectedFiles() {
|
||||
resetSummaryPrefillState();
|
||||
setFiles([]);
|
||||
setIgnoredLocalMetadataPaths([]);
|
||||
setPendingFileRemovalIndex(null);
|
||||
setMetadataPrefillNote(null);
|
||||
resetFileInput();
|
||||
}
|
||||
|
||||
@@ -1045,7 +1051,6 @@ export function Upload() {
|
||||
}
|
||||
onChange={(event) => {
|
||||
markFieldDirty("displayName");
|
||||
setMetadataPrefillNote(null);
|
||||
setDisplayName(event.target.value);
|
||||
}}
|
||||
placeholder="My skill"
|
||||
@@ -1067,7 +1072,6 @@ export function Upload() {
|
||||
className={showSlugStatusIcon ? "pr-10" : undefined}
|
||||
onChange={(event) => {
|
||||
markFieldDirty("slug");
|
||||
setMetadataPrefillNote(null);
|
||||
setSlug(event.target.value);
|
||||
}}
|
||||
placeholder="skill-name"
|
||||
@@ -1103,20 +1107,46 @@ export function Upload() {
|
||||
id="skillSummary"
|
||||
value={summary}
|
||||
disabled={isSubmitting}
|
||||
recommendation={
|
||||
files.length > 0 &&
|
||||
Boolean(uploadedSkillSummary) &&
|
||||
!summaryTouchedRef.current &&
|
||||
!summaryRecommendationDismissed
|
||||
}
|
||||
onDismissRecommendation={() => {
|
||||
setSummaryRecommendationDismissed(true);
|
||||
}}
|
||||
onChange={(nextSummary) => {
|
||||
summaryTouchedRef.current = true;
|
||||
setSummary(nextSummary);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{metadataPrefillNote ? (
|
||||
<p
|
||||
className="flex items-center gap-1.5 text-sm leading-5 text-[#1f6feb] dark:text-[#8fbdff]"
|
||||
role="status"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="leading-5">{metadataPrefillNote}</span>
|
||||
</p>
|
||||
|
||||
<div className="border-t border-[color:var(--line)] pt-5">
|
||||
<CatalogMetadataFields
|
||||
kind="skill"
|
||||
categories={categories}
|
||||
suggestedCategories={suggestedCategories}
|
||||
topics={topics}
|
||||
disabled={isSubmitting}
|
||||
onCategoriesChange={(nextCategories) => {
|
||||
categoriesTouchedRef.current = true;
|
||||
setCategories(nextCategories);
|
||||
}}
|
||||
onTopicsChange={(nextTopics) => {
|
||||
topicsTouchedRef.current = true;
|
||||
setTopics(nextTopics);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{visibleMetadataIssues.length > 0 ? (
|
||||
<ul className="flex flex-col gap-1 list-disc pl-5 text-sm text-[color:var(--ink-soft)]">
|
||||
{visibleMetadataIssues.map((issue) => (
|
||||
<li key={issue}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -1153,69 +1183,41 @@ export function Upload() {
|
||||
) : null}
|
||||
<InlineValidationMessage id="owner-validation-error" message={ownerIssue} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="version">Version</Label>
|
||||
<VersionInput
|
||||
id="version"
|
||||
value={version}
|
||||
aria-invalid={Boolean(versionIssue)}
|
||||
aria-describedby={versionIssue ? "version-validation-error" : undefined}
|
||||
onValueChange={(nextVersion) => {
|
||||
markFieldDirty("version");
|
||||
setVersion(nextVersion);
|
||||
}}
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
<InlineValidationMessage id="version-validation-error" message={versionIssue} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="tags">Release tags</Label>
|
||||
<Input
|
||||
id="tags"
|
||||
value={tags}
|
||||
onChange={(event) => {
|
||||
markFieldDirty("tags");
|
||||
setTags(event.target.value);
|
||||
}}
|
||||
placeholder="latest, stable"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{visibleMetadataIssues.length > 0 ? (
|
||||
<ul className="flex flex-col gap-1 list-disc pl-5 text-sm text-[color:var(--ink-soft)]">
|
||||
{visibleMetadataIssues.map((issue) => (
|
||||
<li key={issue}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="gap-4">
|
||||
<div>
|
||||
<CardTitle>Catalog metadata</CardTitle>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
Choose browse categories and author topics for this skill.
|
||||
</p>
|
||||
<div className="grid gap-x-4 gap-y-4 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="version">Version</Label>
|
||||
<VersionInput
|
||||
id="version"
|
||||
value={version}
|
||||
aria-invalid={Boolean(versionIssue)}
|
||||
aria-describedby={versionIssue ? "version-validation-error" : undefined}
|
||||
onValueChange={(nextVersion) => {
|
||||
markFieldDirty("version");
|
||||
setVersion(nextVersion);
|
||||
}}
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
<InlineValidationMessage id="version-validation-error" message={versionIssue} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="tags">Release tags</Label>
|
||||
<Input
|
||||
id="tags"
|
||||
value={tags}
|
||||
onChange={(event) => {
|
||||
markFieldDirty("tags");
|
||||
setTags(event.target.value);
|
||||
}}
|
||||
placeholder="latest, stable"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CatalogMetadataFields
|
||||
kind="skill"
|
||||
categories={categories}
|
||||
suggestedCategories={suggestedCategories}
|
||||
topics={topics}
|
||||
disabled={isSubmitting}
|
||||
onCategoriesChange={(nextCategories) => {
|
||||
categoriesTouchedRef.current = true;
|
||||
setCategories(nextCategories);
|
||||
}}
|
||||
onTopicsChange={(nextTopics) => {
|
||||
topicsTouchedRef.current = true;
|
||||
setTopics(nextTopics);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user