merge(F7): integrate W3 frente F7 into base

This commit is contained in:
diegosouzapw
2026-05-27 22:17:26 -03:00
7 changed files with 1352 additions and 170 deletions

View File

@@ -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 (
<div className="flex items-start gap-3 rounded-lg border border-border p-3 animate-pulse">
<div className="h-9 w-9 rounded-lg bg-bg-subtle shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-3 w-1/3 rounded bg-bg-subtle" />
<div className="h-2 w-full rounded bg-bg-subtle" />
<div className="h-2 w-4/5 rounded bg-bg-subtle" />
</div>
</div>
);
}
function CoverageBarSkeleton(): JSX.Element {
return (
<div className="space-y-2 animate-pulse">
<div className="flex gap-2">
<div className="h-2 w-16 rounded bg-bg-subtle" />
<div className="flex-1 h-2 rounded bg-bg-subtle" />
</div>
<div className="flex gap-2">
<div className="h-2 w-16 rounded bg-bg-subtle" />
<div className="flex-1 h-2 rounded bg-bg-subtle" />
</div>
</div>
);
}
// ── Main component ───────────────────────────────────────────────────────────
export function AgentSkillsPageClient(): JSX.Element {
const t = useTranslations("agentSkills");
// State
const [catalog, setCatalog] = useState<AgentSkill[]>([]);
const [coverage, setCoverage] = useState<SkillCoverage | null>(null);
const [loadingCatalog, setLoadingCatalog] = useState(true);
const [filter, setFilter] = useState<FilterCategory>("all");
const [searchTerm, setSearchTerm] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [markdownCache, setMarkdownCache] = useState<Map<string, string>>(new Map());
const [loadingPreview, setLoadingPreview] = useState(false);
const [generatingSkills, setGeneratingSkills] = useState(false);
const abortRef = useRef<AbortController | null>(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 (
<div className="flex flex-col gap-4">
{/* Concept card — full width */}
<SkillsConceptCard variant="agent" />
{/* Header: coverage + MCP/A2A bar + generate button */}
<div className="flex flex-col gap-3 rounded-xl border border-border bg-bg p-4">
{/* Coverage */}
<div>
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wide">
{t("coverageLabel")}
</span>
{showGenerateButton && (
<button
onClick={() => void handleGenerate()}
disabled={generatingSkills}
className="flex items-center gap-1.5 rounded-lg border border-primary/30 bg-primary/5 px-3 py-1.5 text-xs font-medium text-primary hover:bg-primary/10 transition-colors disabled:opacity-60"
data-testid="generate-button"
>
<span
className={`material-symbols-outlined text-[14px] ${generatingSkills ? "animate-spin" : ""}`}
>
{generatingSkills ? "refresh" : "auto_fix_high"}
</span>
{generatingSkills ? t("regenerateRunning") : t("generateButton")}
</button>
)}
</div>
{coverage ? (
<CoverageBar coverage={coverage} />
) : (
<CoverageBarSkeleton />
)}
</div>
{/* MCP + A2A links */}
<McpA2aLinksBar />
</div>
{/* Search + filters */}
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 material-symbols-outlined text-[16px] text-text-muted pointer-events-none">
search
</span>
<input
type="search"
value={searchTerm}
onChange={(e) => 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"
/>
</div>
<div className="flex gap-1" role="group" aria-label={t("filters.category")}>
{(["all", "api", "cli"] as FilterCategory[]).map((cat) => (
<button
key={cat}
onClick={() => setFilter(cat)}
data-testid={`filter-${cat}`}
className={`rounded-lg px-3 py-2 text-xs font-medium transition-colors border ${
filter === cat
? "bg-primary/10 text-primary border-primary/30"
: "bg-bg text-text-muted border-border hover:bg-bg-subtle hover:text-text-main"
}`}
>
{cat === "all"
? "Todas"
: cat === "api"
? t("categoryApi")
: t("categoryCli")}
</button>
))}
</div>
</div>
{/* Two-column grid: left = skill cards, right = preview */}
<div className="grid grid-cols-12 gap-4" data-testid="skills-grid">
{/* Left: skill cards list (col-span 7) */}
<div
className="col-span-12 lg:col-span-7 flex flex-col gap-2"
data-testid="skills-list"
>
{loadingCatalog ? (
Array.from({ length: 6 }).map((_, i) => <SkillCardSkeleton key={i} />)
) : filteredSkills.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border p-8 text-center">
<span className="material-symbols-outlined text-[32px] text-text-muted mb-3">
search_off
</span>
<p className="text-sm text-text-muted">{t("noSkillsFound")}</p>
</div>
) : (
filteredSkills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
selected={selectedId === skill.id}
onClick={() => handleSelectCard(skill.id)}
/>
))
)}
</div>
{/* Right: preview pane (col-span 5) */}
<div className="col-span-12 lg:col-span-5" data-testid="preview-column">
<SkillPreviewPane
skillId={selectedId}
markdown={selectedMarkdown}
loading={loadingPreview}
onRefresh={selectedId ? handleRefreshPreview : undefined}
/>
</div>
</div>
</div>
);
}
export default AgentSkillsPageClient;

View File

@@ -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 (
<div className="flex flex-col gap-2 text-xs" data-testid="coverage-bar">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-text-muted shrink-0">
{t("categoryApi")} {api.have}/{api.total}
</span>
<div
className={`flex-1 h-2 rounded-full overflow-hidden ${trackColor(api.have, api.total)}`}
>
<div
role="progressbar"
aria-valuenow={api.have}
aria-valuemin={0}
aria-valuemax={api.total}
aria-label={`${t("categoryApi")} ${api.have}/${api.total}`}
className={`h-full rounded-full transition-all duration-500 ${barColor(api.have, api.total)}`}
style={{ width: `${api.total > 0 ? (api.have / api.total) * 100 : 0}%` }}
/>
</div>
<span className="shrink-0 text-text-muted w-12 text-right">
{api.total > 0 ? Math.round((api.have / api.total) * 100) : 0}%
</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-text-muted shrink-0">
{t("categoryCli")} {cli.have}/{cli.total}
</span>
<div
className={`flex-1 h-2 rounded-full overflow-hidden ${trackColor(cli.have, cli.total)}`}
>
<div
role="progressbar"
aria-valuenow={cli.have}
aria-valuemin={0}
aria-valuemax={cli.total}
aria-label={`${t("categoryCli")} ${cli.have}/${cli.total}`}
className={`h-full rounded-full transition-all duration-500 ${barColor(cli.have, cli.total)}`}
style={{ width: `${cli.total > 0 ? (cli.have / cli.total) * 100 : 0}%` }}
/>
</div>
<span className="shrink-0 text-text-muted w-12 text-right">
{cli.total > 0 ? Math.round((cli.have / cli.total) * 100) : 0}%
</span>
</div>
</div>
);
}
export default CoverageBar;

View File

@@ -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 (
<div className="flex-1 flex items-start gap-3 rounded-lg border border-border bg-bg-subtle p-3 min-w-0">
<div className="flex items-center justify-center size-8 rounded-lg bg-primary/10 shrink-0">
<span className="material-symbols-outlined text-primary text-[16px]">{icon}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1 mb-1">
<span className="text-xs font-semibold text-text-main">{label}</span>
<button
onClick={() => void handleCopy()}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors shrink-0 ${
copied
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
: "bg-bg text-text-muted hover:text-text-main"
}`}
title={t("copyUrl")}
aria-label={`${t("copyUrl")} ${label}`}
>
<span className="material-symbols-outlined text-[11px]">
{copied ? "check" : "content_copy"}
</span>
{copied ? "✓" : t("copyUrl")}
</button>
</div>
<code className="block truncate text-[10px] font-mono text-text-muted">{url}</code>
<p className="mt-1 text-[10px] text-text-muted leading-relaxed italic">{prompt}</p>
</div>
</div>
);
}
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 (
<div className="flex flex-col sm:flex-row gap-2" data-testid="mcp-a2a-links-bar">
<LinkCard
label={t("mcpUrl")}
url={mcpUrl}
icon="electrical_services"
prompt="Add this MCP endpoint to your agent to give it 37 OmniRoute tools."
/>
<LinkCard
label={t("a2aLink")}
url={a2aUrl}
icon="hub"
prompt="Register this Agent Card with your orchestrator to enable A2A task delegation."
/>
</div>
);
}
export default McpA2aLinksBar;

View File

@@ -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<HTMLDivElement>) => {
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 (
<div
role="button"
tabIndex={0}
aria-pressed={selected}
onClick={onClick}
onKeyDown={handleKeyDown}
data-testid={`skill-card-${skill.id}`}
className={`flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-primary/50 ${
selected
? "border-primary/40 bg-primary/5"
: "border-border bg-bg hover:bg-bg-subtle hover:border-border"
}`}
>
<div
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${
selected ? "bg-primary/15" : "bg-bg-subtle"
}`}
>
<span
className={`material-symbols-outlined text-[18px] ${
selected ? "text-primary" : "text-text-muted"
}`}
>
{skill.icon ?? "article"}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex flex-wrap items-center gap-1.5">
<span className="text-sm font-semibold text-text-main">{skill.name}</span>
<span
className={`rounded-full px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide ${
skill.category === "api"
? "bg-blue-500/10 text-blue-700 dark:text-blue-400"
: "bg-violet-500/10 text-violet-700 dark:text-violet-400"
}`}
>
{skill.category === "api" ? t("categoryApi") : t("categoryCli")}
</span>
{skill.isEntry && (
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary">
start
</span>
)}
{skill.isNew && (
<span className="rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700 dark:text-amber-400">
new
</span>
)}
</div>
<p className="text-xs leading-relaxed text-text-muted line-clamp-2">{skill.description}</p>
{previewItems.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{previewItems.map((item) => (
<code
key={item}
className="rounded bg-bg-subtle px-1.5 py-0.5 font-mono text-[10px] text-text-muted border border-border/50"
>
{item}
</code>
))}
</div>
)}
</div>
</div>
);
}
export default SkillCard;

View File

@@ -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: () => <SkeletonLines lines={6} />,
});
// 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 (
<div className="space-y-2 animate-pulse" aria-hidden="true">
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className="h-3 rounded bg-bg-subtle"
style={{ width: `${60 + ((i * 17) % 40)}%` }}
/>
))}
</div>
);
}
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 (
<div
className="flex flex-col items-center justify-center h-full min-h-[300px] rounded-xl border border-dashed border-border bg-bg-subtle/30 p-8 text-center"
data-testid="skill-preview-empty"
>
<span className="material-symbols-outlined text-[32px] text-text-muted mb-3">
article
</span>
<p className="text-sm text-text-muted">{t("previewEmpty")}</p>
</div>
);
}
return (
<div
className="flex flex-col rounded-xl border border-border bg-bg h-full"
data-testid="skill-preview-pane"
>
{/* Header */}
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2.5 shrink-0">
<span className="text-xs font-mono font-semibold text-text-muted truncate">
{skillId}/SKILL.md
</span>
<div className="flex items-center gap-1 shrink-0">
{onRefresh && (
<button
onClick={onRefresh}
disabled={loading}
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors disabled:opacity-50"
aria-label="Refresh"
>
<span
className={`material-symbols-outlined text-[14px] ${loading ? "animate-spin" : ""}`}
>
refresh
</span>
</button>
)}
<button
onClick={() => void handleCopyRawUrl()}
disabled={!skillId}
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
title={t("copyUrl")}
aria-label={t("copyUrl")}
>
<span className="material-symbols-outlined text-[14px]">content_copy</span>
</button>
{githubUrl && (
<a
href={githubUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
title={t("viewOnGithub")}
aria-label={t("viewOnGithub")}
>
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
</a>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 min-h-0">
{loading ? (
<SkeletonLines lines={12} />
) : markdown ? (
<div
className="prose prose-sm dark:prose-invert max-w-none text-text-main"
data-testid="skill-preview-markdown"
>
<ReactMarkdown>{markdown}</ReactMarkdown>
</div>
) : (
<div
className="flex items-center gap-2 rounded-lg border border-red-300 bg-red-50 dark:bg-red-950/30 p-3 text-xs text-red-700 dark:text-red-400"
data-testid="skill-preview-error"
>
<span className="material-symbols-outlined text-[16px]">error</span>
{t("previewError")}
</div>
)}
</div>
</div>
);
}
export default SkillPreviewPane;

View File

@@ -1,172 +1,5 @@
"use client";
import { AgentSkillsPageClient } from "./AgentSkillsPageClient";
import { useTranslations } from "next-intl";
import {
AGENT_SKILLS,
AGENT_SKILLS_REPO_URL,
getAgentSkillRawUrl,
getAgentSkillBlobUrl,
type AgentSkill,
} from "@/shared/constants/agentSkills";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
function CopyButton({ url }: { url: string }) {
const t = useTranslations("agents");
const { copied, copy } = useCopyToClipboard();
const isCopied = copied === url;
return (
<button
onClick={() => void copy(url, url)}
className={`flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition-colors ${
isCopied
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
: "bg-bg-subtle text-text-muted hover:text-text-main"
}`}
title={t("copyRawUrlTitle")}
>
<span className="material-symbols-outlined text-[14px]">
{isCopied ? "check" : "content_copy"}
</span>
{isCopied ? t("copied") : t("copyUrl")}
</button>
);
}
function SkillRow({ skill }: { skill: AgentSkill }) {
const t = useTranslations("agents");
const rawUrl = getAgentSkillRawUrl(skill.id);
const blobUrl = getAgentSkillBlobUrl(skill.id);
return (
<div className="flex items-start gap-3 rounded-lg border border-border p-3 transition-colors hover:bg-bg-subtle">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-bg-subtle">
<span className="material-symbols-outlined text-[18px] text-text-muted">{skill.icon}</span>
</div>
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex flex-wrap items-center gap-1.5">
<span className="text-sm font-semibold text-text-main">{skill.name}</span>
{skill.isEntry && (
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary">
{t("startHere")}
</span>
)}
{skill.isNew && (
<span className="rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700 dark:text-amber-400">
{t("badgeNew")}
</span>
)}
{skill.endpoint && (
<code className="rounded bg-bg-subtle px-1.5 py-0.5 font-mono text-[10px] text-text-muted">
{skill.endpoint}
</code>
)}
</div>
<p className="text-xs leading-relaxed text-text-muted">{skill.description}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<a
href={blobUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-text-muted transition-colors hover:bg-bg-subtle hover:text-text-main"
title={t("viewOnGithub")}
>
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
</a>
<CopyButton url={rawUrl} />
</div>
</div>
);
}
function SkillSection({
title,
subtitle,
icon,
skills,
}: {
title: string;
subtitle: string;
icon: string;
skills: AgentSkill[];
}) {
return (
<section className="space-y-3 rounded-xl border border-border bg-bg p-4">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-text-muted">{icon}</span>
<div>
<h2 className="text-sm font-semibold text-text-main">{title}</h2>
<p className="text-xs text-text-muted">{subtitle}</p>
</div>
</div>
<div className="flex flex-col gap-2">
{skills.map((skill) => (
<SkillRow key={skill.id} skill={skill} />
))}
</div>
</section>
);
}
export default function AgentSkillsPage() {
const t = useTranslations("agents");
const apiSkills = AGENT_SKILLS.filter((s) => s.category === "api");
const cliSkills = AGENT_SKILLS.filter((s) => s.category === "cli");
return (
<div className="space-y-6">
{/* How to use — full width */}
<div className="rounded-xl border border-border bg-bg-subtle/50 p-4">
<div className="mb-2 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-primary">info</span>
<span className="text-sm font-semibold text-text-main">{t("howToUse")}</span>
</div>
<ol className="space-y-1 text-xs text-text-muted">
<li>
1.{" "}
{t.rich("howToUseStep1", {
copyUrl: t("copyUrl"),
bold: (chunks) => <strong className="text-text-main">{chunks}</strong>,
})}
</li>
<li>
2. {t("howToUseStep2")}
<br />
<code className="mt-1 block rounded border border-border bg-bg px-2 py-1 font-mono text-[11px]">
{t("howToUseStep2Code")}
</code>
</li>
<li>3. {t("howToUseStep3")}</li>
</ol>
<a
href={AGENT_SKILLS_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
{t("browseAllSkillsOnGithub")}
</a>
</div>
{/* Two-column grid: API Skills | CLI Skills */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SkillSection
title={t("apiSkills")}
subtitle={t("apiSkillsSubtitle", { count: apiSkills.length })}
icon="api"
skills={apiSkills}
/>
<SkillSection
title={t("cliSkills")}
subtitle={t("cliSkillsSubtitle", { count: cliSkills.length })}
icon="terminal"
skills={cliSkills}
/>
</div>
</div>
);
export default function Page() {
return <AgentSkillsPageClient />;
}

View File

@@ -0,0 +1,631 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Root } from "react-dom/client";
import type { AgentSkill, SkillCoverage } from "../../src/lib/agentSkills/types";
// ── i18n stub ────────────────────────────────────────────────────────────────
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
// ── next/link stub ───────────────────────────────────────────────────────────
vi.mock("next/link", () => ({
default: ({
href,
children,
className,
}: {
href: string;
children: React.ReactNode;
className?: string;
}) => (
<a href={href} className={className}>
{children}
</a>
),
}));
// ── next/dynamic stub — renders placeholder immediately ───────────────────────
vi.mock("next/dynamic", () => ({
default: (loader: () => Promise<{ default: React.ComponentType<{ children: string }> }>, _opts?: unknown) => {
// Return a synchronous stub that renders children as plain text.
return function DynamicStub({ children }: { children: string }) {
return <div data-testid="react-markdown">{children}</div>;
};
},
}));
// ── Fixture helpers ──────────────────────────────────────────────────────────
function makeSkill(overrides: Partial<AgentSkill> = {}): AgentSkill {
return {
id: "omni-providers",
name: "Providers",
description: "Manage provider connections and API keys.",
category: "api",
area: "providers",
icon: "hub",
endpoints: ["POST /api/providers", "GET /api/providers"],
rawUrl: "https://raw.githubusercontent.com/diegosouzapw/OmniRoute/refs/heads/main/skills/omni-providers/SKILL.md",
githubUrl: "https://github.com/diegosouzapw/OmniRoute/blob/main/skills/omni-providers/SKILL.md",
...overrides,
};
}
function make42Skills(): AgentSkill[] {
const skills: AgentSkill[] = [];
for (let i = 0; i < 22; i++) {
skills.push(
makeSkill({
id: `omni-skill-${i}`,
name: `API Skill ${i}`,
category: "api",
}),
);
}
for (let i = 0; i < 20; i++) {
skills.push(
makeSkill({
id: `cli-skill-${i}`,
name: `CLI Skill ${i}`,
category: "cli",
endpoints: undefined,
cliCommands: [`skill${i} run`, `skill${i} status`],
}),
);
}
return skills;
}
const FULL_COVERAGE: SkillCoverage = {
api: { have: 22, total: 22 },
cli: { have: 20, total: 20 },
totalSkills: 42,
generatedAt: new Date().toISOString(),
};
const PARTIAL_COVERAGE: SkillCoverage = {
api: { have: 10, total: 22 },
cli: { have: 8, total: 20 },
totalSkills: 18,
generatedAt: new Date().toISOString(),
};
// ── Fetch mock factory ───────────────────────────────────────────────────────
function mockFetch(skills: AgentSkill[], coverage: SkillCoverage, rawMarkdown = "# Test Skill\nContent here.") {
return vi.fn(async (url: string | Request) => {
const urlStr = typeof url === "string" ? url : url.toString();
if (urlStr === "/api/agent-skills") {
return new Response(JSON.stringify({ skills, coverage }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (/\/api\/agent-skills\/.+\/raw/.test(urlStr)) {
return new Response(JSON.stringify({ body: rawMarkdown }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({}), { status: 404 });
});
}
// ── Test setup ───────────────────────────────────────────────────────────────
const cleanupCallbacks: Array<() => void> = [];
let root: Root | null = null;
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => container.remove());
return container;
}
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
// Mock clipboard
Object.defineProperty(navigator, "clipboard", {
value: { writeText: vi.fn().mockResolvedValue(undefined) },
configurable: true,
});
// Mock window.location.origin
Object.defineProperty(window, "location", {
value: { origin: "http://localhost:20128" },
configurable: true,
});
// Mock window.confirm
vi.spyOn(window, "confirm").mockReturnValue(false);
});
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount();
});
root = null;
}
while (cleanupCallbacks.length > 0) {
cleanupCallbacks.pop()?.();
}
document.body.innerHTML = "";
vi.clearAllMocks();
vi.unstubAllGlobals();
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("AgentSkillsPageClient", () => {
it("renders 42 skill cards after fetch resolves", async () => {
const skills = make42Skills();
vi.stubGlobal("fetch", mockFetch(skills, FULL_COVERAGE));
const { AgentSkillsPageClient } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
);
const container = makeContainer();
root = createRoot(container);
await act(async () => {
root?.render(<AgentSkillsPageClient />);
});
const cards = container.querySelectorAll("[data-testid^='skill-card-']");
expect(cards.length).toBe(42);
});
it("renders SkillsConceptCard variant=agent at the top", async () => {
vi.stubGlobal("fetch", mockFetch(make42Skills(), FULL_COVERAGE));
const { AgentSkillsPageClient } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
);
const container = makeContainer();
root = createRoot(container);
await act(async () => {
root?.render(<AgentSkillsPageClient />);
});
// SkillsConceptCard uses i18n key conceptCard.agent.title
expect(container.textContent).toContain("conceptCard.agent.title");
});
it("filter API shows only api-category cards", async () => {
const skills = make42Skills();
vi.stubGlobal("fetch", mockFetch(skills, FULL_COVERAGE));
const { AgentSkillsPageClient } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
);
const container = makeContainer();
root = createRoot(container);
await act(async () => {
root?.render(<AgentSkillsPageClient />);
});
const filterApiBtn = container.querySelector("[data-testid='filter-api']") as HTMLButtonElement | null;
expect(filterApiBtn).not.toBeNull();
await act(async () => {
filterApiBtn?.click();
});
const cards = container.querySelectorAll("[data-testid^='skill-card-']");
expect(cards.length).toBe(22);
});
it("filter CLI shows only cli-category cards", async () => {
const skills = make42Skills();
vi.stubGlobal("fetch", mockFetch(skills, FULL_COVERAGE));
const { AgentSkillsPageClient } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
);
const container = makeContainer();
root = createRoot(container);
await act(async () => {
root?.render(<AgentSkillsPageClient />);
});
const filterCliBtn = container.querySelector("[data-testid='filter-cli']") as HTMLButtonElement | null;
await act(async () => {
filterCliBtn?.click();
});
const cards = container.querySelectorAll("[data-testid^='skill-card-']");
expect(cards.length).toBe(20);
});
it("clicking a card triggers preview fetch", async () => {
const skills = make42Skills();
const fetchMock = mockFetch(skills, FULL_COVERAGE, "# omni-skill-0 doc");
vi.stubGlobal("fetch", fetchMock);
const { AgentSkillsPageClient } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
);
const container = makeContainer();
root = createRoot(container);
await act(async () => {
root?.render(<AgentSkillsPageClient />);
});
const firstCard = container.querySelector("[data-testid='skill-card-omni-skill-0']") as HTMLElement | null;
expect(firstCard).not.toBeNull();
await act(async () => {
firstCard?.click();
});
// A raw fetch should have been made
const rawFetchCalls = (fetchMock as ReturnType<typeof vi.fn>).mock.calls.filter(
([url]: [string]) => typeof url === "string" && url.includes("/raw"),
);
expect(rawFetchCalls.length).toBeGreaterThan(0);
});
it("preview pane shows empty state when no card is selected", async () => {
vi.stubGlobal("fetch", mockFetch(make42Skills(), FULL_COVERAGE));
const { AgentSkillsPageClient } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
);
const container = makeContainer();
root = createRoot(container);
await act(async () => {
root?.render(<AgentSkillsPageClient />);
});
const emptyState = container.querySelector("[data-testid='skill-preview-empty']");
expect(emptyState).not.toBeNull();
});
it("CoverageBar is rendered with 100% = green bars when coverage is full", async () => {
vi.stubGlobal("fetch", mockFetch(make42Skills(), FULL_COVERAGE));
const { AgentSkillsPageClient } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
);
const container = makeContainer();
root = createRoot(container);
await act(async () => {
root?.render(<AgentSkillsPageClient />);
});
const coverageBar = container.querySelector("[data-testid='coverage-bar']");
expect(coverageBar).not.toBeNull();
const progressBars = container.querySelectorAll("[role='progressbar']");
expect(progressBars.length).toBe(2);
// API bar — 22/22 = 100%, should have emerald color class
const apiBar = progressBars[0] as HTMLElement;
expect(apiBar.className).toContain("bg-emerald-500");
// CLI bar — 20/20 = 100%, should have emerald color class
const cliBar = progressBars[1] as HTMLElement;
expect(cliBar.className).toContain("bg-emerald-500");
});
it("generate button is hidden when coverage is 100%", async () => {
vi.stubGlobal("fetch", mockFetch(make42Skills(), FULL_COVERAGE));
const { AgentSkillsPageClient } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
);
const container = makeContainer();
root = createRoot(container);
await act(async () => {
root?.render(<AgentSkillsPageClient />);
});
const generateBtn = container.querySelector("[data-testid='generate-button']");
expect(generateBtn).toBeNull();
});
it("generate button is visible when coverage is partial", async () => {
vi.stubGlobal("fetch", mockFetch(make42Skills(), PARTIAL_COVERAGE));
const { AgentSkillsPageClient } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
);
const container = makeContainer();
root = createRoot(container);
await act(async () => {
root?.render(<AgentSkillsPageClient />);
});
const generateBtn = container.querySelector("[data-testid='generate-button']");
expect(generateBtn).not.toBeNull();
});
it("search filters cards by name", async () => {
const skills = make42Skills();
vi.stubGlobal("fetch", mockFetch(skills, FULL_COVERAGE));
const { AgentSkillsPageClient } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
);
const container = makeContainer();
root = createRoot(container);
await act(async () => {
root?.render(<AgentSkillsPageClient />);
});
const searchInput = container.querySelector("[data-testid='search-input']") as HTMLInputElement | null;
expect(searchInput).not.toBeNull();
await act(async () => {
if (searchInput) {
searchInput.value = "API Skill 0";
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
// React uses onChange
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(searchInput, "API Skill 0");
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
// After search, cards with "API Skill 0" in name should be visible
// (at minimum the one exact match)
const cards = container.querySelectorAll("[data-testid^='skill-card-']");
expect(cards.length).toBeLessThanOrEqual(42);
});
it("MCP and A2A links bar is present", async () => {
vi.stubGlobal("fetch", mockFetch(make42Skills(), FULL_COVERAGE));
const { AgentSkillsPageClient } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient"
);
const container = makeContainer();
root = createRoot(container);
await act(async () => {
root?.render(<AgentSkillsPageClient />);
});
const linksBar = container.querySelector("[data-testid='mcp-a2a-links-bar']");
expect(linksBar).not.toBeNull();
});
});
// ── CoverageBar isolated tests ───────────────────────────────────────────────
describe("CoverageBar", () => {
it("renders two progressbars with correct aria attributes", async () => {
const { CoverageBar } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar"
);
const container = makeContainer();
const localRoot = createRoot(container);
await act(async () => {
localRoot.render(<CoverageBar coverage={FULL_COVERAGE} />);
});
const bars = container.querySelectorAll("[role='progressbar']");
expect(bars.length).toBe(2);
const apiBar = bars[0] as HTMLElement;
expect(apiBar.getAttribute("aria-valuenow")).toBe("22");
expect(apiBar.getAttribute("aria-valuemax")).toBe("22");
const cliBar = bars[1] as HTMLElement;
expect(cliBar.getAttribute("aria-valuenow")).toBe("20");
expect(cliBar.getAttribute("aria-valuemax")).toBe("20");
await act(async () => localRoot.unmount());
});
it("applies red color class when coverage is below 75%", async () => {
const { CoverageBar } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar"
);
const lowCoverage: SkillCoverage = {
api: { have: 5, total: 22 },
cli: { have: 0, total: 20 },
totalSkills: 5,
generatedAt: new Date().toISOString(),
};
const container = makeContainer();
const localRoot = createRoot(container);
await act(async () => {
localRoot.render(<CoverageBar coverage={lowCoverage} />);
});
const bars = container.querySelectorAll("[role='progressbar']");
const apiBar = bars[0] as HTMLElement;
expect(apiBar.className).toContain("bg-red-500");
await act(async () => localRoot.unmount());
});
it("applies amber color class when coverage is between 75% and 100%", async () => {
const { CoverageBar } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar"
);
const partialCoverage: SkillCoverage = {
api: { have: 18, total: 22 }, // ~81.8% = amber
cli: { have: 15, total: 20 }, // 75% = amber
totalSkills: 33,
generatedAt: new Date().toISOString(),
};
const container = makeContainer();
const localRoot = createRoot(container);
await act(async () => {
localRoot.render(<CoverageBar coverage={partialCoverage} />);
});
const bars = container.querySelectorAll("[role='progressbar']");
const apiBar = bars[0] as HTMLElement;
expect(apiBar.className).toContain("bg-amber-400");
await act(async () => localRoot.unmount());
});
});
// ── SkillCard isolated tests ─────────────────────────────────────────────────
describe("SkillCard", () => {
it("renders skill name and description", async () => {
const { SkillCard } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/components/SkillCard"
);
const skill = makeSkill({ name: "Providers", description: "Manage connections" });
const container = makeContainer();
const localRoot = createRoot(container);
await act(async () => {
localRoot.render(<SkillCard skill={skill} selected={false} onClick={() => {}} />);
});
expect(container.textContent).toContain("Providers");
expect(container.textContent).toContain("Manage connections");
await act(async () => localRoot.unmount());
});
it("has role=button and aria-pressed=false when not selected", async () => {
const { SkillCard } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/components/SkillCard"
);
const container = makeContainer();
const localRoot = createRoot(container);
await act(async () => {
localRoot.render(<SkillCard skill={makeSkill()} selected={false} onClick={() => {}} />);
});
const btn = container.querySelector("[role='button']") as HTMLElement | null;
expect(btn).not.toBeNull();
expect(btn?.getAttribute("aria-pressed")).toBe("false");
await act(async () => localRoot.unmount());
});
it("has aria-pressed=true when selected", async () => {
const { SkillCard } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/components/SkillCard"
);
const container = makeContainer();
const localRoot = createRoot(container);
await act(async () => {
localRoot.render(<SkillCard skill={makeSkill()} selected={true} onClick={() => {}} />);
});
const btn = container.querySelector("[role='button']") as HTMLElement | null;
expect(btn?.getAttribute("aria-pressed")).toBe("true");
await act(async () => localRoot.unmount());
});
it("calls onClick when clicked", async () => {
const { SkillCard } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/components/SkillCard"
);
const handleClick = vi.fn();
const container = makeContainer();
const localRoot = createRoot(container);
await act(async () => {
localRoot.render(<SkillCard skill={makeSkill()} selected={false} onClick={handleClick} />);
});
const btn = container.querySelector("[role='button']") as HTMLElement | null;
await act(async () => btn?.click());
expect(handleClick).toHaveBeenCalledTimes(1);
await act(async () => localRoot.unmount());
});
it("shows first 2 endpoints as chips for API skill", async () => {
const { SkillCard } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/components/SkillCard"
);
const skill = makeSkill({
endpoints: ["POST /api/providers", "GET /api/providers", "DELETE /api/providers/:id"],
});
const container = makeContainer();
const localRoot = createRoot(container);
await act(async () => {
localRoot.render(<SkillCard skill={skill} selected={false} onClick={() => {}} />);
});
const chips = container.querySelectorAll("code");
expect(chips.length).toBe(2);
expect(chips[0].textContent).toBe("POST /api/providers");
expect(chips[1].textContent).toBe("GET /api/providers");
await act(async () => localRoot.unmount());
});
});
// ── SkillPreviewPane isolated tests ──────────────────────────────────────────
describe("SkillPreviewPane", () => {
it("renders empty state when skillId is null", async () => {
const { SkillPreviewPane } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane"
);
const container = makeContainer();
const localRoot = createRoot(container);
await act(async () => {
localRoot.render(
<SkillPreviewPane skillId={null} markdown={null} loading={false} />,
);
});
const empty = container.querySelector("[data-testid='skill-preview-empty']");
expect(empty).not.toBeNull();
expect(container.textContent).toContain("previewEmpty");
await act(async () => localRoot.unmount());
});
it("renders markdown when skillId and markdown are provided", async () => {
const { SkillPreviewPane } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane"
);
const container = makeContainer();
const localRoot = createRoot(container);
await act(async () => {
localRoot.render(
<SkillPreviewPane
skillId="omni-providers"
markdown="# Providers\nContent here."
loading={false}
/>,
);
});
const preview = container.querySelector("[data-testid='skill-preview-pane']");
expect(preview).not.toBeNull();
expect(container.textContent).toContain("Providers");
await act(async () => localRoot.unmount());
});
it("shows error state when skillId provided but markdown is empty string", async () => {
const { SkillPreviewPane } = await import(
"../../src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane"
);
const container = makeContainer();
const localRoot = createRoot(container);
await act(async () => {
localRoot.render(
<SkillPreviewPane skillId="omni-providers" markdown="" loading={false} />,
);
});
// markdown is "" (falsy) — should show error state
const errorEl = container.querySelector("[data-testid='skill-preview-error']");
expect(errorEl).not.toBeNull();
await act(async () => localRoot.unmount());
});
});