From 1aaf43d89efb0e3c351a4c8db9c438475aa61d0e Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 22:10:08 -0300 Subject: [PATCH] feat(agent-skills): add SkillCard, SkillPreviewPane, CoverageBar, McpA2aLinksBar --- .../agent-skills/components/CoverageBar.tsx | 77 +++++++++ .../components/McpA2aLinksBar.tsx | 93 +++++++++++ .../agent-skills/components/SkillCard.tsx | 105 +++++++++++++ .../components/SkillPreviewPane.tsx | 147 ++++++++++++++++++ 4 files changed, 422 insertions(+) create mode 100644 src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar.tsx create mode 100644 src/app/(dashboard)/dashboard/agent-skills/components/McpA2aLinksBar.tsx create mode 100644 src/app/(dashboard)/dashboard/agent-skills/components/SkillCard.tsx create mode 100644 src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane.tsx 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 ( +