+
+ {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 (
+
+ {Array.from({ length: lines }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+export function SkillPreviewPane({
+ skillId,
+ markdown,
+ loading,
+ onRefresh,
+}: SkillPreviewPaneProps): JSX.Element {
+ const t = useTranslations("agentSkills");
+
+ const handleCopyRawUrl = useCallback(async () => {
+ if (!skillId) return;
+ const rawUrl = `https://raw.githubusercontent.com/diegosouzapw/OmniRoute/refs/heads/main/skills/${skillId}/SKILL.md`;
+ try {
+ await navigator.clipboard.writeText(rawUrl);
+ } catch {
+ // clipboard not available — silently ignore
+ }
+ }, [skillId]);
+
+ const githubUrl = skillId
+ ? `https://github.com/diegosouzapw/OmniRoute/blob/main/skills/${skillId}/SKILL.md`
+ : null;
+
+ // Empty state
+ if (!skillId) {
+ return (
+
+
+ article
+
+
{t("previewEmpty")}
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ {skillId}/SKILL.md
+
+
+ {onRefresh && (
+
+ )}
+
+ {githubUrl && (
+
+ open_in_new
+
+ )}
+
+
+
+ {/* Content */}
+
+ {loading ? (
+
+ ) : markdown ? (
+
+ {markdown}
+
+ ) : (
+
+ error
+ {t("previewError")}
+
+ )}
+
+
+ );
+}
+
+export default SkillPreviewPane;