diff --git a/src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient.tsx b/src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient.tsx new file mode 100644 index 0000000000..6eadae3dc5 --- /dev/null +++ b/src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient.tsx @@ -0,0 +1,296 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { useTranslations } from "next-intl"; +import { SkillsConceptCard } from "@/shared/components/SkillsConceptCard"; +import { CoverageBar } from "./components/CoverageBar"; +import { McpA2aLinksBar } from "./components/McpA2aLinksBar"; +import { SkillCard } from "./components/SkillCard"; +import { SkillPreviewPane } from "./components/SkillPreviewPane"; +import type { AgentSkill, SkillCoverage } from "@/lib/agentSkills/types"; + +type FilterCategory = "all" | "api" | "cli"; + +// ── Skeleton helpers ───────────────────────────────────────────────────────── + +function SkillCardSkeleton(): JSX.Element { + return ( +
+
+
+
+
+
+
+
+ ); +} + +function CoverageBarSkeleton(): JSX.Element { + return ( +
+
+
+
+
+
+
+
+
+
+ ); +} + +// ── Main component ─────────────────────────────────────────────────────────── + +export function AgentSkillsPageClient(): JSX.Element { + const t = useTranslations("agentSkills"); + + // State + const [catalog, setCatalog] = useState([]); + const [coverage, setCoverage] = useState(null); + const [loadingCatalog, setLoadingCatalog] = useState(true); + const [filter, setFilter] = useState("all"); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedId, setSelectedId] = useState(null); + const [markdownCache, setMarkdownCache] = useState>(new Map()); + const [loadingPreview, setLoadingPreview] = useState(false); + const [generatingSkills, setGeneratingSkills] = useState(false); + + const abortRef = useRef(null); + + // ── Fetch catalog + coverage on mount ──────────────────────────────────── + useEffect(() => { + const controller = new AbortController(); + abortRef.current = controller; + + const load = async () => { + try { + setLoadingCatalog(true); + const res = await fetch("/api/agent-skills", { signal: controller.signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = (await res.json()) as { skills: AgentSkill[]; coverage: SkillCoverage }; + setCatalog(json.skills ?? []); + setCoverage(json.coverage ?? null); + } catch (err) { + if ((err as Error).name !== "AbortError") { + setCatalog([]); + } + } finally { + setLoadingCatalog(false); + } + }; + + void load(); + return () => { + controller.abort(); + }; + }, []); + + // ── Fetch raw markdown when a card is selected ──────────────────────────── + const loadPreview = useCallback( + async (id: string) => { + if (markdownCache.has(id)) return; + setLoadingPreview(true); + try { + const res = await fetch(`/api/agent-skills/${id}/raw`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = (await res.json()) as { body: string }; + setMarkdownCache((prev) => new Map(prev).set(id, json.body ?? "")); + } catch { + setMarkdownCache((prev) => { + const next = new Map(prev); + // Set empty string to signal "failed" so we show error state + next.set(id, ""); + return next; + }); + } finally { + setLoadingPreview(false); + } + }, + [markdownCache], + ); + + const handleSelectCard = useCallback( + (id: string) => { + setSelectedId(id); + void loadPreview(id); + }, + [loadPreview], + ); + + const handleRefreshPreview = useCallback(() => { + if (!selectedId) return; + setMarkdownCache((prev) => { + const next = new Map(prev); + next.delete(selectedId); + return next; + }); + void loadPreview(selectedId); + }, [selectedId, loadPreview]); + + // ── Generate missing skills ─────────────────────────────────────────────── + const handleGenerate = useCallback(async () => { + const confirmed = window.confirm(t("regenerateConfirm")); + if (!confirmed) return; + setGeneratingSkills(true); + try { + const res = await fetch("/api/agent-skills/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dryRun: false, prune: false }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + // Refresh catalog + coverage after generation + const res2 = await fetch("/api/agent-skills"); + if (res2.ok) { + const json = (await res2.json()) as { skills: AgentSkill[]; coverage: SkillCoverage }; + setCatalog(json.skills ?? []); + setCoverage(json.coverage ?? null); + } + } catch { + // Error handled silently — could extend with toast notification + } finally { + setGeneratingSkills(false); + } + }, [t]); + + // ── Filtering + search ──────────────────────────────────────────────────── + const filteredSkills = catalog.filter((s) => { + if (filter !== "all" && s.category !== filter) return false; + if (searchTerm.trim()) { + const q = searchTerm.toLowerCase(); + return ( + s.name.toLowerCase().includes(q) || + s.description.toLowerCase().includes(q) || + s.id.toLowerCase().includes(q) + ); + } + return true; + }); + + const selectedMarkdown = selectedId ? markdownCache.get(selectedId) ?? null : null; + const coverageTotal = + coverage !== null ? coverage.api.have + coverage.cli.have : null; + const showGenerateButton = coverageTotal !== null && coverageTotal < 42; + + return ( +
+ {/* Concept card — full width */} + + + {/* Header: coverage + MCP/A2A bar + generate button */} +
+ {/* Coverage */} +
+
+ + {t("coverageLabel")} + + {showGenerateButton && ( + + )} +
+ {coverage ? ( + + ) : ( + + )} +
+ + {/* MCP + A2A links */} + +
+ + {/* Search + filters */} +
+
+ + search + + setSearchTerm(e.target.value)} + placeholder={t("filters.searchPlaceholder")} + className="w-full rounded-lg border border-border bg-bg py-2 pl-9 pr-3 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40" + data-testid="search-input" + /> +
+
+ {(["all", "api", "cli"] as FilterCategory[]).map((cat) => ( + + ))} +
+
+ + {/* Two-column grid: left = skill cards, right = preview */} +
+ {/* Left: skill cards list (col-span 7) */} +
+ {loadingCatalog ? ( + Array.from({ length: 6 }).map((_, i) => ) + ) : filteredSkills.length === 0 ? ( +
+ + search_off + +

{t("noSkillsFound")}

+
+ ) : ( + filteredSkills.map((skill) => ( + handleSelectCard(skill.id)} + /> + )) + )} +
+ + {/* Right: preview pane (col-span 5) */} +
+ +
+
+
+ ); +} + +export default AgentSkillsPageClient; diff --git a/src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar.tsx b/src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar.tsx new file mode 100644 index 0000000000..07846e8240 --- /dev/null +++ b/src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import type { SkillCoverage } from "@/lib/agentSkills/types"; + +interface CoverageBarProps { + coverage: SkillCoverage; +} + +function barColor(have: number, total: number): string { + const pct = total > 0 ? have / total : 0; + if (pct >= 1) return "bg-emerald-500"; + if (pct >= 0.75) return "bg-amber-400"; + return "bg-red-500"; +} + +function trackColor(have: number, total: number): string { + const pct = total > 0 ? have / total : 0; + if (pct >= 1) return "bg-emerald-500/20"; + if (pct >= 0.75) return "bg-amber-400/20"; + return "bg-red-500/20"; +} + +export function CoverageBar({ coverage }: CoverageBarProps): JSX.Element { + const t = useTranslations("agentSkills"); + const { api, cli } = coverage; + + return ( +
+
+ + {t("categoryApi")} {api.have}/{api.total} + +
+
0 ? (api.have / api.total) * 100 : 0}%` }} + /> +
+ + {api.total > 0 ? Math.round((api.have / api.total) * 100) : 0}% + +
+ +
+ + {t("categoryCli")} {cli.have}/{cli.total} + +
+
0 ? (cli.have / cli.total) * 100 : 0}%` }} + /> +
+ + {cli.total > 0 ? Math.round((cli.have / cli.total) * 100) : 0}% + +
+
+ ); +} + +export default CoverageBar; diff --git a/src/app/(dashboard)/dashboard/agent-skills/components/McpA2aLinksBar.tsx b/src/app/(dashboard)/dashboard/agent-skills/components/McpA2aLinksBar.tsx new file mode 100644 index 0000000000..cddb9cb6dd --- /dev/null +++ b/src/app/(dashboard)/dashboard/agent-skills/components/McpA2aLinksBar.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useCallback, useState, useSyncExternalStore } from "react"; +import { useTranslations } from "next-intl"; + +// SSR-safe origin via useSyncExternalStore. +// Server snapshot returns "" (avoids hydration mismatch). +function useOrigin(): string { + return useSyncExternalStore( + () => () => {}, // no external subscription needed + () => (typeof window !== "undefined" ? window.location.origin : ""), + () => "", // server snapshot + ); +} + +interface LinkCardProps { + label: string; + url: string; + icon: string; + prompt: string; +} + +function LinkCard({ label, url, icon, prompt }: LinkCardProps): JSX.Element { + const t = useTranslations("agentSkills"); + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // clipboard not available — silently ignore + } + }, [url]); + + return ( +
+
+ {icon} +
+
+
+ {label} + +
+ {url} +

{prompt}

+
+
+ ); +} + +export function McpA2aLinksBar(): JSX.Element { + const t = useTranslations("agentSkills"); + const origin = useOrigin(); + + const mcpUrl = origin ? `${origin}/api/mcp/sse` : "/api/mcp/sse"; + const a2aUrl = origin ? `${origin}/.well-known/agent.json` : "/.well-known/agent.json"; + + return ( +
+ + +
+ ); +} + +export default McpA2aLinksBar; diff --git a/src/app/(dashboard)/dashboard/agent-skills/components/SkillCard.tsx b/src/app/(dashboard)/dashboard/agent-skills/components/SkillCard.tsx new file mode 100644 index 0000000000..aab1ac205f --- /dev/null +++ b/src/app/(dashboard)/dashboard/agent-skills/components/SkillCard.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useCallback } from "react"; +import { useTranslations } from "next-intl"; +import type { AgentSkill } from "@/lib/agentSkills/types"; + +interface SkillCardProps { + skill: AgentSkill; + selected: boolean; + onClick: () => void; +} + +export function SkillCard({ skill, selected, onClick }: SkillCardProps): JSX.Element { + const t = useTranslations("agentSkills"); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }, + [onClick], + ); + + const previewItems: string[] = + skill.category === "api" + ? (skill.endpoints ?? []).slice(0, 2) + : (skill.cliCommands ?? []).slice(0, 2); + + return ( +
+
+ + {skill.icon ?? "article"} + +
+ +
+
+ {skill.name} + + + {skill.category === "api" ? t("categoryApi") : t("categoryCli")} + + + {skill.isEntry && ( + + start + + )} + + {skill.isNew && ( + + new + + )} +
+ +

{skill.description}

+ + {previewItems.length > 0 && ( +
+ {previewItems.map((item) => ( + + {item} + + ))} +
+ )} +
+
+ ); +} + +export default SkillCard; diff --git a/src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane.tsx b/src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane.tsx new file mode 100644 index 0000000000..e73ff67157 --- /dev/null +++ b/src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useCallback } from "react"; +import dynamic from "next/dynamic"; +import { useTranslations } from "next-intl"; + +// Lazy-load react-markdown to reduce initial bundle size. +const ReactMarkdown = dynamic(() => import("react-markdown"), { + loading: () => , +}); + +// remark-gfm loaded lazily alongside ReactMarkdown via remarkPlugins prop. +// We avoid rehype-raw to prevent XSS (Hard Rule #7 context). + +interface SkillPreviewPaneProps { + skillId: string | null; + markdown: string | null; + loading: boolean; + onRefresh?: () => void; +} + +function SkeletonLines({ lines }: { lines: number }): JSX.Element { + return ( +