chore: publish from main
@@ -0,0 +1,10 @@
|
||||
scripts:
|
||||
- name: Generate readme
|
||||
command: npm run build
|
||||
- name: Run website
|
||||
command: "npm run website:dev"
|
||||
- name: Install dependencies
|
||||
command: npm install && cd website && npm install
|
||||
automation:
|
||||
auto_issue_session: true
|
||||
remote_control: false
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install root dependencies
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install root dependencies
|
||||
|
||||
@@ -25,3 +25,4 @@ website/public/llms.txt
|
||||
obj/
|
||||
bin/
|
||||
.impeccable
|
||||
.playwright-mcp
|
||||
|
||||
@@ -1389,7 +1389,7 @@ function generateCanvasManifest(gitDates, commitSha) {
|
||||
canvasId: id,
|
||||
extensionId: id,
|
||||
extensionName: name,
|
||||
pluginName: null,
|
||||
pluginName: name,
|
||||
name: displayName,
|
||||
version: normalizeText(ext?.version, "1.0.0"),
|
||||
readmeFile: null,
|
||||
@@ -1414,7 +1414,10 @@ function generateCanvasManifest(gitDates, commitSha) {
|
||||
imageUrl,
|
||||
assetPath: null,
|
||||
installUrl: null,
|
||||
installCommand: null,
|
||||
// Registered in plugins/external.json, so it is installable from the
|
||||
// awesome-copilot marketplace by plugin name even though it is hosted
|
||||
// externally.
|
||||
installCommand: `copilot plugin install ${name}@awesome-copilot`,
|
||||
sourceUrl,
|
||||
externalSource,
|
||||
external: true,
|
||||
@@ -1463,7 +1466,8 @@ function generateSearchIndex(
|
||||
agents,
|
||||
instructions,
|
||||
skills,
|
||||
plugins
|
||||
plugins,
|
||||
extensions
|
||||
) {
|
||||
const index = [];
|
||||
|
||||
@@ -1519,6 +1523,20 @@ function generateSearchIndex(
|
||||
});
|
||||
}
|
||||
|
||||
for (const extension of extensions) {
|
||||
index.push({
|
||||
type: "extension",
|
||||
id: extension.id,
|
||||
title: extension.name || extension.title || extension.id,
|
||||
description: extension.description || "",
|
||||
path: extension.path,
|
||||
lastUpdated: extension.lastUpdated,
|
||||
searchText:
|
||||
extension.searchText ||
|
||||
`${extension.name || extension.title || extension.id} ${extension.description || ""}`.toLowerCase(),
|
||||
});
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
@@ -1724,7 +1742,8 @@ async function main() {
|
||||
agents,
|
||||
instructions,
|
||||
skills,
|
||||
plugins
|
||||
plugins,
|
||||
extensions
|
||||
);
|
||||
console.log(`✓ Generated search index with ${searchIndex.length} items`);
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ function skipsAsBundledAsset(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
const BUILD_OUTPUT_DIRECTORIES = new Set(["bin", "obj"]);
|
||||
|
||||
/**
|
||||
* Parse frontmatter from a markdown file using vfile-matter
|
||||
* Works with any markdown file that has YAML frontmatter (agents, prompts, instructions)
|
||||
@@ -168,9 +170,7 @@ function parseSkillMetadata(skillPath) {
|
||||
entries.forEach((entry) => {
|
||||
const filePath = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "bin" || entry.name === "obj") {
|
||||
return; // Skip bin and obj directories as they are .NET project output folders and not part of the skill assets
|
||||
}
|
||||
if (BUILD_OUTPUT_DIRECTORIES.has(entry.name)) return;
|
||||
arrayOfFiles = getAllFiles(filePath, arrayOfFiles);
|
||||
} else if (
|
||||
!entry.isSymbolicLink() ||
|
||||
|
||||
@@ -72,6 +72,19 @@ test("recurses into nested and non-spec subdirectories instead of listing the di
|
||||
}
|
||||
});
|
||||
|
||||
test("excludes ignored build output directories from bundled assets", () => {
|
||||
const skillPath = createSkill({
|
||||
"SKILL.md": SKILL_MD,
|
||||
"scripts/generate.cs": "source",
|
||||
"scripts/bin/Debug/tool.dll": "build output",
|
||||
"scripts/obj/project.assets.json": "build output",
|
||||
});
|
||||
|
||||
const metadata = parseSkillMetadata(skillPath);
|
||||
|
||||
assert.deepEqual(metadata.assets, ["scripts/generate.cs"]);
|
||||
});
|
||||
|
||||
test("excludes only the root SKILL.md, keeping nested ones as bundled assets", () => {
|
||||
const skillPath = createSkill({
|
||||
"SKILL.md": SKILL_MD,
|
||||
|
||||
@@ -157,7 +157,6 @@ Use these sections during Phase 2 to inform investigation questions and identify
|
||||
| Asset | When to load |
|
||||
|-------|-------------|
|
||||
| [`scripts/scan.py`](scripts/scan.py) | Phase 1 — run first, before reading any code (Python 3.8+ required) |
|
||||
|
||||
| [`references/inquiry-checkpoints.md`](references/inquiry-checkpoints.md) | Phase 2 — load for per-template investigation questions |
|
||||
| [`references/stack-detection.md`](references/stack-detection.md) | Phase 2 — only if stack is ambiguous |
|
||||
| [`assets/templates/STACK.md`](assets/templates/STACK.md) | Phase 3 step 1 |
|
||||
|
||||
@@ -1,15 +1,44 @@
|
||||
import { unified } from "@astrojs/markdown-remark";
|
||||
import sitemap from "@astrojs/sitemap";
|
||||
import starlight from "@astrojs/starlight";
|
||||
import react from "@astrojs/react";
|
||||
import { unified } from "@astrojs/markdown-remark";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "astro/config";
|
||||
import remarkGithubAdmonitionsToDirectives from "remark-github-admonitions-to-directives";
|
||||
import pagefindResources from "./src/integrations/pagefind-resources";
|
||||
|
||||
// Learning Hub course content mirrored from external workshop repos is authored
|
||||
// in GitHub admonition syntax (`> [!NOTE]`). This remark plugin rewrites those
|
||||
// callouts into Starlight aside directives before Starlight renders them, so the
|
||||
// same syntax used in the source repos and on github.com also produces styled
|
||||
// callouts here. The mapping targets Starlight's aside types (note / tip / caution).
|
||||
// The contributor count is read from the repo's all-contributors manifest at
|
||||
// build time and inlined as a literal into both the server render and the
|
||||
// client bundle. It cannot be read at module scope in src/ because the shells
|
||||
// that display it are `client:load` hydrated: `node:fs` is unavailable in the
|
||||
// browser, so the read would fail there and the badge would reset to 0 on
|
||||
// hydration even though the server-rendered HTML had the right number.
|
||||
function readContributorsTotal() {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const manifest = path.resolve(here, "..", ".all-contributorsrc");
|
||||
try {
|
||||
const rc = JSON.parse(fs.readFileSync(manifest, "utf8"));
|
||||
if (!Array.isArray(rc.contributors)) {
|
||||
throw new Error("`contributors` is missing or is not an array");
|
||||
}
|
||||
return rc.contributors.length;
|
||||
} catch (error) {
|
||||
const message = `Could not read the contributor count from ${manifest}: ${error.message}`;
|
||||
// Falling back to 0 silently is how this badge regressed before, so make a
|
||||
// broken manifest fail the production build rather than ship a wrong count.
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error(message);
|
||||
}
|
||||
console.warn(`[contributors] ${message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Learning Hub course content mirrored from external workshop repos is authored in
|
||||
// GitHub admonition syntax (`> [!NOTE]`). This remark plugin rewrites those
|
||||
// callouts into directives before rendering, so the same syntax used in the
|
||||
// source repos and on github.com also produces styled callouts here.
|
||||
const githubAdmonitionMapping = {
|
||||
NOTE: "note",
|
||||
TIP: "tip",
|
||||
@@ -19,15 +48,6 @@ const githubAdmonitionMapping = {
|
||||
};
|
||||
|
||||
const site = "https://awesome-copilot.github.com/";
|
||||
const siteDescription =
|
||||
"Community-contributed agents, instructions, and skills to enhance your GitHub Copilot experience";
|
||||
// Social preview image used for all Open Graph / Twitter cards (e.g. LinkedIn, which is
|
||||
// Open Graph-driven). socialImageWidth/Height MUST match the actual pixels of social-image.png.
|
||||
// If a page ever overrides og:image, also override og:image:width/height and twitter:image
|
||||
// (Head.astro derives og:image:secure_url from twitter:image first).
|
||||
const socialImageUrl = new URL("/images/social-image.png", site).toString();
|
||||
const socialImageWidth = "2400";
|
||||
const socialImageHeight = "1260";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
@@ -35,270 +55,84 @@ export default defineConfig({
|
||||
base: "/",
|
||||
output: "static",
|
||||
markdown: {
|
||||
// Astro 7.1+ uses Sätteri as the default Markdown processor, but its plugin
|
||||
// API is incompatible with remark/rehype plugins. We explicitly opt into the
|
||||
// unified() (remark/rehype) processor so we can keep using
|
||||
// remark-github-admonitions-to-directives, which rewrites > [!NOTE] callouts
|
||||
// from Learning Hub course content into Starlight aside directives.
|
||||
// Astro 7 deprecated top-level `remarkPlugins`/`rehypePlugins`/
|
||||
// `remarkRehype` in favour of configuring the unified pipeline directly.
|
||||
processor: unified({
|
||||
remarkPlugins: [
|
||||
[remarkGithubAdmonitionsToDirectives, { mapping: githubAdmonitionMapping }],
|
||||
[
|
||||
remarkGithubAdmonitionsToDirectives,
|
||||
{ mapping: githubAdmonitionMapping },
|
||||
],
|
||||
],
|
||||
}),
|
||||
// The prototype's own code blocks (SyntaxHighlightedCode.tsx) are styled
|
||||
// entirely through brand CSS tokens rather than baked-in theme colours, so
|
||||
// they automatically match the site's light/dark mode. Raw markdown code
|
||||
// fences render through Shiki instead, which by default bakes a fixed
|
||||
// "github-dark" theme's literal colours into inline styles — that ignores
|
||||
// the site's actual colour mode and clashes with the prototype's bordered,
|
||||
// canvas-subtle code block styling. The "css-variables" theme emits
|
||||
// `var(--astro-code-*)` custom properties instead of literal colours,
|
||||
// which are mapped to the same brand tokens in
|
||||
// src/components/brand/styles/{dotnet-upgrade,github-copilot-app}.module.css.
|
||||
shikiConfig: {
|
||||
theme: "css-variables",
|
||||
},
|
||||
},
|
||||
integrations: [
|
||||
starlight({
|
||||
title: "Awesome GitHub Copilot",
|
||||
favicon: "/images/favicon.svg",
|
||||
description: siteDescription,
|
||||
social: [],
|
||||
head: [
|
||||
{
|
||||
tag: "meta",
|
||||
attrs: {
|
||||
property: "og:image",
|
||||
content: socialImageUrl,
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: "meta",
|
||||
attrs: {
|
||||
property: "og:image:width",
|
||||
content: socialImageWidth,
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: "meta",
|
||||
attrs: {
|
||||
property: "og:image:height",
|
||||
content: socialImageHeight,
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: "meta",
|
||||
attrs: {
|
||||
property: "og:image:alt",
|
||||
content: siteDescription,
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: "meta",
|
||||
attrs: {
|
||||
name: "twitter:image",
|
||||
content: socialImageUrl,
|
||||
},
|
||||
},
|
||||
],
|
||||
customCss: [
|
||||
"./src/styles/starlight-overrides.css",
|
||||
"./src/styles/global.css",
|
||||
],
|
||||
// English is served at the site root (no locale prefix), preserving all
|
||||
// existing URLs. Additional locales are served under a locale prefix
|
||||
// (e.g. /es-es/…) and fall back to the English page when a translation
|
||||
// does not yet exist. These keys match the locale directory names used by
|
||||
// mirrored Learning Hub course content (website/src/content/docs/<locale>/…).
|
||||
defaultLocale: "root",
|
||||
locales: {
|
||||
root: { label: "English", lang: "en" },
|
||||
"es-es": { label: "Español", lang: "es-ES" },
|
||||
"ja-jp": { label: "日本語", lang: "ja-JP" },
|
||||
"ko-kr": { label: "한국어", lang: "ko-KR" },
|
||||
"pt-br": { label: "Português (Brasil)", lang: "pt-BR" },
|
||||
"zh-cn": { label: "简体中文", lang: "zh-CN" },
|
||||
},
|
||||
editLink: {
|
||||
baseUrl:
|
||||
"https://github.com/github/awesome-copilot/edit/staged/website/",
|
||||
},
|
||||
sidebar: [
|
||||
{
|
||||
label: "Fundamentals",
|
||||
items: [
|
||||
"learning-hub/github-copilot-app",
|
||||
"learning-hub/working-with-canvas-extensions",
|
||||
"learning-hub/using-automations-in-copilot-app",
|
||||
"learning-hub/what-are-agents-skills-instructions",
|
||||
"learning-hub/agents-and-subagents",
|
||||
"learning-hub/understanding-copilot-context",
|
||||
"learning-hub/copilot-configuration-basics",
|
||||
"learning-hub/defining-custom-instructions",
|
||||
"learning-hub/creating-effective-skills",
|
||||
"learning-hub/building-custom-agents",
|
||||
"learning-hub/understanding-mcp-servers",
|
||||
"learning-hub/automating-with-hooks",
|
||||
"learning-hub/agentic-workflows",
|
||||
"learning-hub/using-copilot-coding-agent",
|
||||
"learning-hub/installing-and-using-plugins",
|
||||
"learning-hub/before-after-customization-examples",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Reference",
|
||||
items: ["learning-hub/github-copilot-terminology-glossary"],
|
||||
},
|
||||
{
|
||||
label: "Copilot CLI for Beginners",
|
||||
items: [
|
||||
{
|
||||
label: "Overview",
|
||||
link: "/learning-hub/cli-for-beginners/",
|
||||
},
|
||||
"learning-hub/cli-for-beginners/00-quick-start",
|
||||
"learning-hub/cli-for-beginners/01-setup-and-first-steps",
|
||||
"learning-hub/cli-for-beginners/02-context-and-conversations",
|
||||
"learning-hub/cli-for-beginners/03-development-workflows",
|
||||
"learning-hub/cli-for-beginners/04-agents-and-custom-instructions",
|
||||
"learning-hub/cli-for-beginners/05-skills",
|
||||
"learning-hub/cli-for-beginners/06-mcp-servers",
|
||||
"learning-hub/cli-for-beginners/07-putting-it-all-together",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Advanced Copilot CLI",
|
||||
items: [
|
||||
{
|
||||
label: "Overview",
|
||||
link: "/learning-hub/advanced-copilot-cli/",
|
||||
},
|
||||
"learning-hub/advanced-copilot-cli/00-prerequisites",
|
||||
"learning-hub/advanced-copilot-cli/01-working-with-copilot-cli",
|
||||
"learning-hub/advanced-copilot-cli/02-building-ai-infrastructure",
|
||||
"learning-hub/advanced-copilot-cli/03-test-suite-remote-delegation",
|
||||
"learning-hub/advanced-copilot-cli/04-lifecycle-hooks",
|
||||
"learning-hub/advanced-copilot-cli/05-add-feature-barcode",
|
||||
"learning-hub/advanced-copilot-cli/06-modernize-apps",
|
||||
"learning-hub/advanced-copilot-cli/07-manage-infrastructure",
|
||||
"learning-hub/advanced-copilot-cli/08-wrap-up",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Copilot Workshops",
|
||||
items: [
|
||||
{
|
||||
label: "Overview",
|
||||
link: "/learning-hub/copilot-workshops/",
|
||||
},
|
||||
{
|
||||
label: "VS Code",
|
||||
items: [
|
||||
{
|
||||
label: "Overview",
|
||||
link: "/learning-hub/copilot-workshops/vscode/",
|
||||
},
|
||||
"learning-hub/copilot-workshops/vscode/0-prerequisites",
|
||||
"learning-hub/copilot-workshops/vscode/1-custom-instructions",
|
||||
"learning-hub/copilot-workshops/vscode/2-agent-mode",
|
||||
"learning-hub/copilot-workshops/vscode/3-mcp",
|
||||
"learning-hub/copilot-workshops/vscode/4-custom-agents",
|
||||
"learning-hub/copilot-workshops/vscode/5-managing-agents",
|
||||
"learning-hub/copilot-workshops/vscode/6-iterating",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Copilot CLI",
|
||||
items: [
|
||||
{
|
||||
label: "Overview",
|
||||
link: "/learning-hub/copilot-workshops/cli/",
|
||||
},
|
||||
"learning-hub/copilot-workshops/cli/0-prerequisites",
|
||||
"learning-hub/copilot-workshops/cli/1-install-copilot-cli",
|
||||
"learning-hub/copilot-workshops/cli/2-custom-instructions",
|
||||
"learning-hub/copilot-workshops/cli/3-generating-code",
|
||||
"learning-hub/copilot-workshops/cli/4-mcp",
|
||||
"learning-hub/copilot-workshops/cli/5-agent-skills",
|
||||
"learning-hub/copilot-workshops/cli/6-custom-agents",
|
||||
"learning-hub/copilot-workshops/cli/7-slash-commands",
|
||||
"learning-hub/copilot-workshops/cli/8-review",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Copilot App",
|
||||
items: [
|
||||
{
|
||||
label: "Overview",
|
||||
link: "/learning-hub/copilot-workshops/app/",
|
||||
},
|
||||
"learning-hub/copilot-workshops/app/0-prerequisites",
|
||||
"learning-hub/copilot-workshops/app/1-install-copilot-app",
|
||||
"learning-hub/copilot-workshops/app/2-add-star-rating",
|
||||
"learning-hub/copilot-workshops/app/3-custom-instructions",
|
||||
"learning-hub/copilot-workshops/app/4-build-filtering",
|
||||
"learning-hub/copilot-workshops/app/5-mcp-playwright",
|
||||
"learning-hub/copilot-workshops/app/6-agent-merge",
|
||||
"learning-hub/copilot-workshops/app/7-canvases",
|
||||
"learning-hub/copilot-workshops/app/8-review",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Copilot Cloud Agent",
|
||||
items: [
|
||||
{
|
||||
label: "Overview",
|
||||
link: "/learning-hub/copilot-workshops/cloud/",
|
||||
},
|
||||
"learning-hub/copilot-workshops/cloud/0-prerequisites",
|
||||
"learning-hub/copilot-workshops/cloud/1-custom-instructions",
|
||||
"learning-hub/copilot-workshops/cloud/2-cloud-agent",
|
||||
"learning-hub/copilot-workshops/cloud/3-custom-agents",
|
||||
"learning-hub/copilot-workshops/cloud/4-managing-agents",
|
||||
"learning-hub/copilot-workshops/cloud/5-iterating",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Hands-on",
|
||||
items: [
|
||||
{
|
||||
label: "Cookbook",
|
||||
link: "/learning-hub/cookbook/",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Browse Resources",
|
||||
items: [
|
||||
{ label: "Home", link: "/" },
|
||||
{ label: "Agents", link: "/agents/" },
|
||||
{ label: "Instructions", link: "/instructions/" },
|
||||
{ label: "Skills", link: "/skills/" },
|
||||
{ label: "Canvas Extensions", link: "/extensions/" },
|
||||
{ label: "Plugins", link: "/plugins/" },
|
||||
{ label: "Contributors", link: "/contributors/" },
|
||||
],
|
||||
},
|
||||
],
|
||||
disable404Route: true,
|
||||
// pagefind: true is required so Starlight renders the search UI.
|
||||
// Our pagefindResources() integration overwrites the index after build.
|
||||
pagefind: true,
|
||||
tableOfContents: { minHeadingLevel: 2, maxHeadingLevel: 3 },
|
||||
components: {
|
||||
Head: "./src/components/Head.astro",
|
||||
Footer: "./src/components/Footer.astro",
|
||||
Search: "./src/components/Search.astro",
|
||||
LanguageSelect: "./src/components/LanguageSelect.astro",
|
||||
},
|
||||
}),
|
||||
sitemap(),
|
||||
pagefindResources(),
|
||||
],
|
||||
// English is served at the site root (no locale prefix), preserving all
|
||||
// existing URLs. Additional locales are served under a locale prefix
|
||||
// (e.g. /es-es/…) and fall back to the English page when a translation does
|
||||
// not yet exist. These keys match the locale directory names used by mirrored
|
||||
// Learning Hub course content (website/src/content/docs/<locale>/…).
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en", "es-es", "ja-jp", "ko-kr", "pt-br", "zh-cn"],
|
||||
routing: {
|
||||
prefixDefaultLocale: false,
|
||||
fallbackType: "rewrite",
|
||||
},
|
||||
fallback: {
|
||||
"es-es": "en",
|
||||
"ja-jp": "en",
|
||||
"ko-kr": "en",
|
||||
"pt-br": "en",
|
||||
"zh-cn": "en",
|
||||
},
|
||||
},
|
||||
integrations: [react(), sitemap(), pagefindResources()],
|
||||
redirects: {
|
||||
"/samples/": "/learning-hub/cookbook/",
|
||||
"/hooks/": "https://github.com/github/awesome-copilot/tree/main/hooks",
|
||||
"/workflows/": "https://github.com/github/awesome-copilot/tree/main/workflows",
|
||||
"/tools/": "https://github.com/github/awesome-copilot/blob/main/website/data/tools.yml",
|
||||
},
|
||||
build: {
|
||||
assets: "assets",
|
||||
},
|
||||
trailingSlash: "always",
|
||||
vite: {
|
||||
define: {
|
||||
__CONTRIBUTORS_TOTAL__: JSON.stringify(readContributorsTotal()),
|
||||
},
|
||||
// @primer/react-brand's default entrypoint is CJS, so Node's ESM loader
|
||||
// cannot detect its named exports during SSR. The package also ships a
|
||||
// proper ESM build; alias to it so named imports resolve in both the
|
||||
// server render and the client bundle.
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
// Only the bare package specifier (the JS entrypoint) is redirected;
|
||||
// subpath imports such as `/lib/css/main.css` must resolve normally.
|
||||
find: /^@primer\/react-brand$/,
|
||||
replacement: "@primer/react-brand/esm",
|
||||
},
|
||||
],
|
||||
// The ESM build imports its own .css files, which Node's loader cannot
|
||||
// handle, so it has to be bundled rather than externalised for SSR.
|
||||
noExternal: ["@primer/react-brand"],
|
||||
},
|
||||
build: {
|
||||
// Production sourcemaps trigger a known warning in the expressive-code Vite plugin.
|
||||
// The docs site does not need emitted JS sourcemaps for its validation build.
|
||||
sourcemap: false,
|
||||
// Starlight ships large syntax-highlighting chunks that are expected for this site.
|
||||
// Raise the threshold so Vite only warns on materially larger regressions.
|
||||
chunkSizeWarningLimit: 900,
|
||||
},
|
||||
css: {
|
||||
|
||||
@@ -20,20 +20,35 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@astrojs/markdown-remark": "^7.2.0",
|
||||
"@astrojs/react": "^6.0.2",
|
||||
"@astrojs/sitemap": "^3.7.2",
|
||||
"@astrojs/starlight": "^0.41.1",
|
||||
"@primer/octicons-react": "^19.33.0",
|
||||
"@primer/react-brand": "^0.72.1-rc.c5f3a3b5",
|
||||
"astro": "^7.3.2",
|
||||
"choices.js": "^11.2.2",
|
||||
"clsx": "^2.1.1",
|
||||
"front-matter": "^4.0.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
"isomorphic-dompurify": "^2.36.0",
|
||||
"jszip": "^3.10.1",
|
||||
"marked": "^18.0.2",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"remark-github-admonitions-to-directives": "^2.1.0",
|
||||
"shiki": "^4.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"axe-core": "^4.10.2",
|
||||
"pagefind": "^1.5.2",
|
||||
"playwright": "^1.49.0"
|
||||
},
|
||||
"overrides": {
|
||||
"js-yaml": "^4.3.2",
|
||||
"front-matter": {
|
||||
"js-yaml": "^3.15.2"
|
||||
},
|
||||
"gray-matter": {
|
||||
"js-yaml": "^3.15.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.favicon-mark { fill: #24292f; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.favicon-mark { fill: #ffffff; }
|
||||
}
|
||||
</style>
|
||||
<g clip-path="url(#clip0_731_27436)">
|
||||
<g clip-path="url(#clip1_731_27436)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M95.6877 67.9677C92.2446 73.9488 72.2522 88.0617 48 88.0617C23.7478 88.0617 3.75535 73.9488 0.312312 67.9677C0.0605215 67.5303 -0.0207525 67.031 -0.0207525 66.5263L-0.0207524 55.8787C-0.0207524 55.4373 0.0473833 54.9986 0.210699 54.5885C1.69959 50.8499 5.59895 45.4192 10.6313 43.9621C11.2985 42.2494 12.2867 39.7456 13.2087 37.8974C13.0543 36.4833 13 35.0248 13 33.5512C13 28.2274 14.1285 23.5576 17.5288 20.0801C19.117 18.4559 21.0876 17.21 23.4246 16.2735C29.0217 11.7264 36.992 7.90112 47.9136 7.90112C58.8352 7.90112 66.9783 11.7264 72.5754 16.2735C74.9124 17.21 76.883 18.4559 78.4712 20.0801C81.8715 23.5576 83 28.2274 83 33.5512C83 35.0248 82.9457 36.4833 82.7913 37.8974C83.7133 39.7456 84.7015 42.2494 85.3687 43.9621C90.401 45.4192 94.3004 50.8499 95.7893 54.5885C95.9526 54.9986 96.0208 55.4373 96.0208 55.8787L96.0208 66.5263C96.0208 67.031 95.9395 67.5303 95.6877 67.9677ZM51.253 32.2606C51.0828 30.9345 51.0018 29.747 50.9995 28.686L50.9995 28.6021C51.0048 25.5242 51.6776 23.5215 52.7524 22.2914C54.1172 20.7295 56.9379 19.5328 62.8829 20.1762C68.9059 20.828 72.2725 22.3229 74.1812 24.2749C76.0291 26.1648 76.9999 28.9922 76.9999 33.5512C76.9999 38.3951 76.3018 41.2568 74.767 42.9977C73.3075 44.6531 70.4335 46 64.1386 46C59.2994 46 56.5326 44.4261 54.7637 42.2493C52.8644 39.912 51.7955 36.4878 51.253 32.2606ZM44.747 32.2606C44.9172 30.9344 44.9982 29.747 45.0005 28.686L45.0005 28.6021C44.9952 25.5242 44.3224 23.5214 43.2476 22.2914C41.8828 20.7295 39.0621 19.5328 33.1171 20.1762C27.0941 20.828 23.7275 22.3229 21.8188 24.2749C19.9709 26.1647 19.0001 28.9922 19.0001 33.5512C19.0001 38.3951 19.6982 41.2568 21.233 42.9977C22.6925 44.6531 25.5665 46 31.8614 46C36.7006 46 39.4674 44.4261 41.2363 42.2493C43.1356 39.9119 44.2045 36.4878 44.747 32.2606ZM48.6889 43.9983C48.4592 43.9995 48.9185 43.9983 48.6889 43.9983C48.4594 43.9983 47.5408 43.9995 47.3111 43.9983C46.8877 44.7075 46.4168 45.3882 45.8926 46.0332C42.8139 49.8219 38.2182 52 31.8613 52C24.9615 52 19.9049 50.564 16.7324 46.9657C16.552 46.7611 16.3906 46.5469 16.3906 46.5469L16 46.9657L16 73.3023C21.7392 76.4213 34.0576 82.0184 48 82.0184C61.9424 82.0184 74.2608 76.4213 80 73.3023L80 46.9657L79.6094 46.5469C79.6094 46.5469 79.4772 46.728 79.2676 46.9657C76.0951 50.564 71.0385 52 64.1387 52C57.7818 52 53.1861 49.8219 50.1074 46.0332C49.5832 45.3882 49.1123 44.7075 48.6889 43.9983Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M58 57C60.2091 57 62 58.7909 62 61L62 69C62 71.2092 60.2091 73 58 73C55.7909 73 54 71.2092 54 69L54 61C54 58.7909 55.7909 57 58 57Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38 57C40.2091 57 42 58.7909 42 61L42 69C42 71.2092 40.2091 73 38 73C35.7909 73 34 71.2092 34 69L34 61C34 58.7909 35.7909 57 38 57Z" fill="white"/>
|
||||
<path class="favicon-mark" fill-rule="evenodd" clip-rule="evenodd" d="M95.6877 67.9677C92.2446 73.9488 72.2522 88.0617 48 88.0617C23.7478 88.0617 3.75535 73.9488 0.312312 67.9677C0.0605215 67.5303 -0.0207525 67.031 -0.0207525 66.5263L-0.0207524 55.8787C-0.0207524 55.4373 0.0473833 54.9986 0.210699 54.5885C1.69959 50.8499 5.59895 45.4192 10.6313 43.9621C11.2985 42.2494 12.2867 39.7456 13.2087 37.8974C13.0543 36.4833 13 35.0248 13 33.5512C13 28.2274 14.1285 23.5576 17.5288 20.0801C19.117 18.4559 21.0876 17.21 23.4246 16.2735C29.0217 11.7264 36.992 7.90112 47.9136 7.90112C58.8352 7.90112 66.9783 11.7264 72.5754 16.2735C74.9124 17.21 76.883 18.4559 78.4712 20.0801C81.8715 23.5576 83 28.2274 83 33.5512C83 35.0248 82.9457 36.4833 82.7913 37.8974C83.7133 39.7456 84.7015 42.2494 85.3687 43.9621C90.401 45.4192 94.3004 50.8499 95.7893 54.5885C95.9526 54.9986 96.0208 55.4373 96.0208 55.8787L96.0208 66.5263C96.0208 67.031 95.9395 67.5303 95.6877 67.9677ZM51.253 32.2606C51.0828 30.9345 51.0018 29.747 50.9995 28.686L50.9995 28.6021C51.0048 25.5242 51.6776 23.5215 52.7524 22.2914C54.1172 20.7295 56.9379 19.5328 62.8829 20.1762C68.9059 20.828 72.2725 22.3229 74.1812 24.2749C76.0291 26.1648 76.9999 28.9922 76.9999 33.5512C76.9999 38.3951 76.3018 41.2568 74.767 42.9977C73.3075 44.6531 70.4335 46 64.1386 46C59.2994 46 56.5326 44.4261 54.7637 42.2493C52.8644 39.912 51.7955 36.4878 51.253 32.2606ZM44.747 32.2606C44.9172 30.9344 44.9982 29.747 45.0005 28.686L45.0005 28.6021C44.9952 25.5242 44.3224 23.5214 43.2476 22.2914C41.8828 20.7295 39.0621 19.5328 33.1171 20.1762C27.0941 20.828 23.7275 22.3229 21.8188 24.2749C19.9709 26.1647 19.0001 28.9922 19.0001 33.5512C19.0001 38.3951 19.6982 41.2568 21.233 42.9977C22.6925 44.6531 25.5665 46 31.8614 46C36.7006 46 39.4674 44.4261 41.2363 42.2493C43.1356 39.9119 44.2045 36.4878 44.747 32.2606ZM48.6889 43.9983C48.4592 43.9995 48.9185 43.9983 48.6889 43.9983C48.4594 43.9983 47.5408 43.9995 47.3111 43.9983C46.8877 44.7075 46.4168 45.3882 45.8926 46.0332C42.8139 49.8219 38.2182 52 31.8613 52C24.9615 52 19.9049 50.564 16.7324 46.9657C16.552 46.7611 16.3906 46.5469 16.3906 46.5469L16 46.9657L16 73.3023C21.7392 76.4213 34.0576 82.0184 48 82.0184C61.9424 82.0184 74.2608 76.4213 80 73.3023L80 46.9657L79.6094 46.5469C79.6094 46.5469 79.4772 46.728 79.2676 46.9657C76.0951 50.564 71.0385 52 64.1387 52C57.7818 52 53.1861 49.8219 50.1074 46.0332C49.5832 45.3882 49.1123 44.7075 48.6889 43.9983Z"/>
|
||||
<path class="favicon-mark" fill-rule="evenodd" clip-rule="evenodd" d="M58 57C60.2091 57 62 58.7909 62 61L62 69C62 71.2092 60.2091 73 58 73C55.7909 73 54 71.2092 54 69L54 61C54 58.7909 55.7909 57 58 57Z"/>
|
||||
<path class="favicon-mark" fill-rule="evenodd" clip-rule="evenodd" d="M38 57C40.2091 57 42 58.7909 42 61L42 69C42 71.2092 40.2091 73 38 73C35.7909 73 34 71.2092 34 69L34 61C34 58.7909 35.7909 57 38 57Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="20" viewBox="0 0 24 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.9429 0.185587C14.0827 0.209694 14.3432 0.283749 14.4819 0.324577C15.4445 0.602404 16.3345 1.08816 17.0889 1.74755C18.0697 2.61357 18.8014 3.77996 19.0743 5.06576C19.1496 5.42018 19.1738 5.69532 19.1964 6.05452C19.9052 6.47241 21.2122 6.2735 21.9765 6.03087C22.1873 5.96385 22.3845 5.87582 22.6099 5.82415C22.9897 5.73725 23.1819 6.00675 23.0693 6.35913C22.8743 6.87065 22.4732 7.20858 21.9897 7.44881C22.0123 7.61916 21.9954 7.74974 21.9265 7.90591C21.8274 8.12719 21.6671 8.31554 21.4645 8.4488C20.9198 8.80304 19.9085 8.88554 19.275 9.03554C19.0621 9.08601 18.8856 9.11698 18.6757 9.20013C18.6987 9.4758 18.8254 9.88842 18.8772 10.1801C19.0389 11.0894 19.1707 11.983 19.1081 12.9078C18.9947 14.5849 18.2823 16.1646 17.0032 17.2757C15.9232 18.2137 13.9932 19.1155 12.5675 19.2694C11.871 19.3447 10.4044 19.2245 9.68554 19.1726C9.05337 19.1213 8.36025 19.0719 7.74226 18.9414C7.30463 18.8528 6.9007 18.6595 6.50142 18.4785C4.66837 17.648 3.37669 16.1668 2.36961 14.4607C2.08619 13.9807 1.77939 13.4403 1.45674 12.9741C1.30495 12.7518 1.14267 12.5371 0.970473 12.3303C0.725396 12.0342 0.394927 11.6638 0.219525 11.3284C0.0631726 11.034 -0.0120767 10.7034 0.00148597 10.3704C0.0188007 10.0126 0.141781 9.70504 0.407086 9.45774C0.866194 9.02986 1.65549 9.09982 2.25038 9.13383C2.46709 8.8137 2.77553 8.62464 3.16888 8.62028C3.29122 8.61884 3.46177 8.64084 3.56944 8.59358C3.83632 8.47664 3.99491 8.29038 4.27854 8.18377C4.77063 7.99883 5.45129 8.23071 5.90622 8.44268C5.91912 8.08903 5.8361 7.70143 5.75546 7.35716C5.5471 6.46729 5.44281 5.76631 5.63389 4.85059C5.95244 3.32398 6.76008 2.08157 8.07312 1.2288C9.88348 0.0530301 11.8548 -0.242356 13.9429 0.185587Z" fill="#5FED83"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -28,21 +28,19 @@ const routes = [
|
||||
'/agents/',
|
||||
'/instructions/',
|
||||
'/skills/',
|
||||
'/hooks/',
|
||||
'/workflows/',
|
||||
'/extensions/',
|
||||
'/plugins/',
|
||||
'/tools/',
|
||||
'/contributors/',
|
||||
'/learning-hub/cookbook/',
|
||||
'/learning-hub/',
|
||||
// Keep a representative Learning Hub article in the matrix so the article
|
||||
// chassis is audited in addition to the Learning Hub landing page.
|
||||
'/learning-hub/github-copilot-app/',
|
||||
'/learning-hub/cookbook/',
|
||||
// Representative dedicated detail pages (one per resource type) so the audit
|
||||
// covers the shared detail layout, sidebar, install buttons, and file browser.
|
||||
'/agent/accessibility/',
|
||||
'/instruction/a11y/',
|
||||
'/skill/acquire-codebase-knowledge/',
|
||||
'/hook/dependency-license-checker/',
|
||||
'/workflow/daily-issues-report/',
|
||||
'/plugin/accessibility-kanban/',
|
||||
'/extension/accessibility-kanban/',
|
||||
];
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
---
|
||||
// Back to Top Button Component
|
||||
---
|
||||
|
||||
<button
|
||||
id="back-to-top"
|
||||
class="back-to-top"
|
||||
aria-label="Back to top"
|
||||
title="Back to top"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 15l-6-6-6 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const button = document.getElementById('back-to-top');
|
||||
if (!button) return;
|
||||
|
||||
// Move button to body level to escape stacking contexts
|
||||
if (button.parentElement !== document.body) {
|
||||
document.body.appendChild(button);
|
||||
}
|
||||
|
||||
// Show/hide button based on scroll position
|
||||
function toggleVisibility() {
|
||||
const scrollY = window.scrollY || document.documentElement.scrollTop;
|
||||
if (scrollY > 400) {
|
||||
button.classList.add('visible');
|
||||
} else {
|
||||
button.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to top with smooth behavior
|
||||
function scrollToTop() {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
|
||||
// Throttled scroll handler
|
||||
let ticking = false;
|
||||
window.addEventListener('scroll', () => {
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
toggleVisibility();
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
// Click handler
|
||||
button.addEventListener('click', scrollToTop);
|
||||
|
||||
// Initial check
|
||||
toggleVisibility();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style is:global>
|
||||
.back-to-top {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(20px) scale(0.8);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 12px rgba(133, 52, 243, 0.4);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.back-to-top.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.back-to-top:hover {
|
||||
background: var(--color-accent-hover);
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow: 0 6px 20px rgba(133, 52, 243, 0.5);
|
||||
}
|
||||
|
||||
.back-to-top:active {
|
||||
transform: translateY(0) scale(0.95);
|
||||
}
|
||||
|
||||
.back-to-top:focus-visible {
|
||||
outline: 2px solid var(--color-text-emphasis);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.back-to-top {
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Respect reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.back-to-top {
|
||||
transition: opacity 0.2s ease;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.back-to-top.visible {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,108 +0,0 @@
|
||||
---
|
||||
interface Props {
|
||||
resourceType: string;
|
||||
}
|
||||
|
||||
const { resourceType } = Astro.props;
|
||||
const contributingUrl = 'https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md';
|
||||
const newIssueUrl = 'https://github.com/github/awesome-copilot/issues/new';
|
||||
---
|
||||
|
||||
<aside class="contribute-cta" aria-label="Contribute">
|
||||
<div class="contribute-cta-inner">
|
||||
<div class="contribute-cta-content">
|
||||
<h2 class="contribute-cta-title">Don't see what you're looking for?</h2>
|
||||
<p class="contribute-cta-description">This collection is community-driven. Share your own {resourceType} to help others get more out of GitHub Copilot.</p>
|
||||
</div>
|
||||
<div class="contribute-cta-actions">
|
||||
<a href={contributingUrl} class="btn btn-primary" target="_blank" rel="noopener">
|
||||
Contribute yours
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
|
||||
<path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href={newIssueUrl} class="btn btn-outline" target="_blank" rel="noopener">
|
||||
Request a resource
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.contribute-cta {
|
||||
margin-top: 48px;
|
||||
padding: 0 0 16px;
|
||||
}
|
||||
|
||||
.contribute-cta-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 32px;
|
||||
padding: 32px 40px;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.contribute-cta-inner::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--gradient-primary);
|
||||
}
|
||||
|
||||
.contribute-cta-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.contribute-cta-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-emphasis);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.contribute-cta-description {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.contribute-cta-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.contribute-cta-actions .btn {
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.contribute-cta-inner {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: 28px 24px;
|
||||
}
|
||||
|
||||
.contribute-cta-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.contribute-cta-actions .btn {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
import {
|
||||
getEmbeddedDataElementId,
|
||||
serializeEmbeddedData,
|
||||
} from "../scripts/embedded-data";
|
||||
|
||||
interface Props {
|
||||
filename: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
const { filename, data } = Astro.props;
|
||||
---
|
||||
|
||||
<script
|
||||
id={getEmbeddedDataElementId(filename)}
|
||||
type="application/json"
|
||||
set:html={serializeEmbeddedData(data)}
|
||||
></script>
|
||||
@@ -1,257 +0,0 @@
|
||||
---
|
||||
import EditLink from "@astrojs/starlight/components/EditLink.astro";
|
||||
import LastUpdated from "@astrojs/starlight/components/LastUpdated.astro";
|
||||
import Pagination from "@astrojs/starlight/components/Pagination.astro";
|
||||
import config from "virtual:starlight/user-config";
|
||||
import { Icon } from "@astrojs/starlight/components";
|
||||
import ThemeToggle from "./ThemeToggle.astro";
|
||||
const currentYear = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer class="sl-flex">
|
||||
<div class="meta sl-flex">
|
||||
<EditLink />
|
||||
<LastUpdated />
|
||||
</div>
|
||||
<Pagination />
|
||||
|
||||
{
|
||||
config.credits && (
|
||||
<a class="kudos sl-flex" href="https://starlight.astro.build">
|
||||
<Icon name={"starlight"} /> {Astro.locals.t("starlight:builtWithStarlight.label")}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
<p class="made-by">Made with love by our amazing <a href="/contributors/">contributors</a></p>
|
||||
|
||||
<div class="subfooter-bar">
|
||||
<div class="subfooter">
|
||||
<nav aria-label="Legal and resource links" class="subfooter-legal-nav">
|
||||
<ul class="subfooter-legal">
|
||||
<li class="subfooter-copyright">
|
||||
<time datetime={String(currentYear)}>© {currentYear} GitHub, Inc.</time>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://docs.github.com/site-policy/github-terms/github-terms-of-service">
|
||||
Terms
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://docs.github.com/site-policy/privacy-policies/github-privacy-statement">
|
||||
Privacy
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<nav aria-label="GitHub's social media links">
|
||||
<ul class="social-links">
|
||||
<li>
|
||||
<a href="https://www.linkedin.com/company/github" aria-label="GitHub on LinkedIn">
|
||||
<svg viewBox="0 0 19 18" aria-hidden="true">
|
||||
<path d="M3.94 2A2 2 0 1 1 2 0a2 2 0 0 1 1.94 2zM4 5.48H0V18h4zm6.32 0H6.34V18h3.94v-6.57c0-3.66 4.77-4 4.77 0V18H19v-7.93c0-6.17-7.06-5.94-8.72-2.91z" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.instagram.com/github" aria-label="GitHub on Instagram">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 0C8.74 0 8.33.02 7.05.07 5.78.13 4.91.33 4.14.63c-.79.31-1.46.72-2.13 1.38S.94 3.35.63 4.14C.33 4.91.13 5.78.07 7.05.01 8.33 0 8.74 0 12s.02 3.67.07 4.95c.06 1.28.26 2.15.56 2.91.31.79.72 1.46 1.38 2.13s1.34 1.08 2.13 1.38c.77.3 1.64.5 2.91.56C8.33 23.99 8.74 24 12 24s3.67-.02 4.95-.07c1.28-.06 2.15-.26 2.91-.56.79-.31 1.46-.72 2.13-1.38s1.08-1.34 1.38-2.13c.3-.77.5-1.64.56-2.91.06-1.28.07-1.69.07-4.95s-.02-3.67-.07-4.95c-.06-1.28-.26-2.15-.56-2.91-.31-.79-.72-1.46-1.38-2.13S20.65.94 19.86.63c-.77-.3-1.64-.5-2.91-.56C15.67.01 15.26 0 12 0zm0 5.84A6.16 6.16 0 1 0 12 18.16 6.16 6.16 0 0 0 12 5.84zm0 10.16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm7.85-10.41a1.44 1.44 0 1 1-2.88 0 1.44 1.44 0 0 1 2.88 0z" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.youtube.com/github" aria-label="GitHub on YouTube">
|
||||
<svg viewBox="0 0 19.17 13.6" aria-hidden="true">
|
||||
<path d="M18.77 2.13A2.4 2.4 0 0 0 17.09.42C15.59 0 9.58 0 9.58 0a57.55 57.55 0 0 0-7.5.4A2.49 2.49 0 0 0 .39 2.13 26.27 26.27 0 0 0 0 6.8a26.15 26.15 0 0 0 .39 4.67 2.43 2.43 0 0 0 1.69 1.71c1.52.42 7.5.42 7.5.42a57.69 57.69 0 0 0 7.51-.4 2.4 2.4 0 0 0 1.68-1.71 25.63 25.63 0 0 0 .4-4.67 24 24 0 0 0-.4-4.69zM7.67 9.71V3.89l5 2.91z" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/github" aria-label="GitHub on X">
|
||||
<svg viewBox="0 0 1200 1227" aria-hidden="true">
|
||||
<path d="M714.16 519.28 1160.89 0h-105.86L667.14 450.89 357.33 0H0l468.49 681.82L0 1226.37h105.87l409.62-476.15 327.18 476.15H1200L714.14 519.28h.02ZM569.17 687.83 144.01 79.69h162.6l752.47 1070.61H892.48L569.17 687.83Z" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.tiktok.com/@github" aria-label="GitHub on TikTok">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12.53.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.twitch.tv/github" aria-label="GitHub on Twitch">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M11.57 4.71h1.72v5.15h-1.72zm4.72 0H18v5.15h-1.71zM6 0 1.71 4.29v15.42h5.15V24l4.28-4.29h3.43L22.29 12V0zm14.57 11.14-3.43 3.43h-3.43l-3 3v-3H6.86V1.71h13.71Z" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/github" aria-label="GitHub's organization on GitHub">
|
||||
<svg viewBox="0 0 98 96" aria-hidden="true">
|
||||
<path d="M48.85 0C21.84 0 0 22 0 49.22c0 21.75 13.99 40.17 33.41 46.69 2.42.49 3.31-1.06 3.31-2.36 0-1.14-.08-5.05-.08-9.13-13.59 2.94-16.42-5.87-16.42-5.87-2.18-5.7-5.42-7.17-5.42-7.17-4.45-3.01.33-3.01.33-3.01 4.93.32 7.52 5.05 7.52 5.05 4.37 7.5 11.4 5.38 14.24 4.07.4-3.18 1.7-5.38 3.07-6.6-10.84-1.14-22.24-5.38-22.24-24.28 0-5.38 1.94-9.78 5.01-13.2-.48-1.22-2.18-6.28.49-13.04 0 0 4.12-1.3 13.42 5.05a46.97 46.97 0 0 1 12.22-1.63c4.12 0 8.33.57 12.21 1.63 9.3-6.35 13.43-5.05 13.43-5.05 2.67 6.76.97 11.82.48 13.04 3.16 3.42 5.02 7.82 5.02 13.2 0 18.9-11.4 23.06-22.32 24.28 1.78 1.55 3.31 4.48 3.31 9.13 0 6.6-.08 11.9-.08 13.53 0 1.3.89 2.85 3.32 2.36 19.41-6.52 33.4-24.94 33.4-46.69C97.71 22 75.79 0 48.85 0z" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<ThemeToggle />
|
||||
|
||||
<style>
|
||||
footer {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
gap: 0.75rem 3rem;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.75rem;
|
||||
font-size: var(--sl-text-sm);
|
||||
color: var(--sl-color-gray-3);
|
||||
}
|
||||
|
||||
.meta > :global(p:only-child) {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
:global(.pagination-links:empty) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.made-by {
|
||||
text-align: center;
|
||||
margin: 0 auto 0.75rem;
|
||||
font-size: var(--sl-text-xs);
|
||||
color: var(--sl-color-gray-3);
|
||||
}
|
||||
|
||||
.kudos {
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
margin: 0 auto;
|
||||
font-size: var(--sl-text-xs);
|
||||
text-decoration: none;
|
||||
color: var(--sl-color-gray-3);
|
||||
}
|
||||
|
||||
.kudos:hover {
|
||||
color: var(--sl-color-white);
|
||||
}
|
||||
|
||||
.kudos :global(svg) {
|
||||
color: var(--sl-color-orange);
|
||||
}
|
||||
|
||||
.made-by a {
|
||||
color: var(--sl-color-gray-3);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15em;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.subfooter a {
|
||||
color: var(--sl-color-gray-3);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.made-by a:hover,
|
||||
.subfooter a:hover {
|
||||
color: var(--sl-color-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.subfooter-bar {
|
||||
width: 100%;
|
||||
margin-top: 1.5rem;
|
||||
background: var(--sl-color-bg-nav);
|
||||
border-top: 1px solid var(--sl-color-gray-5);
|
||||
}
|
||||
|
||||
.subfooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
color: var(--sl-color-gray-3);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.subfooter ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.subfooter-legal-nav {
|
||||
color: var(--sl-color-gray-3);
|
||||
font-family: var(--__sl-font-mono);
|
||||
font-size: var(--sl-text-xs);
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.subfooter-legal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem 1.25rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.subfooter-copyright {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.social-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.social-links a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.social-links a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.social-links svg {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.subfooter {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
|
||||
.subfooter-legal {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,116 +0,0 @@
|
||||
---
|
||||
import Default from '@astrojs/starlight/components/Head.astro';
|
||||
const { head, entry } = Astro.locals.starlightRoute;
|
||||
const basePath = import.meta.env.BASE_URL;
|
||||
|
||||
const getMetaContent = (key: string, attribute: 'name' | 'property' = 'name') =>
|
||||
head.find((tag) => tag.tag === 'meta' && tag.attrs?.[attribute] === key)?.attrs?.content;
|
||||
|
||||
const getLinkHref = (rel: string) =>
|
||||
head.find((tag) => tag.tag === 'link' && tag.attrs?.rel === rel)?.attrs?.href;
|
||||
|
||||
const twitterTitle = entry.data.title;
|
||||
const description =
|
||||
entry.data.description ??
|
||||
getMetaContent('description') ??
|
||||
getMetaContent('og:description', 'property');
|
||||
const canonicalUrl =
|
||||
getLinkHref('canonical') ?? getMetaContent('og:url', 'property');
|
||||
const socialImageUrl =
|
||||
getMetaContent('twitter:image') ?? getMetaContent('og:image', 'property');
|
||||
const socialImageAlt =
|
||||
getMetaContent('twitter:image:alt') ??
|
||||
getMetaContent('og:image:alt', 'property') ??
|
||||
description;
|
||||
const socialImageType = socialImageUrl?.endsWith('.png')
|
||||
? 'image/png'
|
||||
: socialImageUrl?.endsWith('.jpg') || socialImageUrl?.endsWith('.jpeg')
|
||||
? 'image/jpeg'
|
||||
: socialImageUrl?.endsWith('.webp')
|
||||
? 'image/webp'
|
||||
: undefined;
|
||||
|
||||
const twitterDomain =
|
||||
canonicalUrl && URL.canParse(canonicalUrl)
|
||||
? new URL(canonicalUrl).hostname
|
||||
: undefined;
|
||||
const contentSecurityPolicy = [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'",
|
||||
"form-action 'none'",
|
||||
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://analytics.githubassets.com",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: https:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' https://raw.githubusercontent.com https://collector.githubapp.com",
|
||||
"worker-src 'self' blob:",
|
||||
"upgrade-insecure-requests",
|
||||
].join("; ");
|
||||
---
|
||||
|
||||
<meta http-equiv="Content-Security-Policy" content={contentSecurityPolicy} />
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||
<Default><slot /></Default>
|
||||
{twitterTitle && <meta name="twitter:title" content={twitterTitle} />}
|
||||
{description && <meta name="twitter:description" content={description} />}
|
||||
{canonicalUrl && <meta property="twitter:url" content={canonicalUrl} />}
|
||||
{twitterDomain && <meta property="twitter:domain" content={twitterDomain} />}
|
||||
{socialImageUrl && <meta property="og:image:secure_url" content={socialImageUrl} />}
|
||||
{socialImageType && <meta property="og:image:type" content={socialImageType} />}
|
||||
{socialImageAlt && <meta name="twitter:image:alt" content={socialImageAlt} />}
|
||||
|
||||
<!-- Theme initialization script (runs early to prevent flash) -->
|
||||
<script is:inline>
|
||||
(function() {
|
||||
const STORAGE_KEY = 'awesome-copilot-theme';
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
|
||||
// Theme handling:
|
||||
// - 'dark' or 'light' → set data-theme attribute to that value
|
||||
// - 'auto' or unset → don't set data-theme (CSS media query handles system preference)
|
||||
if (stored === 'dark' || stored === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', stored);
|
||||
}
|
||||
// For 'auto' or unset, no attribute means CSS media query controls theme
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script is:inline define:vars={{ basePath }}>
|
||||
// basePath setup for runtime use
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.body.dataset.basePath = basePath;
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.body.dataset.basePath = basePath;
|
||||
});
|
||||
</script>
|
||||
{import.meta.env.PROD && (
|
||||
<>
|
||||
<meta
|
||||
name="ha-url"
|
||||
content="https://collector.githubapp.com/awesome-copilot-web/collect"
|
||||
/>
|
||||
<script is:inline>
|
||||
(function() {
|
||||
const doNotTrackEnabled =
|
||||
navigator.doNotTrack === "1" ||
|
||||
navigator.doNotTrack === "yes" ||
|
||||
window.doNotTrack === "1" ||
|
||||
navigator.msDoNotTrack === "1";
|
||||
const globalPrivacyControlEnabled =
|
||||
typeof navigator.globalPrivacyControl === "boolean" &&
|
||||
navigator.globalPrivacyControl;
|
||||
|
||||
if (doNotTrackEnabled || globalPrivacyControlEnabled) return;
|
||||
|
||||
const analyticsScript = document.createElement("script");
|
||||
analyticsScript.async = true;
|
||||
analyticsScript.defer = true;
|
||||
analyticsScript.src = "https://analytics.githubassets.com/hydro-marketing.min.js";
|
||||
document.head.appendChild(analyticsScript);
|
||||
})();
|
||||
</script>
|
||||
</>
|
||||
)}
|
||||
@@ -1,120 +0,0 @@
|
||||
---
|
||||
// Icon component with SVG icons
|
||||
// Icons are either fill-based (from GitHub Primer) or stroke-based (custom)
|
||||
// GitHub Primer icons are sourced from https://primer.style/foundations/icons/
|
||||
|
||||
export interface Props {
|
||||
name: 'agents' | 'instructions' | 'skills' | 'hooks' | 'workflows' | 'plugins' | 'tools' | 'learning' | 'close' | 'copy' | 'download' | 'share' | 'external' | 'plus' | 'search' | 'chevron-down' | 'document' | 'lightning' | 'hook' | 'workflow' | 'plug' | 'wrench' | 'book' | 'robot' | 'sync';
|
||||
size?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { name, size = 24, class: className = '' } = Astro.props;
|
||||
|
||||
// Icon definitions: { path: SVG path(s), fill: true for fill-based icons }
|
||||
const icons: Record<string, { path: string; fill?: boolean }> = {
|
||||
// Resource type icons - using GitHub Primer icons where available
|
||||
|
||||
// Agent icon - using GitHub Primer's agent-24 (sparkle over workflow)
|
||||
// Source: https://primer.style/foundations/icons/agent-24
|
||||
'robot': {
|
||||
fill: true,
|
||||
path: `<path d="M22.5 13.919v-.278a5.097 5.097 0 0 0-4.961-5.086.858.858 0 0 1-.754-.497l-.149-.327A6.414 6.414 0 0 0 10.81 4a6.133 6.133 0 0 0-6.13 6.32l.019.628a.863.863 0 0 1-.67.869A3.263 3.263 0 0 0 1.5 14.996v.108A3.397 3.397 0 0 0 4.896 18.5h1.577a.75.75 0 0 1 0 1.5H4.896A4.896 4.896 0 0 1 0 15.104v-.108a4.761 4.761 0 0 1 3.185-4.493l-.004-.137A7.633 7.633 0 0 1 10.81 2.5a7.911 7.911 0 0 1 7.176 4.58C21.36 7.377 24 10.207 24 13.641v.278a.75.75 0 0 1-1.5 0Z"/><path d="m12.306 11.77 3.374 3.375a.749.749 0 0 1 0 1.061l-3.375 3.375-.057.051a.751.751 0 0 1-1.004-.051.751.751 0 0 1-.051-1.004l.051-.057 2.845-2.845-2.844-2.844a.75.75 0 1 1 1.061-1.061ZM22.5 19.8H18a.75.75 0 0 1 0-1.5h4.5a.75.75 0 0 1 0 1.5Z"/>`
|
||||
},
|
||||
|
||||
// Document icon - custom stroke-based
|
||||
'document': {
|
||||
path: `<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
// Lightning icon - custom stroke-based (for skills)
|
||||
'lightning': {
|
||||
path: `<path d="M13 2 4.09 12.11a1.23 1.23 0 0 0 .13 1.72l.16.14a1.23 1.23 0 0 0 1.52 0L13 9.5V22l8.91-10.11a1.23 1.23 0 0 0-.13-1.72l-.16-.14a1.23 1.23 0 0 0-1.52 0L13 14.5V2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
// Hook icon - using GitHub Primer's sync-24 (represents hooks/iterations)
|
||||
// Source: https://primer.style/foundations/icons/sync-24
|
||||
'hook': {
|
||||
fill: true,
|
||||
path: `<path d="M3.38 8A9.502 9.502 0 0 1 12 2.5a9.502 9.502 0 0 1 9.215 7.182.75.75 0 1 0 1.456-.364C21.473 4.539 17.15 1 12 1a10.995 10.995 0 0 0-9.5 5.452V4.75a.75.75 0 0 0-1.5 0V8.5a1 1 0 0 0 1 1h3.75a.75.75 0 0 0 0-1.5H3.38Zm-.595 6.318a.75.75 0 0 0-1.455.364C2.527 19.461 6.85 23 12 23c4.052 0 7.592-2.191 9.5-5.451v1.701a.75.75 0 0 0 1.5 0V15.5a1 1 0 0 0-1-1h-3.75a.75.75 0 0 0 0 1.5h2.37A9.502 9.502 0 0 1 12 21.5c-4.446 0-8.181-3.055-9.215-7.182Z"/>`
|
||||
},
|
||||
|
||||
// Workflow icon - using GitHub Primer's workflow-24
|
||||
// Source: https://primer.style/foundations/icons/workflow-24
|
||||
// Also used by https://github.github.com/gh-aw/
|
||||
'workflow': {
|
||||
fill: true,
|
||||
path: `<path d="M1 3a2 2 0 0 1 2-2h6.5a2 2 0 0 1 2 2v6.5a2 2 0 0 1-2 2H7v4.063C7 16.355 7.644 17 8.438 17H12.5v-2.5a2 2 0 0 1 2-2H21a2 2 0 0 1 2 2V21a2 2 0 0 1-2 2h-6.5a2 2 0 0 1-2-2v-2.5H8.437A2.939 2.939 0 0 1 5.5 15.562V11.5H3a2 2 0 0 1-2-2Zm2-.5a.5.5 0 0 0-.5.5v6.5a.5.5 0 0 0 .5.5h6.5a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5ZM14.5 14a.5.5 0 0 0-.5.5V21a.5.5 0 0 0 .5.5H21a.5.5 0 0 0 .5-.5v-6.5a.5.5 0 0 0-.5-.5Z"/>`
|
||||
},
|
||||
|
||||
// Plug icon - using GitHub Primer's plug-24
|
||||
// Source: https://primer.style/foundations/icons/plug-24
|
||||
'plug': {
|
||||
fill: true,
|
||||
path: `<path d="M7 11.5H2.938c-.794 0-1.438.644-1.438 1.437v8.313a.75.75 0 0 1-1.5 0v-8.312A2.939 2.939 0 0 1 2.937 10H7V6.151c0-.897.678-1.648 1.57-1.74l6.055-.626 1.006-1.174A1.752 1.752 0 0 1 16.96 2h1.29c.966 0 1.75.784 1.75 1.75V6h3.25a.75.75 0 0 1 0 1.5H20V14h3.25a.75.75 0 0 1 0 1.5H20v2.25a1.75 1.75 0 0 1-1.75 1.75h-1.29a1.75 1.75 0 0 1-1.329-.611l-1.006-1.174-6.055-.627A1.749 1.749 0 0 1 7 15.348Zm9.77-7.913v.001l-1.201 1.4a.75.75 0 0 1-.492.258l-6.353.657a.25.25 0 0 0-.224.249v9.196a.25.25 0 0 0 .224.249l6.353.657c.191.02.368.112.493.258l1.2 1.401a.252.252 0 0 0 .19.087h1.29a.25.25 0 0 0 .25-.25v-14a.25.25 0 0 0-.25-.25h-1.29a.252.252 0 0 0-.19.087Z"/>`
|
||||
},
|
||||
|
||||
// Wrench icon - custom stroke-based (for tools)
|
||||
'wrench': {
|
||||
path: `<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
// Book icon - custom stroke-based (for learning)
|
||||
'book': {
|
||||
path: `<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
// Action icons - all custom stroke-based
|
||||
'close': {
|
||||
path: `<path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
'copy': {
|
||||
path: `<path d="M8 4h8a2 2 0 0 1 2 2v8M8 4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 4v10a2 2 0 0 0 2 2h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
'download': {
|
||||
path: `<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
'share': {
|
||||
path: `<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
'external': {
|
||||
path: `<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14 21 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
'plus': {
|
||||
path: `<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
'search': {
|
||||
path: `<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="m21 21-4.35-4.35" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
'chevron-down': {
|
||||
path: `<path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
// Alias for hook - same as 'hook'
|
||||
'sync': {
|
||||
fill: true,
|
||||
path: `<path d="M3.38 8A9.502 9.502 0 0 1 12 2.5a9.502 9.502 0 0 1 9.215 7.182.75.75 0 1 0 1.456-.364C21.473 4.539 17.15 1 12 1a10.995 10.995 0 0 0-9.5 5.452V4.75a.75.75 0 0 0-1.5 0V8.5a1 1 0 0 0 1 1h3.75a.75.75 0 0 0 0-1.5H3.38Zm-.595 6.318a.75.75 0 0 0-1.455.364C2.527 19.461 6.85 23 12 23c4.052 0 7.592-2.191 9.5-5.451v1.701a.75.75 0 0 0 1.5 0V15.5a1 1 0 0 0-1-1h-3.75a.75.75 0 0 0 0 1.5h2.37A9.502 9.502 0 0 1 12 21.5c-4.446 0-8.181-3.055-9.215-7.182Z"/>`
|
||||
},
|
||||
};
|
||||
|
||||
const iconData = icons[name] || { path: '' };
|
||||
const isFill = iconData.fill ?? false;
|
||||
const iconPath = iconData.path;
|
||||
---
|
||||
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
fill={isFill ? 'currentColor' : 'none'}
|
||||
class={className}
|
||||
aria-hidden="true"
|
||||
set:html={iconPath}
|
||||
/>
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
// Custom override of Starlight's LanguageSelect.
|
||||
//
|
||||
// By default Starlight renders the language dropdown on every page as soon as
|
||||
// more than one locale is configured. Most of this site's docs are English-only,
|
||||
// so we only want the picker to appear on pages that actually have a translation
|
||||
// in a non-English locale. This override checks the docs collection for a
|
||||
// translated version of the current page and renders the stock picker only when
|
||||
// one exists; otherwise it renders nothing (no dropdown).
|
||||
import Default from "@astrojs/starlight/components/LanguageSelect.astro";
|
||||
import config from "virtual:starlight/user-config";
|
||||
import { getCollection } from "astro:content";
|
||||
|
||||
// Locale directory names configured for the site, excluding the English root.
|
||||
const localeCodes = Object.keys(config.locales ?? {}).filter(
|
||||
(code) => code !== "root"
|
||||
);
|
||||
|
||||
/** Strip a leading `<locale>/` segment from a docs entry id, if present. */
|
||||
function baseSlug(id) {
|
||||
const [first, ...rest] = id.split("/");
|
||||
return localeCodes.includes(first) ? rest.join("/") : id;
|
||||
}
|
||||
|
||||
// Set of base slugs (locale-stripped) that have at least one non-English
|
||||
// translation available in the docs collection.
|
||||
const docs = await getCollection("docs");
|
||||
const translatedBaseSlugs = new Set();
|
||||
for (const entry of docs) {
|
||||
const [first, ...rest] = entry.id.split("/");
|
||||
if (localeCodes.includes(first)) {
|
||||
translatedBaseSlugs.add(rest.join("/"));
|
||||
}
|
||||
}
|
||||
|
||||
const currentBaseSlug = baseSlug(Astro.locals.starlightRoute.id);
|
||||
const hasTranslations = translatedBaseSlugs.has(currentBaseSlug);
|
||||
---
|
||||
|
||||
{hasTranslations && <Default {...Astro.props} />}
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: 'robot' | 'document' | 'lightning' | 'hook' | 'workflow' | 'plug' | 'wrench' | 'book';
|
||||
}
|
||||
|
||||
const { title, description, icon } = Astro.props;
|
||||
const contributingUrl = 'https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md';
|
||||
---
|
||||
|
||||
<div class="page-header">
|
||||
<div class="container">
|
||||
<div class="page-header-row">
|
||||
<div>
|
||||
<h1>
|
||||
{icon && <Icon name={icon} size={28} />}
|
||||
<Fragment set:html={title} />
|
||||
</h1>
|
||||
<p><slot><Fragment set:html={description} /></slot></p>
|
||||
</div>
|
||||
<a href={contributingUrl} class="contribute-link" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>
|
||||
Contribute
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page-header h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,516 +0,0 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import project from 'virtual:starlight/project-context';
|
||||
|
||||
const pagefindTranslations = {
|
||||
placeholder: Astro.locals.t('search.label'),
|
||||
...Object.fromEntries(
|
||||
Object.entries(Astro.locals.t.all())
|
||||
.filter(([key]) => key.startsWith('pagefind.'))
|
||||
.map(([key, value]) => [key.replace('pagefind.', ''), value])
|
||||
),
|
||||
};
|
||||
|
||||
const dataAttributes: DOMStringMap = { 'data-translations': JSON.stringify(pagefindTranslations) };
|
||||
if (project.trailingSlash === 'never') dataAttributes['data-strip-trailing-slash'] = '';
|
||||
---
|
||||
|
||||
<site-search class={Astro.props.class} {...dataAttributes}>
|
||||
<button
|
||||
data-open-modal
|
||||
disabled
|
||||
aria-label={Astro.locals.t('search.label')}
|
||||
aria-keyshortcuts="Control+K"
|
||||
>
|
||||
<Icon name="search" />
|
||||
<span class="sl-hidden md:sl-block" aria-hidden="true">{Astro.locals.t('search.label')}</span>
|
||||
<kbd class="sl-hidden md:sl-flex" style="display: none;">
|
||||
<kbd>{Astro.locals.t('search.ctrlKey')}</kbd><kbd>K</kbd>
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<dialog style="padding:0" aria-label={Astro.locals.t('search.label')}>
|
||||
<div class="dialog-frame sl-flex">
|
||||
{
|
||||
/* TODO: Make the layout of this button flexible to accommodate different word lengths. Currently hard-coded for English: “Cancel” */
|
||||
}
|
||||
<button data-close-modal class="sl-flex md:sl-hidden">
|
||||
{Astro.locals.t('search.cancelLabel')}
|
||||
</button>
|
||||
{
|
||||
import.meta.env.DEV ? (
|
||||
<div style="margin: auto; text-align: center; white-space: pre-line;" dir="ltr">
|
||||
<p>{Astro.locals.t('search.devWarning')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div class="search-container">
|
||||
<div id="starlight__search" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</dialog>
|
||||
</site-search>
|
||||
|
||||
{
|
||||
/**
|
||||
* This is intentionally inlined to avoid briefly showing an invalid shortcut.
|
||||
* Purposely using the deprecated `navigator.platform` property to detect Apple devices, as the
|
||||
* user agent is spoofed by some browsers when opening the devtools.
|
||||
*/
|
||||
}
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const openBtn = document.querySelector('button[data-open-modal]');
|
||||
const shortcut = openBtn?.querySelector('kbd');
|
||||
if (!openBtn || !(shortcut instanceof HTMLElement)) return;
|
||||
const platformKey = shortcut.querySelector('kbd');
|
||||
if (platformKey && /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform)) {
|
||||
platformKey.textContent = '⌘';
|
||||
openBtn.setAttribute('aria-keyshortcuts', 'Meta+K');
|
||||
}
|
||||
shortcut.style.display = '';
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { pagefindUserConfig } from 'virtual:starlight/pagefind-config';
|
||||
|
||||
const ROUTE_TYPE_FILTERS: Record<string, string> = {
|
||||
'/agents': 'agent',
|
||||
'/instructions': 'instruction',
|
||||
'/skills': 'skill',
|
||||
'/hooks': 'hook',
|
||||
'/workflows': 'workflow',
|
||||
'/plugins': 'plugin',
|
||||
'/tools': 'tool',
|
||||
};
|
||||
|
||||
function getRouteTypeFilter(pathname: string, baseUrl: string): string | undefined {
|
||||
const normalizedBaseUrl = baseUrl === '/' ? '' : baseUrl.replace(/\/$/, '');
|
||||
const pathWithoutBase =
|
||||
normalizedBaseUrl && pathname.startsWith(normalizedBaseUrl)
|
||||
? pathname.slice(normalizedBaseUrl.length) || '/'
|
||||
: pathname;
|
||||
const normalizedPath = pathWithoutBase.replace(/\/$/, '') || '/';
|
||||
return ROUTE_TYPE_FILTERS[normalizedPath];
|
||||
}
|
||||
|
||||
class SiteSearch extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
const openBtn = this.querySelector<HTMLButtonElement>('button[data-open-modal]')!;
|
||||
const closeBtn = this.querySelector<HTMLButtonElement>('button[data-close-modal]')!;
|
||||
const dialog = this.querySelector('dialog')!;
|
||||
const dialogFrame = this.querySelector('.dialog-frame')!;
|
||||
|
||||
/** Close the modal if a user clicks on a link or outside of the modal. */
|
||||
const onClick = (event: MouseEvent) => {
|
||||
const isLink = 'href' in (event.target || {});
|
||||
if (
|
||||
isLink ||
|
||||
(document.body.contains(event.target as Node) &&
|
||||
!dialogFrame.contains(event.target as Node))
|
||||
) {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
const openModal = (event?: MouseEvent) => {
|
||||
dialog.showModal();
|
||||
document.body.toggleAttribute('data-search-modal-open', true);
|
||||
this.querySelector('input')?.focus();
|
||||
event?.stopPropagation();
|
||||
window.addEventListener('click', onClick);
|
||||
};
|
||||
|
||||
const closeModal = () => dialog.close();
|
||||
|
||||
openBtn.addEventListener('click', openModal);
|
||||
openBtn.disabled = false;
|
||||
closeBtn.addEventListener('click', closeModal);
|
||||
|
||||
dialog.addEventListener('close', () => {
|
||||
document.body.toggleAttribute('data-search-modal-open', false);
|
||||
window.removeEventListener('click', onClick);
|
||||
});
|
||||
|
||||
// Listen for `ctrl + k` and `cmd + k` keyboard shortcuts.
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if ((e.metaKey === true || e.ctrlKey === true) && e.key === 'k') {
|
||||
dialog.open ? closeModal() : openModal();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
let translations = {};
|
||||
try {
|
||||
translations = JSON.parse(this.dataset.translations || '{}');
|
||||
} catch {}
|
||||
|
||||
const shouldStrip = this.dataset.stripTrailingSlash !== undefined;
|
||||
const stripTrailingSlash = (path: string) => path.replace(/(.)\/(#.*)?$/, '$1$2');
|
||||
const formatURL = shouldStrip ? stripTrailingSlash : (path: string) => path;
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
if (import.meta.env.DEV) return;
|
||||
const onIdle = window.requestIdleCallback || ((cb) => setTimeout(cb, 1));
|
||||
onIdle(async () => {
|
||||
// @ts-expect-error — Missing types for @pagefind/default-ui package.
|
||||
const { PagefindUI } = await import('@pagefind/default-ui');
|
||||
const pagefind = new PagefindUI({
|
||||
...pagefindUserConfig,
|
||||
element: '#starlight__search',
|
||||
baseUrl: import.meta.env.BASE_URL,
|
||||
bundlePath: import.meta.env.BASE_URL.replace(/\/$/, '') + '/pagefind/',
|
||||
showImages: false,
|
||||
translations,
|
||||
showSubResults: true,
|
||||
processResult: (result: { url: string; sub_results: Array<{ url: string }> }) => {
|
||||
result.url = formatURL(result.url);
|
||||
result.sub_results = result.sub_results.map((sub_result) => {
|
||||
sub_result.url = formatURL(sub_result.url);
|
||||
return sub_result;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const routeTypeFilter = getRouteTypeFilter(
|
||||
window.location.pathname,
|
||||
import.meta.env.BASE_URL
|
||||
);
|
||||
if (routeTypeFilter) {
|
||||
pagefind.triggerFilters({ type: routeTypeFilter });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
customElements.define('site-search', SiteSearch);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@layer starlight.core {
|
||||
site-search {
|
||||
display: contents;
|
||||
}
|
||||
button[data-open-modal] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
color: var(--sl-color-gray-1);
|
||||
cursor: pointer;
|
||||
height: 2.5rem;
|
||||
font-size: var(--sl-text-xl);
|
||||
}
|
||||
|
||||
@media (min-width: 50rem) {
|
||||
button[data-open-modal] {
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
border-radius: 0.5rem;
|
||||
padding-inline-start: 0.75rem;
|
||||
padding-inline-end: 0.5rem;
|
||||
background-color: var(--sl-color-black);
|
||||
color: var(--sl-color-gray-2);
|
||||
font-size: var(--sl-text-sm);
|
||||
width: 100%;
|
||||
max-width: 22rem;
|
||||
}
|
||||
button[data-open-modal]:hover {
|
||||
border-color: var(--sl-color-gray-2);
|
||||
color: var(--sl-color-white);
|
||||
}
|
||||
|
||||
button[data-open-modal] > :last-child {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
}
|
||||
|
||||
button > kbd {
|
||||
border-radius: 0.25rem;
|
||||
font-size: var(--sl-text-2xs);
|
||||
gap: 0.25em;
|
||||
padding-inline: 0.375rem;
|
||||
background-color: var(--sl-color-gray-6);
|
||||
}
|
||||
|
||||
kbd {
|
||||
font-family: var(--__sl-font);
|
||||
}
|
||||
|
||||
dialog {
|
||||
margin: 0;
|
||||
background-color: var(--sl-color-gray-6);
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
box-shadow: var(--sl-shadow-lg);
|
||||
}
|
||||
dialog[open] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background-color: var(--sl-color-backdrop-overlay);
|
||||
-webkit-backdrop-filter: blur(0.25rem);
|
||||
backdrop-filter: blur(0.25rem);
|
||||
}
|
||||
|
||||
.dialog-frame {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
button[data-close-modal] {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
align-items: center;
|
||||
align-self: flex-end;
|
||||
height: calc(64px * var(--pagefind-ui-scale));
|
||||
padding: 0.25rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--sl-color-text-accent);
|
||||
}
|
||||
|
||||
#starlight__search {
|
||||
--pagefind-ui-primary: var(--sl-color-text);
|
||||
--pagefind-ui-text: var(--sl-color-gray-2);
|
||||
--pagefind-ui-font: var(--__sl-font);
|
||||
--pagefind-ui-background: var(--sl-color-black);
|
||||
--pagefind-ui-border: var(--sl-color-gray-5);
|
||||
--pagefind-ui-border-width: 1px;
|
||||
--pagefind-ui-tag: var(--sl-color-gray-5);
|
||||
--sl-search-cancel-space: 5rem;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] #starlight__search {
|
||||
--pagefind-ui-tag: var(--sl-color-gray-6);
|
||||
}
|
||||
|
||||
@media (min-width: 50rem) {
|
||||
#starlight__search {
|
||||
--sl-search-cancel-space: 0px;
|
||||
}
|
||||
|
||||
dialog {
|
||||
margin: 4rem auto auto;
|
||||
border-radius: 0.5rem;
|
||||
width: 90%;
|
||||
max-width: 40rem;
|
||||
height: max-content;
|
||||
min-height: 15rem;
|
||||
max-height: calc(100% - 8rem);
|
||||
}
|
||||
|
||||
.dialog-frame {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style is:global>
|
||||
@import url('@pagefind/default-ui/css/ui.css') layer(starlight.core);
|
||||
|
||||
@layer starlight.core {
|
||||
[data-search-modal-open] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#starlight__search {
|
||||
--sl-search-result-spacing: calc(1.25rem * var(--pagefind-ui-scale));
|
||||
--sl-search-result-pad-inline-start: calc(3.75rem * var(--pagefind-ui-scale));
|
||||
--sl-search-result-pad-inline-end: calc(1.25rem * var(--pagefind-ui-scale));
|
||||
--sl-search-result-pad-block: calc(0.9375rem * var(--pagefind-ui-scale));
|
||||
--sl-search-result-nested-pad-block: calc(0.625rem * var(--pagefind-ui-scale));
|
||||
--sl-search-corners: calc(0.3125rem * var(--pagefind-ui-scale));
|
||||
--sl-search-page-icon-size: calc(1.875rem * var(--pagefind-ui-scale));
|
||||
--sl-search-page-icon-inline-start: calc(
|
||||
(var(--sl-search-result-pad-inline-start) - var(--sl-search-page-icon-size)) / 2
|
||||
);
|
||||
--sl-search-tree-diagram-size: calc(2.5rem * var(--pagefind-ui-scale));
|
||||
--sl-search-tree-diagram-inline-start: calc(
|
||||
(var(--sl-search-result-pad-inline-start) - var(--sl-search-tree-diagram-size)) / 2
|
||||
);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__form::before {
|
||||
--pagefind-ui-text: var(--sl-color-gray-1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__search-input {
|
||||
color: var(--sl-color-white);
|
||||
font-weight: 400;
|
||||
width: calc(100% - var(--sl-search-cancel-space));
|
||||
}
|
||||
|
||||
#starlight__search input:focus {
|
||||
--pagefind-ui-border: var(--sl-color-accent);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__search-clear {
|
||||
inset-inline-end: var(--sl-search-cancel-space);
|
||||
width: calc(60px * var(--pagefind-ui-scale));
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
#starlight__search .pagefind-ui__search-clear:focus {
|
||||
outline: 1px solid var(--sl-color-accent);
|
||||
}
|
||||
#starlight__search .pagefind-ui__search-clear::before {
|
||||
content: '';
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m13.41 12 6.3-6.29a1 1 0 1 0-1.42-1.42L12 10.59l-6.29-6.3a1 1 0 0 0-1.42 1.42l6.3 6.29-6.3 6.29a1 1 0 0 0 .33 1.64 1 1 0 0 0 1.09-.22l6.29-6.3 6.29 6.3a1 1 0 0 0 1.64-.33 1 1 0 0 0-.22-1.09L13.41 12Z'/%3E%3C/svg%3E")
|
||||
center / 50% no-repeat;
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m13.41 12 6.3-6.29a1 1 0 1 0-1.42-1.42L12 10.59l-6.29-6.3a1 1 0 0 0-1.42 1.42l6.3 6.29-6.3 6.29a1 1 0 0 0 .33 1.64 1 1 0 0 0 1.09-.22l6.29-6.3 6.29 6.3a1 1 0 0 0 1.64-.33 1 1 0 0 0-.22-1.09L13.41 12Z'/%3E%3C/svg%3E")
|
||||
center / 50% no-repeat;
|
||||
background-color: var(--sl-color-text-accent);
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__results > * + * {
|
||||
margin-top: var(--sl-search-result-spacing);
|
||||
}
|
||||
#starlight__search .pagefind-ui__result {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-nested {
|
||||
position: relative;
|
||||
padding: var(--sl-search-result-nested-pad-block) var(--sl-search-result-pad-inline-end);
|
||||
padding-inline-start: var(--sl-search-result-pad-inline-start);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)),
|
||||
#starlight__search .pagefind-ui__result-nested {
|
||||
position: relative;
|
||||
background-color: var(--sl-color-black);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)):hover,
|
||||
#starlight__search
|
||||
.pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)):focus-within,
|
||||
#starlight__search .pagefind-ui__result-nested:hover,
|
||||
#starlight__search .pagefind-ui__result-nested:focus-within {
|
||||
outline: 1px solid var(--sl-color-accent-high);
|
||||
}
|
||||
|
||||
#starlight__search
|
||||
.pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)):focus-within,
|
||||
#starlight__search .pagefind-ui__result-nested:focus-within {
|
||||
background-color: var(--sl-color-accent-low);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-thumb,
|
||||
#starlight__search .pagefind-ui__result-inner {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-inner > :first-child {
|
||||
border-radius: var(--sl-search-corners) var(--sl-search-corners) 0 0;
|
||||
}
|
||||
#starlight__search .pagefind-ui__result-inner > :last-child {
|
||||
border-radius: 0 0 var(--sl-search-corners) var(--sl-search-corners);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-inner > .pagefind-ui__result-title {
|
||||
padding: var(--sl-search-result-pad-block) var(--sl-search-result-pad-inline-end);
|
||||
padding-inline-start: var(--sl-search-result-pad-inline-start);
|
||||
}
|
||||
#starlight__search .pagefind-ui__result-inner > .pagefind-ui__result-title::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: var(--sl-search-page-icon-inline-start);
|
||||
width: var(--sl-search-page-icon-size);
|
||||
background: var(--sl-color-gray-3);
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='currentColor' viewBox='0 0 24 24'%3E%3Cpath d='M9 10h1a1 1 0 1 0 0-2H9a1 1 0 0 0 0 2Zm0 2a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2H9Zm11-3V8l-6-6a1 1 0 0 0-1 0H7a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V9Zm-6-4 3 3h-2a1 1 0 0 1-1-1V5Zm4 14a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5v3a3 3 0 0 0 3 3h3v9Zm-3-3H9a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2Z'/%3E%3C/svg%3E")
|
||||
center no-repeat;
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='currentColor' viewBox='0 0 24 24'%3E%3Cpath d='M9 10h1a1 1 0 1 0 0-2H9a1 1 0 0 0 0 2Zm0 2a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2H9Zm11-3V8l-6-6a1 1 0 0 0-1 0H7a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V9Zm-6-4 3 3h-2a1 1 0 0 1-1-1V5Zm4 14a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5v3a3 3 0 0 0 3 3h3v9Zm-3-3H9a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2Z'/%3E%3C/svg%3E")
|
||||
center no-repeat;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-inner {
|
||||
align-items: stretch;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-link {
|
||||
position: unset;
|
||||
--pagefind-ui-text: var(--sl-color-white);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-nested .pagefind-ui__result-link::before {
|
||||
content: unset;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-nested::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: var(--sl-search-tree-diagram-inline-start);
|
||||
width: var(--sl-search-tree-diagram-size);
|
||||
background: var(--sl-color-gray-4);
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' viewBox='0 0 16 1000' preserveAspectRatio='xMinYMin slice'%3E%3Cpath d='M8 0v1000m6-988H8'/%3E%3C/svg%3E")
|
||||
0% 0% / 100% no-repeat;
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' viewBox='0 0 16 1000' preserveAspectRatio='xMinYMin slice'%3E%3Cpath d='M8 0v1000m6-988H8'/%3E%3C/svg%3E")
|
||||
0% 0% / 100% no-repeat;
|
||||
}
|
||||
#starlight__search .pagefind-ui__result-nested:last-of-type::before {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 16 16'%3E%3Cpath d='M8 0v12m6 0H8'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 16 16'%3E%3Cpath d='M8 0v12m6 0H8'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Flip page and tree icons around the vertical axis when in an RTL layout. */
|
||||
[dir='rtl'] .pagefind-ui__result-title::before,
|
||||
[dir='rtl'] .pagefind-ui__result-nested::before {
|
||||
transform: matrix(-1, 0, 0, 1, 0, 0);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-excerpt {
|
||||
font-size: calc(1rem * var(--pagefind-ui-scale));
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#starlight__search mark {
|
||||
color: var(--sl-color-gray-2);
|
||||
background-color: transparent;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__filter-value::before {
|
||||
border-color: var(--sl-color-text-invert);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-tags {
|
||||
background-color: var(--sl-color-black);
|
||||
margin-top: 0;
|
||||
padding: var(--sl-search-result-nested-pad-block) var(--sl-search-result-pad-inline-end);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,170 +0,0 @@
|
||||
---
|
||||
// Theme Toggle Component - 3 state slider: Auto | Dark | Light
|
||||
---
|
||||
|
||||
<div class="theme-toggle-container">
|
||||
<button
|
||||
id="theme-toggle"
|
||||
class="theme-toggle"
|
||||
aria-label="Toggle theme"
|
||||
title="Change theme"
|
||||
>
|
||||
<span class="theme-icon moon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="theme-icon auto" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 2v10l4.5 4.5"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="theme-icon sun" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/>
|
||||
<line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/>
|
||||
<line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="theme-slider"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Move theme toggle to body level to escape any stacking contexts
|
||||
const container = document.querySelector('.theme-toggle-container');
|
||||
if (container && container.parentElement !== document.body) {
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'awesome-copilot-theme';
|
||||
const toggle = document.getElementById('theme-toggle');
|
||||
const html = document.documentElement;
|
||||
|
||||
const themes = ['dark', 'auto', 'light'];
|
||||
const icons = ['moon', 'auto', 'sun'];
|
||||
|
||||
function getThemeIndex() {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored && themes.includes(stored)) {
|
||||
return themes.indexOf(stored);
|
||||
}
|
||||
// Default to light theme
|
||||
return 2;
|
||||
}
|
||||
|
||||
function applyTheme(index: number) {
|
||||
const theme = themes[index];
|
||||
if (theme === 'auto') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
html.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
|
||||
} else {
|
||||
html.setAttribute('data-theme', theme);
|
||||
}
|
||||
|
||||
// Move slider
|
||||
const slider = toggle?.querySelector('.theme-slider') as HTMLElement;
|
||||
if (slider) {
|
||||
slider.style.transform = `translateX(${index * 100}%)`;
|
||||
}
|
||||
|
||||
// Highlight active icon
|
||||
const themeToggle = document.querySelector('.theme-toggle');
|
||||
themeToggle?.setAttribute('data-active', String(index));
|
||||
}
|
||||
|
||||
function cycleTheme() {
|
||||
const currentIndex = getThemeIndex();
|
||||
const nextIndex = (currentIndex + 1) % themes.length;
|
||||
localStorage.setItem(STORAGE_KEY, themes[nextIndex]);
|
||||
applyTheme(nextIndex);
|
||||
}
|
||||
|
||||
// Initialize
|
||||
applyTheme(getThemeIndex());
|
||||
|
||||
// Click handler
|
||||
toggle?.addEventListener('click', cycleTheme);
|
||||
|
||||
// Keyboard shortcut
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'L') {
|
||||
e.preventDefault();
|
||||
cycleTheme();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for system theme changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (localStorage.getItem(STORAGE_KEY) === 'auto') {
|
||||
applyTheme(1); // auto
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style is:global>
|
||||
.theme-toggle-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
display: flex;
|
||||
position: relative;
|
||||
width: 108px;
|
||||
height: 36px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 20px;
|
||||
background: var(--color-bg-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.theme-toggle:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 30px;
|
||||
border-radius: 16px;
|
||||
color: var(--color-text-muted);
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.theme-icon.moon { color: #9898a6; }
|
||||
.theme-icon.auto { color: #9898a6; }
|
||||
.theme-icon.sun { color: #9898a6; }
|
||||
|
||||
.theme-toggle[data-active="0"] .moon,
|
||||
.theme-toggle[data-active="1"] .auto,
|
||||
.theme-toggle[data-active="2"] .sun {
|
||||
color: var(--color-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-slider {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
import { DownloadIcon, MarkGithubIcon, PlusIcon } from "@primer/octicons-react";
|
||||
import React from "react";
|
||||
|
||||
import { ActionMenu, Button } from "@primer/react-brand";
|
||||
|
||||
import { DetailChassis, type DetailSibling } from "./DetailChassis";
|
||||
import { ResourceMeta } from "./ResourceMeta";
|
||||
import { pageHref } from "./pageHref";
|
||||
import { downloadFile } from "./resourceActions";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/dotnet-upgrade.module.css";
|
||||
|
||||
export type AgentDetailItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
model?: string | string[];
|
||||
tools?: string[];
|
||||
handoffs?: (string | { label?: string; agent?: string })[];
|
||||
mcpServers?: string[];
|
||||
path: string;
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
export type AgentDetailProps = {
|
||||
item: AgentDetailItem;
|
||||
/** Rendered, sanitized markdown body with heading ids already stamped. */
|
||||
markdownHtml: string;
|
||||
vscodeUrl: string;
|
||||
insidersUrl: string;
|
||||
githubUrl: string;
|
||||
downloadUrl: string;
|
||||
rawMarkdown: string;
|
||||
lastUpdated?: string | null;
|
||||
previous?: DetailSibling;
|
||||
next?: DetailSibling;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
};
|
||||
|
||||
export function AgentDetail({
|
||||
item,
|
||||
markdownHtml,
|
||||
vscodeUrl,
|
||||
insidersUrl,
|
||||
githubUrl,
|
||||
downloadUrl,
|
||||
rawMarkdown,
|
||||
lastUpdated,
|
||||
previous,
|
||||
next,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
}: AgentDetailProps) {
|
||||
const models = Array.isArray(item.model)
|
||||
? item.model
|
||||
: item.model
|
||||
? [item.model]
|
||||
: [];
|
||||
|
||||
// Handoffs are recorded as `{ label, agent }` records; the chip shows the
|
||||
// human label, falling back to the agent id.
|
||||
const handoffs = (item.handoffs ?? [])
|
||||
.map((handoff) =>
|
||||
typeof handoff === "string"
|
||||
? handoff
|
||||
: (handoff.label ?? handoff.agent ?? ""),
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
const handleCopyMarkdown = React.useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(rawMarkdown);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
}, [rawMarkdown]);
|
||||
|
||||
const handleDownload = React.useCallback(
|
||||
() => downloadFile(downloadUrl, item.filename ?? `${item.id}.agent.md`),
|
||||
[downloadUrl, item.filename, item.id],
|
||||
);
|
||||
|
||||
const install = (
|
||||
<>
|
||||
<ActionMenu mode="split-button" menuAlignment="start">
|
||||
<ActionMenu.Button
|
||||
as="a"
|
||||
href={vscodeUrl}
|
||||
variant="primary"
|
||||
leadingVisual={PlusIcon}
|
||||
>
|
||||
Install
|
||||
</ActionMenu.Button>
|
||||
<ActionMenu.Overlay aria-label={`Install the ${item.title} agent`}>
|
||||
<ActionMenu.Item as="a" href={vscodeUrl}>
|
||||
Install in VS Code
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item as="a" href={insidersUrl}>
|
||||
Install in VS Code Insiders
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item onClick={() => void handleDownload()}>
|
||||
Download file
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item onClick={handleCopyMarkdown}>
|
||||
Copy markdown
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
<Button
|
||||
as="a"
|
||||
href={githubUrl}
|
||||
variant="secondary"
|
||||
className={styles.iconButton}
|
||||
aria-label={`View the ${item.title} agent on GitHub`}
|
||||
>
|
||||
<MarkGithubIcon size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
as="button"
|
||||
variant="secondary"
|
||||
onClick={() => void handleDownload()}
|
||||
className={styles.iconButton}
|
||||
aria-label={`Download the ${item.title} agent file`}
|
||||
>
|
||||
<DownloadIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailChassis
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
breadcrumbs={[
|
||||
{ label: "Agents", href: pageHref("agents") },
|
||||
{ label: item.title },
|
||||
]}
|
||||
install={install}
|
||||
sidebar={
|
||||
<ResourceMeta
|
||||
kicker="Agent details"
|
||||
groups={[
|
||||
{ label: "Models", items: models },
|
||||
{ label: "Tools", items: item.tools ?? [] },
|
||||
{ label: "Handoffs", items: handoffs },
|
||||
{ label: "MCP servers", items: item.mcpServers ?? [] },
|
||||
]}
|
||||
lastUpdated={lastUpdated}
|
||||
sourceUrl={githubUrl}
|
||||
/>
|
||||
}
|
||||
previous={previous}
|
||||
next={next}
|
||||
currentPage="agents"
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
>
|
||||
<section className={styles.articleSection}>
|
||||
<div dangerouslySetInnerHTML={{ __html: markdownHtml }} />
|
||||
</section>
|
||||
</DetailChassis>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
import { ChevronDownIcon, DownloadIcon, MarkGithubIcon } from "@primer/octicons-react";
|
||||
import {
|
||||
ActionMenu,
|
||||
Box,
|
||||
Button,
|
||||
CTABanner,
|
||||
Card,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
Grid,
|
||||
Heading,
|
||||
Pagination,
|
||||
Section,
|
||||
Stack,
|
||||
Text,
|
||||
useTheme,
|
||||
} from "@primer/react-brand";
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { PageShell } from "./PageShell";
|
||||
import { CatalogSortControl, CATALOG_SORT_OPTIONS } from "./CatalogSortControl";
|
||||
import { daysSince, toggleValue, updatedBuckets, updatedBucketOf } from "./catalogFilters";
|
||||
import { pageHref } from "./pageHref";
|
||||
import { downloadFile } from "./resourceActions";
|
||||
import { getScrollBehavior } from "./scrollBehavior";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/agents.module.css";
|
||||
|
||||
const CONTRIBUTE_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/docs/README.agents.md#how-to-contribute";
|
||||
const REQUEST_URL = "https://github.com/github/awesome-copilot/issues/new";
|
||||
|
||||
const RAW_BASE = "https://raw.githubusercontent.com/github/awesome-copilot/main";
|
||||
const GITHUB_BASE = "https://github.com/github/awesome-copilot/blob/main";
|
||||
const INSTALL_REDIRECT = "https://aka.ms/awesome-copilot/install/agent";
|
||||
|
||||
/** An agent record as emitted into public/data/agents.json. */
|
||||
export type AgentItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
model?: unknown;
|
||||
tools?: string[];
|
||||
path: string;
|
||||
filename: string;
|
||||
lastUpdated: string;
|
||||
};
|
||||
|
||||
/** Filter facets emitted alongside the items in public/data/agents.json. */
|
||||
export type AgentFilters = {
|
||||
models?: unknown[];
|
||||
tools?: string[];
|
||||
};
|
||||
|
||||
export const agentSourceUrl = (agent: AgentItem) => `${GITHUB_BASE}/${agent.path}`;
|
||||
|
||||
export const downloadUrl = (agent: AgentItem) => `${RAW_BASE}/${agent.path}`;
|
||||
|
||||
/**
|
||||
* VS Code deep link, matching the scheme used by the agent detail pages
|
||||
* (`src/lib/detail-page.ts`): an `aka.ms` redirect wrapping the editor URI.
|
||||
*/
|
||||
export const installUrl = (agent: AgentItem, insiders = false) => {
|
||||
const editor = insiders ? "vscode-insiders" : "vscode";
|
||||
const innerUrl = `${editor}:chat-agent/install?url=${encodeURIComponent(
|
||||
downloadUrl(agent),
|
||||
)}`;
|
||||
return `${INSTALL_REDIRECT}?url=${encodeURIComponent(innerUrl)}`;
|
||||
};
|
||||
|
||||
const agentDetailHref = (agent: AgentItem) => pageHref(`agent/${agent.id}`);
|
||||
|
||||
/** `model` may be a single name or a list of accepted models. */
|
||||
const modelsOf = (agent: AgentItem): string[] => flattenNames(agent.model);
|
||||
|
||||
function flattenNames(value: unknown): string[] {
|
||||
if (typeof value === "string") return [value];
|
||||
if (Array.isArray(value)) return value.flatMap(flattenNames);
|
||||
return [];
|
||||
}
|
||||
|
||||
const uniqueSorted = (values: string[]) =>
|
||||
Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
type FilterGroupId = "model" | "tools" | "updated";
|
||||
|
||||
type FilterState = Record<FilterGroupId, string[]>;
|
||||
|
||||
const emptyFilters: FilterState = { model: [], tools: [], updated: [] };
|
||||
|
||||
type SortMode = "az" | "newest";
|
||||
|
||||
const PAGE_SIZE = 6;
|
||||
|
||||
/**
|
||||
* Long facets collapse behind a "Show N more" toggle, matching the prototype's
|
||||
* extensions page. Real data produces far longer option lists than the
|
||||
* prototype's hardcoded arrays, so every catalog needs this.
|
||||
*/
|
||||
const FILTER_COLLAPSE_LIMIT = 10;
|
||||
|
||||
/**
|
||||
* The Agents catalog, ported from the design prototype's `agents.tsx`. The
|
||||
* prototype's hardcoded array and hardcoded model/capability options are
|
||||
* replaced by build-time data; the layout, filters, and interactions are
|
||||
* unchanged.
|
||||
*/
|
||||
export function AgentsCatalog({
|
||||
agents,
|
||||
filters: dataFilters,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
}: {
|
||||
agents: AgentItem[];
|
||||
filters?: AgentFilters;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
}) {
|
||||
const { colorMode } = useTheme();
|
||||
const [sortMode, setSortMode] = useState<SortMode>("az");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const previousPage = useRef(currentPage);
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<FilterState>(emptyFilters);
|
||||
const [expandedGroups, setExpandedGroups] = useState<
|
||||
Record<FilterGroupId, boolean>
|
||||
>({ model: false, tools: false, updated: false });
|
||||
const toggleGroupExpanded = (groupId: FilterGroupId) => {
|
||||
setExpandedGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] }));
|
||||
};
|
||||
|
||||
const filterGroups = useMemo<
|
||||
{ id: FilterGroupId; label: string; options: string[] }[]
|
||||
>(() => {
|
||||
const models = dataFilters?.models
|
||||
? uniqueSorted(flattenNames(dataFilters.models))
|
||||
: uniqueSorted(agents.flatMap(modelsOf));
|
||||
const tools = dataFilters?.tools
|
||||
? uniqueSorted(dataFilters.tools)
|
||||
: uniqueSorted(agents.flatMap((agent) => agent.tools ?? []));
|
||||
return [
|
||||
{ id: "model", label: "Model", options: models },
|
||||
{ id: "tools", label: "Tools", options: tools },
|
||||
{
|
||||
id: "updated",
|
||||
label: "Last updated",
|
||||
options: updatedBuckets.map((bucket) => bucket.label),
|
||||
},
|
||||
];
|
||||
}, [agents, dataFilters]);
|
||||
|
||||
const sortedAgents = useMemo(() => {
|
||||
const copy = agents.filter((agent) => {
|
||||
const models = modelsOf(agent);
|
||||
const modelOk =
|
||||
filters.model.length === 0 ||
|
||||
filters.model.some((model) => models.includes(model));
|
||||
const toolsOk =
|
||||
filters.tools.length === 0 ||
|
||||
filters.tools.every((tool) => (agent.tools ?? []).includes(tool));
|
||||
const updatedOk =
|
||||
filters.updated.length === 0 ||
|
||||
filters.updated.includes(updatedBucketOf(daysSince(agent.lastUpdated)));
|
||||
return modelOk && toolsOk && updatedOk;
|
||||
});
|
||||
if (sortMode === "newest") {
|
||||
copy.sort((a, b) => daysSince(a.lastUpdated) - daysSince(b.lastUpdated));
|
||||
} else {
|
||||
copy.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
return copy;
|
||||
}, [agents, filters, sortMode]);
|
||||
|
||||
const toggleFilter = (groupId: FilterGroupId, option: string) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[groupId]: toggleValue(prev[groupId], option),
|
||||
}));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters(emptyFilters);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const activeFilterCount =
|
||||
filters.model.length + filters.tools.length + filters.updated.length;
|
||||
const hasActiveFilters = activeFilterCount > 0;
|
||||
const pageCount = Math.max(1, Math.ceil(sortedAgents.length / PAGE_SIZE));
|
||||
const page = Math.min(currentPage, pageCount);
|
||||
const visibleAgents = sortedAgents.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousPage.current === currentPage) return;
|
||||
previousPage.current = currentPage;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const catalog = document.getElementById("catalog");
|
||||
if (!catalog) return;
|
||||
catalog.scrollIntoView({ behavior: getScrollBehavior(), block: "start" });
|
||||
catalog.focus({ preventScroll: true });
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [currentPage]);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
styles={styles}
|
||||
currentPage="agents"
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
searchAriaLabel="Search agents"
|
||||
>
|
||||
<Box className={styles.hero}>
|
||||
<div className={styles.heroInner}>
|
||||
<svg
|
||||
className={styles.heroIcon}
|
||||
width="36"
|
||||
height="36"
|
||||
viewBox="0 0 96 96"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M48 53L23 72"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M47.9998 53L72.9998 72"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48.01 53L48.01 26"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M87 91C91.9706 91 96 86.9706 96 82C96 77.0294 91.9706 73 87 73C82.0294 73 78 77.0294 78 82C78 86.9706 82.0294 91 87 91Z"
|
||||
fill="var(--base-color-scale-gray-5)"
|
||||
/>
|
||||
<path
|
||||
d="M48 18C52.9706 18 57 13.9706 57 9C57 4.02944 52.9706 0 48 0C43.0294 0 39 4.02944 39 9C39 13.9706 43.0294 18 48 18Z"
|
||||
fill="var(--base-color-scale-gray-5)"
|
||||
/>
|
||||
<path
|
||||
d="M9 91C13.9706 91 18 86.9706 18 82C18 77.0294 13.9706 73 9 73C4.02944 73 0 77.0294 0 82C0 86.9706 4.02944 91 9 91Z"
|
||||
fill="var(--base-color-scale-gray-5)"
|
||||
/>
|
||||
</svg>
|
||||
<Heading as="h1" size="3" className={styles.heroHeading}>
|
||||
Agents
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted" className={styles.heroText}>
|
||||
Specialized agents that enhance GitHub Copilot for specific
|
||||
technologies, workflows, and domains — contributed and curated by
|
||||
the community.
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Section
|
||||
id="catalog"
|
||||
tabIndex={-1}
|
||||
paddingBlockStart="none"
|
||||
paddingBlockEnd="none"
|
||||
>
|
||||
<Box className={styles.catalog}>
|
||||
<aside className={styles.filterNav} aria-label="Filter agents">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filterToggle}
|
||||
aria-expanded={mobileFiltersOpen}
|
||||
onClick={() => setMobileFiltersOpen((open) => !open)}
|
||||
>
|
||||
<span>
|
||||
Filters{hasActiveFilters ? ` (${activeFilterCount})` : ""}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
size={16}
|
||||
className={clsx(
|
||||
styles.filterToggleChevron,
|
||||
mobileFiltersOpen && styles.filterToggleChevronOpen,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.filterBody,
|
||||
!mobileFiltersOpen && styles.filterBodyCollapsed,
|
||||
)}
|
||||
>
|
||||
{filterGroups.map((group) => {
|
||||
const isExpandable =
|
||||
group.options.length > FILTER_COLLAPSE_LIMIT;
|
||||
const expanded = expandedGroups[group.id];
|
||||
const visibleOptions =
|
||||
isExpandable && !expanded
|
||||
? group.options.slice(0, FILTER_COLLAPSE_LIMIT)
|
||||
: group.options;
|
||||
return (
|
||||
<div className={styles.filterGroup} key={group.id}>
|
||||
<Text as="h2" size="100" className={styles.filterHeading}>
|
||||
{group.label}
|
||||
</Text>
|
||||
<div className={styles.filterOptions}>
|
||||
{visibleOptions.map((option) => (
|
||||
<div className={styles.filterOption} key={option}>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={filters[group.id].includes(option)}
|
||||
onChange={() => toggleFilter(group.id, option)}
|
||||
/>
|
||||
<FormControl.Label>{option}</FormControl.Label>
|
||||
</FormControl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{isExpandable ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.showMoreButton}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => toggleGroupExpanded(group.id)}
|
||||
>
|
||||
{expanded
|
||||
? "Show less"
|
||||
: `Show ${group.options.length - FILTER_COLLAPSE_LIMIT} more`}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hasActiveFilters ? (
|
||||
<div className={styles.filterActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
hasArrow={false}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear all ({activeFilterCount})
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Box className={styles.catalogMain}>
|
||||
<Box className={styles.toolbar}>
|
||||
<CatalogSortControl
|
||||
ariaLabel="Sort agents"
|
||||
value={sortMode}
|
||||
options={CATALOG_SORT_OPTIONS}
|
||||
styles={styles}
|
||||
onChange={(value) => {
|
||||
setSortMode(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box className={styles.gridFrame} data-mode={colorMode}>
|
||||
<Box className={styles.gridContent}>
|
||||
<Grid
|
||||
className={styles.threeUp}
|
||||
columnGap="none"
|
||||
rowGap="none"
|
||||
enableGutters={false}
|
||||
>
|
||||
{visibleAgents.map((agent) => (
|
||||
<Grid.Column
|
||||
key={agent.id}
|
||||
span={{ xsmall: 12, medium: 6, large: 6 }}
|
||||
className={styles.col}
|
||||
>
|
||||
<Box className={clsx(styles.item, styles.itemHover)}>
|
||||
<Card
|
||||
href={agentDetailHref(agent)}
|
||||
fullWidth
|
||||
ctaVariant="none"
|
||||
backgroundColor="none"
|
||||
className={styles.card}
|
||||
>
|
||||
<Card.Heading as="h2">{agent.title}</Card.Heading>
|
||||
<Card.Description>
|
||||
<span className={styles.cardDescText}>
|
||||
{agent.description}
|
||||
</span>
|
||||
</Card.Description>
|
||||
</Card>
|
||||
<div className={styles.cardActions}>
|
||||
<ActionMenu mode="split-button" menuAlignment="start">
|
||||
<ActionMenu.Button
|
||||
as="a"
|
||||
href={installUrl(agent)}
|
||||
variant="primary"
|
||||
>
|
||||
Install
|
||||
</ActionMenu.Button>
|
||||
<ActionMenu.Overlay
|
||||
aria-label={`Install ${agent.title} to your editor`}
|
||||
>
|
||||
<ActionMenu.Item as="a" href={installUrl(agent, false)}>
|
||||
VS Code
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item as="a" href={installUrl(agent, true)}>
|
||||
VS Code Insiders
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
<Button
|
||||
as="button"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
void downloadFile(downloadUrl(agent), agent.filename)
|
||||
}
|
||||
aria-label={`Download ${agent.title} agent file`}
|
||||
className={styles.iconButton}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href={agentSourceUrl(agent)}
|
||||
variant="secondary"
|
||||
aria-label={`View ${agent.title} agent on GitHub`}
|
||||
className={styles.iconButton}
|
||||
>
|
||||
<MarkGithubIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</Grid.Column>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{sortedAgents.length === 0 ? (
|
||||
<Box className={styles.emptyState}>
|
||||
<Text as="p" variant="muted">
|
||||
No agents match the selected filters.
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.paginationRow}>
|
||||
<Stack direction="horizontal" justifyContent="center" padding="none">
|
||||
<Pagination
|
||||
className={styles.pagination}
|
||||
pageCount={pageCount}
|
||||
currentPage={page}
|
||||
onPageChange={(e, n) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(n);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.ctaFrame}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.ctaFrameInner}>
|
||||
<CTABanner align="center" backgroundColor="subtle">
|
||||
<CTABanner.Heading>
|
||||
Can’t find the agent you need?
|
||||
</CTABanner.Heading>
|
||||
<CTABanner.Description>
|
||||
This library is community-built. Share an agent you use—or
|
||||
request one—to help developers ship faster with GitHub
|
||||
Copilot.
|
||||
</CTABanner.Description>
|
||||
<CTABanner.ButtonGroup>
|
||||
<Button as="a" href={CONTRIBUTE_URL}>
|
||||
Submit an agent
|
||||
</Button>
|
||||
<Button as="a" href={REQUEST_URL}>
|
||||
Request an agent
|
||||
</Button>
|
||||
</CTABanner.ButtonGroup>
|
||||
</CTABanner>
|
||||
</Box>
|
||||
</Section>
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { CheckIcon, ChevronDownIcon } from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
type CatalogSortStyles = {
|
||||
sortControl: string;
|
||||
sortLabel: string;
|
||||
sortMenu: string;
|
||||
sortTrigger: string;
|
||||
sortChevron: string;
|
||||
sortOverlay: string;
|
||||
sortOption: string;
|
||||
sortOptionActive: string;
|
||||
sortOptionCheck: string;
|
||||
};
|
||||
|
||||
export type CatalogSortOption<T extends string> = {
|
||||
value: T;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const CATALOG_SORT_OPTIONS = [
|
||||
{ value: "az", label: "A-Z" },
|
||||
{ value: "newest", label: "Recently updated" },
|
||||
] as const satisfies readonly CatalogSortOption<"az" | "newest">[];
|
||||
|
||||
type CatalogSortControlProps<T extends string> = {
|
||||
ariaLabel: string;
|
||||
onChange: (value: T) => void;
|
||||
options: readonly CatalogSortOption<T>[];
|
||||
styles: CatalogSortStyles;
|
||||
value: T;
|
||||
};
|
||||
|
||||
export function CatalogSortControl<T extends string>({
|
||||
ariaLabel,
|
||||
onChange,
|
||||
options,
|
||||
styles,
|
||||
value,
|
||||
}: CatalogSortControlProps<T>) {
|
||||
const selectedLabel =
|
||||
options.find((option) => option.value === value)?.label ?? value;
|
||||
|
||||
return (
|
||||
<div className={styles.sortControl}>
|
||||
<span className={styles.sortLabel}>Sort by:</span>
|
||||
<details className={styles.sortMenu}>
|
||||
<summary className={styles.sortTrigger} aria-label={ariaLabel}>
|
||||
{selectedLabel}
|
||||
<ChevronDownIcon size={16} className={styles.sortChevron} />
|
||||
</summary>
|
||||
<div className={styles.sortOverlay} role="menu">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={value === option.value}
|
||||
className={clsx(
|
||||
styles.sortOption,
|
||||
value === option.value && styles.sortOptionActive,
|
||||
)}
|
||||
onClick={(event) => {
|
||||
onChange(option.value);
|
||||
event.currentTarget.closest("details")?.removeAttribute("open");
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
<CheckIcon
|
||||
size={16}
|
||||
className={styles.sortOptionCheck}
|
||||
aria-hidden={value !== option.value}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
type CommunityIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function CommunityIcon({ size = 64 }: CommunityIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Community"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M31.999 0.294922C49.5094 0.295276 63.7048 14.4906 63.7051 32.001C63.705 49.5115 49.5095 63.7067 31.999 63.707C14.4882 63.707 0.293059 49.5117 0.292969 32.001C0.293013 29.0498 0.697785 26.1932 1.45215 23.4824C1.9826 24.6649 2.64702 25.7739 3.42578 26.791C3.18471 28.121 3.03461 29.4826 2.98145 30.8691C3.18557 30.7421 3.42639 30.669 3.68457 30.6689H7.62207C10.2268 32.355 13.3313 33.3349 16.665 33.335C19.999 33.3349 23.104 32.3552 25.709 30.6689H43.1084C42.9723 26.4828 42.1725 22.3445 40.7324 18.4053H34.4043C33.9397 18.4051 33.5306 18.1672 33.292 17.8066C33.3174 17.4303 33.332 17.0507 33.332 16.668C33.332 16.5413 33.329 16.415 33.3262 16.2891C33.5685 15.9561 33.961 15.7394 34.4043 15.7393H39.6475C37.6975 11.4105 34.9796 7.45605 31.6221 4.05762C30.9351 4.75295 30.2749 5.47159 29.6426 6.21191C29.0801 5.51473 28.4629 4.86371 27.7969 4.26562C28.1273 3.88376 28.4634 3.50633 28.8076 3.13574C28.1279 3.21003 27.4558 3.3103 26.791 3.43066C25.7726 2.65054 24.6618 1.98518 23.4775 1.4541C26.1891 0.699263 29.0468 0.294922 31.999 0.294922ZM42.3301 48.7783C40.3944 53.2545 37.6991 57.3684 34.3613 60.9443C43.1709 60.2349 50.87 55.5949 55.7031 48.7783H42.3301ZM8.29297 48.7783C12.9739 55.38 20.3433 59.9397 28.8057 60.8652C25.5023 57.3079 22.8333 53.2211 20.9121 48.7783H8.29297ZM23.8359 48.7783C25.7666 52.9062 28.3983 56.6838 31.6221 59.9473C34.8458 56.6838 37.4767 52.9062 39.4072 48.7783H23.8359ZM2.98145 33.1328C3.1615 37.8318 4.45885 42.2435 6.61426 46.1123H19.8652C18.4089 42.0006 17.5984 37.6923 17.4658 33.335H3.68457C3.42621 33.3349 3.18567 33.26 2.98145 33.1328ZM20.1338 33.335C20.2759 37.7035 21.145 42.0194 22.7061 46.1123H40.5371C42.0981 42.0194 42.9673 37.7035 43.1094 33.335H20.1338ZM61.0137 33.1328C60.8095 33.2597 60.5695 33.335 60.3115 33.335H45.7773C45.6447 37.6923 44.8342 42.0006 43.3779 46.1123H57.3828C59.538 42.2436 60.8336 37.8316 61.0137 33.1328ZM43.5586 18.4053C44.9024 22.3616 45.6493 26.493 45.7764 30.6689H60.3115C60.5693 30.6689 60.8096 30.7425 61.0137 30.8691C60.8415 26.3779 59.6502 22.1486 57.6631 18.4053H43.5586ZM34.3604 3.05664C37.7925 6.73256 40.5485 10.9762 42.4951 15.5986C42.5147 15.6452 42.5323 15.6926 42.5518 15.7393H56.0605C51.2544 8.64143 43.3906 3.78357 34.3604 3.05664Z"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
<path
|
||||
d="M31.9987 16.6673C31.9986 25.1356 25.1336 32.0006 16.6654 32.0007C8.19701 32.0007 1.33212 25.1356 1.33203 16.6673C1.33203 8.19891 8.19696 1.33398 16.6654 1.33398C25.1337 1.33407 31.9987 8.19897 31.9987 16.6673Z"
|
||||
stroke="var(--brand-color-text-default)"
|
||||
strokeWidth="2.66667"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M12.1084 18.2175C15.2297 17.6921 18.418 17.7009 21.5361 18.2449C24.4973 18.7616 26.86 21.005 27.5303 23.9353L28.1826 26.7888C27.6491 27.3955 27.0694 27.9604 26.4473 28.4763C26.0427 28.3242 25.72 27.9789 25.6162 27.5261L24.9307 24.529C24.4997 22.6459 22.9812 21.2051 21.0781 20.8728C18.2579 20.3808 15.3739 20.3722 12.5508 20.8474L12.4062 20.8708C10.412 21.2066 8.8173 22.712 8.36621 24.6833L7.71582 27.5261C7.61198 27.9793 7.28787 28.3244 6.88281 28.4763C6.26111 27.9606 5.68164 27.3961 5.14844 26.7898L5.7666 24.0896C6.45828 21.0656 8.90482 18.7569 11.9639 18.2419L12.1084 18.2175Z"
|
||||
fill="var(--brand-color-text-default)"
|
||||
/>
|
||||
<rect
|
||||
x="12.6641"
|
||||
y="7.33398"
|
||||
width="8"
|
||||
height="8"
|
||||
rx="4"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import styles from "./styles/ContributorsHoverCard.module.css";
|
||||
import { CommunityIcon } from "./CommunityIcon";
|
||||
|
||||
type ContributorsHoverCardProps = {
|
||||
size?: number;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export function ContributorsHoverCard({
|
||||
size = 56,
|
||||
href,
|
||||
}: ContributorsHoverCardProps) {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<a
|
||||
href={href}
|
||||
className={styles.trigger}
|
||||
aria-label="Meet the contributors"
|
||||
>
|
||||
<CommunityIcon size={size} />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { HeartFillIcon } from "@primer/octicons-react";
|
||||
|
||||
import styles from "./styles/ContributorsNavButton.module.css";
|
||||
|
||||
type ContributorsNavButtonProps = {
|
||||
href: string;
|
||||
current?: boolean;
|
||||
/** Live contributor count, injected from build-time data. */
|
||||
total?: number;
|
||||
};
|
||||
|
||||
export function ContributorsNavButton({
|
||||
href,
|
||||
current = false,
|
||||
total = 0,
|
||||
}: ContributorsNavButtonProps) {
|
||||
return (
|
||||
<a
|
||||
className={styles.trigger}
|
||||
href={href}
|
||||
aria-current={current ? "page" : undefined}
|
||||
aria-label={`Meet all ${total.toLocaleString()} contributors`}
|
||||
>
|
||||
<span className={styles.heart} aria-hidden="true">
|
||||
<HeartFillIcon size={14} />
|
||||
</span>
|
||||
<span className={styles.countLabel}>{total.toLocaleString()}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { ArrowUpIcon } from "@primer/octicons-react";
|
||||
import { Avatar, Box, Button, Heading, Text } from "@primer/react-brand";
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { sanitizeHttpUrl } from "../../lib/external-source";
|
||||
import { PageShell } from "./PageShell";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import contributorsStyles from "./styles/contributorsPage.module.css";
|
||||
import styles from "./styles/styles.module.css";
|
||||
|
||||
const CONTRIBUTING_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md";
|
||||
|
||||
/** A contributor as recorded in the repository's `.all-contributorsrc`. */
|
||||
export type Contributor = {
|
||||
login: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
profileUrl: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The contributors page, ported from the design prototype's `contributors.tsx`.
|
||||
* The prototype's static `contributors.ts` array is replaced by build-time data
|
||||
* read from `.all-contributorsrc`; the layout and interactions are unchanged.
|
||||
*/
|
||||
export function ContributorsPage({
|
||||
contributors,
|
||||
searchIndex,
|
||||
}: {
|
||||
contributors: Contributor[];
|
||||
searchIndex?: SearchItem[];
|
||||
}) {
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
const total = contributors.length;
|
||||
|
||||
useEffect(() => {
|
||||
const updateBackToTop = () => setShowBackToTop(window.scrollY > 200);
|
||||
updateBackToTop();
|
||||
window.addEventListener("scroll", updateBackToTop, { passive: true });
|
||||
return () => window.removeEventListener("scroll", updateBackToTop);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
? "auto"
|
||||
: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
styles={styles}
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={total}
|
||||
contributorsCurrent
|
||||
searchAriaLabel="Search the library"
|
||||
>
|
||||
<Box className={contributorsStyles.hero}>
|
||||
<div className={contributorsStyles.heroInner}>
|
||||
<Heading as="h1" size="3" className={contributorsStyles.heroHeading}>
|
||||
Our contributors
|
||||
</Heading>
|
||||
<Text
|
||||
as="p"
|
||||
size="300"
|
||||
variant="muted"
|
||||
className={contributorsStyles.heroText}
|
||||
>
|
||||
Thank you to the {total.toLocaleString()} people who have shared
|
||||
ideas, examples, and improvements with Awesome Copilot.
|
||||
</Text>
|
||||
<Button as="a" href={CONTRIBUTING_URL} variant="primary">
|
||||
Become a contributor
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<section className={contributorsStyles.section} aria-label="Contributors">
|
||||
<div className={contributorsStyles.sectionInner}>
|
||||
<ul className={contributorsStyles.grid}>
|
||||
{contributors.map((contributor) => {
|
||||
const safeProfileUrl = sanitizeHttpUrl(contributor.profileUrl);
|
||||
const safeAvatarUrl = sanitizeHttpUrl(contributor.avatarUrl);
|
||||
const profileContent = (
|
||||
<>
|
||||
{safeAvatarUrl !== "#" ? (
|
||||
<Avatar
|
||||
src={safeAvatarUrl}
|
||||
alt=""
|
||||
size={64}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
<span className={contributorsStyles.contributorName}>
|
||||
{contributor.name}
|
||||
</span>
|
||||
<span className={contributorsStyles.contributorLogin}>
|
||||
@{contributor.login}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={contributorsStyles.gridCell}
|
||||
key={contributor.login}
|
||||
>
|
||||
{safeProfileUrl !== "#" ? (
|
||||
<a
|
||||
className={contributorsStyles.contributorLink}
|
||||
href={safeProfileUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{profileContent}
|
||||
</a>
|
||||
) : (
|
||||
<div className={contributorsStyles.contributorLink}>
|
||||
{profileContent}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
contributorsStyles.backToTop,
|
||||
showBackToTop && contributorsStyles.backToTopVisible,
|
||||
)}
|
||||
onClick={scrollToTop}
|
||||
aria-label="Back to top"
|
||||
aria-hidden={!showBackToTop}
|
||||
tabIndex={showBackToTop ? 0 : -1}
|
||||
>
|
||||
<ArrowUpIcon size={24} />
|
||||
</button>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { DownloadIcon, FileDirectoryIcon, MarkGithubIcon } from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
import React from "react";
|
||||
|
||||
import { Button, Text } from "@primer/react-brand";
|
||||
|
||||
import { DetailChassis, type DetailTocItem } from "./DetailChassis";
|
||||
import { ResourceMeta } from "./ResourceMeta";
|
||||
import {
|
||||
SyntaxHighlightedCode,
|
||||
detectCodeLanguage,
|
||||
type CodeLanguage,
|
||||
} from "./SyntaxHighlightedCode";
|
||||
import { pageHref } from "./pageHref";
|
||||
import { downloadFile } from "./resourceActions";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/dotnet-upgrade.module.css";
|
||||
import fileStyles from "./styles/skill-files.module.css";
|
||||
|
||||
export type RecipeFile = {
|
||||
/** Repo-relative path, e.g. `cookbook/copilot-sdk/nodejs/error-handling.md`. */
|
||||
path: string;
|
||||
/** File name shown on the tab. */
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type CookbookRecipeProps = {
|
||||
cookbookName: string;
|
||||
recipeName: string;
|
||||
recipeDescription: string;
|
||||
languageName: string;
|
||||
languageIcon?: string;
|
||||
tags: string[];
|
||||
files: RecipeFile[];
|
||||
/** Path of the file rendered server-side (the recipe's markdown doc). */
|
||||
primaryPath: string;
|
||||
/** Rendered, sanitized markdown for `primaryPath`. */
|
||||
markdownHtml: string;
|
||||
rawMarkdown: string;
|
||||
toc: DetailTocItem[];
|
||||
/** `https://github.com/.../blob/main` — per-file links are built from this. */
|
||||
githubBlobBase: string;
|
||||
/** `https://raw.githubusercontent.com/.../main` — lazy file fetches. */
|
||||
rawBase: string;
|
||||
lastUpdated?: string | null;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
};
|
||||
|
||||
type FileKind = "markdown" | "code" | "text";
|
||||
|
||||
const CODE_LANGUAGES: Record<string, CodeLanguage> = {
|
||||
ts: "tsx",
|
||||
tsx: "tsx",
|
||||
js: "tsx",
|
||||
jsx: "tsx",
|
||||
mjs: "tsx",
|
||||
cs: "tsx",
|
||||
go: "tsx",
|
||||
java: "tsx",
|
||||
py: "bash",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
ps1: "bash",
|
||||
json: "json",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
html: "markup",
|
||||
xml: "markup",
|
||||
};
|
||||
|
||||
function extensionOf(path: string): string {
|
||||
return path.split(".").pop()?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function kindOf(path: string): FileKind {
|
||||
const ext = extensionOf(path);
|
||||
if (ext === "md" || ext === "markdown") return "markdown";
|
||||
if (ext in CODE_LANGUAGES) return "code";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function encodeRepoPath(filePath: string): string {
|
||||
return filePath.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookbook recipe detail route.
|
||||
*
|
||||
* A recipe has one doc and (usually) one runnable example per language, so the
|
||||
* content region is the same file switcher used by skills: the doc is rendered
|
||||
* at build time, the example is fetched from raw.githubusercontent.com on first
|
||||
* selection and then cached. The `#file=<path>` deep-link convention from the
|
||||
* previous file browser is preserved so existing shared links keep working.
|
||||
*/
|
||||
export function CookbookRecipe({
|
||||
cookbookName,
|
||||
recipeName,
|
||||
recipeDescription,
|
||||
languageName,
|
||||
languageIcon,
|
||||
tags,
|
||||
files,
|
||||
primaryPath,
|
||||
markdownHtml,
|
||||
rawMarkdown,
|
||||
toc,
|
||||
githubBlobBase,
|
||||
rawBase,
|
||||
lastUpdated,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
}: CookbookRecipeProps) {
|
||||
const [activePath, setActivePath] = React.useState(primaryPath);
|
||||
const [contents, setContents] = React.useState<Record<string, string>>({});
|
||||
const [status, setStatus] = React.useState<"idle" | "loading" | "error">(
|
||||
"idle",
|
||||
);
|
||||
|
||||
const active = files.find((file) => file.path === activePath) ?? files[0];
|
||||
const isPrimary = active?.path === primaryPath;
|
||||
const kind = active ? kindOf(active.path) : "text";
|
||||
|
||||
React.useEffect(() => {
|
||||
const fromHash = () => {
|
||||
const match = /^#file=(.+)$/.exec(window.location.hash);
|
||||
if (!match) return;
|
||||
let wanted: string | undefined;
|
||||
try {
|
||||
wanted = decodeURIComponent(match[1]);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (files.some((file) => file.path === wanted)) setActivePath(wanted);
|
||||
};
|
||||
fromHash();
|
||||
window.addEventListener("hashchange", fromHash);
|
||||
return () => window.removeEventListener("hashchange", fromHash);
|
||||
}, [files]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active || isPrimary) return;
|
||||
if (contents[active.path] !== undefined) {
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setStatus("loading");
|
||||
fetch(`${rawBase}/${encodeRepoPath(active.path)}`)
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(String(response.status));
|
||||
return response.text();
|
||||
})
|
||||
.then((text) => {
|
||||
if (cancelled) return;
|
||||
setContents((current) => ({ ...current, [active.path]: text }));
|
||||
setStatus("idle");
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStatus("error");
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active, isPrimary, rawBase, contents]);
|
||||
|
||||
const selectFile = React.useCallback((path: string) => {
|
||||
setActivePath(path);
|
||||
const hash = `#file=${encodeURIComponent(path)}`;
|
||||
if (window.location.hash !== hash) history.replaceState(null, "", hash);
|
||||
}, []);
|
||||
|
||||
const activeGithubUrl = active
|
||||
? `${githubBlobBase}/${encodeRepoPath(active.path)}`
|
||||
: `${githubBlobBase}/${encodeRepoPath(primaryPath)}`;
|
||||
const activeRawUrl = active
|
||||
? `${rawBase}/${encodeRepoPath(active.path)}`
|
||||
: undefined;
|
||||
|
||||
const install = (
|
||||
<Button
|
||||
as="a"
|
||||
href={activeGithubUrl}
|
||||
variant="secondary"
|
||||
className={styles.iconButton}
|
||||
aria-label={`View the ${recipeName} recipe on GitHub`}
|
||||
>
|
||||
<MarkGithubIcon size={16} />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailChassis
|
||||
title={recipeName}
|
||||
description={recipeDescription}
|
||||
breadcrumbs={[
|
||||
{ label: "Cookbook", href: pageHref("cookbook") },
|
||||
{ label: cookbookName, href: pageHref("cookbook") },
|
||||
{ label: `${recipeName} (${languageName})` },
|
||||
]}
|
||||
install={install}
|
||||
/* Anchors only exist while the recipe doc is on screen. */
|
||||
toc={isPrimary ? toc : []}
|
||||
sidebar={
|
||||
<ResourceMeta
|
||||
kicker="Recipe details"
|
||||
groups={[
|
||||
{ label: "Cookbook", items: [cookbookName] },
|
||||
{
|
||||
label: "Language",
|
||||
items: [
|
||||
languageIcon ? `${languageIcon} ${languageName}` : languageName,
|
||||
],
|
||||
},
|
||||
{ label: "Tags", items: tags },
|
||||
{
|
||||
label: "Files",
|
||||
items: [`${files.length} file${files.length === 1 ? "" : "s"}`],
|
||||
},
|
||||
]}
|
||||
lastUpdated={lastUpdated}
|
||||
sourceUrl={`${githubBlobBase}/${encodeRepoPath(primaryPath)}`}
|
||||
/>
|
||||
}
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
>
|
||||
<section className={styles.articleSection}>
|
||||
{files.length > 1 ? (
|
||||
<>
|
||||
<span className={styles.codeLabel}>
|
||||
<FileDirectoryIcon size={16} /> Files in this recipe
|
||||
</span>
|
||||
<ul
|
||||
className={fileStyles.fileTabs}
|
||||
aria-label="Files in this recipe"
|
||||
>
|
||||
{files.map((file) => (
|
||||
<li key={file.path}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
fileStyles.fileTab,
|
||||
file.path === activePath && fileStyles.fileTabActive,
|
||||
)}
|
||||
aria-current={file.path === activePath ? "true" : undefined}
|
||||
onClick={() => selectFile(file.path)}
|
||||
>
|
||||
{file.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{active ? (
|
||||
<div className={fileStyles.fileBar}>
|
||||
<span className={fileStyles.filePath}>{active.name}</span>
|
||||
<div className={fileStyles.fileActions}>
|
||||
<Button
|
||||
as="a"
|
||||
href={activeGithubUrl}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className={styles.iconButton}
|
||||
aria-label={`View ${active.name} on GitHub`}
|
||||
>
|
||||
<MarkGithubIcon size={16} />
|
||||
</Button>
|
||||
{activeRawUrl ? (
|
||||
<Button
|
||||
as="button"
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
void downloadFile(activeRawUrl, active.name)
|
||||
}
|
||||
className={styles.iconButton}
|
||||
aria-label={`Download ${active.name}`}
|
||||
>
|
||||
<DownloadIcon size={16} />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<RecipeFileView
|
||||
active={active ?? null}
|
||||
isPrimary={isPrimary}
|
||||
kind={kind}
|
||||
markdownHtml={markdownHtml}
|
||||
rawMarkdown={rawMarkdown}
|
||||
text={active ? contents[active.path] : undefined}
|
||||
status={status}
|
||||
githubUrl={activeGithubUrl}
|
||||
/>
|
||||
</section>
|
||||
</DetailChassis>
|
||||
);
|
||||
}
|
||||
|
||||
function RecipeFileView({
|
||||
active,
|
||||
isPrimary,
|
||||
kind,
|
||||
markdownHtml,
|
||||
rawMarkdown,
|
||||
text,
|
||||
status,
|
||||
githubUrl,
|
||||
}: {
|
||||
active: RecipeFile | null;
|
||||
isPrimary: boolean;
|
||||
kind: FileKind;
|
||||
markdownHtml: string;
|
||||
rawMarkdown: string;
|
||||
text?: string;
|
||||
status: "idle" | "loading" | "error";
|
||||
githubUrl: string;
|
||||
}) {
|
||||
const [markdown, setMarkdown] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isPrimary || kind !== "markdown" || text === undefined) {
|
||||
setMarkdown(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void Promise.all([
|
||||
import("marked"),
|
||||
import("../../lib/sanitize-html"),
|
||||
import("../../lib/markdown-a11y"),
|
||||
]).then(([{ marked }, { sanitizeHtml }, { enhanceMarkdownA11y }]) => {
|
||||
if (cancelled) return;
|
||||
setMarkdown(
|
||||
enhanceMarkdownA11y(
|
||||
sanitizeHtml(marked.parse(text, { async: false }) as string),
|
||||
),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isPrimary, kind, text]);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
if (isPrimary) {
|
||||
return markdownHtml ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: markdownHtml }} />
|
||||
) : (
|
||||
<pre tabIndex={0} className={styles.codeBlock}>
|
||||
<code>{rawMarkdown}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<Text as="p" size="200" variant="muted">
|
||||
Loading {active.name}…
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error" || text === undefined) {
|
||||
return (
|
||||
<Text as="p" size="200" variant="muted">
|
||||
Couldn't load this file. <a href={githubUrl}>View it on GitHub</a>.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "markdown") {
|
||||
return markdown ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: markdown }} />
|
||||
) : (
|
||||
<Text as="p" size="200" variant="muted">
|
||||
Rendering {active.name}…
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "code") {
|
||||
const language =
|
||||
CODE_LANGUAGES[extensionOf(active.path)] ?? detectCodeLanguage(text);
|
||||
return (
|
||||
<SyntaxHighlightedCode
|
||||
className={styles.codeBlock}
|
||||
code={text}
|
||||
language={language}
|
||||
lineClassName={styles.codeLine}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<pre tabIndex={0} className={styles.codeBlock}>
|
||||
<code>{text}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import {
|
||||
BookIcon,
|
||||
BrowserIcon,
|
||||
ChecklistIcon,
|
||||
CopilotIcon,
|
||||
MarkGithubIcon,
|
||||
PlugIcon,
|
||||
ZapIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CTABanner,
|
||||
Grid,
|
||||
Hero,
|
||||
Image,
|
||||
Section,
|
||||
Stack,
|
||||
Token,
|
||||
useTheme,
|
||||
} from "@primer/react-brand";
|
||||
|
||||
import styles from "./styles/styles.module.css";
|
||||
import brandDivider from "./brand-divider-copilot-sitting.webp";
|
||||
import { LargeFooter } from "./LargeFooter";
|
||||
import { ContributorsHoverCard } from "./ContributorsHoverCard";
|
||||
import { ContributorsNavButton } from "./ContributorsNavButton";
|
||||
import { contributorsTotal as siteContributorsTotal } from "../../lib/site-data";
|
||||
import { LearningIcon } from "./LearningIcon";
|
||||
import type { PrototypePageProps } from "./pageHref";
|
||||
import { getAwesomeCopilotNavLinks } from "./navigation";
|
||||
import { TopNav } from "./TopNav";
|
||||
import { SkipLink } from "./SkipLink";
|
||||
import { TopNavSearch } from "./TopNavSearch";
|
||||
|
||||
const REPO_URL = "https://github.com/github/awesome-copilot";
|
||||
const CONTRIBUTING_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md";
|
||||
|
||||
type ResourceIcon = React.ComponentType<{
|
||||
size?: number | "small" | "medium" | "large";
|
||||
}>;
|
||||
|
||||
type Resource = {
|
||||
icon: ResourceIcon;
|
||||
name: string;
|
||||
count?: string;
|
||||
description: string;
|
||||
page?: string;
|
||||
anchor?: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
const resources: Resource[] = [
|
||||
{
|
||||
icon: CopilotIcon,
|
||||
name: "Agents",
|
||||
count: "220",
|
||||
description:
|
||||
"Custom agents that give Copilot specialized skills for your projects.",
|
||||
page: "agents",
|
||||
id: "agents",
|
||||
},
|
||||
{
|
||||
icon: ChecklistIcon,
|
||||
name: "Instructions",
|
||||
count: "189",
|
||||
description:
|
||||
"Coding standards and best practices that steer Copilot's output.",
|
||||
page: "instructions",
|
||||
id: "instructions",
|
||||
},
|
||||
{
|
||||
icon: ZapIcon,
|
||||
name: "Skills",
|
||||
count: "386",
|
||||
description:
|
||||
"Self-contained folders that bundle instructions and resources together.",
|
||||
page: "skills",
|
||||
id: "skills",
|
||||
},
|
||||
{
|
||||
icon: PlugIcon,
|
||||
name: "Plugins",
|
||||
count: "110",
|
||||
description:
|
||||
"Curated plugins, grouped by theme, that extend what Copilot can do.",
|
||||
page: "plugins",
|
||||
id: "plugins",
|
||||
},
|
||||
{
|
||||
icon: BrowserIcon,
|
||||
name: "Extensions",
|
||||
count: "14",
|
||||
description:
|
||||
"Interactive canvas extensions that enrich the Copilot app experience.",
|
||||
page: "extensions",
|
||||
id: "extensions",
|
||||
},
|
||||
{
|
||||
icon: BookIcon,
|
||||
name: "Learning Hub",
|
||||
count: "27",
|
||||
description:
|
||||
"Articles and guides for getting the most from every agent and skill.",
|
||||
page: "learning-hub-copilot-app",
|
||||
id: "learning-hub-copilot-app",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AwesomeCopilot({ pageHref }: PrototypePageProps) {
|
||||
const { colorMode } = useTheme();
|
||||
const subNavLinks = getAwesomeCopilotNavLinks(pageHref);
|
||||
const internalHref = ({ page, anchor }: { page?: string; anchor?: string }) =>
|
||||
page ? pageHref(page) : `#${anchor}`;
|
||||
|
||||
return (
|
||||
<Box className={styles.page} backgroundColor="default" data-mode={colorMode}>
|
||||
<SkipLink />
|
||||
<header className={styles.topBar}>
|
||||
<nav className={styles.topBarInner} aria-label="Primary">
|
||||
<a href={pageHref()} className={styles.subNavTitle}>
|
||||
<MarkGithubIcon size={20} />
|
||||
Awesome GitHub Copilot
|
||||
</a>
|
||||
<TopNav
|
||||
styles={styles}
|
||||
links={subNavLinks}
|
||||
pageHref={pageHref}
|
||||
/>
|
||||
<div className={styles.topBarActions}>
|
||||
<TopNavSearch
|
||||
pageHref={pageHref}
|
||||
styles={styles}
|
||||
inputAriaLabel="Search the library"
|
||||
/>
|
||||
<ContributorsNavButton
|
||||
href={pageHref("contributors")}
|
||||
total={siteContributorsTotal}
|
||||
/>
|
||||
<Button as="a" href={CONTRIBUTING_URL} variant="subtle" size="small">
|
||||
Contribute
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content" tabIndex={-1}>
|
||||
<Box className={styles.heroFrame}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.heroFrameInner}>
|
||||
<Stack
|
||||
direction="vertical"
|
||||
alignItems="center"
|
||||
gap="normal"
|
||||
padding="none"
|
||||
>
|
||||
<div className={styles.heroRiseIcon}>
|
||||
<ContributorsHoverCard
|
||||
size={56}
|
||||
href={pageHref("contributors")}
|
||||
/>
|
||||
</div>
|
||||
<Hero align="center">
|
||||
<Hero.Heading>
|
||||
The community library for GitHub Copilot
|
||||
</Hero.Heading>
|
||||
<Hero.Description>
|
||||
Discover reusable agents, skills, instructions, hooks, and
|
||||
tools built by developers to help you ship faster with
|
||||
Copilot.
|
||||
</Hero.Description>
|
||||
<Hero.PrimaryAction href={REPO_URL}>
|
||||
Explore repository
|
||||
</Hero.PrimaryAction>
|
||||
<Hero.SecondaryAction href={CONTRIBUTING_URL}>
|
||||
Become a contributor
|
||||
</Hero.SecondaryAction>
|
||||
</Hero>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Section>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
id="resources"
|
||||
className={styles.cardGridFrame}
|
||||
marginBlockEnd={{ narrow: 24, wide: 80 }}
|
||||
>
|
||||
<Box className={styles.cardGridContent}>
|
||||
<Grid columnGap="none" rowGap="none" enableGutters={false}>
|
||||
{resources.map((item) => (
|
||||
<Grid.Column
|
||||
key={item.name}
|
||||
span={{ xsmall: 12, small: 6, xlarge: 4 }}
|
||||
className={clsx(
|
||||
styles.cardGridColumn,
|
||||
styles.cardGridColumnArrowHover,
|
||||
)}
|
||||
>
|
||||
<Box className={styles.cardGridItem} id={item.id}>
|
||||
<Card
|
||||
href={internalHref(item)}
|
||||
fullWidth
|
||||
ctaVariant="arrow"
|
||||
ctaText={`Explore ${item.name}`}
|
||||
backgroundColor="none"
|
||||
className={styles.resourceCard}
|
||||
>
|
||||
<Card.Heading as="h2" size="5">
|
||||
<span className={styles.cardHeadingRow}>
|
||||
<span>{item.name}</span>
|
||||
{item.count ? (
|
||||
<Token variant="default">{item.count}</Token>
|
||||
) : null}
|
||||
</span>
|
||||
</Card.Heading>
|
||||
<Card.Description>{item.description}</Card.Description>
|
||||
</Card>
|
||||
</Box>
|
||||
</Grid.Column>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.ctaFrame}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box id="learning-hub" className={styles.ctaFrameInner}>
|
||||
<CTABanner align="center" hasGridLines>
|
||||
<CTABanner.Logo>
|
||||
<LearningIcon size={64} />
|
||||
</CTABanner.Logo>
|
||||
<CTABanner.Heading as="h2" size="3">
|
||||
Master GitHub Copilot
|
||||
</CTABanner.Heading>
|
||||
<CTABanner.Description>
|
||||
Read the documentation to learn every feature and
|
||||
workflow, then head to GitHub’s YouTube channel for videos,
|
||||
demos, and talks.
|
||||
</CTABanner.Description>
|
||||
<CTABanner.ButtonGroup>
|
||||
<Button
|
||||
as="a"
|
||||
href="https://docs.github.com/en/copilot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read the docs
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href="https://www.youtube.com/@GitHub/featured"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Watch on YouTube
|
||||
</Button>
|
||||
</CTABanner.ButtonGroup>
|
||||
</CTABanner>
|
||||
</Box>
|
||||
</Section>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.dividerFrame}>
|
||||
<Section paddingBlockStart="normal" paddingBlockEnd="none">
|
||||
<Stack justifyContent="center" padding="none">
|
||||
<Image
|
||||
src={brandDivider}
|
||||
alt=""
|
||||
width={1230}
|
||||
height={157}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ display: "block", width: "100%", height: "auto" }}
|
||||
/>
|
||||
</Stack>
|
||||
</Section>
|
||||
</Box>
|
||||
|
||||
</main>
|
||||
|
||||
<LargeFooter />
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import { ArrowLeftIcon, ArrowRightIcon, ArrowUpIcon } from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
import React from "react";
|
||||
|
||||
import { Box, Breadcrumbs, Heading, Section, Text } from "@primer/react-brand";
|
||||
|
||||
import { PageShell } from "./PageShell";
|
||||
import { LargeFooter } from "./LargeFooter";
|
||||
import type { AwesomeCopilotPage } from "./navigation";
|
||||
import { getScrollBehavior } from "./scrollBehavior";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/dotnet-upgrade.module.css";
|
||||
import {
|
||||
useAgentDetailHeroPin,
|
||||
useAgentDetailProgress,
|
||||
} from "./useAgentDetailScroll";
|
||||
|
||||
export type DetailCrumb = {
|
||||
label: string;
|
||||
/** Omitted for the trailing (current) crumb. */
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export type DetailTocItem = { id: string; label: string };
|
||||
|
||||
export type DetailSibling = { label: string; href: string };
|
||||
|
||||
export type DetailChassisProps = {
|
||||
/** Resource name — the hero `<h1>`. */
|
||||
title: string;
|
||||
/** One-line summary under the hero title. */
|
||||
description?: string;
|
||||
/** Trail rendered in the hero. The last entry is marked as the current page. */
|
||||
breadcrumbs: DetailCrumb[];
|
||||
/** Hero action row: install split-button, source/download buttons, etc. */
|
||||
install?: React.ReactNode;
|
||||
/** Extra hero content rendered under the description (e.g. `applyTo` tokens). */
|
||||
heroExtras?: React.ReactNode;
|
||||
/** "In this article" entries; ids must exist in the content region. */
|
||||
toc?: DetailTocItem[];
|
||||
/** Extra sidebar panels rendered under the table of contents. */
|
||||
sidebar?: React.ReactNode;
|
||||
previous?: DetailSibling;
|
||||
next?: DetailSibling;
|
||||
/** Catalog tab to mark current in the top nav. */
|
||||
currentPage?: AwesomeCopilotPage;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
/** Main content region — markdown body, capability lists, file trees, … */
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const TWO_COLUMN_QUERY = "(min-width: 75rem)";
|
||||
|
||||
/**
|
||||
* Shared shell for every resource detail route.
|
||||
*
|
||||
* Reproduces the designers' `agent.tsx` layout: breadcrumbs and a sticky hero
|
||||
* carrying a reading-progress rule, a two-column body whose sidebar holds the
|
||||
* in-page table of contents, sibling navigation, and a back-to-top control —
|
||||
* all inside the site-wide `PageShell` chrome.
|
||||
*
|
||||
* Everything type-specific (install affordances, metadata panels, the content
|
||||
* region itself) arrives through props so the five resource types can differ
|
||||
* without forking this file.
|
||||
*/
|
||||
export function DetailChassis({
|
||||
title,
|
||||
description,
|
||||
breadcrumbs,
|
||||
install,
|
||||
heroExtras,
|
||||
toc = [],
|
||||
sidebar,
|
||||
previous,
|
||||
next,
|
||||
currentPage,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
children,
|
||||
}: DetailChassisProps) {
|
||||
const contentScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const [showBackToTop, setShowBackToTop] = React.useState(false);
|
||||
const [heroBurst, setHeroBurst] = React.useState(false);
|
||||
const [activeSection, setActiveSection] = React.useState(toc[0]?.id ?? "");
|
||||
|
||||
const lastSectionId = toc[toc.length - 1]?.id ?? "";
|
||||
|
||||
useAgentDetailHeroPin(contentScrollRef, {
|
||||
hero: styles.hero,
|
||||
heroInner: styles.heroInner,
|
||||
heroContent: styles.heroContent,
|
||||
});
|
||||
const scrollToTop = useAgentDetailProgress(
|
||||
contentScrollRef,
|
||||
lastSectionId,
|
||||
setShowBackToTop,
|
||||
setHeroBurst,
|
||||
);
|
||||
|
||||
useScrollHostLayout(contentScrollRef);
|
||||
useScrollSpy(contentScrollRef, toc, setActiveSection);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
styles={styles}
|
||||
currentPage={currentPage}
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
renderFooter={false}
|
||||
>
|
||||
<div className={styles.scrollHost} ref={contentScrollRef}>
|
||||
<Box as="section" className={styles.hero}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<div className={styles.heroInner}>
|
||||
<div className={styles.heroBreadcrumbs}>
|
||||
<Breadcrumbs>
|
||||
{breadcrumbs.map((crumb, index) => {
|
||||
const selected = index === breadcrumbs.length - 1;
|
||||
return (
|
||||
<Breadcrumbs.Item
|
||||
key={`${crumb.label}-${index}`}
|
||||
href={crumb.href ?? "#"}
|
||||
selected={selected}
|
||||
>
|
||||
{index === 0 ? (
|
||||
<ArrowLeftIcon
|
||||
size={16}
|
||||
className={styles.heroBreadcrumbBackIcon}
|
||||
/>
|
||||
) : null}
|
||||
{crumb.label}
|
||||
</Breadcrumbs.Item>
|
||||
);
|
||||
})}
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
<div className={styles.heroContent}>
|
||||
<Heading as="h1" size="4">
|
||||
{title}
|
||||
</Heading>
|
||||
{description ? (
|
||||
<Text
|
||||
as="p"
|
||||
size="200"
|
||||
variant="muted"
|
||||
className={clsx(
|
||||
styles.heroDescription,
|
||||
styles.heroDescriptionClamp,
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
{heroExtras}
|
||||
{install ? (
|
||||
<div className={styles.heroActions}>{install}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
<div
|
||||
className={styles.progressRider}
|
||||
data-burst={heroBurst ? "true" : undefined}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className={styles.progressDuck} />
|
||||
<span className={styles.confetti}>
|
||||
{Array.from({ length: 14 }).map((_, i) => (
|
||||
<i key={i} className={styles.confettiPiece} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box as="section" className={styles.body}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<div className={styles.bodyInner}>
|
||||
<div className={styles.layout}>
|
||||
<article className={styles.contentCol}>{children}</article>
|
||||
|
||||
<aside className={styles.sidebarCol}>
|
||||
<div className={styles.sidebarSticky}>
|
||||
{toc.length > 0 ? (
|
||||
<div className={styles.sidebarSection}>
|
||||
<div className={styles.sidebarSummary}>
|
||||
<span className={styles.sidebarKicker}>
|
||||
In this article
|
||||
</span>
|
||||
</div>
|
||||
<nav className={styles.toc} aria-label="In this article">
|
||||
<ul className={styles.tocList}>
|
||||
{toc.map((section) => (
|
||||
<li key={section.id}>
|
||||
<a
|
||||
href={`#${section.id}`}
|
||||
className={clsx(
|
||||
styles.tocLink,
|
||||
activeSection === section.id &&
|
||||
styles.tocLinkActive,
|
||||
)}
|
||||
aria-current={
|
||||
activeSection === section.id
|
||||
? "true"
|
||||
: undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
document
|
||||
.getElementById(section.id)
|
||||
?.scrollIntoView({
|
||||
behavior: getScrollBehavior(),
|
||||
block: "start",
|
||||
});
|
||||
setActiveSection(section.id);
|
||||
}}
|
||||
>
|
||||
{section.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
) : null}
|
||||
{sidebar}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{previous || next ? (
|
||||
<aside className={styles.nextUp} aria-label="More like this">
|
||||
{previous ? (
|
||||
<div className={styles.nextUpMain}>
|
||||
<div className={styles.nextUpKickerRow}>
|
||||
<span className={styles.nextUpKicker}>Previous</span>
|
||||
</div>
|
||||
<a href={previous.href} className={styles.nextUpLink}>
|
||||
<ArrowLeftIcon
|
||||
size={16}
|
||||
className={styles.nextUpArrow}
|
||||
/>
|
||||
{previous.label}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{next ? (
|
||||
<div
|
||||
className={clsx(styles.nextUpMain, styles.nextUpNext)}
|
||||
>
|
||||
<div className={styles.nextUpKickerRow}>
|
||||
<span className={styles.nextUpKicker}>Up next</span>
|
||||
</div>
|
||||
<a href={next.href} className={styles.nextUpLink}>
|
||||
{next.label}
|
||||
<ArrowRightIcon
|
||||
size={16}
|
||||
className={styles.nextUpArrow}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</Section>
|
||||
</Box>
|
||||
<LargeFooter />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
styles.backToTop,
|
||||
showBackToTop && styles.backToTopVisible,
|
||||
)}
|
||||
onClick={scrollToTop}
|
||||
aria-label="Back to top"
|
||||
aria-hidden={!showBackToTop}
|
||||
tabIndex={showBackToTop ? 0 : -1}
|
||||
>
|
||||
<ArrowUpIcon size={24} />
|
||||
</button>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The prototype nested the scroll region directly inside `.page`; under
|
||||
* `PageShell` it sits one level deeper, inside the `<main>` landmark. Make that
|
||||
* landmark a transparent flex pass-through so the measured 100vh scroll region
|
||||
* still resolves, and mirror the hero-pin flag the scroll hook writes onto it
|
||||
* back up to `.page`, which is what the stylesheet keys off.
|
||||
*/
|
||||
function useScrollHostLayout(
|
||||
contentScrollRef: React.RefObject<HTMLDivElement | null>,
|
||||
) {
|
||||
React.useLayoutEffect(() => {
|
||||
const host = contentScrollRef.current;
|
||||
const landmark = host?.parentElement;
|
||||
if (!host || !landmark) return;
|
||||
|
||||
landmark.style.display = "flex";
|
||||
landmark.style.flexDirection = "column";
|
||||
landmark.style.flex = "1 1 auto";
|
||||
landmark.style.minHeight = "0";
|
||||
|
||||
const page = landmark.parentElement;
|
||||
if (!page) return;
|
||||
|
||||
// The scroll hooks write their measurements to the scroll host's parent.
|
||||
// Republish them on `.page`, which the stylesheet's selectors key off.
|
||||
const mirror = () => {
|
||||
const pin = landmark.dataset.heroPin;
|
||||
if (pin === undefined) delete page.dataset.heroPin;
|
||||
else page.dataset.heroPin = pin;
|
||||
for (const name of Array.from(landmark.style)) {
|
||||
if (name.startsWith("--dotnet-")) {
|
||||
page.style.setProperty(name, landmark.style.getPropertyValue(name));
|
||||
}
|
||||
}
|
||||
};
|
||||
mirror();
|
||||
const observer = new MutationObserver(mirror);
|
||||
observer.observe(landmark, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-hero-pin", "style"],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, [contentScrollRef]);
|
||||
}
|
||||
|
||||
/** Highlight the section the reader is currently in, below the pinned hero. */
|
||||
function useScrollSpy(
|
||||
contentScrollRef: React.RefObject<HTMLDivElement | null>,
|
||||
toc: DetailTocItem[],
|
||||
setActiveSection: React.Dispatch<React.SetStateAction<string>>,
|
||||
) {
|
||||
React.useEffect(() => {
|
||||
if (toc.length === 0) return;
|
||||
const scroller = contentScrollRef.current;
|
||||
if (!scroller) return;
|
||||
|
||||
let observer: IntersectionObserver | null = null;
|
||||
const build = () => {
|
||||
observer?.disconnect();
|
||||
const internal = window.matchMedia(TWO_COLUMN_QUERY).matches;
|
||||
const root = internal ? scroller : null;
|
||||
const viewport = internal ? scroller.clientHeight : window.innerHeight;
|
||||
const hero = scroller.querySelector<HTMLElement>(`.${styles.hero}`);
|
||||
const pinned = internal && hero ? hero.getBoundingClientRect().height : 0;
|
||||
const bottomMargin = Math.round(Math.max(0, viewport - pinned) * 0.65);
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
||||
if (visible[0]) setActiveSection(visible[0].target.id);
|
||||
},
|
||||
{
|
||||
root,
|
||||
rootMargin: `-${Math.round(pinned)}px 0px -${bottomMargin}px 0px`,
|
||||
threshold: 0,
|
||||
},
|
||||
);
|
||||
toc.forEach((section) => {
|
||||
const el = document.getElementById(section.id);
|
||||
if (el) observer?.observe(el);
|
||||
});
|
||||
};
|
||||
|
||||
build();
|
||||
window.addEventListener("resize", build);
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", build);
|
||||
};
|
||||
}, [contentScrollRef, toc, setActiveSection]);
|
||||
}
|
||||
|
||||
const slugify = (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.replace(/&[a-z]+;|&#\d+;/g, " ")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
const stripHtmlTags = (html: string) => {
|
||||
let text = "";
|
||||
let inTag = false;
|
||||
let quote: '"' | "'" | undefined;
|
||||
|
||||
for (const character of html) {
|
||||
if (inTag) {
|
||||
if (quote) {
|
||||
if (character === quote) quote = undefined;
|
||||
} else if (character === '"' || character === "'") {
|
||||
quote = character;
|
||||
} else if (character === ">") {
|
||||
inTag = false;
|
||||
}
|
||||
} else if (character === "<") {
|
||||
inTag = true;
|
||||
} else {
|
||||
text += character;
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive the in-page table of contents from rendered markdown, stamping a
|
||||
* stable `id` onto every `<h2>` so the TOC links and scroll-spy have anchors.
|
||||
*
|
||||
* Pure and deterministic, so the ids match between the build-time render and
|
||||
* client hydration.
|
||||
*/
|
||||
export function buildDetailToc(markdownHtml: string): {
|
||||
html: string;
|
||||
toc: DetailTocItem[];
|
||||
} {
|
||||
if (!markdownHtml) return { html: markdownHtml, toc: [] };
|
||||
|
||||
const toc: DetailTocItem[] = [];
|
||||
const used = new Map<string, number>();
|
||||
|
||||
const html = markdownHtml.replace(
|
||||
/<h2([^>]*)>([\s\S]*?)<\/h2>/g,
|
||||
(match, attrs: string, inner: string) => {
|
||||
const label = stripHtmlTags(inner)
|
||||
.replace(/<|>|"|'|&/g, (entity) => {
|
||||
switch (entity) {
|
||||
case "<":
|
||||
return "<";
|
||||
case ">":
|
||||
return ">";
|
||||
case """:
|
||||
return '"';
|
||||
case "'":
|
||||
return "'";
|
||||
default:
|
||||
return "&";
|
||||
}
|
||||
})
|
||||
.trim();
|
||||
if (!label) return match;
|
||||
|
||||
const existing = /\bid="([^"]+)"/.exec(attrs)?.[1];
|
||||
let id = existing ?? slugify(label);
|
||||
if (!id) return match;
|
||||
if (!existing) {
|
||||
const seen = used.get(id) ?? 0;
|
||||
used.set(id, seen + 1);
|
||||
if (seen > 0) id = `${id}-${seen}`;
|
||||
}
|
||||
|
||||
toc.push({ id, label });
|
||||
const nextAttrs = existing ? attrs : ` id="${id}"${attrs}`;
|
||||
return `<h2${nextAttrs}>${inner}</h2>`;
|
||||
},
|
||||
);
|
||||
|
||||
return { html, toc };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
type DuckIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function DuckIcon({ size = 20 }: DuckIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={(size * 24) / 20}
|
||||
height={size}
|
||||
viewBox="0 0 24 20"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.9419 0.185999C14.0817 0.210106 14.3422 0.284161 14.4809 0.324989C15.4436 0.602816 16.3335 1.08858 17.0879 1.74796C18.0687 2.61398 18.8004 3.78037 19.0733 5.06617C19.1486 5.42059 19.1728 5.69573 19.1954 6.05493C19.9042 6.47282 21.2113 6.27391 21.9755 6.03128C22.1864 5.96426 22.3835 5.87623 22.609 5.82457C22.9887 5.73766 23.181 6.00716 23.0683 6.35954C22.8734 6.87106 22.4723 7.209 21.9887 7.44922C22.0113 7.61957 21.9944 7.75015 21.9255 7.90632C21.8264 8.1276 21.6661 8.31595 21.4636 8.44922C20.9188 8.80345 19.9075 8.88595 19.274 9.03595C19.0611 9.08642 18.8846 9.11739 18.6748 9.20055C18.6978 9.47621 18.8244 9.88883 18.8762 10.1806C19.0379 11.0898 19.1697 11.9834 19.1072 12.9082C18.9938 14.5853 18.2813 16.165 17.0022 17.2761C15.9222 18.2142 13.9922 19.1159 12.5665 19.2698C11.87 19.3451 10.4034 19.2249 9.68457 19.173C9.05239 19.1217 8.35927 19.0723 7.74129 18.9418C7.30365 18.8532 6.89972 18.6599 6.50045 18.479C4.66739 17.6484 3.37572 16.1673 2.36863 14.4611C2.08521 13.9811 1.77842 13.4407 1.45576 12.9745C1.30397 12.7523 1.14169 12.5375 0.969497 12.3307C0.72442 12.0346 0.393951 11.6642 0.218548 11.3288C0.062196 11.0344 -0.0130532 10.7038 0.000509405 10.3708C0.0178241 10.013 0.140805 9.70545 0.40611 9.45815C0.865218 9.03028 1.65451 9.10023 2.2494 9.13424C2.46611 8.81411 2.77456 8.62505 3.1679 8.62069C3.29024 8.61925 3.46079 8.64125 3.56847 8.59399C3.83534 8.47705 3.99394 8.29079 4.27756 8.18418C4.76965 7.99925 5.45031 8.23112 5.90525 8.44309C5.91815 8.08944 5.83512 7.70184 5.75448 7.35757C5.54612 6.46771 5.44183 5.76672 5.63291 4.851C5.95146 3.32439 6.75911 2.08198 8.07214 1.22922C9.88251 0.0534421 11.8538 -0.241944 13.9419 0.185999Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
LinkExternalIcon,
|
||||
MarkGithubIcon,
|
||||
PlusIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import React from "react";
|
||||
|
||||
import { Button, Token } from "@primer/react-brand";
|
||||
|
||||
import { DetailChassis, type DetailSibling } from "./DetailChassis";
|
||||
import { ResourceMeta } from "./ResourceMeta";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/dotnet-upgrade.module.css";
|
||||
// The framed media block and the install command bar are prototype treatments
|
||||
// that live in the Copilot app page's stylesheet; reused here verbatim.
|
||||
import appStyles from "./styles/github-copilot-app.module.css";
|
||||
|
||||
export type ExtensionDetailImage = { url: string; alt: string };
|
||||
|
||||
export type ExtensionDetailItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
version?: string | null;
|
||||
pluginName?: string | null;
|
||||
external?: boolean;
|
||||
externalSource?: string | null;
|
||||
keywords?: string[];
|
||||
author?: { name?: string; url?: string } | null;
|
||||
};
|
||||
|
||||
export type ExtensionDetailProps = {
|
||||
item: ExtensionDetailItem;
|
||||
/** Preview screenshots, largest/primary first. */
|
||||
images: ExtensionDetailImage[];
|
||||
/** Rendered, sanitized README with heading ids already stamped. */
|
||||
markdownHtml: string;
|
||||
/** `ghapp://` deep link — absent for external extensions. */
|
||||
installUrl?: string | null;
|
||||
/** Copilot CLI command — absent for external extensions. */
|
||||
installCommand?: string | null;
|
||||
/** Repository the extension is published from. */
|
||||
sourceUrl?: string | null;
|
||||
lastUpdated?: string | null;
|
||||
previous?: DetailSibling;
|
||||
next?: DetailSibling;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
};
|
||||
|
||||
const PREVIEW_SECTION_ID = "preview";
|
||||
|
||||
/** Copyable `copilot plugin install …` line, matching the prototype install bar. */
|
||||
function InstallCommand({ command }: { command: string }) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const timer = React.useRef<number | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => () => window.clearTimeout(timer.current), []);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!navigator.clipboard?.writeText) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setCopied(true);
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={appStyles.installBar}>
|
||||
<code className={appStyles.installCommand} tabIndex={0}>
|
||||
{command}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className={appStyles.installCopy}
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? "Copied to clipboard" : "Copy install command"}
|
||||
>
|
||||
{copied ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
|
||||
<span>{copied ? "Copied" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail route for a canvas extension.
|
||||
*
|
||||
* Two things set it apart from the other resource details: the preview
|
||||
* screenshot leads the content column at hero scale (a gallery when an
|
||||
* extension ships more than one), and installation has two paths — the
|
||||
* `ghapp://` deep link and the Copilot CLI command. Externally hosted
|
||||
* extensions carry neither, so they fall back to a link to their source repo.
|
||||
*/
|
||||
export function ExtensionDetail({
|
||||
item,
|
||||
images,
|
||||
markdownHtml,
|
||||
installUrl,
|
||||
installCommand,
|
||||
sourceUrl,
|
||||
lastUpdated,
|
||||
previous,
|
||||
next,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
}: ExtensionDetailProps) {
|
||||
const hasPreview = images.length > 0;
|
||||
|
||||
const install = (
|
||||
<>
|
||||
{installUrl ? (
|
||||
<Button
|
||||
as="a"
|
||||
href={installUrl}
|
||||
variant="primary"
|
||||
hasArrow={false}
|
||||
leadingVisual={PlusIcon}
|
||||
>
|
||||
Open in Copilot app
|
||||
</Button>
|
||||
) : null}
|
||||
{sourceUrl ? (
|
||||
<Button
|
||||
as="a"
|
||||
href={sourceUrl}
|
||||
variant={installUrl ? "secondary" : "primary"}
|
||||
hasArrow={false}
|
||||
leadingVisual={installUrl ? undefined : LinkExternalIcon}
|
||||
className={installUrl ? styles.iconButton : undefined}
|
||||
aria-label={
|
||||
installUrl ? `View ${item.name} on GitHub` : undefined
|
||||
}
|
||||
>
|
||||
{installUrl ? <MarkGithubIcon size={16} /> : "View source repository"}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const heroExtras =
|
||||
item.version || item.external ? (
|
||||
<div className={styles.metaValues}>
|
||||
{item.version ? <Token text={`v${item.version}`} /> : null}
|
||||
{item.external ? <Token text="Externally hosted" /> : null}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<DetailChassis
|
||||
title={item.name}
|
||||
description={item.description}
|
||||
breadcrumbs={[
|
||||
{ label: "Extensions", href: pageHref("extensions") },
|
||||
{ label: item.name },
|
||||
]}
|
||||
install={install}
|
||||
heroExtras={heroExtras}
|
||||
sidebar={
|
||||
<ResourceMeta
|
||||
kicker="Extension details"
|
||||
groups={[
|
||||
{ label: "Keywords", items: item.keywords ?? [] },
|
||||
{
|
||||
label: "Source",
|
||||
items: item.externalSource ? [item.externalSource] : [],
|
||||
},
|
||||
]}
|
||||
author={item.author?.name ?? null}
|
||||
lastUpdated={lastUpdated}
|
||||
sourceUrl={sourceUrl ?? undefined}
|
||||
/>
|
||||
}
|
||||
previous={previous}
|
||||
next={next}
|
||||
currentPage="extensions"
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
>
|
||||
{hasPreview ? (
|
||||
<section id={PREVIEW_SECTION_ID} className={styles.articleSection}>
|
||||
<h2 className={styles.articleHeading}>Preview</h2>
|
||||
{images.map((image, index) => (
|
||||
<figure key={image.url} className={appStyles.videoFigure}>
|
||||
<img
|
||||
className={appStyles.gif}
|
||||
src={image.url}
|
||||
alt={image.alt}
|
||||
loading={index === 0 ? "eager" : "lazy"}
|
||||
decoding="async"
|
||||
width={1280}
|
||||
height={720}
|
||||
/>
|
||||
</figure>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{installUrl || installCommand ? (
|
||||
<section className={styles.articleSection}>
|
||||
<h2 className={styles.articleHeading}>Install</h2>
|
||||
{installCommand ? <InstallCommand command={installCommand} /> : null}
|
||||
{installUrl ? (
|
||||
<p>
|
||||
Or{" "}
|
||||
<a href={installUrl}>open {item.name} directly in the Copilot app</a>
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
) : sourceUrl ? (
|
||||
<section className={styles.articleSection}>
|
||||
<h2 className={styles.articleHeading}>Install</h2>
|
||||
<p>
|
||||
{item.name} is hosted outside this repository. Follow the
|
||||
installation steps in its{" "}
|
||||
<a href={sourceUrl}>source repository</a>.
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{markdownHtml ? (
|
||||
<section className={styles.articleSection}>
|
||||
<div dangerouslySetInnerHTML={{ __html: markdownHtml }} />
|
||||
</section>
|
||||
) : null}
|
||||
</DetailChassis>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
CopyIcon,
|
||||
CheckIcon,
|
||||
MarkGithubIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CTABanner,
|
||||
Card,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
Grid,
|
||||
Heading,
|
||||
Pagination,
|
||||
Section,
|
||||
Stack,
|
||||
Text,
|
||||
useTheme,
|
||||
} from "@primer/react-brand";
|
||||
|
||||
import { ExtensionsIcon } from "./ExtensionsIcon";
|
||||
import { PageShell } from "./PageShell";
|
||||
import { CatalogSortControl, CATALOG_SORT_OPTIONS } from "./CatalogSortControl";
|
||||
import { TypingText } from "./TypingText";
|
||||
import { httpUrl } from "./resourceActions";
|
||||
import {
|
||||
daysSince,
|
||||
toggleValue,
|
||||
updatedBuckets,
|
||||
updatedBucketOf,
|
||||
} from "./catalogFilters";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/extensions.module.css";
|
||||
import { getScrollBehavior } from "./scrollBehavior";
|
||||
|
||||
const CONTRIBUTE_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md#adding-canvas-extensions";
|
||||
|
||||
const REQUEST_URL = "https://github.com/github/awesome-copilot/issues/new";
|
||||
|
||||
/** An extension record as emitted into public/data/extensions.json. */
|
||||
export type ExtensionItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
lastUpdated: string;
|
||||
pluginName?: string | null;
|
||||
imageUrl?: string | null;
|
||||
installUrl?: string | null;
|
||||
installCommand?: string | null;
|
||||
sourceUrl?: string | null;
|
||||
external?: boolean;
|
||||
author?: { name?: string; url?: string } | null;
|
||||
keywords?: string[];
|
||||
};
|
||||
|
||||
/** Where the "view on GitHub" action points: an external extension carries its
|
||||
* own source repo, an in-repo one is covered by the install URL. */
|
||||
const extensionSourceUrl = (ext: ExtensionItem) =>
|
||||
ext.external
|
||||
? httpUrl(ext.sourceUrl ?? ext.installUrl)
|
||||
: ext.sourceUrl ?? ext.installUrl ?? undefined;
|
||||
|
||||
const appInstallUrl = (ext: ExtensionItem) =>
|
||||
!ext.external && ext.pluginName
|
||||
? `ghapp://plugins/install?source=${encodeURIComponent(
|
||||
`${ext.pluginName}@awesome-copilot`,
|
||||
)}`
|
||||
: undefined;
|
||||
|
||||
const uniqueSorted = (values: string[]) =>
|
||||
Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
type FilterGroupId = "author" | "updated" | "keyword";
|
||||
|
||||
const FILTER_COLLAPSE_LIMIT = 10;
|
||||
|
||||
type FilterState = Record<FilterGroupId, string[]>;
|
||||
|
||||
const emptyFilters: FilterState = { author: [], updated: [], keyword: [] };
|
||||
|
||||
const PAGE_SIZE = 6;
|
||||
|
||||
type SortMode = "az" | "newest";
|
||||
|
||||
function CopyInstallButton({
|
||||
ext,
|
||||
command,
|
||||
onCopied,
|
||||
}: {
|
||||
ext: ExtensionItem;
|
||||
command: string;
|
||||
onCopied: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timer = React.useRef<number | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => () => window.clearTimeout(timer.current), []);
|
||||
|
||||
const onCopy = async () => {
|
||||
if (!navigator.clipboard?.writeText) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setCopied(true);
|
||||
onCopied();
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
hasArrow={false}
|
||||
onClick={onCopy}
|
||||
aria-label={
|
||||
copied
|
||||
? `Copied install command for ${ext.name}`
|
||||
: `Copy install command for ${ext.name}`
|
||||
}
|
||||
className={styles.iconButton}
|
||||
>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The canvas extensions catalog, ported from the design prototype's
|
||||
* `extensions.tsx`. The prototype's hardcoded array is replaced by build-time
|
||||
* data; the layout, filters, and interactions are unchanged.
|
||||
*/
|
||||
export function ExtensionsCatalog({
|
||||
extensions,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
}: {
|
||||
extensions: ExtensionItem[];
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
}) {
|
||||
const { colorMode } = useTheme();
|
||||
const [sortMode, setSortMode] = useState<SortMode>("az");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const previousPage = React.useRef(currentPage);
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimer = React.useRef<number | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => () => window.clearTimeout(copyTimer.current), []);
|
||||
|
||||
const showCopiedToast = React.useCallback(() => {
|
||||
setCopied(true);
|
||||
window.clearTimeout(copyTimer.current);
|
||||
copyTimer.current = window.setTimeout(() => setCopied(false), 2000);
|
||||
}, []);
|
||||
|
||||
const [filters, setFilters] = useState<FilterState>(emptyFilters);
|
||||
const [expandedGroups, setExpandedGroups] = useState<
|
||||
Record<FilterGroupId, boolean>
|
||||
>({ author: false, updated: false, keyword: false });
|
||||
|
||||
const toggleGroupExpanded = (groupId: FilterGroupId) => {
|
||||
setExpandedGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] }));
|
||||
};
|
||||
|
||||
const filterGroups = useMemo(() => {
|
||||
const authorOptions = uniqueSorted(
|
||||
extensions
|
||||
.map((ext) => ext.author?.name)
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
);
|
||||
const keywordOptions = uniqueSorted(
|
||||
extensions.flatMap((ext) => ext.keywords ?? []),
|
||||
);
|
||||
const updatedOptions = updatedBuckets
|
||||
.map((bucket) => bucket.label)
|
||||
.filter((label) =>
|
||||
extensions.some(
|
||||
(ext) => updatedBucketOf(daysSince(ext.lastUpdated)) === label,
|
||||
),
|
||||
);
|
||||
return [
|
||||
{ id: "author" as const, label: "Author", options: authorOptions },
|
||||
{ id: "updated" as const, label: "Last updated", options: updatedOptions },
|
||||
{ id: "keyword" as const, label: "Keyword", options: keywordOptions },
|
||||
];
|
||||
}, [extensions]);
|
||||
|
||||
const sortedExtensions = useMemo(() => {
|
||||
const copy = extensions.filter((ext) => {
|
||||
const authorOk =
|
||||
filters.author.length === 0 ||
|
||||
(ext.author?.name !== undefined &&
|
||||
filters.author.includes(ext.author.name));
|
||||
const updatedOk =
|
||||
filters.updated.length === 0 ||
|
||||
filters.updated.includes(updatedBucketOf(daysSince(ext.lastUpdated)));
|
||||
const keywordOk =
|
||||
filters.keyword.length === 0 ||
|
||||
filters.keyword.every((keyword) =>
|
||||
(ext.keywords ?? []).includes(keyword),
|
||||
);
|
||||
return authorOk && updatedOk && keywordOk;
|
||||
});
|
||||
if (sortMode === "newest") {
|
||||
copy.sort((a, b) => daysSince(a.lastUpdated) - daysSince(b.lastUpdated));
|
||||
} else {
|
||||
copy.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
return copy;
|
||||
}, [extensions, filters, sortMode]);
|
||||
|
||||
const toggleFilter = (groupId: FilterGroupId, option: string) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[groupId]: toggleValue(prev[groupId], option),
|
||||
}));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters(emptyFilters);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const activeFilterCount =
|
||||
filters.author.length + filters.updated.length + filters.keyword.length;
|
||||
const hasActiveFilters = activeFilterCount > 0;
|
||||
const pageCount = Math.max(1, Math.ceil(sortedExtensions.length / PAGE_SIZE));
|
||||
const page = Math.min(currentPage, pageCount);
|
||||
const visibleExtensions = sortedExtensions.slice(
|
||||
(page - 1) * PAGE_SIZE,
|
||||
page * PAGE_SIZE,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (previousPage.current === currentPage) return;
|
||||
previousPage.current = currentPage;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const catalog = document.getElementById("catalog");
|
||||
if (!catalog) return;
|
||||
catalog.scrollIntoView({ behavior: getScrollBehavior(), block: "start" });
|
||||
catalog.focus({ preventScroll: true });
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [currentPage]);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
styles={styles}
|
||||
currentPage="extensions"
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
searchAriaLabel="Search extensions"
|
||||
>
|
||||
<Box className={styles.hero}>
|
||||
<div className={styles.heroInner}>
|
||||
<span className={styles.heroIcon} aria-hidden="true">
|
||||
<ExtensionsIcon size={36} />
|
||||
</span>
|
||||
<Heading as="h1" size="3" className={styles.heroHeading}>
|
||||
Canvas Extensions
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted" className={styles.heroText}>
|
||||
Canvas extensions that bring interactive GitHub Copilot app
|
||||
experiences to life — contributed and curated by the community.
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Section
|
||||
id="catalog"
|
||||
tabIndex={-1}
|
||||
paddingBlockStart="none"
|
||||
paddingBlockEnd="none"
|
||||
>
|
||||
<Box className={styles.catalog}>
|
||||
<aside className={styles.filterNav} aria-label="Filter extensions">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filterToggle}
|
||||
aria-expanded={mobileFiltersOpen}
|
||||
onClick={() => setMobileFiltersOpen((open) => !open)}
|
||||
>
|
||||
<span>
|
||||
Filters{hasActiveFilters ? ` (${activeFilterCount})` : ""}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
size={16}
|
||||
className={clsx(
|
||||
styles.filterToggleChevron,
|
||||
mobileFiltersOpen && styles.filterToggleChevronOpen,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.filterBody,
|
||||
!mobileFiltersOpen && styles.filterBodyCollapsed,
|
||||
)}
|
||||
>
|
||||
{filterGroups.map((group) => {
|
||||
const isExpandable =
|
||||
group.options.length > FILTER_COLLAPSE_LIMIT;
|
||||
const expanded = expandedGroups[group.id];
|
||||
const visibleOptions =
|
||||
isExpandable && !expanded
|
||||
? group.options.slice(0, FILTER_COLLAPSE_LIMIT)
|
||||
: group.options;
|
||||
return (
|
||||
<div className={styles.filterGroup} key={group.id}>
|
||||
<Text as="h2" size="100" className={styles.filterHeading}>
|
||||
{group.label}
|
||||
</Text>
|
||||
<div className={styles.filterOptions}>
|
||||
{visibleOptions.map((option) => (
|
||||
<div className={styles.filterOption} key={option}>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={filters[group.id].includes(option)}
|
||||
onChange={() => toggleFilter(group.id, option)}
|
||||
/>
|
||||
<FormControl.Label>{option}</FormControl.Label>
|
||||
</FormControl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{isExpandable ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.showMoreButton}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => toggleGroupExpanded(group.id)}
|
||||
>
|
||||
{expanded
|
||||
? "Show less"
|
||||
: `Show ${group.options.length - FILTER_COLLAPSE_LIMIT} more`}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hasActiveFilters ? (
|
||||
<div className={styles.filterActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
hasArrow={false}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear all ({activeFilterCount})
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Box className={styles.catalogMain}>
|
||||
<Box className={styles.toolbar}>
|
||||
<CatalogSortControl
|
||||
ariaLabel="Sort extensions"
|
||||
value={sortMode}
|
||||
options={CATALOG_SORT_OPTIONS}
|
||||
styles={styles}
|
||||
onChange={(value) => {
|
||||
setSortMode(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box className={styles.gridFrame} data-mode={colorMode}>
|
||||
<Box className={styles.gridContent}>
|
||||
<Grid
|
||||
className={styles.threeUp}
|
||||
columnGap="none"
|
||||
rowGap="none"
|
||||
enableGutters={false}
|
||||
>
|
||||
{visibleExtensions.map((ext, index) => {
|
||||
const detailHref = pageHref(`extension/${ext.id}`);
|
||||
const sourceUrl = extensionSourceUrl(ext);
|
||||
const installUrl = appInstallUrl(ext);
|
||||
const safeImageUrl = httpUrl(ext.imageUrl);
|
||||
return (
|
||||
<Grid.Column
|
||||
key={ext.id}
|
||||
span={{ xsmall: 12, medium: 6, large: 6 }}
|
||||
className={styles.col}
|
||||
>
|
||||
<Box className={clsx(styles.item, styles.itemHover)}>
|
||||
{safeImageUrl ? (
|
||||
<a
|
||||
href={detailHref}
|
||||
className={styles.cardMedia}
|
||||
aria-label={`View ${ext.name}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<img
|
||||
className={styles.cardImage}
|
||||
src={safeImageUrl}
|
||||
alt={`${ext.name} preview`}
|
||||
loading={index === 0 ? "eager" : "lazy"}
|
||||
decoding="async"
|
||||
width={1280}
|
||||
height={720}
|
||||
/>
|
||||
</a>
|
||||
) : null}
|
||||
<Card
|
||||
href={detailHref}
|
||||
fullWidth
|
||||
ctaVariant="none"
|
||||
backgroundColor="none"
|
||||
className={styles.card}
|
||||
>
|
||||
<Card.Heading as="h2">{ext.name}</Card.Heading>
|
||||
<Card.Description>
|
||||
<span className={styles.cardDescText}>
|
||||
{ext.description}
|
||||
</span>
|
||||
</Card.Description>
|
||||
</Card>
|
||||
<div className={styles.cardActions}>
|
||||
{installUrl ? (
|
||||
<Button
|
||||
as="a"
|
||||
href={installUrl}
|
||||
variant="primary"
|
||||
hasArrow={false}
|
||||
>
|
||||
Open in Copilot app
|
||||
</Button>
|
||||
) : null}
|
||||
{ext.installCommand ? (
|
||||
<CopyInstallButton
|
||||
ext={ext}
|
||||
command={ext.installCommand}
|
||||
onCopied={showCopiedToast}
|
||||
/>
|
||||
) : null}
|
||||
{sourceUrl ? (
|
||||
<Button
|
||||
as="a"
|
||||
href={sourceUrl}
|
||||
variant="secondary"
|
||||
aria-label={`View ${ext.name} on GitHub`}
|
||||
className={styles.iconButton}
|
||||
>
|
||||
<MarkGithubIcon />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{ext.author?.name ? (
|
||||
<Text as="p" size="100" className={styles.cardBadge}>
|
||||
<TypingText
|
||||
text={ext.author.name}
|
||||
className={styles.cardBadgeName}
|
||||
/>
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Grid.Column>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{sortedExtensions.length === 0 ? (
|
||||
<Box className={styles.emptyState}>
|
||||
<Text as="p" variant="muted">
|
||||
No extensions match the selected filters.
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.paginationRow}>
|
||||
<Stack direction="horizontal" justifyContent="center" padding="none">
|
||||
<Pagination
|
||||
className={styles.pagination}
|
||||
pageCount={pageCount}
|
||||
currentPage={page}
|
||||
onPageChange={(e, n) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(n);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.ctaFrame}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.ctaFrameInner}>
|
||||
<CTABanner align="center" backgroundColor="subtle">
|
||||
<CTABanner.Heading>
|
||||
Don’t see what you’re looking for?
|
||||
</CTABanner.Heading>
|
||||
<CTABanner.Description>
|
||||
This collection is community-built. Share your own canvas
|
||||
extension
|
||||
<br />
|
||||
—or request one—to help others get more out of the
|
||||
GitHub Copilot app.
|
||||
</CTABanner.Description>
|
||||
<CTABanner.ButtonGroup>
|
||||
<Button as="a" href={CONTRIBUTE_URL}>
|
||||
Submit an extension
|
||||
</Button>
|
||||
<Button as="a" href={REQUEST_URL}>
|
||||
Request an extension
|
||||
</Button>
|
||||
</CTABanner.ButtonGroup>
|
||||
</CTABanner>
|
||||
</Box>
|
||||
</Section>
|
||||
</Box>
|
||||
|
||||
<div
|
||||
className={styles.toast}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-visible={copied ? "true" : undefined}
|
||||
>
|
||||
Install command copied!
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
type ExtensionsIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function ExtensionsIcon({ size = 36 }: ExtensionsIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 96 96"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Canvas Extensions"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M36 56H18"
|
||||
stroke="var(--brand-color-text-default)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M90 40L6 40"
|
||||
stroke="var(--brand-color-text-muted)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M90 73L6 73"
|
||||
stroke="var(--brand-color-text-muted)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 23L48 90"
|
||||
stroke="var(--brand-color-text-muted)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M90 23L6 23"
|
||||
stroke="var(--brand-color-text-muted)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M6 82V14C6 9.58172 9.58172 6 14 6H82C86.4183 6 90 9.58172 90 14V82C90 86.4183 86.4183 90 82 90H14C9.58172 90 6 86.4183 6 82Z"
|
||||
stroke="var(--brand-color-text-default)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 71.5V41.5C48 40.6716 48.6716 40 49.5 40H88.5C89.3284 40 90 40.6716 90 41.5V71.5C90 72.3284 89.3284 73 88.5 73H49.5C48.6716 73 48 72.3284 48 71.5Z"
|
||||
stroke="var(--brand-color-text-default)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M69 65C73.9706 65 78 60.9706 78 56C78 51.0294 73.9706 47 69 47C64.0294 47 60 51.0294 60 56C60 60.9706 64.0294 65 69 65Z"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import {
|
||||
BookIcon,
|
||||
BrowserIcon,
|
||||
ChecklistIcon,
|
||||
CopilotIcon,
|
||||
PlugIcon,
|
||||
ZapIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CTABanner,
|
||||
Grid,
|
||||
Hero,
|
||||
Image,
|
||||
Section,
|
||||
Stack,
|
||||
Token,
|
||||
} from "@primer/react-brand";
|
||||
|
||||
import styles from "./styles/styles.module.css";
|
||||
import { ContributorsHoverCard } from "./ContributorsHoverCard";
|
||||
import { LearningIcon } from "./LearningIcon";
|
||||
import { PageShell } from "./PageShell";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import { contributorsTotal as siteContributorsTotal } from "../../lib/site-data";
|
||||
|
||||
const REPO_URL = "https://github.com/github/awesome-copilot";
|
||||
const CONTRIBUTING_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md";
|
||||
|
||||
type ResourceIcon = React.ComponentType<{
|
||||
size?: number | "small" | "medium" | "large";
|
||||
}>;
|
||||
|
||||
type Resource = {
|
||||
icon: ResourceIcon;
|
||||
name: string;
|
||||
count?: string;
|
||||
description: string;
|
||||
page?: string;
|
||||
anchor?: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
/** Live counts injected at build time, replacing the prototype's hardcoded figures. */
|
||||
export type HomePageCounts = {
|
||||
agents: number;
|
||||
instructions: number;
|
||||
skills: number;
|
||||
plugins: number;
|
||||
extensions: number;
|
||||
learningHub: number;
|
||||
};
|
||||
|
||||
export type HomePageProps = {
|
||||
counts: HomePageCounts;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
};
|
||||
|
||||
const buildResources = (counts: HomePageCounts): Resource[] => [
|
||||
{
|
||||
icon: CopilotIcon,
|
||||
name: "Agents",
|
||||
count: String(counts.agents),
|
||||
description:
|
||||
"Custom agents that give Copilot specialized skills for your projects.",
|
||||
page: "agents",
|
||||
id: "agents",
|
||||
},
|
||||
{
|
||||
icon: ChecklistIcon,
|
||||
name: "Instructions",
|
||||
count: String(counts.instructions),
|
||||
description:
|
||||
"Coding standards and best practices that steer Copilot's output.",
|
||||
page: "instructions",
|
||||
id: "instructions",
|
||||
},
|
||||
{
|
||||
icon: ZapIcon,
|
||||
name: "Skills",
|
||||
count: String(counts.skills),
|
||||
description:
|
||||
"Self-contained folders that bundle instructions and resources together.",
|
||||
page: "skills",
|
||||
id: "skills",
|
||||
},
|
||||
{
|
||||
icon: PlugIcon,
|
||||
name: "Plugins",
|
||||
count: String(counts.plugins),
|
||||
description:
|
||||
"Curated plugins, grouped by theme, that extend what Copilot can do.",
|
||||
page: "plugins",
|
||||
id: "plugins",
|
||||
},
|
||||
{
|
||||
icon: BrowserIcon,
|
||||
name: "Extensions",
|
||||
count: String(counts.extensions),
|
||||
description:
|
||||
"Interactive canvas extensions that enrich the Copilot app experience.",
|
||||
page: "extensions",
|
||||
id: "extensions",
|
||||
},
|
||||
{
|
||||
icon: BookIcon,
|
||||
name: "Learning Hub",
|
||||
count: String(counts.learningHub),
|
||||
description:
|
||||
"Articles and guides for getting the most from every agent and skill.",
|
||||
page: "learning-hub-copilot-app",
|
||||
id: "learning-hub-copilot-app",
|
||||
},
|
||||
];
|
||||
|
||||
export function HomePage({
|
||||
counts,
|
||||
searchIndex = [],
|
||||
contributorsTotal = siteContributorsTotal,
|
||||
}: HomePageProps) {
|
||||
const resources = buildResources(counts);
|
||||
const internalHref = ({ page, anchor }: { page?: string; anchor?: string }) =>
|
||||
page ? pageHref(page) : `#${anchor}`;
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
styles={styles}
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
searchAriaLabel="Search the library"
|
||||
>
|
||||
<Box className={styles.heroFrame}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.heroFrameInner}>
|
||||
<Stack
|
||||
direction="vertical"
|
||||
alignItems="center"
|
||||
gap="normal"
|
||||
padding="none"
|
||||
>
|
||||
<div className={styles.heroRiseIcon}>
|
||||
<ContributorsHoverCard
|
||||
size={56}
|
||||
href={pageHref("contributors")}
|
||||
/>
|
||||
</div>
|
||||
<Hero align="center">
|
||||
<Hero.Heading>
|
||||
The community library for GitHub Copilot
|
||||
</Hero.Heading>
|
||||
<Hero.Description>
|
||||
Discover reusable agents, skills, instructions, hooks, and
|
||||
tools built by developers to help you ship faster with
|
||||
Copilot.
|
||||
</Hero.Description>
|
||||
<Hero.PrimaryAction href={REPO_URL}>
|
||||
Explore repository
|
||||
</Hero.PrimaryAction>
|
||||
<Hero.SecondaryAction href={CONTRIBUTING_URL}>
|
||||
Become a contributor
|
||||
</Hero.SecondaryAction>
|
||||
</Hero>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Section>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
id="resources"
|
||||
className={styles.cardGridFrame}
|
||||
marginBlockEnd={{ narrow: 24, wide: 80 }}
|
||||
>
|
||||
<Box className={styles.cardGridContent}>
|
||||
<Grid columnGap="none" rowGap="none" enableGutters={false}>
|
||||
{resources.map((item) => (
|
||||
<Grid.Column
|
||||
key={item.name}
|
||||
span={{ xsmall: 12, small: 6, xlarge: 4 }}
|
||||
className={clsx(
|
||||
styles.cardGridColumn,
|
||||
styles.cardGridColumnArrowHover,
|
||||
)}
|
||||
>
|
||||
<Box className={styles.cardGridItem} id={item.id}>
|
||||
<Card
|
||||
href={internalHref(item)}
|
||||
fullWidth
|
||||
ctaVariant="arrow"
|
||||
ctaText={`Explore ${item.name}`}
|
||||
backgroundColor="none"
|
||||
className={styles.resourceCard}
|
||||
>
|
||||
<Card.Heading as="h2" size="5">
|
||||
<span className={styles.cardHeadingRow}>
|
||||
<span>{item.name}</span>
|
||||
{item.count ? (
|
||||
<Token variant="default">{item.count}</Token>
|
||||
) : null}
|
||||
</span>
|
||||
</Card.Heading>
|
||||
<Card.Description>{item.description}</Card.Description>
|
||||
</Card>
|
||||
</Box>
|
||||
</Grid.Column>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.ctaFrame}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box id="learning-hub" className={styles.ctaFrameInner}>
|
||||
<CTABanner align="center" hasGridLines>
|
||||
<CTABanner.Logo>
|
||||
<LearningIcon size={64} />
|
||||
</CTABanner.Logo>
|
||||
<CTABanner.Heading as="h2" size="3">
|
||||
Master GitHub Copilot
|
||||
</CTABanner.Heading>
|
||||
<CTABanner.Description>
|
||||
Read the documentation to learn every feature and workflow, then
|
||||
head to GitHub’s YouTube channel for videos, demos, and
|
||||
talks.
|
||||
</CTABanner.Description>
|
||||
<CTABanner.ButtonGroup>
|
||||
<Button
|
||||
as="a"
|
||||
href="https://docs.github.com/en/copilot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read the docs
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href="https://www.youtube.com/@GitHub/featured"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Watch on YouTube
|
||||
</Button>
|
||||
</CTABanner.ButtonGroup>
|
||||
</CTABanner>
|
||||
</Box>
|
||||
</Section>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.dividerFrame}>
|
||||
<Section paddingBlockStart="normal" paddingBlockEnd="none">
|
||||
<Stack justifyContent="center" padding="none">
|
||||
<Image
|
||||
src="/media/brand-divider-copilot-sitting.webp"
|
||||
alt=""
|
||||
width={1230}
|
||||
height={157}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ display: "block", width: "100%", height: "auto" }}
|
||||
/>
|
||||
</Stack>
|
||||
</Section>
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { CheckIcon, CopyIcon } from "@primer/octicons-react";
|
||||
import React from "react";
|
||||
|
||||
import { ActionMenu, Button } from "@primer/react-brand";
|
||||
|
||||
import styles from "./styles/github-copilot-app.module.css";
|
||||
|
||||
type InstallOption = { id: string; label: string; command: string };
|
||||
|
||||
const INSTALL_OPTIONS: InstallOption[] = [
|
||||
{
|
||||
id: "script",
|
||||
label: "Install with script",
|
||||
command: "curl -fsSL https://gh.io/copilot-install | bash",
|
||||
},
|
||||
{
|
||||
id: "winget",
|
||||
label: "Install with WinGet",
|
||||
command: "winget install GitHub.Copilot",
|
||||
},
|
||||
{
|
||||
id: "homebrew",
|
||||
label: "Install with Homebrew",
|
||||
command: "brew install copilot-cli",
|
||||
},
|
||||
{
|
||||
id: "npm",
|
||||
label: "Install with npm",
|
||||
command: "npm install -g @github/copilot",
|
||||
},
|
||||
];
|
||||
|
||||
/** Package-manager selector + copyable install command, matching the install
|
||||
* bar on github.com/features/copilot/cli. */
|
||||
export function InstallCommandBar() {
|
||||
const [selectedId, setSelectedId] = React.useState("homebrew");
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const selected =
|
||||
INSTALL_OPTIONS.find((option) => option.id === selectedId) ??
|
||||
INSTALL_OPTIONS[0];
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(selected.command);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.installBar}>
|
||||
<ActionMenu
|
||||
selectionVariant="single"
|
||||
onSelect={(id) => setSelectedId(id)}
|
||||
>
|
||||
<ActionMenu.Button>{selected.label}</ActionMenu.Button>
|
||||
<ActionMenu.Overlay aria-label="Choose an install method">
|
||||
{INSTALL_OPTIONS.map((option) => (
|
||||
<ActionMenu.Item
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
selected={option.id === selectedId}
|
||||
>
|
||||
{option.label}
|
||||
</ActionMenu.Item>
|
||||
))}
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
<code className={styles.installCommand} tabIndex={0}>
|
||||
{selected.command}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.installCopy}
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? "Copied to clipboard" : "Copy install command"}
|
||||
>
|
||||
{copied ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
|
||||
<span>{copied ? "Copied" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.installMobileCta}>
|
||||
<Button
|
||||
as="a"
|
||||
variant="primary"
|
||||
size="medium"
|
||||
href="https://github.com/github/copilot-cli"
|
||||
>
|
||||
Get Copilot CLI
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { DownloadIcon, MarkGithubIcon, PlusIcon } from "@primer/octicons-react";
|
||||
import React from "react";
|
||||
|
||||
import { ActionMenu, Button, Token } from "@primer/react-brand";
|
||||
|
||||
import { DetailChassis, type DetailSibling } from "./DetailChassis";
|
||||
import { ResourceMeta } from "./ResourceMeta";
|
||||
import { pageHref } from "./pageHref";
|
||||
import { downloadFile } from "./resourceActions";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/dotnet-upgrade.module.css";
|
||||
|
||||
export type InstructionDetailItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
applyTo?: string | string[] | null;
|
||||
applyToPatterns?: string[];
|
||||
extensions?: string[];
|
||||
path: string;
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
export type InstructionDetailProps = {
|
||||
item: InstructionDetailItem;
|
||||
/** Rendered, sanitized markdown body with heading ids already stamped. */
|
||||
markdownHtml: string;
|
||||
/** `chat-instructions` VS Code install URL (see src/lib/detail-page.ts). */
|
||||
vscodeUrl: string;
|
||||
insidersUrl: string;
|
||||
githubUrl: string;
|
||||
downloadUrl: string;
|
||||
rawMarkdown: string;
|
||||
lastUpdated?: string | null;
|
||||
previous?: DetailSibling;
|
||||
next?: DetailSibling;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
};
|
||||
|
||||
export function InstructionDetail({
|
||||
item,
|
||||
markdownHtml,
|
||||
vscodeUrl,
|
||||
insidersUrl,
|
||||
githubUrl,
|
||||
downloadUrl,
|
||||
rawMarkdown,
|
||||
lastUpdated,
|
||||
previous,
|
||||
next,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
}: InstructionDetailProps) {
|
||||
const applyTo = Array.isArray(item.applyTo)
|
||||
? item.applyTo
|
||||
: item.applyTo
|
||||
? [item.applyTo]
|
||||
: (item.applyToPatterns ?? []);
|
||||
|
||||
const handleCopyMarkdown = React.useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(rawMarkdown);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
}, [rawMarkdown]);
|
||||
|
||||
const handleDownload = React.useCallback(
|
||||
() =>
|
||||
downloadFile(
|
||||
downloadUrl,
|
||||
item.filename ?? `${item.id}.instructions.md`,
|
||||
),
|
||||
[downloadUrl, item.filename, item.id],
|
||||
);
|
||||
|
||||
const install = (
|
||||
<>
|
||||
<ActionMenu mode="split-button" menuAlignment="start">
|
||||
<ActionMenu.Button
|
||||
as="a"
|
||||
href={vscodeUrl}
|
||||
variant="primary"
|
||||
leadingVisual={PlusIcon}
|
||||
>
|
||||
Install
|
||||
</ActionMenu.Button>
|
||||
<ActionMenu.Overlay
|
||||
aria-label={`Install the ${item.title} instructions`}
|
||||
>
|
||||
<ActionMenu.Item as="a" href={vscodeUrl}>
|
||||
Install in VS Code
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item as="a" href={insidersUrl}>
|
||||
Install in VS Code Insiders
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item onClick={() => void handleDownload()}>
|
||||
Download file
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item onClick={handleCopyMarkdown}>
|
||||
Copy markdown
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
<Button
|
||||
as="a"
|
||||
href={githubUrl}
|
||||
variant="secondary"
|
||||
className={styles.iconButton}
|
||||
aria-label={`View the ${item.title} instructions on GitHub`}
|
||||
>
|
||||
<MarkGithubIcon size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
as="button"
|
||||
variant="secondary"
|
||||
onClick={() => void handleDownload()}
|
||||
className={styles.iconButton}
|
||||
aria-label={`Download the ${item.title} instructions file`}
|
||||
>
|
||||
<DownloadIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
// The glob patterns decide when Copilot applies these instructions, so they
|
||||
// are promoted into the hero rather than left in the sidebar.
|
||||
const heroExtras =
|
||||
applyTo.length > 0 ? (
|
||||
<div className={styles.metaValues} role="list" aria-label="Applies to">
|
||||
{applyTo.map((pattern) => (
|
||||
<span key={pattern} role="listitem">
|
||||
<Token text={pattern} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<DetailChassis
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
breadcrumbs={[
|
||||
{ label: "Instructions", href: pageHref("instructions") },
|
||||
{ label: item.title },
|
||||
]}
|
||||
install={install}
|
||||
heroExtras={heroExtras}
|
||||
sidebar={
|
||||
<ResourceMeta
|
||||
kicker="Instruction details"
|
||||
groups={[
|
||||
{ label: "Applies to", items: applyTo },
|
||||
{ label: "Extensions", items: item.extensions ?? [] },
|
||||
]}
|
||||
lastUpdated={lastUpdated}
|
||||
sourceUrl={githubUrl}
|
||||
/>
|
||||
}
|
||||
previous={previous}
|
||||
next={next}
|
||||
currentPage="instructions"
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
>
|
||||
<section className={styles.articleSection}>
|
||||
<div dangerouslySetInnerHTML={{ __html: markdownHtml }} />
|
||||
</section>
|
||||
</DetailChassis>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
import { ChevronDownIcon, DownloadIcon, MarkGithubIcon } from "@primer/octicons-react";
|
||||
import {
|
||||
ActionMenu,
|
||||
Box,
|
||||
Button,
|
||||
CTABanner,
|
||||
Card,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
Grid,
|
||||
Heading,
|
||||
Pagination,
|
||||
Section,
|
||||
Stack,
|
||||
Text,
|
||||
useTheme,
|
||||
} from "@primer/react-brand";
|
||||
import { clsx } from "clsx";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { PageShell } from "./PageShell";
|
||||
import { CatalogSortControl, CATALOG_SORT_OPTIONS } from "./CatalogSortControl";
|
||||
import {
|
||||
daysSince,
|
||||
toggleValue,
|
||||
updatedBuckets,
|
||||
updatedBucketOf,
|
||||
} from "./catalogFilters";
|
||||
import { pageHref } from "./pageHref";
|
||||
import { downloadFile } from "./resourceActions";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/instructions.module.css";
|
||||
|
||||
const CONTRIBUTE_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/docs/README.instructions.md#how-to-contribute";
|
||||
|
||||
const REQUEST_URL = "https://github.com/github/awesome-copilot/issues/new";
|
||||
|
||||
const RAW_BASE = "https://raw.githubusercontent.com/github/awesome-copilot/main";
|
||||
const GITHUB_BASE = "https://github.com/github/awesome-copilot/blob/main";
|
||||
|
||||
/** Sentinel emitted by the data generator for items with no file extensions. */
|
||||
const NO_EXTENSION = "(none)";
|
||||
|
||||
/** An instruction record as emitted into public/data/instructions.json. */
|
||||
export type InstructionItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
applyTo: string | null;
|
||||
applyToPatterns: string[];
|
||||
extensions: string[];
|
||||
path: string;
|
||||
filename: string;
|
||||
lastUpdated: string;
|
||||
};
|
||||
|
||||
export type InstructionFilters = {
|
||||
patterns: string[];
|
||||
extensions: string[];
|
||||
};
|
||||
|
||||
const instructionSourceUrl = (instruction: InstructionItem) =>
|
||||
`${GITHUB_BASE}/${instruction.path}`;
|
||||
|
||||
/**
|
||||
* VS Code deep link for instructions, matching the scheme used by the detail
|
||||
* pages (`src/lib/detail-page.ts`): the `chat-instructions` command wrapped in
|
||||
* the aka.ms redirector.
|
||||
*/
|
||||
const installUrl = (instruction: InstructionItem, insiders = false) => {
|
||||
const rawUrl = `${RAW_BASE}/${instruction.path}`;
|
||||
const editor = insiders ? "vscode-insiders" : "vscode";
|
||||
const innerUrl = `${editor}:chat-instructions/install?url=${encodeURIComponent(rawUrl)}`;
|
||||
return `https://aka.ms/awesome-copilot/install/instructions?url=${encodeURIComponent(innerUrl)}`;
|
||||
};
|
||||
|
||||
const downloadUrl = (instruction: InstructionItem) =>
|
||||
`${RAW_BASE}/${instruction.path}`;
|
||||
|
||||
type SortMode = "az" | "newest";
|
||||
|
||||
type FilterGroupId = "pattern" | "extension" | "updated";
|
||||
|
||||
type FilterState = Record<FilterGroupId, string[]>;
|
||||
|
||||
const emptyFilters: FilterState = { pattern: [], extension: [], updated: [] };
|
||||
|
||||
const PAGE_SIZE = 6;
|
||||
|
||||
/**
|
||||
* Long facets collapse behind a "Show N more" toggle, matching the prototype's
|
||||
* extensions page. Real data produces far longer option lists than the
|
||||
* prototype's hardcoded arrays, so every catalog needs this.
|
||||
*/
|
||||
const FILTER_COLLAPSE_LIMIT = 10;
|
||||
|
||||
/**
|
||||
* The Instructions catalog, ported from the design prototype's
|
||||
* `instructions.tsx`. The prototype's hardcoded array and filter options are
|
||||
* replaced by build-time data; the layout, filters, and interactions are
|
||||
* unchanged.
|
||||
*/
|
||||
export function InstructionsCatalog({
|
||||
instructions,
|
||||
filters: filterOptions,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
}: {
|
||||
instructions: InstructionItem[];
|
||||
filters?: InstructionFilters;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
}) {
|
||||
const { colorMode } = useTheme();
|
||||
const [sortMode, setSortMode] = useState<SortMode>("az");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<FilterState>(emptyFilters);
|
||||
const [expandedGroups, setExpandedGroups] = useState<
|
||||
Record<FilterGroupId, boolean>
|
||||
>({ pattern: false, extension: false, updated: false });
|
||||
const toggleGroupExpanded = (groupId: FilterGroupId) => {
|
||||
setExpandedGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] }));
|
||||
};
|
||||
|
||||
const filterGroups = useMemo<
|
||||
{ id: FilterGroupId; label: string; options: string[] }[]
|
||||
>(
|
||||
() => [
|
||||
{
|
||||
id: "pattern",
|
||||
label: "Applies to",
|
||||
options: filterOptions?.patterns ?? [],
|
||||
},
|
||||
{
|
||||
id: "extension",
|
||||
label: "File extension",
|
||||
options: filterOptions?.extensions ?? [],
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
label: "Last updated",
|
||||
options: updatedBuckets.map((bucket) => bucket.label),
|
||||
},
|
||||
],
|
||||
[filterOptions],
|
||||
);
|
||||
|
||||
const sortedInstructions = useMemo(() => {
|
||||
const copy = instructions.filter((item) => {
|
||||
const patternOk =
|
||||
filters.pattern.length === 0 ||
|
||||
filters.pattern.some((pattern) => item.applyToPatterns.includes(pattern));
|
||||
const extensionOk =
|
||||
filters.extension.length === 0 ||
|
||||
filters.extension.some((ext) =>
|
||||
ext === NO_EXTENSION
|
||||
? item.extensions.length === 0
|
||||
: item.extensions.includes(ext),
|
||||
);
|
||||
const updatedOk =
|
||||
filters.updated.length === 0 ||
|
||||
filters.updated.includes(updatedBucketOf(daysSince(item.lastUpdated)));
|
||||
return patternOk && extensionOk && updatedOk;
|
||||
});
|
||||
if (sortMode === "newest") {
|
||||
copy.sort((a, b) => daysSince(a.lastUpdated) - daysSince(b.lastUpdated));
|
||||
} else {
|
||||
copy.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
return copy;
|
||||
}, [instructions, sortMode, filters]);
|
||||
|
||||
const toggleFilter = (groupId: FilterGroupId, option: string) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[groupId]: toggleValue(prev[groupId], option),
|
||||
}));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters(emptyFilters);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const activeFilterCount =
|
||||
filters.pattern.length + filters.extension.length + filters.updated.length;
|
||||
const hasActiveFilters = activeFilterCount > 0;
|
||||
const pageCount = Math.max(
|
||||
1,
|
||||
Math.ceil(sortedInstructions.length / PAGE_SIZE),
|
||||
);
|
||||
const page = Math.min(currentPage, pageCount);
|
||||
const visibleInstructions = sortedInstructions.slice(
|
||||
(page - 1) * PAGE_SIZE,
|
||||
page * PAGE_SIZE,
|
||||
);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
styles={styles}
|
||||
currentPage="instructions"
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
searchAriaLabel="Search instructions"
|
||||
>
|
||||
<Box className={styles.hero}>
|
||||
<div className={styles.heroInner}>
|
||||
<svg
|
||||
className={styles.heroIcon}
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 96 96"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M26 18C26 21.3137 28.6863 24 32 24C35.3137 24 38 21.3137 38 18C38 14.6863 35.3137 12 32 12C28.6863 12 26 14.6863 26 18Z"
|
||||
fill="#96A199"
|
||||
/>
|
||||
<path
|
||||
d="M26 61C26 64.3137 28.6863 67 32 67C35.3137 67 38 64.3137 38 61C38 57.6863 35.3137 55 32 55C28.6863 55 26 57.6863 26 61Z"
|
||||
fill="#96A199"
|
||||
/>
|
||||
<path
|
||||
d="M2 51.75C2 48.0221 5.134 45 8.99999 45C12.866 45 16 48.0221 16 51.75"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M16 52C16 54 14.5 56 12.2991 57.5L2 64H17"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2 5L8.96802 2.48376C9.61985 2.24838 10.3077 2.7313 10.3077 3.42432V21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M17 21H2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 2H55"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M65 2H83"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 14H60"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M60 14H72"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M72 14H94"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 26H76"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M76 26H94"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 44H55"
|
||||
stroke="#96A199"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M65 44H83"
|
||||
stroke="#96A199"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 56H60"
|
||||
stroke="#96A199"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M60 56H72"
|
||||
stroke="#96A199"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M72 56H94"
|
||||
stroke="#96A199"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 68H76"
|
||||
stroke="#96A199"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M76 68H94"
|
||||
stroke="#96A199"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 93H88"
|
||||
stroke="#96A199"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 81H94"
|
||||
stroke="#96A199"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<Heading as="h1" size="3" className={styles.heroHeading}>
|
||||
Instructions
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted" className={styles.heroText}>
|
||||
Community-curated standards and best practices that teach Copilot
|
||||
your team’s conventions.
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Section id="catalog" paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.catalog}>
|
||||
<aside className={styles.filterNav} aria-label="Filter instructions">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filterToggle}
|
||||
aria-expanded={mobileFiltersOpen}
|
||||
onClick={() => setMobileFiltersOpen((open) => !open)}
|
||||
>
|
||||
<span>
|
||||
Filters{hasActiveFilters ? ` (${activeFilterCount})` : ""}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
size={16}
|
||||
className={clsx(
|
||||
styles.filterToggleChevron,
|
||||
mobileFiltersOpen && styles.filterToggleChevronOpen,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.filterBody,
|
||||
!mobileFiltersOpen && styles.filterBodyCollapsed,
|
||||
)}
|
||||
>
|
||||
{filterGroups.map((group) => {
|
||||
const isExpandable =
|
||||
group.options.length > FILTER_COLLAPSE_LIMIT;
|
||||
const expanded = expandedGroups[group.id];
|
||||
const visibleOptions =
|
||||
isExpandable && !expanded
|
||||
? group.options.slice(0, FILTER_COLLAPSE_LIMIT)
|
||||
: group.options;
|
||||
return (
|
||||
<div className={styles.filterGroup} key={group.id}>
|
||||
<Text as="h2" size="100" className={styles.filterHeading}>
|
||||
{group.label}
|
||||
</Text>
|
||||
<div className={styles.filterOptions}>
|
||||
{visibleOptions.map((option) => (
|
||||
<div className={styles.filterOption} key={option}>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={filters[group.id].includes(option)}
|
||||
onChange={() => toggleFilter(group.id, option)}
|
||||
/>
|
||||
<FormControl.Label>{option}</FormControl.Label>
|
||||
</FormControl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{isExpandable ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.showMoreButton}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => toggleGroupExpanded(group.id)}
|
||||
>
|
||||
{expanded
|
||||
? "Show less"
|
||||
: `Show ${group.options.length - FILTER_COLLAPSE_LIMIT} more`}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hasActiveFilters ? (
|
||||
<div className={styles.filterActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
hasArrow={false}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear all ({activeFilterCount})
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Box className={styles.catalogMain}>
|
||||
<Box className={styles.toolbar}>
|
||||
<CatalogSortControl
|
||||
ariaLabel="Sort instructions"
|
||||
value={sortMode}
|
||||
options={CATALOG_SORT_OPTIONS}
|
||||
styles={styles}
|
||||
onChange={(value) => {
|
||||
setSortMode(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box className={styles.gridFrame} data-mode={colorMode}>
|
||||
<Box className={styles.gridContent}>
|
||||
<Grid
|
||||
className={styles.threeUp}
|
||||
columnGap="none"
|
||||
rowGap="none"
|
||||
enableGutters={false}
|
||||
>
|
||||
{visibleInstructions.map((item) => (
|
||||
<Grid.Column
|
||||
key={item.id}
|
||||
span={{ xsmall: 12, medium: 6, large: 6 }}
|
||||
className={styles.col}
|
||||
>
|
||||
<Box className={clsx(styles.item, styles.itemHover)}>
|
||||
<Card
|
||||
href={pageHref(`instruction/${item.id}`)}
|
||||
fullWidth
|
||||
ctaVariant="none"
|
||||
backgroundColor="none"
|
||||
className={styles.card}
|
||||
>
|
||||
<Card.Heading as="h2">{item.title}</Card.Heading>
|
||||
<Card.Description>
|
||||
<span className={styles.cardDescText}>
|
||||
{item.description}
|
||||
</span>
|
||||
</Card.Description>
|
||||
</Card>
|
||||
<div className={styles.cardActions}>
|
||||
<ActionMenu mode="split-button" menuAlignment="start">
|
||||
<ActionMenu.Button
|
||||
as="a"
|
||||
href={installUrl(item)}
|
||||
variant="primary"
|
||||
>
|
||||
Install
|
||||
</ActionMenu.Button>
|
||||
<ActionMenu.Overlay
|
||||
aria-label={`Install ${item.title} to your editor`}
|
||||
>
|
||||
<ActionMenu.Item
|
||||
as="a"
|
||||
href={installUrl(item, false)}
|
||||
>
|
||||
VS Code
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item
|
||||
as="a"
|
||||
href={installUrl(item, true)}
|
||||
>
|
||||
VS Code Insiders
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
<Button
|
||||
as="button"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
void downloadFile(downloadUrl(item), item.filename)
|
||||
}
|
||||
aria-label={`Download ${item.title} instruction file`}
|
||||
className={styles.iconButton}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href={instructionSourceUrl(item)}
|
||||
variant="secondary"
|
||||
aria-label={`View ${item.title} instruction on GitHub`}
|
||||
className={styles.iconButton}
|
||||
>
|
||||
<MarkGithubIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</Grid.Column>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{sortedInstructions.length === 0 ? (
|
||||
<Box className={styles.emptyState}>
|
||||
<Text as="p" variant="muted">
|
||||
No instructions match the selected filters.
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.paginationRow}>
|
||||
<Stack direction="horizontal" justifyContent="center" padding="none">
|
||||
<Pagination
|
||||
className={styles.pagination}
|
||||
pageCount={pageCount}
|
||||
currentPage={page}
|
||||
onPageChange={(e, n) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(n);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.ctaFrame}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.ctaFrameInner}>
|
||||
<CTABanner align="center" backgroundColor="subtle">
|
||||
<CTABanner.Heading>
|
||||
Can’t find the instructions you need?
|
||||
</CTABanner.Heading>
|
||||
<CTABanner.Description>
|
||||
This library is community-built. Share an instruction file you
|
||||
use—or request one—
|
||||
<br />
|
||||
to help developers ship faster with GitHub Copilot.
|
||||
</CTABanner.Description>
|
||||
<CTABanner.ButtonGroup>
|
||||
<Button as="a" href={CONTRIBUTE_URL}>
|
||||
Submit an instruction
|
||||
</Button>
|
||||
<Button as="a" href={REQUEST_URL}>
|
||||
Request an instruction
|
||||
</Button>
|
||||
</CTABanner.ButtonGroup>
|
||||
</CTABanner>
|
||||
</Box>
|
||||
</Section>
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { GlobeIcon } from "@primer/octicons-react";
|
||||
import { ActionMenu } from "@primer/react-brand";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
SITE_LOCALES,
|
||||
splitLocale,
|
||||
type SiteLocale,
|
||||
} from "./locales";
|
||||
import { pageHref } from "./pageHref";
|
||||
|
||||
const BASE = import.meta.env.BASE_URL ?? "/";
|
||||
|
||||
/** Strip the configured base path from a browser pathname. */
|
||||
function stripBase(pathname: string): string {
|
||||
const base = BASE.endsWith("/") ? BASE : `${BASE}/`;
|
||||
return pathname.startsWith(base)
|
||||
? pathname.slice(base.length)
|
||||
: pathname.replace(/^\//, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the equivalent URL for `locale`, keeping the rest of the current path,
|
||||
* the query string and the hash. Astro serves the default locale unprefixed,
|
||||
* so switching to it drops the prefix entirely.
|
||||
*/
|
||||
function localeHref(locale: string, pathname: string, suffix: string): string {
|
||||
const { rest } = splitLocale(stripBase(pathname));
|
||||
const target = locale === DEFAULT_LOCALE ? rest : `${locale}/${rest}`;
|
||||
const normalized = target.replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
return `${normalized ? pageHref(normalized) : pageHref()}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Language picker for the top navigation.
|
||||
*
|
||||
* Replaces the Starlight `LanguageSelect.astro` override removed during the
|
||||
* migration. Locale switching is a pure path rewrite performed in the browser,
|
||||
* so a single statically rendered menu works on every page.
|
||||
*/
|
||||
export function LanguageSelect({
|
||||
locales = SITE_LOCALES,
|
||||
label = "Language",
|
||||
}: {
|
||||
locales?: SiteLocale[];
|
||||
label?: string;
|
||||
}) {
|
||||
const [current, setCurrent] = useState(DEFAULT_LOCALE);
|
||||
const [hrefs, setHrefs] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(
|
||||
locales.map((locale) => [
|
||||
locale.code,
|
||||
locale.code === DEFAULT_LOCALE ? pageHref() : pageHref(locale.code),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const { pathname, search, hash } = window.location;
|
||||
const suffix = `${search}${hash}`;
|
||||
setCurrent(splitLocale(stripBase(pathname)).locale);
|
||||
setHrefs(
|
||||
Object.fromEntries(
|
||||
locales.map((locale) => [
|
||||
locale.code,
|
||||
localeHref(locale.code, pathname, suffix),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}, [locales]);
|
||||
|
||||
const active =
|
||||
locales.find((locale) => locale.code === current) ?? locales[0];
|
||||
|
||||
return (
|
||||
<ActionMenu size="small" menuAlignment="end" selectionVariant="none">
|
||||
<ActionMenu.Button
|
||||
variant="subtle"
|
||||
size="small"
|
||||
leadingVisual={<GlobeIcon />}
|
||||
aria-label={`${label}: ${active.label}`}
|
||||
>
|
||||
{active.label}
|
||||
</ActionMenu.Button>
|
||||
<ActionMenu.Overlay aria-label={label}>
|
||||
{locales.map((locale) => (
|
||||
<ActionMenu.Item
|
||||
as="a"
|
||||
key={locale.code}
|
||||
href={hrefs[locale.code]}
|
||||
hrefLang={locale.code}
|
||||
lang={locale.code}
|
||||
aria-current={locale.code === current ? "true" : undefined}
|
||||
>
|
||||
{locale.label}
|
||||
</ActionMenu.Item>
|
||||
))}
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MinimalFooter } from "@primer/react-brand";
|
||||
|
||||
export function LargeFooter() {
|
||||
return (
|
||||
<MinimalFooter logoHref="https://github.com" socialLinks={false}>
|
||||
<MinimalFooter.Link href="https://docs.github.com/site-policy/github-terms/github-terms-of-service">
|
||||
Terms
|
||||
</MinimalFooter.Link>
|
||||
<MinimalFooter.Link href="https://docs.github.com/site-policy/privacy-policies/github-general-privacy-statement">
|
||||
Privacy
|
||||
</MinimalFooter.Link>
|
||||
<MinimalFooter.Link href="https://docs.github.com">
|
||||
GitHub Docs
|
||||
</MinimalFooter.Link>
|
||||
<MinimalFooter.Link href="https://github.community">
|
||||
Community
|
||||
</MinimalFooter.Link>
|
||||
<MinimalFooter.Link href="https://support.github.com">
|
||||
Support
|
||||
</MinimalFooter.Link>
|
||||
</MinimalFooter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowUpIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
MarkGithubIcon,
|
||||
XIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Heading,
|
||||
Section,
|
||||
Text,
|
||||
ThemeProvider,
|
||||
useTheme,
|
||||
} from "@primer/react-brand";
|
||||
|
||||
import styles from "./styles/github-copilot-app.module.css";
|
||||
import { LargeFooter } from "./LargeFooter";
|
||||
import { TypingText } from "./TypingText";
|
||||
import type { PrototypePageProps } from "./pageHref";
|
||||
import { getAwesomeCopilotNavLinks } from "./navigation";
|
||||
import { getScrollBehavior } from "./scrollBehavior";
|
||||
import { TopNav } from "./TopNav";
|
||||
import { LanguageSelect } from "./LanguageSelect";
|
||||
import { SkipLink } from "./SkipLink";
|
||||
import {
|
||||
type CodeLanguage,
|
||||
SyntaxHighlightedCode,
|
||||
} from "./SyntaxHighlightedCode";
|
||||
import { TopNavSearch } from "./TopNavSearch";
|
||||
import { ContributorsNavButton } from "./ContributorsNavButton";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import {
|
||||
contributorsTotal as siteContributorsTotal,
|
||||
searchIndex as siteSearchIndex,
|
||||
} from "../../lib/site-data";
|
||||
|
||||
const CONTRIBUTING_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md";
|
||||
|
||||
// Minimum article reading strip (px) that must remain below a pinned hero. When
|
||||
// pinning the hero would leave less than this — because the viewport is short or
|
||||
// the hero is tall (e.g. the CLI page's install bar) — the hero is released to
|
||||
// scroll away instead of trapping the article in a narrow band.
|
||||
const MIN_HERO_READING = 464;
|
||||
|
||||
/** Copyable, syntax-highlighted code block for learning articles. */
|
||||
export function CopyBlock({
|
||||
code,
|
||||
label,
|
||||
language,
|
||||
}: {
|
||||
code: string;
|
||||
label?: string;
|
||||
language?: CodeLanguage;
|
||||
}) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.codeBlockWrap}>
|
||||
{label ? <span className={styles.codeLabel}>{label}</span> : null}
|
||||
<div className={styles.codeSurface}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.copyButton}
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? "Copied to clipboard" : "Copy code"}
|
||||
>
|
||||
{copied ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
|
||||
</button>
|
||||
<SyntaxHighlightedCode
|
||||
className={styles.codeBlock}
|
||||
code={code}
|
||||
language={language}
|
||||
lineClassName={styles.codeLine}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Dismissible "Pro tip" callout in the article flow. */
|
||||
export function ProTip({ children }: { children: React.ReactNode }) {
|
||||
const [open, setOpen] = React.useState(true);
|
||||
if (!open) return null;
|
||||
return (
|
||||
<aside className={styles.proTip}>
|
||||
<div className={styles.proTipHeader}>
|
||||
<span className={styles.proTipMark}>
|
||||
<MarkGithubIcon size={20} />
|
||||
</span>
|
||||
<span className={styles.proTipTitle}>Pro tip</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.proTipClose}
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Dismiss pro tip"
|
||||
>
|
||||
<XIcon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.proTipBody}>{children}</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export type TocSection = { id: string; label: string };
|
||||
|
||||
export type LearningArticleLayoutProps = {
|
||||
pageHref: PrototypePageProps["pageHref"];
|
||||
/** Page slug of this article, used for the current breadcrumb link. */
|
||||
currentPage: string;
|
||||
/** Short label for the current (selected) breadcrumb. */
|
||||
breadcrumbLabel: string;
|
||||
heroTitle: string;
|
||||
heroSubtitle: string;
|
||||
/** Type the hero title out one character at a time on load, with a blinking
|
||||
* terminal caret. Opt-in per page. */
|
||||
animateHeroTitle?: boolean;
|
||||
/** Optional primary CTA button in the hero. */
|
||||
heroCta?: { label: string; href: string };
|
||||
/** Optional custom content rendered in the hero below the description. */
|
||||
heroExtra?: React.ReactNode;
|
||||
/** Sections shown in the sticky "In this article" list; ids must match the
|
||||
* `id` on each `<section>` rendered in `children`. */
|
||||
tocSections: TocSection[];
|
||||
/** Site-wide search index, injected from build-time data. */
|
||||
searchIndex?: SearchItem[];
|
||||
/** Live contributor count for the nav button. */
|
||||
contributorsTotal?: number;
|
||||
/** Optional "Up next" band pinned to the footer's green line. */
|
||||
upNext?: { label: string; href: string };
|
||||
/**
|
||||
* Only pages that actually have a mirrored translation should offer a
|
||||
* language switch. Defaults to false; the Learning Hub article body passes
|
||||
* true for the `copilot-workshops/app` track.
|
||||
*/
|
||||
showLanguageSelect?: boolean;
|
||||
/** Article body — a sequence of `<section id=...>` blocks (and any ProTip). */
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function LearningArticleLayout(props: LearningArticleLayoutProps) {
|
||||
return (
|
||||
<ThemeProvider colorMode="auto">
|
||||
<LearningArticleLayoutBody {...props} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function LearningArticleLayoutBody({
|
||||
pageHref,
|
||||
currentPage,
|
||||
breadcrumbLabel,
|
||||
heroTitle,
|
||||
heroSubtitle,
|
||||
animateHeroTitle = false,
|
||||
heroCta,
|
||||
heroExtra,
|
||||
tocSections,
|
||||
searchIndex = siteSearchIndex,
|
||||
contributorsTotal = siteContributorsTotal,
|
||||
upNext,
|
||||
showLanguageSelect = false,
|
||||
children,
|
||||
}: LearningArticleLayoutProps) {
|
||||
const { colorMode } = useTheme();
|
||||
const subNavLinks = getAwesomeCopilotNavLinks(
|
||||
pageHref,
|
||||
"learning-hub-copilot-app",
|
||||
);
|
||||
const contentScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const [showBackToTop, setShowBackToTop] = React.useState(false);
|
||||
const [heroBurst, setHeroBurst] = React.useState(false);
|
||||
const heroBurstRef = React.useRef(false);
|
||||
const [activeSection, setActiveSection] = React.useState(
|
||||
tocSections[0]?.id ?? "",
|
||||
);
|
||||
// Visible height of the hero once it pins to the top of the scroll region
|
||||
// (measured below). The scroll-spy detection zone is offset by this so a
|
||||
// section only becomes "active" once it clears the fixed hero.
|
||||
const [pinnedHeight, setPinnedHeight] = React.useState(0);
|
||||
|
||||
const scrollToTop = () => {
|
||||
contentScrollRef.current?.scrollTo({
|
||||
top: 0,
|
||||
behavior: getScrollBehavior(),
|
||||
});
|
||||
};
|
||||
|
||||
// Scroll-spy: highlight the current section in the "In this article" list. The
|
||||
// pinned hero covers the top of the scroll region, so the detection zone is
|
||||
// pushed below it (top margin = hero height) and its lower bound tracks the
|
||||
// remaining visible area — reproducing "top third of what the reader can see".
|
||||
React.useEffect(() => {
|
||||
const scroller = contentScrollRef.current;
|
||||
if (!scroller) return;
|
||||
let observer: IntersectionObserver | null = null;
|
||||
const build = () => {
|
||||
observer?.disconnect();
|
||||
const viewport = scroller.clientHeight;
|
||||
const below = Math.max(0, viewport - pinnedHeight);
|
||||
const bottomMargin = Math.round(below * 0.65);
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort(
|
||||
(a, b) => a.boundingClientRect.top - b.boundingClientRect.top,
|
||||
);
|
||||
if (visible[0]) {
|
||||
setActiveSection(visible[0].target.id);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scroller,
|
||||
rootMargin: `-${Math.round(pinnedHeight)}px 0px -${bottomMargin}px 0px`,
|
||||
threshold: 0,
|
||||
},
|
||||
);
|
||||
tocSections.forEach((section) => {
|
||||
const el = document.getElementById(section.id);
|
||||
if (el) observer?.observe(el);
|
||||
});
|
||||
};
|
||||
build();
|
||||
window.addEventListener("resize", build);
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", build);
|
||||
};
|
||||
}, [tocSections, pinnedHeight]);
|
||||
|
||||
// Expose the scroll region's scrollbar width so the fixed top bar and hero can
|
||||
// inset by the same amount and keep every vertical gridline aligned.
|
||||
React.useEffect(() => {
|
||||
const el = contentScrollRef.current;
|
||||
if (!el) return;
|
||||
const page = el.parentElement;
|
||||
const setRegion = () => {
|
||||
const scrollbar = el.offsetWidth - el.clientWidth;
|
||||
page?.style.setProperty("--app-scrollbar", `${scrollbar}px`);
|
||||
// Align the progress line + duck to the framed content (between the outer
|
||||
// vertical gridlines) rather than the viewport edge: expose the frame's
|
||||
// left inset and width so the line and the duck start at the first
|
||||
// vertical line on load and end at the last one.
|
||||
const heroEl = page?.getElementsByClassName(styles.hero)[0];
|
||||
const frameEl = page?.getElementsByClassName(styles.heroInner)[0];
|
||||
const contentEl = page?.getElementsByClassName(styles.heroContent)[0];
|
||||
if (heroEl && frameEl) {
|
||||
const heroRect = heroEl.getBoundingClientRect();
|
||||
const frameRect = frameEl.getBoundingClientRect();
|
||||
const start = Math.round((frameRect.left - heroRect.left) * 100) / 100;
|
||||
const width = Math.round(frameRect.width * 100) / 100;
|
||||
page?.style.setProperty("--app-frame-start", `${start}px`);
|
||||
page?.style.setProperty("--app-frame-width", `${width}px`);
|
||||
// Split the hero into the breadcrumb band that scrolls off the top and
|
||||
// the body that pins below it. --app-hero-crumb is the negative sticky
|
||||
// offset (so the crumbs scroll out of view) and --app-hero-pinned is the
|
||||
// hero's remaining visible height (so the sticky TOC and in-page anchor
|
||||
// jumps clear the pinned hero). Both are parent-minus-child offsets, so
|
||||
// they stay correct whether the hero is in flow or stuck.
|
||||
if (contentEl) {
|
||||
const contentRect = contentEl.getBoundingClientRect();
|
||||
// Geometry of the hero split, independent of whether it is currently
|
||||
// pinned (both are parent-minus-child offsets): the breadcrumb band
|
||||
// that scrolls off the top, and the body that would pin below it.
|
||||
const crumbBand = Math.round((contentRect.top - heroRect.top) * 100) / 100;
|
||||
const contentBand = Math.round((heroRect.bottom - contentRect.top) * 100) / 100;
|
||||
// Only pin the hero in the two-column layout AND when doing so still
|
||||
// leaves a usable reading strip below it. On short viewports — or when
|
||||
// the hero is tall (e.g. the CLI install bar) — release it so the
|
||||
// article isn't squeezed into a narrow horizontal strip. Driven here
|
||||
// rather than by a fixed max-height media query so the threshold
|
||||
// adapts to each page's actual hero height.
|
||||
const twoColumn = window.matchMedia("(min-width: 75rem)").matches;
|
||||
const roomBelow = el.clientHeight - contentBand;
|
||||
const shouldPin = twoColumn && roomBelow >= MIN_HERO_READING;
|
||||
const crumb = shouldPin ? crumbBand : 0;
|
||||
const pinned = shouldPin ? contentBand : 0;
|
||||
if (page) page.dataset.heroPin = shouldPin ? "true" : "false";
|
||||
page?.style.setProperty("--app-hero-crumb", `${crumb}px`);
|
||||
page?.style.setProperty("--app-hero-pinned", `${pinned}px`);
|
||||
setPinnedHeight((prev) =>
|
||||
Math.abs(prev - pinned) > 0.5 ? pinned : prev,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
setRegion();
|
||||
window.addEventListener("resize", setRegion);
|
||||
// The hero's height settles after fonts load and the install bar lays out,
|
||||
// so re-measure whenever it resizes to keep --app-hero-crumb / -pinned exact
|
||||
// (the offsets are scroll-invariant, so observing while pinned is safe).
|
||||
const heroEl = page?.getElementsByClassName(styles.hero)[0];
|
||||
let observer: ResizeObserver | undefined;
|
||||
if (heroEl && typeof ResizeObserver !== "undefined") {
|
||||
observer = new ResizeObserver(() => setRegion());
|
||||
observer.observe(heroEl);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("resize", setRegion);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Drive the hero's green bottom line as a reading-progress indicator. The
|
||||
// "finish line" is the scroll position where the last article title reaches
|
||||
// the reading zone, so the line fills to the very last vertical gridline
|
||||
// exactly when the reader arrives at that title, and the duck bursts into
|
||||
// confetti at the end of the line. An ease-out mapping keeps the line visibly
|
||||
// moving from the very first scroll.
|
||||
React.useEffect(() => {
|
||||
const el = contentScrollRef.current;
|
||||
if (!el) return;
|
||||
const page = el.parentElement;
|
||||
const lastId = tocSections[tocSections.length - 1]?.id;
|
||||
let lastHeadingEl: HTMLElement | null = null;
|
||||
const setProgress = () => {
|
||||
if (!lastHeadingEl && lastId) {
|
||||
const sectionEl = document.getElementById(lastId);
|
||||
lastHeadingEl =
|
||||
(sectionEl?.querySelector("h2") as HTMLElement | null) ?? sectionEl;
|
||||
}
|
||||
const rootRect = el.getBoundingClientRect();
|
||||
let finish = el.scrollHeight - el.clientHeight;
|
||||
if (lastHeadingEl) {
|
||||
const headingRect = lastHeadingEl.getBoundingClientRect();
|
||||
const headingOffset = headingRect.top - rootRect.top + el.scrollTop;
|
||||
finish = Math.max(1, headingOffset - rootRect.height * 0.6);
|
||||
}
|
||||
const progress = finish > 0 ? Math.min(1, el.scrollTop / finish) : 0;
|
||||
const eased = Math.max(progress > 0 ? Math.pow(progress, 0.5) : 0, 0.055);
|
||||
page?.style.setProperty("--app-progress", String(eased));
|
||||
setShowBackToTop(el.scrollTop > 200);
|
||||
const reached = progress >= 1;
|
||||
if (reached !== heroBurstRef.current) {
|
||||
heroBurstRef.current = reached;
|
||||
setHeroBurst(reached);
|
||||
}
|
||||
};
|
||||
setProgress();
|
||||
el.addEventListener("scroll", setProgress, { passive: true });
|
||||
window.addEventListener("resize", setProgress);
|
||||
return () => {
|
||||
el.removeEventListener("scroll", setProgress);
|
||||
window.removeEventListener("resize", setProgress);
|
||||
};
|
||||
}, [tocSections]);
|
||||
|
||||
return (
|
||||
<Box className={styles.page} backgroundColor="default" data-mode={colorMode}>
|
||||
<SkipLink />
|
||||
<header className={styles.topBar}>
|
||||
<nav className={styles.topBarInner} aria-label="Primary">
|
||||
<a href={pageHref()} className={styles.subNavTitle}>
|
||||
<MarkGithubIcon size={20} />
|
||||
Awesome GitHub Copilot
|
||||
</a>
|
||||
<TopNav
|
||||
styles={styles}
|
||||
links={subNavLinks}
|
||||
contributorsHref={pageHref("contributors")}
|
||||
contributorsTotal={contributorsTotal}
|
||||
searchIndex={searchIndex}
|
||||
searchAriaLabel="Search the library"
|
||||
showLanguageSelect={showLanguageSelect}
|
||||
/>
|
||||
<div className={styles.topBarActions}>
|
||||
<TopNavSearch
|
||||
index={searchIndex}
|
||||
styles={styles}
|
||||
inputAriaLabel="Search the library"
|
||||
/>
|
||||
<ContributorsNavButton
|
||||
href={pageHref("contributors")}
|
||||
total={contributorsTotal}
|
||||
/>
|
||||
{showLanguageSelect && <LanguageSelect />}
|
||||
<Button as="a" href={CONTRIBUTING_URL} variant="subtle" size="small">
|
||||
Contribute
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className={styles.scrollHost} ref={contentScrollRef}>
|
||||
<main id="main-content" tabIndex={-1}>
|
||||
<Box as="section" className={styles.hero}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<div className={styles.heroInner}>
|
||||
<div className={styles.heroBreadcrumbs}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item href={pageHref("learning-hub-copilot-app")}>
|
||||
<ArrowLeftIcon
|
||||
size={16}
|
||||
className={styles.heroBreadcrumbBackIcon}
|
||||
/>
|
||||
GitHub Copilot Learning Hub
|
||||
</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Item href={pageHref(currentPage)} selected>
|
||||
{breadcrumbLabel}
|
||||
</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
<div className={styles.heroContent}>
|
||||
<Heading as="h1" size="4">
|
||||
{animateHeroTitle ? (
|
||||
<TypingText
|
||||
text={heroTitle}
|
||||
speedMs={60}
|
||||
caret
|
||||
caretClassName={styles.heroTitleCaret}
|
||||
/>
|
||||
) : (
|
||||
heroTitle
|
||||
)}
|
||||
</Heading>
|
||||
<Text
|
||||
as="p"
|
||||
size="200"
|
||||
variant="muted"
|
||||
className={clsx(
|
||||
styles.heroDescription,
|
||||
animateHeroTitle && styles.heroReveal,
|
||||
animateHeroTitle && styles.heroRevealDescription,
|
||||
)}
|
||||
>
|
||||
{heroSubtitle}
|
||||
</Text>
|
||||
{heroExtra ? (
|
||||
<div
|
||||
className={clsx(
|
||||
styles.heroExtra,
|
||||
animateHeroTitle && styles.heroReveal,
|
||||
animateHeroTitle && styles.heroRevealExtra,
|
||||
)}
|
||||
>
|
||||
{heroExtra}
|
||||
</div>
|
||||
) : null}
|
||||
{heroCta ? (
|
||||
<div
|
||||
className={clsx(
|
||||
styles.heroActions,
|
||||
animateHeroTitle && styles.heroReveal,
|
||||
animateHeroTitle && styles.heroRevealActions,
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
as="a"
|
||||
href={heroCta.href}
|
||||
variant="primary"
|
||||
size="medium"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{heroCta.label}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
<div
|
||||
className={styles.progressRider}
|
||||
data-burst={heroBurst ? "true" : undefined}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className={styles.progressDuck} />
|
||||
<span className={styles.confetti}>
|
||||
{Array.from({ length: 14 }).map((_, i) => (
|
||||
<i key={i} className={styles.confettiPiece} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box as="section" className={styles.body}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<div className={styles.bodyInner}>
|
||||
<div className={styles.layout}>
|
||||
<article className={styles.contentCol}>{children}</article>
|
||||
|
||||
<aside className={styles.sidebarCol}>
|
||||
<div className={styles.sidebarSticky}>
|
||||
<div className={styles.sidebarSection}>
|
||||
<div className={styles.sidebarSummary}>
|
||||
<span className={styles.sidebarKicker}>
|
||||
In this article
|
||||
</span>
|
||||
</div>
|
||||
<nav className={styles.toc} aria-label="In this article">
|
||||
<ul className={styles.tocList}>
|
||||
{tocSections.map((section) => (
|
||||
<li key={section.id}>
|
||||
<a
|
||||
href={`#${section.id}`}
|
||||
className={clsx(
|
||||
styles.tocLink,
|
||||
activeSection === section.id &&
|
||||
styles.tocLinkActive,
|
||||
)}
|
||||
aria-current={
|
||||
activeSection === section.id
|
||||
? "true"
|
||||
: undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
document
|
||||
.getElementById(section.id)
|
||||
?.scrollIntoView({
|
||||
behavior: getScrollBehavior(),
|
||||
block: "start",
|
||||
});
|
||||
setActiveSection(section.id);
|
||||
}}
|
||||
>
|
||||
{section.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{upNext ? (
|
||||
<aside className={styles.nextUp} aria-label="Up next">
|
||||
<div className={styles.nextUpMain}>
|
||||
<div className={styles.nextUpKickerRow}>
|
||||
<span className={styles.nextUpKicker}>Up next</span>
|
||||
</div>
|
||||
<a href={upNext.href} className={styles.nextUpLink}>
|
||||
{upNext.label}
|
||||
<ArrowRightIcon size={16} className={styles.nextUpArrow} />
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</Section>
|
||||
</Box>
|
||||
</main>
|
||||
<LargeFooter />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
styles.backToTop,
|
||||
showBackToTop && styles.backToTopVisible,
|
||||
)}
|
||||
onClick={scrollToTop}
|
||||
aria-label="Back to top"
|
||||
aria-hidden={!showBackToTop}
|
||||
tabIndex={showBackToTop ? 0 : -1}
|
||||
>
|
||||
<ArrowUpIcon size={24} />
|
||||
</button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { AlertIcon, MarkGithubIcon } from "@primer/octicons-react";
|
||||
|
||||
import {
|
||||
LearningArticleLayout,
|
||||
type TocSection,
|
||||
} from "./LearningArticleLayout";
|
||||
import type {
|
||||
ArticleBlock,
|
||||
ArticleSection,
|
||||
CalloutKind,
|
||||
} from "../../lib/learning-hub-article";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import { contributorsTotal as siteContributorsTotal } from "../../lib/site-data";
|
||||
import styles from "./styles/github-copilot-app.module.css";
|
||||
|
||||
const CALLOUT_TITLES: Record<CalloutKind, string> = {
|
||||
note: "Note",
|
||||
tip: "Pro tip",
|
||||
caution: "Caution",
|
||||
};
|
||||
|
||||
/**
|
||||
* Static sibling of `ProTip`. Article bodies are prerendered, so this variant
|
||||
* reuses the same prototype classes and drops the dismiss control that would
|
||||
* require client state.
|
||||
*/
|
||||
function Callout({ kind, html }: { kind: CalloutKind; html: string }) {
|
||||
return (
|
||||
<aside className={styles.proTip}>
|
||||
<div className={styles.proTipHeader}>
|
||||
<span className={styles.proTipMark}>
|
||||
{kind === "caution" ? (
|
||||
<AlertIcon size={20} />
|
||||
) : (
|
||||
<MarkGithubIcon size={20} />
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.proTipTitle}>{CALLOUT_TITLES[kind]}</span>
|
||||
</div>
|
||||
<div
|
||||
className={styles.proTipBody}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Block({ block, index }: { block: ArticleBlock; index: number }) {
|
||||
if (block.type === "callout") {
|
||||
return <Callout key={index} kind={block.kind} html={block.html} />;
|
||||
}
|
||||
return <div key={index} dangerouslySetInnerHTML={{ __html: block.html }} />;
|
||||
}
|
||||
|
||||
export function LearningHubArticleBody({
|
||||
slug,
|
||||
breadcrumbLabel,
|
||||
title,
|
||||
description,
|
||||
sections,
|
||||
tocSections,
|
||||
searchIndex = [],
|
||||
contributorsTotal = siteContributorsTotal,
|
||||
showLanguageSelect = false,
|
||||
}: {
|
||||
/** Site path of this article, e.g. `learning-hub/agentic-workflows`. */
|
||||
slug: string;
|
||||
breadcrumbLabel: string;
|
||||
title: string;
|
||||
description: string;
|
||||
sections: ArticleSection[];
|
||||
tocSections: TocSection[];
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
showLanguageSelect?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<LearningArticleLayout
|
||||
pageHref={pageHref}
|
||||
currentPage={slug}
|
||||
breadcrumbLabel={breadcrumbLabel}
|
||||
heroTitle={title}
|
||||
heroSubtitle={description}
|
||||
tocSections={tocSections}
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
showLanguageSelect={showLanguageSelect}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<section
|
||||
key={section.id}
|
||||
id={section.id}
|
||||
className={styles.articleSection}
|
||||
>
|
||||
{section.blocks.map((block, index) => (
|
||||
<Block key={index} block={block} index={index} />
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</LearningArticleLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
type LearningHubIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function LearningHubIcon({ size = 36 }: LearningHubIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 96 96"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="GitHub Copilot Learning Hub"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#Learning Hub-icon-clip)">
|
||||
<path
|
||||
d="M48 23C50 12.9543 59.9543 6 71 6H90C92.2091 6 94 7.79086 94 10V81C94 83.2091 92.2091 85 90 85H72.5C68.0769 85 63.9581 87.061 61.3028 90.4876C60.3979 91.6553 59.0772 92.5 57.6 92.5H38.4C36.9228 92.5 35.6021 91.6553 34.6972 90.4876C32.0419 87.061 27.9231 85 23.5 85H6C3.79086 85 2 83.2091 2 81V43.5"
|
||||
stroke="var(--brand-color-text-default)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M15.3384 24.9263C10.8911 24.3872 7.75781 21.1865 7.75781 17.0425C7.75781 15.3579 8.36426 13.5386 9.375 12.3257C8.93701 11.2139 9.00439 8.85547 9.50977 7.87842C10.8574 7.70996 12.6768 8.41748 13.7549 9.39453C15.0352 8.99023 16.3828 8.78809 18.0337 8.78809C19.6846 8.78809 21.0322 8.99023 22.2451 9.36084C23.2896 8.41748 25.1426 7.70996 26.4902 7.87842C26.9619 8.78809 27.0293 11.1465 26.5913 12.292C27.6694 13.5723 28.2422 15.2905 28.2422 17.0425C28.2422 21.1865 25.1089 24.3198 20.5942 24.8926C21.7397 25.6338 22.5146 27.251 22.5146 29.104V32.6079C22.5146 33.6187 23.3569 34.1914 24.3677 33.7871C30.4658 31.4624 35.25 25.3643 35.25 17.8174C35.25 8.28271 27.501 0.5 17.9663 0.5C8.43164 0.5 0.75 8.28271 0.75 17.8174C0.75 25.2969 5.50049 31.4961 11.9019 33.8208C12.8115 34.1577 13.6875 33.5513 13.6875 32.6416V29.9463C13.2158 30.1484 12.6094 30.2832 12.0703 30.2832C9.84668 30.2832 8.53272 29.0703 7.58936 26.813C7.21875 25.9033 6.81445 25.3643 6.03955 25.2632C5.63525 25.2295 5.50049 25.061 5.50049 24.8589C5.50049 24.4546 6.17432 24.1514 6.84814 24.1514C7.8252 24.1514 8.66748 24.7578 9.54346 26.0044C10.2173 26.9814 10.9248 27.4194 11.7671 27.4194C12.6094 27.4194 13.1484 27.1162 13.9233 26.3413C14.4961 25.7686 14.9341 25.2632 15.3384 24.9263Z"
|
||||
fill="var(--brand-color-text-default)"
|
||||
/>
|
||||
<path
|
||||
d="M48 35.5L48 80.5"
|
||||
stroke="var(--brand-color-text-muted)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 80.5L44.5627 77.6356C40.9684 74.6403 36.4377 73 31.759 73H14"
|
||||
stroke="var(--brand-color-text-muted)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 80.5L51.4373 77.6356C55.0316 74.6403 59.5623 73 64.241 73H82"
|
||||
stroke="var(--brand-color-text-muted)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="Learning Hub-icon-clip">
|
||||
<rect width="96" height="96" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowUpRightIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
Grid,
|
||||
Heading,
|
||||
Image,
|
||||
Pagination,
|
||||
Section,
|
||||
Stack,
|
||||
Text,
|
||||
useTheme,
|
||||
} from "@primer/react-brand";
|
||||
|
||||
import styles from "./styles/learning-hub-copilot-app.module.css";
|
||||
import { DuckIcon } from "./DuckIcon";
|
||||
import { LearningHubIcon } from "./LearningHubIcon";
|
||||
import { PageShell } from "./PageShell";
|
||||
import { ScrambleText } from "./ScrambleText";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import { contributorsTotal as siteContributorsTotal } from "../../lib/site-data";
|
||||
|
||||
type Topic =
|
||||
| "Getting started"
|
||||
| "Agents & Skills"
|
||||
| "Customization"
|
||||
| "Automation"
|
||||
| "Extensions & MCP"
|
||||
| "CLI";
|
||||
type Kind = "Guide" | "Tutorial" | "Terminology" | "Example" | "Workshop";
|
||||
|
||||
/** Article frontmatter projected out of the `docs` collection at build time. */
|
||||
export type LearningHubArticle = {
|
||||
/** Collection entry id, e.g. `learning-hub/agentic-workflows`. */
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
tags: string[];
|
||||
/** ISO date string; the collection stores a Date, serialised across the island boundary. */
|
||||
lastUpdated?: string;
|
||||
};
|
||||
|
||||
type Entry = LearningHubArticle & {
|
||||
topic: Topic;
|
||||
kind: Kind;
|
||||
isNew?: boolean;
|
||||
};
|
||||
|
||||
// The prototype hardcoded a topic/kind per article. Real frontmatter only
|
||||
// carries free-form `tags`, so the two facets are derived from them; the first
|
||||
// matching tag wins, in the order listed here.
|
||||
const TOPIC_BY_TAG: [string, Topic][] = [
|
||||
["cli", "CLI"],
|
||||
["mcp", "Extensions & MCP"],
|
||||
["canvases", "Extensions & MCP"],
|
||||
["canvas-extensions", "Extensions & MCP"],
|
||||
["extensions", "Extensions & MCP"],
|
||||
["plugins", "Extensions & MCP"],
|
||||
["agents", "Agents & Skills"],
|
||||
["subagents", "Agents & Skills"],
|
||||
["skills", "Agents & Skills"],
|
||||
["orchestration", "Agents & Skills"],
|
||||
["coding-agent", "Automation"],
|
||||
["automation", "Automation"],
|
||||
["automations", "Automation"],
|
||||
["workflows", "Automation"],
|
||||
["hooks", "Automation"],
|
||||
["github-actions", "Automation"],
|
||||
["customization", "Customization"],
|
||||
["instructions", "Customization"],
|
||||
["configuration", "Customization"],
|
||||
];
|
||||
|
||||
const KIND_BY_TAG: [string, Kind][] = [
|
||||
["workshop", "Workshop"],
|
||||
["glossary", "Terminology"],
|
||||
["terminology", "Terminology"],
|
||||
["reference", "Terminology"],
|
||||
["examples", "Example"],
|
||||
["tutorial", "Tutorial"],
|
||||
["setup", "Tutorial"],
|
||||
["desktop", "Tutorial"],
|
||||
];
|
||||
|
||||
function deriveTopic(tags: string[]): Topic {
|
||||
for (const [tag, topic] of TOPIC_BY_TAG) {
|
||||
if (tags.includes(tag)) return topic;
|
||||
}
|
||||
return "Getting started";
|
||||
}
|
||||
|
||||
function deriveKind(tags: string[]): Kind {
|
||||
for (const [tag, kind] of KIND_BY_TAG) {
|
||||
if (tags.includes(tag)) return kind;
|
||||
}
|
||||
return "Guide";
|
||||
}
|
||||
|
||||
type FilterGroupId = "topic" | "kind";
|
||||
|
||||
// The prototype also offered a "Level" facet. No article frontmatter records a
|
||||
// level, so the group is omitted rather than populated with invented values.
|
||||
const filterGroups: { id: FilterGroupId; label: string; options: string[] }[] = [
|
||||
{
|
||||
id: "topic",
|
||||
label: "Topic",
|
||||
options: [
|
||||
"Getting started",
|
||||
"Agents & Skills",
|
||||
"Customization",
|
||||
"Automation",
|
||||
"Extensions & MCP",
|
||||
"CLI",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
label: "Content type",
|
||||
options: ["Guide", "Tutorial", "Terminology", "Example", "Workshop"],
|
||||
},
|
||||
];
|
||||
|
||||
type FilterState = Record<FilterGroupId, string[]>;
|
||||
|
||||
const emptyFilters: FilterState = {
|
||||
topic: [],
|
||||
kind: [],
|
||||
};
|
||||
|
||||
const recommendedCards = [
|
||||
{
|
||||
id: "desktop-app",
|
||||
page: "github-copilot-app",
|
||||
labels: [] as string[],
|
||||
title: "Desktop App",
|
||||
description:
|
||||
"Explore the GitHub Copilot app — a control center for directing multiple agents in parallel. Perfect for agent-native development and parallel work with isolated worktrees.",
|
||||
},
|
||||
{
|
||||
id: "reference",
|
||||
page: "github-copilot-terminology-glossary",
|
||||
labels: [] as string[],
|
||||
title: "Terminology",
|
||||
description:
|
||||
"Quick-lookup resources to keep handy while you work. Browse the GitHub Copilot Terminology Glossary for definitions of common terms and concepts.",
|
||||
},
|
||||
{
|
||||
id: "terminal",
|
||||
page: "cli-for-beginners",
|
||||
labels: [] as string[],
|
||||
title: "Copilot CLI",
|
||||
description:
|
||||
"Looking for a guided path into GitHub Copilot from the terminal? Explore the Copilot CLI for Beginners with a text-based experience or the YouTube video series.",
|
||||
},
|
||||
{
|
||||
id: "fundamentals",
|
||||
page: "what-are-agents-skills-instructions",
|
||||
labels: [] as string[],
|
||||
title: "Fundamentals",
|
||||
description:
|
||||
"Essential concepts to tailor GitHub Copilot beyond its default experience. Start with What are Agents, Skills, and Instructions and work through the full track to master every customization primitive. For delegation and orchestration patterns, continue with Agents and Subagents.",
|
||||
},
|
||||
{
|
||||
id: "automations",
|
||||
page: "using-automations-in-copilot-app",
|
||||
labels: [] as string[],
|
||||
title: "Automations",
|
||||
description:
|
||||
"Start with Using Automations in the GitHub Copilot app for templates, setup guidance, and real examples.",
|
||||
},
|
||||
{
|
||||
id: "canvases",
|
||||
page: "working-with-canvas-extensions",
|
||||
labels: [] as string[],
|
||||
title: "Canvases",
|
||||
description:
|
||||
"Learn Working with Canvas Extensions to create and evolve interactive canvases with /create-canvas.",
|
||||
},
|
||||
{
|
||||
id: "workshop",
|
||||
page: "copilot-workshops",
|
||||
labels: [] as string[],
|
||||
title: "Workshop",
|
||||
description:
|
||||
"Prefer to learn by building? Work through Hands-on with GitHub Copilot's agents — a hands-on workshop with four harnesses (VS Code, Copilot CLI, Copilot app, and cloud agent) built around a shared Tailspin Toys backlog.",
|
||||
},
|
||||
];
|
||||
|
||||
export function LearningHubIndex({
|
||||
articles,
|
||||
searchIndex = [],
|
||||
contributorsTotal = siteContributorsTotal,
|
||||
}: {
|
||||
articles: LearningHubArticle[];
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
}) {
|
||||
return (
|
||||
<PageShell
|
||||
styles={styles}
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
searchAriaLabel="Search the Learning Hub"
|
||||
>
|
||||
<LearningHubIndexBody articles={articles} searchIndex={searchIndex} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function LearningHubIndexBody({
|
||||
articles,
|
||||
searchIndex,
|
||||
}: {
|
||||
articles: LearningHubArticle[];
|
||||
searchIndex: SearchItem[];
|
||||
}) {
|
||||
const { colorMode } = useTheme();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [heroSearchFocused, setHeroSearchFocused] = useState(false);
|
||||
const [heroActiveIndex, setHeroActiveIndex] = useState(-1);
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
const heroSearchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const entries = useMemo<Entry[]>(() => {
|
||||
const newest = articles.reduce<string | undefined>((latest, article) => {
|
||||
if (!article.lastUpdated) return latest;
|
||||
return !latest || article.lastUpdated > latest
|
||||
? article.lastUpdated
|
||||
: latest;
|
||||
}, undefined);
|
||||
return articles.map((article) => ({
|
||||
...article,
|
||||
topic: deriveTopic(article.tags),
|
||||
kind: deriveKind(article.tags),
|
||||
isNew: newest !== undefined && article.lastUpdated === newest,
|
||||
}));
|
||||
}, [articles]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === "/" &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey
|
||||
) {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const isTyping =
|
||||
target?.tagName === "INPUT" ||
|
||||
target?.tagName === "TEXTAREA" ||
|
||||
target?.isContentEditable === true;
|
||||
if (!isTyping) {
|
||||
event.preventDefault();
|
||||
heroSearchInputRef.current?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
const REC_PER_PAGE = 3;
|
||||
const recPageCount = Math.ceil(recommendedCards.length / REC_PER_PAGE);
|
||||
const [recPage, setRecPage] = React.useState(0);
|
||||
const changeRecPage = (direction: 1 | -1) => {
|
||||
setRecPage((page) => (page + direction + recPageCount) % recPageCount);
|
||||
};
|
||||
const visibleRecCards = recommendedCards.slice(
|
||||
recPage * REC_PER_PAGE,
|
||||
recPage * REC_PER_PAGE + REC_PER_PAGE,
|
||||
);
|
||||
|
||||
const [filters, setFilters] = useState<FilterState>(emptyFilters);
|
||||
const [sortOrder, setSortOrder] = useState<"newest" | "oldest">("newest");
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
const query = searchTerm.trim().toLowerCase();
|
||||
return entries.filter((entry) => {
|
||||
const topicOk =
|
||||
filters.topic.length === 0 || filters.topic.includes(entry.topic);
|
||||
const kindOk =
|
||||
filters.kind.length === 0 || filters.kind.includes(entry.kind);
|
||||
const searchOk =
|
||||
query.length === 0 ||
|
||||
[entry.title, entry.description, entry.topic, entry.kind, ...entry.tags]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
return topicOk && kindOk && searchOk;
|
||||
});
|
||||
}, [entries, filters, searchTerm]);
|
||||
|
||||
const sortedEntries = useMemo(() => {
|
||||
return sortOrder === "oldest"
|
||||
? [...filteredEntries].reverse()
|
||||
: filteredEntries;
|
||||
}, [filteredEntries, sortOrder]);
|
||||
|
||||
const articleMatches = useMemo(() => {
|
||||
const query = searchTerm.trim().toLowerCase();
|
||||
if (query.length === 0) return [];
|
||||
return entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.title.toLowerCase().includes(query) ||
|
||||
entry.description.toLowerCase().includes(query),
|
||||
)
|
||||
.slice(0, 5);
|
||||
}, [entries, searchTerm]);
|
||||
|
||||
const resourceMatches = useMemo(() => {
|
||||
const query = searchTerm.trim().toLowerCase();
|
||||
if (query.length === 0) return [];
|
||||
return searchIndex
|
||||
.filter(
|
||||
(resource) =>
|
||||
resource.title.toLowerCase().includes(query) ||
|
||||
resource.description.toLowerCase().includes(query) ||
|
||||
resource.category.toLowerCase().includes(query),
|
||||
)
|
||||
.slice(0, 5);
|
||||
}, [searchIndex, searchTerm]);
|
||||
|
||||
const trimmedSearch = searchTerm.trim();
|
||||
const heroSearchOpen = heroSearchFocused && trimmedSearch.length > 0;
|
||||
const hasSearchResults =
|
||||
articleMatches.length > 0 || resourceMatches.length > 0;
|
||||
|
||||
const heroOptionCount = articleMatches.length + resourceMatches.length;
|
||||
|
||||
// A fresh query re-orders the list, so drop any stale highlight.
|
||||
React.useEffect(() => {
|
||||
setHeroActiveIndex(-1);
|
||||
}, [trimmedSearch]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (heroActiveIndex < 0) return;
|
||||
document
|
||||
.getElementById(`hero-search-option-${heroActiveIndex}`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}, [heroActiveIndex]);
|
||||
|
||||
const handleResultsKeyDown = (
|
||||
event: React.KeyboardEvent<HTMLInputElement>,
|
||||
optionCount: number,
|
||||
activeIndex: number,
|
||||
setActiveIndex: React.Dispatch<React.SetStateAction<number>>,
|
||||
optionIdPrefix: string,
|
||||
) => {
|
||||
if (optionCount === 0) return;
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
setActiveIndex((i) => (i + 1) % optionCount);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
setActiveIndex((i) => (i <= 0 ? optionCount - 1 : i - 1));
|
||||
break;
|
||||
case "Home":
|
||||
event.preventDefault();
|
||||
setActiveIndex(0);
|
||||
break;
|
||||
case "End":
|
||||
event.preventDefault();
|
||||
setActiveIndex(optionCount - 1);
|
||||
break;
|
||||
case "Enter":
|
||||
if (activeIndex >= 0) {
|
||||
event.preventDefault();
|
||||
document.getElementById(`${optionIdPrefix}-${activeIndex}`)?.click();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFilter = (groupId: FilterGroupId, option: string) => {
|
||||
setFilters((prev) => {
|
||||
const current = prev[groupId];
|
||||
const next = current.includes(option)
|
||||
? current.filter((value) => value !== option)
|
||||
: [...current, option];
|
||||
return { ...prev, [groupId]: next };
|
||||
});
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters(emptyFilters);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const activeFilterCount = filters.topic.length + filters.kind.length;
|
||||
const hasActiveFilters = activeFilterCount > 0;
|
||||
|
||||
const pageSize = 6;
|
||||
const pageCount = Math.max(1, Math.ceil(sortedEntries.length / pageSize));
|
||||
const page = Math.min(currentPage, pageCount);
|
||||
const visibleEntries = sortedEntries.slice(
|
||||
(page - 1) * pageSize,
|
||||
page * pageSize,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box className={styles.hero}>
|
||||
<div className={styles.heroInner}>
|
||||
<div className={styles.heroTop}>
|
||||
<span className={styles.heroIcon} aria-hidden="true">
|
||||
<LearningHubIcon size={44} />
|
||||
</span>
|
||||
<Heading as="h1" size="3" className={styles.heroHeading}>
|
||||
GitHub Copilot Learning Hub
|
||||
</Heading>
|
||||
</div>
|
||||
<label className={styles.heroSearch}>
|
||||
<input
|
||||
type="text"
|
||||
ref={heroSearchInputRef}
|
||||
className={styles.heroSearchInput}
|
||||
aria-label="Search the Learning Hub"
|
||||
role="combobox"
|
||||
aria-expanded={heroSearchOpen}
|
||||
aria-controls="hero-search-results"
|
||||
aria-activedescendant={
|
||||
heroSearchOpen && heroActiveIndex >= 0
|
||||
? `hero-search-option-${heroActiveIndex}`
|
||||
: undefined
|
||||
}
|
||||
autoComplete="off"
|
||||
value={searchTerm}
|
||||
onFocus={() => setHeroSearchFocused(true)}
|
||||
onBlur={() => setHeroSearchFocused(false)}
|
||||
onChange={(event) => {
|
||||
setSearchTerm(event.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
setSearchTerm("");
|
||||
setHeroSearchFocused(false);
|
||||
setHeroActiveIndex(-1);
|
||||
event.currentTarget.blur();
|
||||
return;
|
||||
}
|
||||
handleResultsKeyDown(
|
||||
event,
|
||||
heroOptionCount,
|
||||
heroActiveIndex,
|
||||
setHeroActiveIndex,
|
||||
"hero-search-option",
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{searchTerm === "" && (
|
||||
<div className={styles.heroSearchHint} aria-hidden="true">
|
||||
<span className={styles.heroSearchCaret} />
|
||||
<span className={styles.heroSearchHintMono}>
|
||||
<ScrambleText
|
||||
text="What would you like to learn?"
|
||||
cursor="fade"
|
||||
/>
|
||||
</span>
|
||||
<span className={styles.heroSearchDuck}>
|
||||
<DuckIcon size={22} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{searchTerm === "" ? (
|
||||
<kbd className={styles.heroSearchKbd}>/</kbd>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
styles.heroSearchKbd,
|
||||
styles.heroSearchKbdButton,
|
||||
)}
|
||||
aria-label="Clear search"
|
||||
onClick={() => {
|
||||
setSearchTerm("");
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
/
|
||||
</button>
|
||||
)}
|
||||
{heroSearchOpen && (
|
||||
<div
|
||||
id="hero-search-results"
|
||||
className={styles.heroSearchResults}
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
{hasSearchResults ? (
|
||||
<>
|
||||
{articleMatches.length > 0 && (
|
||||
<div className={styles.heroSearchGroup}>
|
||||
<p className={styles.heroSearchGroupLabel}>Articles</p>
|
||||
{articleMatches.map((entry, i) => (
|
||||
<a
|
||||
key={entry.id}
|
||||
id={`hero-search-option-${i}`}
|
||||
href={entry.href}
|
||||
className={clsx(
|
||||
styles.heroSearchResult,
|
||||
heroActiveIndex === i &&
|
||||
styles.heroSearchResultActive,
|
||||
)}
|
||||
role="option"
|
||||
aria-selected={heroActiveIndex === i}
|
||||
onMouseEnter={() => setHeroActiveIndex(i)}
|
||||
>
|
||||
<span className={styles.heroSearchResultText}>
|
||||
{entry.title}
|
||||
</span>
|
||||
<ArrowUpRightIcon
|
||||
size={16}
|
||||
className={styles.heroSearchResultIcon}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{resourceMatches.length > 0 && (
|
||||
<div className={styles.heroSearchGroup}>
|
||||
<p className={styles.heroSearchGroupLabel}>Resources</p>
|
||||
{resourceMatches.map((resource, j) => {
|
||||
const optionIndex = articleMatches.length + j;
|
||||
return (
|
||||
<a
|
||||
key={`${resource.category}-${resource.title}`}
|
||||
id={`hero-search-option-${optionIndex}`}
|
||||
href={resource.href}
|
||||
className={clsx(
|
||||
styles.heroSearchResult,
|
||||
heroActiveIndex === optionIndex &&
|
||||
styles.heroSearchResultActive,
|
||||
)}
|
||||
role="option"
|
||||
aria-selected={heroActiveIndex === optionIndex}
|
||||
onMouseEnter={() => setHeroActiveIndex(optionIndex)}
|
||||
>
|
||||
<span className={styles.heroSearchResultText}>
|
||||
{resource.title}
|
||||
<span className={styles.heroSearchResultMeta}>
|
||||
{resource.category}
|
||||
</span>
|
||||
</span>
|
||||
<ArrowUpRightIcon
|
||||
size={16}
|
||||
className={styles.heroSearchResultIcon}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.heroSearchMore}
|
||||
onClick={() => {
|
||||
setHeroSearchFocused(false);
|
||||
document
|
||||
.getElementById("catalog")
|
||||
?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}}
|
||||
>
|
||||
See all results for “{trimmedSearch}”
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className={styles.heroSearchEmpty}>
|
||||
No results for “{trimmedSearch}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.recommended}>
|
||||
<div className={styles.recommendedInner}>
|
||||
<div className={styles.recommendedHeader}>
|
||||
<Heading as="h2" size="5" className={styles.recommendedTitle}>
|
||||
Get started
|
||||
</Heading>
|
||||
<div className={styles.carouselNav}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.carouselButton}
|
||||
aria-label="Previous recommendations"
|
||||
onClick={() => changeRecPage(-1)}
|
||||
>
|
||||
<ArrowLeftIcon size={20} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.carouselButton}
|
||||
aria-label="Next recommendations"
|
||||
onClick={() => changeRecPage(1)}
|
||||
>
|
||||
<ArrowRightIcon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.carouselTrack} key={recPage}>
|
||||
{visibleRecCards.map((rec) => (
|
||||
<a
|
||||
key={rec.id}
|
||||
href={pageHref(rec.page)}
|
||||
data-rec-card
|
||||
className={styles.recCard}
|
||||
>
|
||||
<div className={styles.recCardLabels}>
|
||||
{rec.labels.map((label) => (
|
||||
<span key={label} className={styles.recCardLabel}>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<h3 className={styles.recCardHeading}>
|
||||
{rec.title}
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</h3>
|
||||
<p className={styles.recCardDesc}>{rec.description}</p>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
styles.recCardImage,
|
||||
rec.id === "desktop-app" && styles.recCardImageDesktop,
|
||||
rec.id === "terminal" && styles.recCardImageTerminal,
|
||||
rec.id === "fundamentals" && styles.recCardImageFundamentals,
|
||||
rec.id === "automations" && styles.recCardImageAutomations,
|
||||
rec.id === "canvases" && styles.recCardImageCanvases,
|
||||
rec.id === "reference" && styles.recCardImageReference,
|
||||
)}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Section
|
||||
id="catalog"
|
||||
className={styles.catalogSection}
|
||||
paddingBlockStart="none"
|
||||
paddingBlockEnd="none"
|
||||
>
|
||||
<div className={styles.catalogHeader}>
|
||||
<Heading as="h2" size="5" className={styles.recommendedTitle}>
|
||||
Articles
|
||||
</Heading>
|
||||
<div className={styles.sortControl}>
|
||||
<span className={styles.sortLabel}>Sort by:</span>
|
||||
<details className={styles.sortMenu}>
|
||||
<summary className={styles.sortTrigger}>
|
||||
{sortOrder === "newest" ? "Newest" : "Oldest"}
|
||||
<ChevronDownIcon size={16} className={styles.sortChevron} />
|
||||
</summary>
|
||||
<div className={styles.sortOverlay} role="menu">
|
||||
{(["newest", "oldest"] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={sortOrder === value}
|
||||
className={clsx(
|
||||
styles.sortOption,
|
||||
sortOrder === value && styles.sortOptionActive,
|
||||
)}
|
||||
onClick={(event) => {
|
||||
setSortOrder(value);
|
||||
setCurrentPage(1);
|
||||
event.currentTarget
|
||||
.closest("details")
|
||||
?.removeAttribute("open");
|
||||
}}
|
||||
>
|
||||
{value === "newest" ? "Newest" : "Oldest"}
|
||||
<CheckIcon
|
||||
size={16}
|
||||
className={styles.sortOptionCheck}
|
||||
aria-hidden={sortOrder !== value}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filterToggle}
|
||||
aria-expanded={mobileFiltersOpen}
|
||||
onClick={() => setMobileFiltersOpen((open) => !open)}
|
||||
>
|
||||
<span>
|
||||
Filters{hasActiveFilters ? ` (${activeFilterCount})` : ""}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
size={16}
|
||||
className={clsx(
|
||||
styles.filterToggleChevron,
|
||||
mobileFiltersOpen && styles.filterToggleChevronOpen,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<Box className={styles.catalog}>
|
||||
<aside className={styles.filterNav} aria-label="Filter guide sections">
|
||||
<div
|
||||
className={clsx(
|
||||
styles.filterBody,
|
||||
!mobileFiltersOpen && styles.filterBodyCollapsed,
|
||||
)}
|
||||
>
|
||||
{filterGroups.map((group) => (
|
||||
<div className={styles.filterGroup} key={group.id}>
|
||||
<Text as="h2" size="100" className={styles.filterHeading}>
|
||||
{group.label}
|
||||
</Text>
|
||||
<div className={styles.filterOptions}>
|
||||
{group.options.map((option) => (
|
||||
<div className={styles.filterOption} key={option}>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={filters[group.id].includes(option)}
|
||||
onChange={() => toggleFilter(group.id, option)}
|
||||
/>
|
||||
<FormControl.Label>{option}</FormControl.Label>
|
||||
</FormControl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{hasActiveFilters ? (
|
||||
<div className={styles.filterActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
hasArrow={false}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear all ({activeFilterCount})
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Box className={styles.catalogMain}>
|
||||
<Box className={styles.gridFrame} data-mode={colorMode}>
|
||||
<Box className={styles.gridContent}>
|
||||
<Grid
|
||||
className={styles.threeUp}
|
||||
columnGap="none"
|
||||
rowGap="none"
|
||||
enableGutters={false}
|
||||
>
|
||||
{visibleEntries.map((entry) => (
|
||||
<Grid.Column
|
||||
key={entry.id}
|
||||
span={{ xsmall: 12, medium: 6, large: 6 }}
|
||||
className={styles.col}
|
||||
>
|
||||
<Box className={clsx(styles.item, styles.itemHover)}>
|
||||
<a href={entry.href} className={styles.catalogCard}>
|
||||
<div className={styles.recCardLabels}>
|
||||
{entry.isNew ? (
|
||||
<span
|
||||
className={clsx(
|
||||
styles.recCardLabel,
|
||||
styles.recCardLabelAccent,
|
||||
)}
|
||||
>
|
||||
New
|
||||
</span>
|
||||
) : null}
|
||||
<span className={styles.recCardLabel}>
|
||||
{entry.topic}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
styles.recCardLabel,
|
||||
styles.recCardLabelOutline,
|
||||
)}
|
||||
>
|
||||
{entry.kind}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className={styles.recCardHeading}>
|
||||
{entry.title}
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</h3>
|
||||
<p className={styles.recCardDesc}>
|
||||
{entry.description}
|
||||
</p>
|
||||
</a>
|
||||
</Box>
|
||||
</Grid.Column>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{filteredEntries.length === 0 ? (
|
||||
<Box className={styles.emptyState}>
|
||||
<Text as="p" variant="muted">
|
||||
No sections match the selected filters.
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.paginationRow}>
|
||||
<Stack direction="horizontal" justifyContent="center" padding="none">
|
||||
<Pagination
|
||||
className={styles.pagination}
|
||||
pageCount={pageCount}
|
||||
currentPage={page}
|
||||
onPageChange={(e, n) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(n);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.dividerFrame}>
|
||||
<Section paddingBlockStart="condensed" paddingBlockEnd="none">
|
||||
<Stack justifyContent="center" padding="none">
|
||||
<Image
|
||||
src="/media/brand-divider-copilot-jumping.webp"
|
||||
alt=""
|
||||
width={1738}
|
||||
height={196}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ display: "block", width: "100%", height: "auto" }}
|
||||
/>
|
||||
</Stack>
|
||||
</Section>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
type LearningIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function LearningIcon({ size = 64 }: LearningIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={(size * 64) / 69}
|
||||
height={size}
|
||||
viewBox="0 0 64 69"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Learning resources"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 1C5.64625 1 0.5 6.14625 0.5 12.5C0.5 17.5887 3.79187 21.8869 8.36312 23.4106C8.93812 23.5112 9.15375 23.1662 9.15375 22.8644C9.15375 22.5912 9.13938 21.6856 9.13938 20.7225C6.25 21.2544 5.5025 20.0181 5.2725 19.3712C5.14313 19.0406 4.5825 18.02 4.09375 17.7469C3.69125 17.5312 3.11625 16.9994 4.07938 16.985C4.985 16.9706 5.63188 17.8187 5.8475 18.1637C6.8825 19.9031 8.53563 19.4144 9.19688 19.1125C9.2975 18.365 9.59938 17.8619 9.93 17.5744C7.37125 17.2869 4.6975 16.295 4.6975 11.8962C4.6975 10.6456 5.14312 9.61062 5.87625 8.80562C5.76125 8.51812 5.35875 7.33937 5.99125 5.75812C5.99125 5.75812 6.95438 5.45625 9.15375 6.93687C10.0738 6.67812 11.0513 6.54875 12.0288 6.54875C13.0063 6.54875 13.9838 6.67812 14.9038 6.93687C17.1031 5.44187 18.0662 5.75812 18.0662 5.75812C18.6987 7.33937 18.2962 8.51812 18.1812 8.80562C18.9144 9.61062 19.36 10.6312 19.36 11.8962C19.36 16.3094 16.6719 17.2869 14.1131 17.5744C14.53 17.9337 14.8894 18.6237 14.8894 19.7019C14.8894 21.24 14.875 22.4762 14.875 22.8644C14.875 23.1662 15.0906 23.5256 15.6656 23.4106C20.2081 21.8869 23.5 17.5744 23.5 12.5C23.5 6.14625 18.3538 1 12 1Z"
|
||||
fill="var(--brand-color-text-default)"
|
||||
/>
|
||||
<path
|
||||
d="M32 23C32.7363 23 33.3328 23.5968 33.333 24.333V51.4863L33.4385 51.3994C36.0742 49.2031 39.3972 48 42.8281 48H54.667C55.4031 48.0002 55.9998 48.5969 56 49.333C56 50.0693 55.4032 50.6658 54.667 50.666H42.8281C40.0211 50.666 37.302 51.6504 35.1455 53.4473L32.8535 55.3574C32.8218 55.3839 32.7885 55.4076 32.7549 55.4307C32.75 55.434 32.7451 55.4372 32.7402 55.4404C32.7208 55.4534 32.7006 55.4647 32.6807 55.4766C32.6697 55.4831 32.6595 55.4909 32.6484 55.4971C32.6428 55.5002 32.6375 55.5038 32.6318 55.5068C32.628 55.5089 32.624 55.5107 32.6201 55.5127C32.6003 55.5231 32.5798 55.5317 32.5596 55.541C32.5471 55.5468 32.535 55.5532 32.5225 55.5586C32.5207 55.5593 32.5193 55.5608 32.5176 55.5615C32.5124 55.5637 32.5071 55.5653 32.502 55.5674C32.4885 55.5728 32.4746 55.577 32.4609 55.582C32.4414 55.5893 32.4222 55.5972 32.4023 55.6035C32.3997 55.6044 32.3972 55.6056 32.3945 55.6064C32.3877 55.6086 32.3808 55.6103 32.374 55.6123C32.3702 55.6134 32.3662 55.6141 32.3623 55.6152C32.3403 55.6214 32.3181 55.6268 32.2959 55.6318C32.2851 55.6343 32.2745 55.6375 32.2637 55.6396C32.2598 55.6404 32.2558 55.6409 32.252 55.6416C32.2131 55.6491 32.1741 55.6562 32.1348 55.6602C32.1309 55.6605 32.1269 55.6598 32.123 55.6602C32.1117 55.6612 32.1003 55.6614 32.0889 55.6621C32.0592 55.6641 32.0297 55.666 32 55.666C31.9703 55.666 31.9408 55.6641 31.9111 55.6621C31.8994 55.6613 31.8877 55.6612 31.876 55.6602C31.8524 55.658 31.8291 55.6538 31.8057 55.6504C31.7845 55.6473 31.7632 55.6448 31.7422 55.6406C31.7409 55.6404 31.7395 55.6409 31.7383 55.6406C31.7171 55.6364 31.6966 55.6302 31.6758 55.625C31.6582 55.6206 31.6405 55.6165 31.623 55.6113C31.6172 55.6096 31.6113 55.6082 31.6055 55.6064C31.5985 55.6043 31.5919 55.6009 31.585 55.5986C31.5538 55.5884 31.5227 55.5779 31.4922 55.5654C31.4906 55.5648 31.4889 55.5642 31.4873 55.5635C31.4857 55.5628 31.484 55.5622 31.4824 55.5615C31.4761 55.5589 31.4701 55.5555 31.4639 55.5527C31.4332 55.5393 31.4027 55.5256 31.373 55.5098C31.3594 55.5025 31.3464 55.4941 31.333 55.4863C31.308 55.4719 31.2829 55.4576 31.2588 55.4414C31.2534 55.4378 31.2485 55.4334 31.2432 55.4297C31.2102 55.4069 31.1776 55.3834 31.1465 55.3574L28.8545 53.4473C26.698 51.6504 23.9789 50.666 21.1719 50.666H9.33301C8.59678 50.6658 8 50.0693 8 49.333C8.00018 48.5969 8.59689 48.0002 9.33301 48H21.1719C24.6028 48 27.9258 49.2031 30.5615 51.3994L30.667 51.4863V24.333C30.6672 23.5968 31.2637 23 32 23Z"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
<path
|
||||
d="M1.3335 25.3333V54.6666C1.3335 56.1394 2.52741 57.3333 4.00016 57.3333H17.7918C19.9718 57.3333 21.9743 58.5348 23.0002 60.4583C23.6157 61.6124 24.8172 62.3333 26.1252 62.3333H37.8752C39.1831 62.3333 40.3846 61.6124 41.0002 60.4583C42.026 58.5348 44.0285 57.3333 46.2085 57.3333H60.0002C61.4729 57.3333 62.6668 56.1394 62.6668 54.6666V7.33329C62.6668 5.86053 61.4729 4.66663 60.0002 4.66663H47.3335C39.0002 4.66663 33.3335 9.30283 32.0002 15.3333C31.1196 12.0798 29.6491 9.72241 27.555 7.99996"
|
||||
stroke="var(--brand-color-text-default)"
|
||||
strokeWidth="2.66667"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { MarkGithubIcon } from "@primer/octicons-react";
|
||||
import { Box, Button, ThemeProvider, useTheme } from "@primer/react-brand";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { contributorsTotal as siteContributorsTotal } from "../../lib/site-data";
|
||||
import { ContributorsNavButton } from "./ContributorsNavButton";
|
||||
import { LanguageSelect } from "./LanguageSelect";
|
||||
import { LargeFooter } from "./LargeFooter";
|
||||
import { SkipLink } from "./SkipLink";
|
||||
import { TopNav } from "./TopNav";
|
||||
import { TopNavSearch } from "./TopNavSearch";
|
||||
import { getAwesomeCopilotNavLinks, type AwesomeCopilotPage } from "./navigation";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
|
||||
const CONTRIBUTING_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md";
|
||||
|
||||
export type PageShellProps = {
|
||||
/** Scoped CSS module for the page, providing the topBar/subNav class names. */
|
||||
styles: Record<string, string | undefined>;
|
||||
/** Catalog page to mark as current in the nav. */
|
||||
currentPage?: AwesomeCopilotPage;
|
||||
/** Site-wide search index, injected from build-time data. */
|
||||
searchIndex?: SearchItem[];
|
||||
/** Live contributor count for the nav button. */
|
||||
contributorsTotal?: number;
|
||||
searchAriaLabel?: string;
|
||||
contributorsCurrent?: boolean;
|
||||
/**
|
||||
* Pages that scroll inside their own element (the detail/article chassis) must
|
||||
* render the footer *inside* that scroll region, otherwise it sits outside the
|
||||
* scrolling box and stays pinned over the content. Those pages opt out here
|
||||
* and render `<LargeFooter />` themselves.
|
||||
*/
|
||||
renderFooter?: boolean;
|
||||
/**
|
||||
* Only pages that actually have a mirrored translation should offer a
|
||||
* language switch. Defaults to false since most of the site is
|
||||
* English-only; opt in per page.
|
||||
*/
|
||||
showLanguageSelect?: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared chrome for every page: skip link, the combined top navigation
|
||||
* (brand mark, Resources menu, Learning Hub tab, search, contributors, contribute),
|
||||
* the `<main>` landmark, and the footer.
|
||||
*
|
||||
* The prototype repeated this block verbatim at the top of each page; it is
|
||||
* extracted here so pages only own their content.
|
||||
*/
|
||||
export function PageShell({
|
||||
styles,
|
||||
currentPage,
|
||||
searchIndex = [],
|
||||
contributorsTotal = siteContributorsTotal,
|
||||
searchAriaLabel = "Search the library",
|
||||
contributorsCurrent = false,
|
||||
renderFooter = true,
|
||||
showLanguageSelect = false,
|
||||
children,
|
||||
}: PageShellProps) {
|
||||
return (
|
||||
<ThemeProvider colorMode="auto">
|
||||
<PageShellBody
|
||||
styles={styles}
|
||||
currentPage={currentPage}
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
searchAriaLabel={searchAriaLabel}
|
||||
contributorsCurrent={contributorsCurrent}
|
||||
renderFooter={renderFooter}
|
||||
showLanguageSelect={showLanguageSelect}
|
||||
>
|
||||
{children}
|
||||
</PageShellBody>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner shell, rendered beneath the ThemeProvider so it can read the resolved
|
||||
* colour mode. The prototype mirrors that mode onto `data-mode`, which its CSS
|
||||
* modules key their light/dark treatments off.
|
||||
*/
|
||||
function PageShellBody({
|
||||
styles,
|
||||
currentPage,
|
||||
searchIndex = [],
|
||||
contributorsTotal = siteContributorsTotal,
|
||||
searchAriaLabel = "Search the library",
|
||||
contributorsCurrent = false,
|
||||
renderFooter = true,
|
||||
showLanguageSelect = false,
|
||||
children,
|
||||
}: PageShellProps) {
|
||||
const { colorMode } = useTheme();
|
||||
const subNavLinks = getAwesomeCopilotNavLinks(pageHref, currentPage);
|
||||
const contributorsHref = pageHref("contributors");
|
||||
|
||||
return (
|
||||
<Box className={styles.page} backgroundColor="default" data-mode={colorMode}>
|
||||
<SkipLink />
|
||||
<header className={styles.topBar}>
|
||||
<nav className={styles.topBarInner} aria-label="Primary">
|
||||
<a href={pageHref()} className={styles.subNavTitle}>
|
||||
<MarkGithubIcon size={24} />
|
||||
Awesome GitHub Copilot
|
||||
</a>
|
||||
<TopNav
|
||||
styles={styles}
|
||||
links={subNavLinks}
|
||||
contributorsHref={contributorsHref}
|
||||
contributorsTotal={contributorsTotal}
|
||||
searchIndex={searchIndex}
|
||||
contributorsCurrent={contributorsCurrent}
|
||||
searchAriaLabel={searchAriaLabel}
|
||||
showLanguageSelect={showLanguageSelect}
|
||||
/>
|
||||
<div className={styles.topBarActions}>
|
||||
<TopNavSearch
|
||||
index={searchIndex}
|
||||
styles={styles}
|
||||
inputAriaLabel={searchAriaLabel}
|
||||
/>
|
||||
<ContributorsNavButton
|
||||
href={contributorsHref}
|
||||
current={contributorsCurrent}
|
||||
total={contributorsTotal}
|
||||
/>
|
||||
{showLanguageSelect && <LanguageSelect />}
|
||||
<Button as="a" href={CONTRIBUTING_URL} variant="subtle" size="small">
|
||||
Contribute
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content" tabIndex={-1}>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{renderFooter ? <LargeFooter /> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { MarkGithubIcon, PlusIcon } from "@primer/octicons-react";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
ActionMenu,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Grid,
|
||||
Heading,
|
||||
Label,
|
||||
Text,
|
||||
Token,
|
||||
useTheme,
|
||||
} from "@primer/react-brand";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
import type { ExternalSource } from "../../lib/external-source";
|
||||
import { DetailChassis, type DetailSibling } from "./DetailChassis";
|
||||
import { ResourceMeta } from "./ResourceMeta";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import gridStyles from "./styles/plugins.module.css";
|
||||
import styles from "./styles/dotnet-upgrade.module.css";
|
||||
|
||||
/** A bundled agent / hook / skill / extension inside a plugin. */
|
||||
export type PluginIncludedItem = {
|
||||
kind: string;
|
||||
path?: string;
|
||||
title?: string | null;
|
||||
/** Absolute site path to the item's own detail page, when one exists. */
|
||||
detailUrl?: string | null;
|
||||
};
|
||||
|
||||
export type PluginDetailItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
version?: string | null;
|
||||
tags?: string[];
|
||||
itemCount?: number;
|
||||
items?: PluginIncludedItem[];
|
||||
external?: boolean;
|
||||
repository?: string | null;
|
||||
homepage?: string | null;
|
||||
license?: string | null;
|
||||
author?: { name?: string; url?: string } | null;
|
||||
source?: ExternalSource | null;
|
||||
};
|
||||
|
||||
export type PluginDetailProps = {
|
||||
item: PluginDetailItem;
|
||||
/** Rendered, sanitized README with heading ids already stamped. */
|
||||
markdownHtml: string;
|
||||
/** GitHub URL for the plugin source (repo tree, or the external repo). */
|
||||
githubUrl: string;
|
||||
/** Copilot CLI command that installs this plugin. */
|
||||
installCommand: string;
|
||||
/** `ghapp://` deep link that installs this plugin into the Copilot app. */
|
||||
appInstallUrl: string;
|
||||
lastUpdated?: string | null;
|
||||
previous?: DetailSibling;
|
||||
next?: DetailSibling;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
};
|
||||
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
agent: "Agent",
|
||||
hook: "Hook",
|
||||
skill: "Skill",
|
||||
extension: "Extension",
|
||||
instruction: "Instruction",
|
||||
mcp: "MCP server",
|
||||
prompt: "Prompt",
|
||||
};
|
||||
|
||||
const kindLabel = (kind: string) =>
|
||||
KIND_LABELS[kind] ?? kind.charAt(0).toUpperCase() + kind.slice(1);
|
||||
|
||||
const GITHUB_TREE_BASE = "https://github.com/github/awesome-copilot/tree/main";
|
||||
const GITHUB_BLOB_BASE = "https://github.com/github/awesome-copilot/blob/main";
|
||||
|
||||
/** Fall back to the trailing path segment when an item carries no title. */
|
||||
function itemTitle(item: PluginIncludedItem): string {
|
||||
if (item.title) return item.title;
|
||||
const segments = (item.path ?? "")
|
||||
.replace(/\/+$/, "")
|
||||
.split("/")
|
||||
.filter(Boolean);
|
||||
const last = segments[segments.length - 1] ?? "";
|
||||
return last.replace(/\.[^.]+$/, "") || kindLabel(item.kind);
|
||||
}
|
||||
|
||||
/** Resolve unresolved plugin contents to their specific repository source. */
|
||||
function itemSourceUrl(item: PluginIncludedItem, fallback: string): string {
|
||||
const itemPath = item.path?.replace(/^\.?\//, "").replace(/\/+$/, "");
|
||||
if (!itemPath) return fallback;
|
||||
const base = /\.[^/]+$/.test(itemPath)
|
||||
? GITHUB_BLOB_BASE
|
||||
: GITHUB_TREE_BASE;
|
||||
return `${base}/${itemPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail route for a plugin.
|
||||
*
|
||||
* Differs from the other resource detail pages in three ways: the plugin's
|
||||
* bundled contents are rendered as a card grid linking to each item's own
|
||||
* detail page, installation deep-links into the Copilot app (with the CLI
|
||||
* command offered as a secondary path), and externally-hosted plugins carry
|
||||
* provenance badges.
|
||||
*/
|
||||
export function PluginDetail({
|
||||
item,
|
||||
markdownHtml,
|
||||
githubUrl,
|
||||
installCommand,
|
||||
appInstallUrl,
|
||||
lastUpdated,
|
||||
previous,
|
||||
next,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
}: PluginDetailProps) {
|
||||
const { colorMode } = useTheme();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const copyTimer = React.useRef<number | null>(null);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(installCommand);
|
||||
setCopied(true);
|
||||
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
|
||||
copyTimer.current = window.setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
}, [installCommand]);
|
||||
|
||||
const includedItems = item.items ?? [];
|
||||
const hasContents = includedItems.length > 0;
|
||||
|
||||
// Installing straight into the Copilot app is the primary path, so it is the
|
||||
// split button's default action. The CLI command stays available behind the
|
||||
// menu for people working in a terminal or on a machine without the app.
|
||||
const install = (
|
||||
<>
|
||||
<ActionMenu mode="split-button" menuAlignment="start">
|
||||
<ActionMenu.Button
|
||||
as="a"
|
||||
href={appInstallUrl}
|
||||
variant="primary"
|
||||
leadingVisual={PlusIcon}
|
||||
>
|
||||
Install
|
||||
</ActionMenu.Button>
|
||||
<ActionMenu.Overlay aria-label={`Install the ${item.name} plugin`}>
|
||||
<ActionMenu.Item as="a" href={appInstallUrl}>
|
||||
Install in Copilot app
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item onClick={handleCopy}>
|
||||
{copied ? "Copied CLI command" : "Copy CLI install command"}
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
<Button
|
||||
as="a"
|
||||
href={githubUrl}
|
||||
variant="secondary"
|
||||
className={styles.iconButton}
|
||||
aria-label={`View the ${item.name} plugin on GitHub`}
|
||||
>
|
||||
<MarkGithubIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
// Provenance sits in the hero: whether the plugin ships from this repository
|
||||
// or from a third-party repo is the first thing a reader needs to know.
|
||||
const heroExtras = (
|
||||
<div className={styles.metaValues} role="group" aria-label="Plugin provenance">
|
||||
<Label color={item.external ? "purple" : "green"} size="medium">
|
||||
{item.external ? "External plugin" : "Built-in"}
|
||||
</Label>
|
||||
{item.version ? <Token>{`v${item.version}`}</Token> : null}
|
||||
{item.author?.name ? <Token>{item.author.name}</Token> : null}
|
||||
{item.license ? <Token>{item.license}</Token> : null}
|
||||
{item.external && item.source?.repo ? (
|
||||
<Token>{item.source.repo}</Token>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailChassis
|
||||
title={item.name}
|
||||
description={item.description}
|
||||
breadcrumbs={[
|
||||
{ label: "Plugins", href: pageHref("plugins") },
|
||||
{ label: item.name },
|
||||
]}
|
||||
install={install}
|
||||
heroExtras={heroExtras}
|
||||
sidebar={
|
||||
<ResourceMeta
|
||||
kicker="Plugin details"
|
||||
groups={[
|
||||
{ label: "Version", items: item.version ? [item.version] : [] },
|
||||
{
|
||||
label: "Source",
|
||||
items: [item.external ? "External" : "Built-in"],
|
||||
},
|
||||
{ label: "License", items: item.license ? [item.license] : [] },
|
||||
{ label: "Tags", items: item.tags ?? [] },
|
||||
]}
|
||||
author={item.author?.name ?? null}
|
||||
lastUpdated={lastUpdated}
|
||||
sourceUrl={githubUrl}
|
||||
/>
|
||||
}
|
||||
previous={previous}
|
||||
next={next}
|
||||
currentPage="plugins"
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
>
|
||||
{item.external ? (
|
||||
<section className={styles.articleSection}>
|
||||
<div className={styles.proTip}>
|
||||
<div className={styles.proTipHeader}>
|
||||
<span className={styles.proTipTitle}>
|
||||
Maintained outside this repository
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.proTipBody}>
|
||||
<p className={styles.proTipLead}>
|
||||
{item.author?.name
|
||||
? `${item.name} is published by ${item.author.name}`
|
||||
: `${item.name} is published by a third party`}
|
||||
{item.source?.repo ? ` from ${item.source.repo}` : ""}
|
||||
{item.source?.sha || item.source?.ref
|
||||
? `, pinned to ${item.source.sha ?? item.source.ref}`
|
||||
: ""}
|
||||
. Review the source before installing.
|
||||
</p>
|
||||
<p>
|
||||
<a className={styles.proTipLink} href={githubUrl}>
|
||||
View the source repository
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{hasContents ? (
|
||||
<section className={styles.articleSection} id="plugin-contents">
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
What’s included
|
||||
</Heading>
|
||||
<Box className={gridStyles.gridFrame} data-mode={colorMode}>
|
||||
<Box className={gridStyles.gridContent}>
|
||||
<Grid
|
||||
className={gridStyles.threeUp}
|
||||
columnGap="none"
|
||||
rowGap="none"
|
||||
enableGutters={false}
|
||||
>
|
||||
{includedItems.map((included, index) => {
|
||||
const title = itemTitle(included);
|
||||
return (
|
||||
<Grid.Column
|
||||
key={`${included.kind}-${included.path ?? title}-${index}`}
|
||||
span={{ xsmall: 12, medium: 6, large: 6 }}
|
||||
className={gridStyles.col}
|
||||
>
|
||||
<Box
|
||||
className={clsx(
|
||||
gridStyles.item,
|
||||
included.detailUrl && gridStyles.itemHover,
|
||||
)}
|
||||
>
|
||||
<Card
|
||||
href={
|
||||
included.detailUrl
|
||||
? pageHref(included.detailUrl)
|
||||
: itemSourceUrl(included, githubUrl)
|
||||
}
|
||||
fullWidth
|
||||
ctaVariant="none"
|
||||
backgroundColor="none"
|
||||
className={gridStyles.card}
|
||||
>
|
||||
<Card.Heading as="h3">{title}</Card.Heading>
|
||||
<Card.Description>
|
||||
<span className={gridStyles.cardDescText}>
|
||||
{included.path ?? kindLabel(included.kind)}
|
||||
</span>
|
||||
</Card.Description>
|
||||
</Card>
|
||||
<div className={gridStyles.cardMeta}>
|
||||
<Token>{kindLabel(included.kind)}</Token>
|
||||
</div>
|
||||
</Box>
|
||||
</Grid.Column>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{markdownHtml ? (
|
||||
<section className={styles.articleSection}>
|
||||
<div dangerouslySetInnerHTML={{ __html: markdownHtml }} />
|
||||
</section>
|
||||
) : (
|
||||
<section className={styles.articleSection}>
|
||||
<Text as="p" variant="muted">
|
||||
This plugin does not ship a README in this repository.
|
||||
{item.external ? " Documentation lives in its source repo." : ""}
|
||||
</Text>
|
||||
</section>
|
||||
)}
|
||||
</DetailChassis>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
import { ChevronDownIcon, MarkGithubIcon } from "@primer/octicons-react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CTABanner,
|
||||
Card,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
Grid,
|
||||
Heading,
|
||||
Pagination,
|
||||
Section,
|
||||
Stack,
|
||||
Text,
|
||||
useTheme,
|
||||
} from "@primer/react-brand";
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { externalRepoUrl, type ExternalSource } from "../../lib/external-source";
|
||||
import { PageShell } from "./PageShell";
|
||||
import { CatalogSortControl, CATALOG_SORT_OPTIONS } from "./CatalogSortControl";
|
||||
import { PluginsIcon } from "./PluginsIcon";
|
||||
import { daysSince, toggleValue } from "./catalogFilters";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/plugins.module.css";
|
||||
import { getScrollBehavior } from "./scrollBehavior";
|
||||
|
||||
const CONTRIBUTE_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/docs/README.plugins.md#how-to-contribute";
|
||||
|
||||
const REQUEST_URL = "https://github.com/github/awesome-copilot/issues/new";
|
||||
|
||||
const GITHUB_TREE = "https://github.com/github/awesome-copilot/tree/main";
|
||||
|
||||
type SortMode = "az" | "newest";
|
||||
|
||||
/** A plugin record as emitted into public/data/plugins.json. */
|
||||
export type PluginItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
version?: string | null;
|
||||
tags?: string[];
|
||||
itemCount: number;
|
||||
lastUpdated?: string;
|
||||
external?: boolean;
|
||||
repository?: string | null;
|
||||
homepage?: string | null;
|
||||
author?: { name: string; url?: string } | null;
|
||||
source?: ExternalSource | null;
|
||||
};
|
||||
|
||||
const sourceUrlOf = (plugin: PluginItem) =>
|
||||
plugin.external
|
||||
? externalRepoUrl(plugin.source, [
|
||||
plugin.repository,
|
||||
plugin.homepage,
|
||||
GITHUB_TREE,
|
||||
])
|
||||
: `${GITHUB_TREE}/${plugin.path}`;
|
||||
|
||||
const sizeBuckets: { label: string; test: (n: number) => boolean }[] = [
|
||||
{ label: "Single item", test: (n) => n === 1 },
|
||||
{ label: "2–4 items", test: (n) => n >= 2 && n <= 4 },
|
||||
{ label: "5+ items", test: (n) => n >= 5 },
|
||||
];
|
||||
|
||||
type FilterGroupId = "source" | "category" | "size";
|
||||
|
||||
type FilterState = Record<FilterGroupId, string[]>;
|
||||
|
||||
const emptyFilters: FilterState = { source: [], category: [], size: [] };
|
||||
|
||||
/**
|
||||
* Long facets collapse behind a "Show N more" toggle, matching the prototype's
|
||||
* extensions page. Real data produces far longer option lists than the
|
||||
* prototype's hardcoded arrays, so every catalog needs this.
|
||||
*/
|
||||
const FILTER_COLLAPSE_LIMIT = 10;
|
||||
|
||||
const sourceOf = (plugin: PluginItem) =>
|
||||
plugin.external ? "External" : "Built-in";
|
||||
|
||||
const sizeLabelOf = (plugin: PluginItem) =>
|
||||
sizeBuckets.find((bucket) => bucket.test(plugin.itemCount))?.label ?? null;
|
||||
|
||||
/**
|
||||
* The prototype hardcoded twelve category options. Real data carries 585 tags,
|
||||
* so the category group keeps the same shape by taking the most-used tags that
|
||||
* the build-time filter list offers.
|
||||
*/
|
||||
const CATEGORY_LIMIT = 12;
|
||||
|
||||
function categoryOptions(plugins: PluginItem[], tags: string[]): string[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const plugin of plugins) {
|
||||
for (const tag of plugin.tags ?? []) {
|
||||
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return tags
|
||||
.filter((tag) => counts.has(tag))
|
||||
.sort((a, b) => (counts.get(b) ?? 0) - (counts.get(a) ?? 0) || a.localeCompare(b))
|
||||
.slice(0, CATEGORY_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Plugins catalog, ported from the design prototype's `plugins.tsx`. The
|
||||
* prototype's hardcoded array is replaced by build-time data; the layout,
|
||||
* filters, and interactions are unchanged.
|
||||
*/
|
||||
export function PluginsCatalog({
|
||||
plugins,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
tags = [],
|
||||
}: {
|
||||
plugins: PluginItem[];
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
tags?: string[];
|
||||
}) {
|
||||
const { colorMode } = useTheme();
|
||||
const [sortMode, setSortMode] = useState<SortMode>("az");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const previousPage = useRef(currentPage);
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
|
||||
const [filters, setFilters] = useState<FilterState>(emptyFilters);
|
||||
const [expandedGroups, setExpandedGroups] = useState<
|
||||
Record<FilterGroupId, boolean>
|
||||
>({ source: false, category: false, size: false });
|
||||
const toggleGroupExpanded = (groupId: FilterGroupId) => {
|
||||
setExpandedGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] }));
|
||||
};
|
||||
|
||||
const filterGroups = useMemo(
|
||||
(): { id: FilterGroupId; label: string; options: string[] }[] => [
|
||||
{ id: "source", label: "Source", options: ["Built-in", "External"] },
|
||||
{
|
||||
id: "category",
|
||||
label: "Category",
|
||||
options: categoryOptions(plugins, tags),
|
||||
},
|
||||
{ id: "size", label: "Contents", options: sizeBuckets.map((b) => b.label) },
|
||||
],
|
||||
[plugins, tags],
|
||||
);
|
||||
|
||||
const sortedPlugins = useMemo(() => {
|
||||
const copy = plugins.filter((plugin) => {
|
||||
const sourceOk =
|
||||
filters.source.length === 0 || filters.source.includes(sourceOf(plugin));
|
||||
const categoryOk =
|
||||
filters.category.length === 0 ||
|
||||
filters.category.some((tag) => (plugin.tags ?? []).includes(tag));
|
||||
const size = sizeLabelOf(plugin);
|
||||
const sizeOk =
|
||||
filters.size.length === 0 ||
|
||||
(size !== null && filters.size.includes(size));
|
||||
return sourceOk && categoryOk && sizeOk;
|
||||
});
|
||||
if (sortMode === "newest") {
|
||||
copy.sort(
|
||||
(a, b) => daysSince(a.lastUpdated ?? undefined) - daysSince(b.lastUpdated ?? undefined),
|
||||
);
|
||||
} else {
|
||||
copy.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
return copy;
|
||||
}, [plugins, sortMode, filters]);
|
||||
|
||||
const toggleFilter = (groupId: FilterGroupId, option: string) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[groupId]: toggleValue(prev[groupId], option),
|
||||
}));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters(emptyFilters);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const activeFilterCount =
|
||||
filters.source.length + filters.category.length + filters.size.length;
|
||||
const hasActiveFilters = activeFilterCount > 0;
|
||||
|
||||
const pageSize = 6;
|
||||
const pageCount = Math.max(1, Math.ceil(sortedPlugins.length / pageSize));
|
||||
const page = Math.min(currentPage, pageCount);
|
||||
const visiblePlugins = sortedPlugins.slice(
|
||||
(page - 1) * pageSize,
|
||||
page * pageSize,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousPage.current === currentPage) return;
|
||||
previousPage.current = currentPage;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const catalog = document.getElementById("catalog");
|
||||
if (!catalog) return;
|
||||
catalog.scrollIntoView({ behavior: getScrollBehavior(), block: "start" });
|
||||
catalog.focus({ preventScroll: true });
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [currentPage]);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
styles={styles}
|
||||
currentPage="plugins"
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
searchAriaLabel="Search plugins"
|
||||
>
|
||||
<Box className={styles.hero}>
|
||||
<div className={styles.heroInner}>
|
||||
<span className={styles.heroIcon} aria-hidden="true">
|
||||
<PluginsIcon size={36} />
|
||||
</span>
|
||||
<Heading as="h1" size="3" className={styles.heroHeading}>
|
||||
Plugins
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted" className={styles.heroText}>
|
||||
Curated plugins of agents, hooks, and skills for specific workflows
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Section
|
||||
id="catalog"
|
||||
tabIndex={-1}
|
||||
paddingBlockStart="none"
|
||||
paddingBlockEnd="none"
|
||||
>
|
||||
<Box className={styles.catalog}>
|
||||
<aside className={styles.filterNav} aria-label="Filter plugins">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filterToggle}
|
||||
aria-expanded={mobileFiltersOpen}
|
||||
onClick={() => setMobileFiltersOpen((open) => !open)}
|
||||
>
|
||||
<span>
|
||||
Filters{hasActiveFilters ? ` (${activeFilterCount})` : ""}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
size={16}
|
||||
className={clsx(
|
||||
styles.filterToggleChevron,
|
||||
mobileFiltersOpen && styles.filterToggleChevronOpen,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.filterBody,
|
||||
!mobileFiltersOpen && styles.filterBodyCollapsed,
|
||||
)}
|
||||
>
|
||||
{filterGroups.map((group) => {
|
||||
const isExpandable =
|
||||
group.options.length > FILTER_COLLAPSE_LIMIT;
|
||||
const expanded = expandedGroups[group.id];
|
||||
const visibleOptions =
|
||||
isExpandable && !expanded
|
||||
? group.options.slice(0, FILTER_COLLAPSE_LIMIT)
|
||||
: group.options;
|
||||
return (
|
||||
<div className={styles.filterGroup} key={group.id}>
|
||||
<Text as="h2" size="100" className={styles.filterHeading}>
|
||||
{group.label}
|
||||
</Text>
|
||||
<div className={styles.filterOptions}>
|
||||
{visibleOptions.map((option) => (
|
||||
<div className={styles.filterOption} key={option}>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={filters[group.id].includes(option)}
|
||||
onChange={() => toggleFilter(group.id, option)}
|
||||
/>
|
||||
<FormControl.Label>{option}</FormControl.Label>
|
||||
</FormControl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{isExpandable ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.showMoreButton}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => toggleGroupExpanded(group.id)}
|
||||
>
|
||||
{expanded
|
||||
? "Show less"
|
||||
: `Show ${group.options.length - FILTER_COLLAPSE_LIMIT} more`}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hasActiveFilters ? (
|
||||
<div className={styles.filterActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
hasArrow={false}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear all ({activeFilterCount})
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Box className={styles.catalogMain}>
|
||||
<Box className={styles.toolbar}>
|
||||
<CatalogSortControl
|
||||
ariaLabel="Sort plugins"
|
||||
value={sortMode}
|
||||
options={CATALOG_SORT_OPTIONS}
|
||||
styles={styles}
|
||||
onChange={(value) => {
|
||||
setSortMode(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box className={styles.gridFrame} data-mode={colorMode}>
|
||||
<Box className={styles.gridContent}>
|
||||
<Grid
|
||||
className={styles.threeUp}
|
||||
columnGap="none"
|
||||
rowGap="none"
|
||||
enableGutters={false}
|
||||
>
|
||||
{visiblePlugins.map((plugin) => (
|
||||
<Grid.Column
|
||||
key={plugin.id}
|
||||
span={{ xsmall: 12, medium: 6, large: 6 }}
|
||||
className={styles.col}
|
||||
>
|
||||
<Box className={clsx(styles.item, styles.itemHover)}>
|
||||
<Card
|
||||
href={pageHref(`plugin/${plugin.id}`)}
|
||||
fullWidth
|
||||
ctaVariant="none"
|
||||
backgroundColor="none"
|
||||
className={styles.card}
|
||||
>
|
||||
<Card.Heading as="h2">{plugin.name}</Card.Heading>
|
||||
<Card.Description>
|
||||
<span className={styles.cardDescText}>
|
||||
{plugin.description}
|
||||
</span>
|
||||
</Card.Description>
|
||||
</Card>
|
||||
<div className={styles.cardActions}>
|
||||
<Button
|
||||
as="a"
|
||||
href={sourceUrlOf(plugin)}
|
||||
variant="primary"
|
||||
leadingVisual={MarkGithubIcon}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View source
|
||||
</Button>
|
||||
</div>
|
||||
{plugin.author?.name ? (
|
||||
<Text
|
||||
as="p"
|
||||
size="100"
|
||||
className={clsx(
|
||||
styles.cardBadge,
|
||||
styles.cardBadgeAuthor,
|
||||
)}
|
||||
>
|
||||
{plugin.author.name}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Grid.Column>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{sortedPlugins.length === 0 ? (
|
||||
<Box className={styles.emptyState}>
|
||||
<Text as="p" variant="muted">
|
||||
No plugins match the selected filters.
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.paginationRow}>
|
||||
<Stack direction="horizontal" justifyContent="center" padding="none">
|
||||
<Pagination
|
||||
className={styles.pagination}
|
||||
pageCount={pageCount}
|
||||
currentPage={page}
|
||||
onPageChange={(e, n) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(n);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.ctaFrame}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.ctaFrameInner}>
|
||||
<CTABanner align="center" backgroundColor="subtle">
|
||||
<CTABanner.Heading>
|
||||
Can’t find the plugin you need?
|
||||
</CTABanner.Heading>
|
||||
<CTABanner.Description>
|
||||
This marketplace is community-built. Share a plugin you use
|
||||
<br />
|
||||
—or request one—to help developers ship faster with
|
||||
GitHub Copilot.
|
||||
</CTABanner.Description>
|
||||
<CTABanner.ButtonGroup>
|
||||
<Button as="a" href={CONTRIBUTE_URL}>
|
||||
Submit a plugin
|
||||
</Button>
|
||||
<Button as="a" href={REQUEST_URL} variant="secondary">
|
||||
Request a plugin
|
||||
</Button>
|
||||
</CTABanner.ButtonGroup>
|
||||
</CTABanner>
|
||||
</Box>
|
||||
</Section>
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
type PluginsIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function PluginsIcon({ size = 36 }: PluginsIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 96 96"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Plugins"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M26.6614 44V59C26.6614 67.8366 33.8248 75 42.6614 75H51.6614"
|
||||
stroke="var(--brand-color-text-default)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M60.3728 52V54H86.3728V52V50H60.3728V52ZM94.3728 60H92.3728V86H94.3728H96.3728V60H94.3728ZM86.3728 94V92H60.3728V94V96H86.3728V94ZM52.3728 86H54.3728V60H52.3728H50.3728V86H52.3728ZM60.3728 94V92C57.0591 92 54.3728 89.3137 54.3728 86H52.3728H50.3728C50.3728 91.5228 54.85 96 60.3728 96V94ZM94.3728 86H92.3728C92.3728 89.3137 89.6865 92 86.3728 92V94V96C91.8956 96 96.3728 91.5228 96.3728 86H94.3728ZM86.3728 52V54C89.6865 54 92.3728 56.6863 92.3728 60H94.3728H96.3728C96.3728 54.4772 91.8956 50 86.3728 50V52ZM60.3728 52V50C54.85 50 50.3728 54.4772 50.3728 60H52.3728H54.3728C54.3728 56.6863 57.0591 54 60.3728 54V52Z"
|
||||
fill="var(--brand-color-text-default)"
|
||||
/>
|
||||
<rect
|
||||
x="18.0001"
|
||||
y="35.9998"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="9"
|
||||
transform="rotate(-90 18.0001 35.9998)"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
<rect
|
||||
x="36.0001"
|
||||
y="35.9998"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="9"
|
||||
transform="rotate(-90 36.0001 35.9998)"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
<rect
|
||||
x="36.0001"
|
||||
y="18"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="9"
|
||||
transform="rotate(180 36.0001 18)"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
<rect
|
||||
x="54.0001"
|
||||
y="18"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="9"
|
||||
transform="rotate(180 54.0001 18)"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
<rect
|
||||
x="6.10352e-05"
|
||||
y="18"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="9"
|
||||
transform="rotate(-90 6.10352e-05 18)"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
<rect
|
||||
x="18.0001"
|
||||
y="36"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="9"
|
||||
transform="rotate(180 18.0001 36)"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { MarkGithubIcon } from "@primer/octicons-react";
|
||||
import { Link } from "@primer/react-brand";
|
||||
|
||||
import styles from "./styles/dotnet-upgrade.module.css";
|
||||
|
||||
export type ResourceMetaProps = {
|
||||
/** Kicker above the panel, e.g. "Agent details". */
|
||||
kicker: string;
|
||||
/** Chip groups: models, tools, applies-to globs, … */
|
||||
groups?: { label: string; items: string[] }[];
|
||||
/** Preformatted date string (see `formatLastUpdated`). */
|
||||
lastUpdated?: string | null;
|
||||
/** GitHub blob URL for the resource's source file. */
|
||||
sourceUrl?: string;
|
||||
/** Contributed-by attribution, when the resource carries one. */
|
||||
author?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sidebar panel listing a resource's metadata chips alongside the
|
||||
* author / last-updated / view-source affordances shared by every detail route.
|
||||
*/
|
||||
export function ResourceMeta({
|
||||
kicker,
|
||||
groups = [],
|
||||
lastUpdated,
|
||||
sourceUrl,
|
||||
author,
|
||||
}: ResourceMetaProps) {
|
||||
const visibleGroups = groups.filter((group) => group.items.length > 0);
|
||||
if (
|
||||
visibleGroups.length === 0 &&
|
||||
!lastUpdated &&
|
||||
!sourceUrl &&
|
||||
!author
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.sidebarSection}>
|
||||
<div className={styles.sidebarSummary}>
|
||||
<span className={styles.sidebarKicker}>{kicker}</span>
|
||||
</div>
|
||||
<div className={styles.sidebarSectionBody}>
|
||||
{visibleGroups.map((group) => (
|
||||
<div key={group.label} className={styles.metaGroup}>
|
||||
<span className={styles.metaLabel}>{group.label}</span>
|
||||
<div className={styles.metaValues}>
|
||||
{group.items.map((item) => (
|
||||
<span key={item} className={styles.metaChip}>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{author ? (
|
||||
<div className={styles.metaGroup}>
|
||||
<span className={styles.metaLabel}>Author</span>
|
||||
<div className={styles.metaValues}>
|
||||
<span className={styles.metaChip}>{author}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{lastUpdated ? (
|
||||
<div className={styles.metaGroup}>
|
||||
<span className={styles.metaLabel}>Last updated</span>
|
||||
<div className={styles.metaValues}>
|
||||
<span className={styles.metaChip}>{lastUpdated}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{sourceUrl ? (
|
||||
<div className={`${styles.metaGroup} ${styles.sourceMetaGroup}`}>
|
||||
<span className={styles.metaLabel}>Source</span>
|
||||
<div className={styles.metaValues}>
|
||||
<Link href={sourceUrl} size="small" className={styles.sourceLink}>
|
||||
<MarkGithubIcon size={16} />
|
||||
<span>View on GitHub</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { clsx } from "clsx";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
import styles from "./styles/learning-hub-copilot-app.module.css";
|
||||
|
||||
const DEFAULT_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!<>-_\\/[]{}=+*^?#";
|
||||
|
||||
type ScrambleCursor = "persist" | "fade" | "none";
|
||||
type ScrambleTrigger = "view" | "hover";
|
||||
|
||||
type ScrambleTextProps = {
|
||||
/** Final, resolved text that also serves as the accessible label. */
|
||||
text: string;
|
||||
/** Optional class applied to the wrapper. */
|
||||
className?: string;
|
||||
/** Character pool used while decoding. */
|
||||
chars?: string;
|
||||
/** Milliseconds each character stays scrambled before it locks in. */
|
||||
msPerChar?: number;
|
||||
/** When the decode plays: once on scroll into view, or on each hover. */
|
||||
trigger?: ScrambleTrigger;
|
||||
/**
|
||||
* Green square behaviour: rest just after the last letter ("persist"), fade
|
||||
* out once the word resolves ("fade"), or never render one ("none").
|
||||
*/
|
||||
cursor?: ScrambleCursor;
|
||||
};
|
||||
/**
|
||||
* GitHub Universe style "decode" reveal: the word starts as green random
|
||||
* glyphs, then resolves one letter at a time from left to right, the letters
|
||||
* ahead scrambling in green. An optional green square cursor can step along and
|
||||
* either rest at the end or fade out. Plays once on scroll into view or on each
|
||||
* hover, and honours prefers-reduced-motion.
|
||||
*/
|
||||
export function ScrambleText({
|
||||
text,
|
||||
className,
|
||||
chars = DEFAULT_CHARS,
|
||||
msPerChar = 55,
|
||||
trigger = "view",
|
||||
cursor = "persist",
|
||||
}: ScrambleTextProps) {
|
||||
const charsRef = useRef<HTMLSpanElement>(null);
|
||||
const cursorRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const charsEl = charsRef.current;
|
||||
if (!charsEl) return;
|
||||
const cursorEl = cursor === "none" ? null : cursorRef.current;
|
||||
|
||||
const spans = Array.from(
|
||||
charsEl.querySelectorAll<HTMLElement>("[data-final]"),
|
||||
);
|
||||
const randomChar = () => chars[Math.floor(Math.random() * chars.length)];
|
||||
const isSpace = (span: HTMLElement) => (span.dataset.final ?? "") === " ";
|
||||
const glyphClass = styles.scrambleGlyph;
|
||||
|
||||
// Lock each character box to its final width so scrambling never reflows.
|
||||
const metrics = spans.map((span) => ({
|
||||
left: span.offsetLeft,
|
||||
top: span.offsetTop,
|
||||
width: span.offsetWidth,
|
||||
height: span.offsetHeight,
|
||||
}));
|
||||
spans.forEach((span, i) => {
|
||||
span.style.width = `${metrics[i].width}px`;
|
||||
});
|
||||
|
||||
// Size the cursor and rest it just past the last glyph. On phones the
|
||||
// placeholder wraps onto two lines, so the square grows to the full line
|
||||
// height and follows the active line vertically; on wider screens it stays
|
||||
// a small square centred on the single line.
|
||||
const fontSize = parseFloat(getComputedStyle(charsEl).fontSize) || 16;
|
||||
const isNarrow =
|
||||
window.matchMedia?.("(max-width: 47.99rem)").matches ?? false;
|
||||
const side = fontSize * 0.62;
|
||||
const gap = fontSize * 0.18;
|
||||
const cursorH = isNarrow ? (metrics[0]?.height ?? fontSize) : side;
|
||||
const cursorY = (i: number) => {
|
||||
const m = metrics[i] ?? metrics[metrics.length - 1];
|
||||
return m ? charsEl.offsetTop + m.top + (m.height - cursorH) / 2 : 0;
|
||||
};
|
||||
const lastMetric = metrics[metrics.length - 1];
|
||||
const restX = (lastMetric ? lastMetric.left + lastMetric.width : 0) + gap;
|
||||
const restY = cursorY(metrics.length - 1);
|
||||
if (cursorEl) {
|
||||
cursorEl.style.width = `${side}px`;
|
||||
cursorEl.style.height = `${cursorH}px`;
|
||||
cursorEl.style.top = "0px";
|
||||
cursorEl.style.transform = `translate(${restX}px, ${restY}px)`;
|
||||
// Glide the square between letters instead of snapping, synced to the step.
|
||||
cursorEl.style.transition = `transform ${msPerChar}ms linear, opacity 150ms ease`;
|
||||
}
|
||||
|
||||
const finalize = () => {
|
||||
for (const span of spans) {
|
||||
span.textContent = span.dataset.final ?? "";
|
||||
span.classList.remove(glyphClass);
|
||||
}
|
||||
if (cursorEl) {
|
||||
if (cursor === "persist") {
|
||||
// Keep the green square resting after the final character.
|
||||
cursorEl.style.transform = `translate(${restX}px, ${restY}px)`;
|
||||
cursorEl.style.opacity = "1";
|
||||
} else {
|
||||
// "fade": the square disappears once the word has resolved.
|
||||
cursorEl.style.opacity = "0";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
|
||||
finalize();
|
||||
return;
|
||||
}
|
||||
|
||||
// Only real letters get a reveal step (spaces resolve for free).
|
||||
const revealOrder = spans
|
||||
.map((span, i) => (isSpace(span) ? -1 : i))
|
||||
.filter((i) => i >= 0);
|
||||
|
||||
const setScrambled = () => {
|
||||
for (const span of spans) {
|
||||
if (isSpace(span)) {
|
||||
span.textContent = " ";
|
||||
continue;
|
||||
}
|
||||
span.textContent = randomChar();
|
||||
span.classList.add(glyphClass);
|
||||
}
|
||||
};
|
||||
|
||||
let raf = 0;
|
||||
let startTime = 0;
|
||||
|
||||
const tick = (now: number) => {
|
||||
if (!startTime) startTime = now;
|
||||
const step = Math.floor((now - startTime) / msPerChar);
|
||||
|
||||
if (step >= revealOrder.length) {
|
||||
finalize();
|
||||
return;
|
||||
}
|
||||
|
||||
const revealIdx = revealOrder[step];
|
||||
|
||||
spans.forEach((span, i) => {
|
||||
if (isSpace(span)) {
|
||||
span.textContent = " ";
|
||||
return;
|
||||
}
|
||||
if (i < revealIdx) {
|
||||
span.textContent = span.dataset.final ?? "";
|
||||
span.classList.remove(glyphClass);
|
||||
} else {
|
||||
if (Math.random() < 0.5) span.textContent = randomChar();
|
||||
span.classList.add(glyphClass);
|
||||
}
|
||||
});
|
||||
|
||||
// Park the square at the boundary between resolved and scrambling text,
|
||||
// following the active line so it stays aligned when the phrase wraps.
|
||||
if (cursorEl) {
|
||||
cursorEl.style.transform = `translate(${metrics[revealIdx].left}px, ${cursorY(revealIdx)}px)`;
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
const play = () => {
|
||||
cancelAnimationFrame(raf);
|
||||
setScrambled();
|
||||
if (cursorEl) cursorEl.style.opacity = "1";
|
||||
startTime = 0;
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
if (trigger === "hover") {
|
||||
// Rest as the finished word; decode again on every hover.
|
||||
const onEnter = () => play();
|
||||
charsEl.addEventListener("mouseenter", onEnter);
|
||||
return () => {
|
||||
charsEl.removeEventListener("mouseenter", onEnter);
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}
|
||||
|
||||
// trigger === "view": start scrambled so the final text never flashes,
|
||||
// then decode once when it scrolls into view.
|
||||
setScrambled();
|
||||
let played = false;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting && !played) {
|
||||
played = true;
|
||||
play();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.6 },
|
||||
);
|
||||
observer.observe(charsEl);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [text, chars, msPerChar, trigger, cursor]);
|
||||
|
||||
// Group characters into non-breaking word spans (with the spaces between them
|
||||
// left as breakable boxes) so that when the phrase wraps it only ever breaks
|
||||
// between words, never mid-word. The animation still drives each character
|
||||
// span individually via the [data-final] lookup above.
|
||||
const charSpan = (ch: string, i: number) => (
|
||||
<span key={`c-${i}`} className={styles.scrambleChar} data-final={ch}>
|
||||
{ch}
|
||||
</span>
|
||||
);
|
||||
|
||||
const nodes: React.ReactNode[] = [];
|
||||
let word: React.ReactNode[] = [];
|
||||
let wordStart = 0;
|
||||
const flushWord = () => {
|
||||
if (word.length) {
|
||||
nodes.push(
|
||||
<span key={`w-${wordStart}`} className={styles.scrambleWord}>
|
||||
{word}
|
||||
</span>,
|
||||
);
|
||||
word = [];
|
||||
}
|
||||
};
|
||||
Array.from(text).forEach((ch, i) => {
|
||||
if (ch === " ") {
|
||||
flushWord();
|
||||
nodes.push(charSpan(ch, i));
|
||||
} else {
|
||||
if (word.length === 0) wordStart = i;
|
||||
word.push(charSpan(ch, i));
|
||||
}
|
||||
});
|
||||
flushWord();
|
||||
|
||||
return (
|
||||
<span className={clsx(styles.scrambleWrap, className)}>
|
||||
<span className={styles.scrambleSrOnly}>{text}</span>
|
||||
<span ref={charsRef} aria-hidden="true" className={styles.scrambleChars}>
|
||||
{nodes}
|
||||
</span>
|
||||
{cursor !== "none" && (
|
||||
<span
|
||||
ref={cursorRef}
|
||||
aria-hidden="true"
|
||||
className={styles.scrambleCursor}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
FileDirectoryIcon,
|
||||
MarkGithubIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
import React from "react";
|
||||
|
||||
import { Button, Text } from "@primer/react-brand";
|
||||
|
||||
import { DetailChassis, type DetailSibling } from "./DetailChassis";
|
||||
import { ResourceMeta } from "./ResourceMeta";
|
||||
import {
|
||||
SyntaxHighlightedCode,
|
||||
detectCodeLanguage,
|
||||
type CodeLanguage,
|
||||
} from "./SyntaxHighlightedCode";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/dotnet-upgrade.module.css";
|
||||
import fileStyles from "./styles/skill-files.module.css";
|
||||
|
||||
export type SkillFile = {
|
||||
/** Repo-relative path, e.g. `skills/my-skill/scripts/scan.py`. */
|
||||
path: string;
|
||||
/** Path relative to the skill folder, e.g. `scripts/scan.py`. */
|
||||
name: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export type SkillDetailItem = {
|
||||
id: string;
|
||||
name?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
skillFile: string;
|
||||
files?: SkillFile[];
|
||||
assets?: string[];
|
||||
hasAssets?: boolean;
|
||||
assetCount?: number;
|
||||
};
|
||||
|
||||
export type SkillDetailProps = {
|
||||
item: SkillDetailItem;
|
||||
/** Rendered, sanitized SKILL.md body with heading ids already stamped. */
|
||||
markdownHtml: string;
|
||||
/** GitHub tree URL for the skill folder. */
|
||||
githubUrl: string;
|
||||
/** `https://github.com/.../blob/main` — per-file links are built from this. */
|
||||
githubBlobBase: string;
|
||||
/** `https://raw.githubusercontent.com/.../main` — lazy file fetches. */
|
||||
rawBase: string;
|
||||
/** `gh skills install …` — skills have no VS Code install URI. */
|
||||
installCommand: string;
|
||||
rawMarkdown: string;
|
||||
lastUpdated?: string | null;
|
||||
previous?: DetailSibling;
|
||||
next?: DetailSibling;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
};
|
||||
|
||||
type FileKind = "markdown" | "code" | "image" | "text";
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"svg",
|
||||
"avif",
|
||||
]);
|
||||
|
||||
const CODE_LANGUAGES: Record<string, CodeLanguage> = {
|
||||
json: "json",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
ts: "tsx",
|
||||
tsx: "tsx",
|
||||
js: "tsx",
|
||||
jsx: "tsx",
|
||||
mjs: "tsx",
|
||||
cjs: "tsx",
|
||||
cs: "tsx",
|
||||
py: "bash",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
ps1: "bash",
|
||||
html: "markup",
|
||||
xml: "markup",
|
||||
svg: "markup",
|
||||
csproj: "markup",
|
||||
props: "markup",
|
||||
targets: "markup",
|
||||
bicep: "bash",
|
||||
};
|
||||
|
||||
function extensionOf(path: string): string {
|
||||
return path.split(".").pop()?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function kindOf(path: string): FileKind {
|
||||
const ext = extensionOf(path);
|
||||
if (IMAGE_EXTENSIONS.has(ext) && ext !== "svg") return "image";
|
||||
if (ext === "md" || ext === "markdown") return "markdown";
|
||||
if (ext in CODE_LANGUAGES) return "code";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function formatSize(bytes?: number): string | null {
|
||||
if (!bytes && bytes !== 0) return null;
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function encodeRepoPath(filePath: string): string {
|
||||
return filePath.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill detail route.
|
||||
*
|
||||
* A skill is a folder, not a file, so the content region is a file switcher
|
||||
* rather than a single article: `SKILL.md` is server-rendered into the page and
|
||||
* every bundled asset is lazily fetched from raw.githubusercontent.com on first
|
||||
* selection, then cached. Markdown assets render as rich markdown, recognised
|
||||
* source files are syntax highlighted with the prototype's highlighter, images
|
||||
* render inline, and anything else falls back to a monospace block.
|
||||
*
|
||||
* The `#file=<path>` deep-link convention from the previous file browser is
|
||||
* preserved so existing shared links keep working.
|
||||
*/
|
||||
export function SkillDetail({
|
||||
item,
|
||||
markdownHtml,
|
||||
githubUrl,
|
||||
githubBlobBase,
|
||||
rawBase,
|
||||
installCommand,
|
||||
rawMarkdown,
|
||||
lastUpdated,
|
||||
previous,
|
||||
next,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
}: SkillDetailProps) {
|
||||
const primaryPath = item.skillFile;
|
||||
const primaryName = primaryPath.split("/").pop() ?? "SKILL.md";
|
||||
|
||||
// SKILL.md always leads; the remaining bundle files follow in data order.
|
||||
const files = React.useMemo<SkillFile[]>(() => {
|
||||
const all = item.files ?? [];
|
||||
const primary = all.find((file) => file.path === primaryPath) ?? {
|
||||
path: primaryPath,
|
||||
name: primaryName,
|
||||
};
|
||||
return [primary, ...all.filter((file) => file.path !== primaryPath)];
|
||||
}, [item.files, primaryPath, primaryName]);
|
||||
|
||||
const [activePath, setActivePath] = React.useState(primaryPath);
|
||||
const [contents, setContents] = React.useState<Record<string, string>>({});
|
||||
const [status, setStatus] = React.useState<"idle" | "loading" | "error">(
|
||||
"idle",
|
||||
);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const active =
|
||||
files.find((file) => file.path === activePath) ?? files[0] ?? null;
|
||||
const isPrimary = active?.path === primaryPath;
|
||||
const kind = active ? kindOf(active.path) : "text";
|
||||
|
||||
// Honour `#file=<path>` on load and on later hash navigation.
|
||||
React.useEffect(() => {
|
||||
const fromHash = () => {
|
||||
const match = /^#file=(.+)$/.exec(window.location.hash);
|
||||
if (!match) return;
|
||||
let wanted: string | undefined;
|
||||
try {
|
||||
wanted = decodeURIComponent(match[1]);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (files.some((file) => file.path === wanted)) setActivePath(wanted);
|
||||
};
|
||||
fromHash();
|
||||
window.addEventListener("hashchange", fromHash);
|
||||
return () => window.removeEventListener("hashchange", fromHash);
|
||||
}, [files]);
|
||||
|
||||
// Lazily fetch the selected asset. SKILL.md and images never need a fetch.
|
||||
React.useEffect(() => {
|
||||
if (!active || isPrimary || kind === "image") return;
|
||||
if (contents[active.path] !== undefined) {
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setStatus("loading");
|
||||
fetch(`${rawBase}/${encodeRepoPath(active.path)}`)
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(String(response.status));
|
||||
return response.text();
|
||||
})
|
||||
.then((text) => {
|
||||
if (cancelled) return;
|
||||
setContents((current) => ({ ...current, [active.path]: text }));
|
||||
setStatus("idle");
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStatus("error");
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active, isPrimary, kind, rawBase, contents]);
|
||||
|
||||
const selectFile = React.useCallback((path: string) => {
|
||||
setActivePath(path);
|
||||
const hash = `#file=${encodeURIComponent(path)}`;
|
||||
if (window.location.hash !== hash) history.replaceState(null, "", hash);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!copied) return;
|
||||
const timer = window.setTimeout(() => setCopied(false), 2000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [copied]);
|
||||
|
||||
const handleCopyInstall = React.useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(installCommand);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
}, [installCommand]);
|
||||
|
||||
const activeGithubUrl = active
|
||||
? `${githubBlobBase}/${encodeRepoPath(active.path)}`
|
||||
: githubUrl;
|
||||
const activeRawUrl = active
|
||||
? `${rawBase}/${encodeRepoPath(active.path)}`
|
||||
: undefined;
|
||||
|
||||
const install = (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCopyInstall}
|
||||
leadingVisual={copied ? CheckIcon : CopyIcon}
|
||||
>
|
||||
{copied ? "Copied" : "Copy install command"}
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href={githubUrl}
|
||||
variant="secondary"
|
||||
className={styles.iconButton}
|
||||
aria-label={`View the ${item.title} skill on GitHub`}
|
||||
>
|
||||
<MarkGithubIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
const fileCount = files.length;
|
||||
const assetCount = item.assetCount ?? Math.max(0, fileCount - 1);
|
||||
|
||||
return (
|
||||
<DetailChassis
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
breadcrumbs={[
|
||||
{ label: "Skills", href: pageHref("skills") },
|
||||
{ label: item.title },
|
||||
]}
|
||||
install={install}
|
||||
heroExtras={
|
||||
<div className={styles.codeBlockWrap}>
|
||||
<span className={styles.codeLabel}>Install with the GitHub CLI</span>
|
||||
<SyntaxHighlightedCode
|
||||
className={styles.codeBlock}
|
||||
code={installCommand}
|
||||
language="bash"
|
||||
lineClassName={styles.codeLine}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
sidebar={
|
||||
<ResourceMeta
|
||||
kicker="Skill details"
|
||||
groups={[
|
||||
{
|
||||
label: "Files",
|
||||
items: [`${fileCount} file${fileCount === 1 ? "" : "s"}`],
|
||||
},
|
||||
{
|
||||
label: "Bundled assets",
|
||||
items: assetCount
|
||||
? [`${assetCount} asset${assetCount === 1 ? "" : "s"}`]
|
||||
: [],
|
||||
},
|
||||
{ label: "Folder", items: [item.path] },
|
||||
]}
|
||||
lastUpdated={lastUpdated}
|
||||
sourceUrl={githubUrl}
|
||||
/>
|
||||
}
|
||||
previous={previous}
|
||||
next={next}
|
||||
currentPage="skills"
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
>
|
||||
<section className={styles.articleSection}>
|
||||
{fileCount > 1 ? (
|
||||
<>
|
||||
<span className={styles.codeLabel}>
|
||||
<FileDirectoryIcon size={16} /> Files in this skill
|
||||
</span>
|
||||
<ul className={fileStyles.fileTabs} aria-label="Files in this skill">
|
||||
{files.map((file) => (
|
||||
<li key={file.path}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
fileStyles.fileTab,
|
||||
file.path === activePath && fileStyles.fileTabActive,
|
||||
)}
|
||||
aria-current={
|
||||
file.path === activePath ? "true" : undefined
|
||||
}
|
||||
onClick={() => selectFile(file.path)}
|
||||
>
|
||||
{file.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{active ? (
|
||||
<div className={fileStyles.fileBar}>
|
||||
<span className={fileStyles.filePath}>
|
||||
{active.name}
|
||||
{formatSize(active.size) ? ` · ${formatSize(active.size)}` : ""}
|
||||
</span>
|
||||
<div className={fileStyles.fileActions}>
|
||||
<Button
|
||||
as="a"
|
||||
href={activeGithubUrl}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className={styles.iconButton}
|
||||
aria-label={`View ${active.name} on GitHub`}
|
||||
>
|
||||
<MarkGithubIcon size={16} />
|
||||
</Button>
|
||||
{activeRawUrl ? (
|
||||
<Button
|
||||
as="a"
|
||||
href={activeRawUrl}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
download
|
||||
className={styles.iconButton}
|
||||
aria-label={`Download ${active.name}`}
|
||||
>
|
||||
<DownloadIcon size={16} />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FileView
|
||||
active={active}
|
||||
isPrimary={isPrimary}
|
||||
kind={kind}
|
||||
markdownHtml={markdownHtml}
|
||||
rawMarkdown={rawMarkdown}
|
||||
text={active ? contents[active.path] : undefined}
|
||||
status={status}
|
||||
rawBase={rawBase}
|
||||
githubUrl={activeGithubUrl}
|
||||
/>
|
||||
</section>
|
||||
</DetailChassis>
|
||||
);
|
||||
}
|
||||
|
||||
function FileView({
|
||||
active,
|
||||
isPrimary,
|
||||
kind,
|
||||
markdownHtml,
|
||||
rawMarkdown,
|
||||
text,
|
||||
status,
|
||||
rawBase,
|
||||
githubUrl,
|
||||
}: {
|
||||
active: SkillFile | null;
|
||||
isPrimary: boolean;
|
||||
kind: FileKind;
|
||||
markdownHtml: string;
|
||||
rawMarkdown: string;
|
||||
text?: string;
|
||||
status: "idle" | "loading" | "error";
|
||||
rawBase: string;
|
||||
githubUrl: string;
|
||||
}) {
|
||||
const [markdown, setMarkdown] = React.useState<string | null>(null);
|
||||
|
||||
// Non-primary markdown assets are parsed in the browser; `marked` and the
|
||||
// sanitizer are only pulled in when such a file is actually opened.
|
||||
React.useEffect(() => {
|
||||
if (isPrimary || kind !== "markdown" || text === undefined) {
|
||||
setMarkdown(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void Promise.all([
|
||||
import("marked"),
|
||||
import("../../lib/sanitize-html"),
|
||||
import("../../lib/markdown-a11y"),
|
||||
]).then(([{ marked }, { sanitizeHtml }, { enhanceMarkdownA11y }]) => {
|
||||
if (cancelled) return;
|
||||
setMarkdown(
|
||||
enhanceMarkdownA11y(
|
||||
sanitizeHtml(marked.parse(text, { async: false }) as string),
|
||||
),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isPrimary, kind, text]);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
if (isPrimary) {
|
||||
return markdownHtml ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: markdownHtml }} />
|
||||
) : (
|
||||
<pre tabIndex={0} className={styles.codeBlock}>
|
||||
<code>{rawMarkdown}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "image") {
|
||||
return (
|
||||
<img
|
||||
className={fileStyles.fileImage}
|
||||
src={`${rawBase}/${encodeRepoPath(active.path)}`}
|
||||
alt={active.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<Text as="p" size="200" variant="muted">
|
||||
Loading {active.name}…
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error" || text === undefined) {
|
||||
return (
|
||||
<Text as="p" size="200" variant="muted">
|
||||
Couldn't load this file. <a href={githubUrl}>View it on GitHub</a>.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "markdown") {
|
||||
return markdown ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: markdown }} />
|
||||
) : (
|
||||
<Text as="p" size="200" variant="muted">
|
||||
Rendering {active.name}…
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "code") {
|
||||
const language =
|
||||
CODE_LANGUAGES[extensionOf(active.path)] ?? detectCodeLanguage(text);
|
||||
return (
|
||||
<SyntaxHighlightedCode
|
||||
className={styles.codeBlock}
|
||||
code={text}
|
||||
language={language}
|
||||
lineClassName={styles.codeLine}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<pre tabIndex={0} className={styles.codeBlock}>
|
||||
<code>{text}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import { ChevronDownIcon, CopyIcon, DownloadIcon, MarkGithubIcon } from "@primer/octicons-react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CTABanner,
|
||||
Card,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
Grid,
|
||||
Heading,
|
||||
Pagination,
|
||||
Section,
|
||||
Stack,
|
||||
Text,
|
||||
useTheme,
|
||||
} from "@primer/react-brand";
|
||||
import { clsx } from "clsx";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
import { PageShell } from "./PageShell";
|
||||
import { CatalogSortControl, CATALOG_SORT_OPTIONS } from "./CatalogSortControl";
|
||||
import { SkillsIcon } from "./SkillsIcon";
|
||||
import {
|
||||
daysSince,
|
||||
fileBuckets,
|
||||
fileBucketOf,
|
||||
toggleValue,
|
||||
updatedBuckets,
|
||||
updatedBucketOf,
|
||||
} from "./catalogFilters";
|
||||
import { pageHref } from "./pageHref";
|
||||
import { downloadFile } from "./resourceActions";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/skills.module.css";
|
||||
|
||||
const CONTRIBUTE_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/docs/README.skills.md#how-to-contribute";
|
||||
const REQUEST_URL = "https://github.com/github/awesome-copilot/issues/new";
|
||||
|
||||
/** A skill record as emitted into public/data/skills.json. */
|
||||
export type SkillItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
assetCount: number;
|
||||
files: { path: string; name: string; size: number }[];
|
||||
lastUpdated: string;
|
||||
};
|
||||
|
||||
const skillSourceUrl = (skill: SkillItem) =>
|
||||
`https://github.com/github/awesome-copilot/tree/main/skills/${skill.id}`;
|
||||
|
||||
const installCommand = (skill: SkillItem) =>
|
||||
`gh skills install github/awesome-copilot ${skill.id}`;
|
||||
|
||||
const downloadUrl = (skill: SkillItem) =>
|
||||
`https://raw.githubusercontent.com/github/awesome-copilot/main/skills/${skill.id}/SKILL.md`;
|
||||
|
||||
type FilterGroupId = "resources" | "files" | "updated";
|
||||
|
||||
const filterGroups: { id: FilterGroupId; label: string; options: string[] }[] = [
|
||||
{
|
||||
id: "resources",
|
||||
label: "Resources",
|
||||
options: ["Includes assets", "Instructions only"],
|
||||
},
|
||||
{ id: "files", label: "Bundled files", options: fileBuckets.map((b) => b.label) },
|
||||
{ id: "updated", label: "Last updated", options: updatedBuckets.map((b) => b.label) },
|
||||
];
|
||||
|
||||
type FilterState = Record<FilterGroupId, string[]>;
|
||||
|
||||
const emptyFilters: FilterState = { resources: [], files: [], updated: [] };
|
||||
|
||||
type SortMode = "az" | "newest";
|
||||
|
||||
const resourceOf = (skill: SkillItem) =>
|
||||
skill.assetCount > 0 ? "Includes assets" : "Instructions only";
|
||||
|
||||
const PAGE_SIZE = 6;
|
||||
|
||||
/**
|
||||
* Long facets collapse behind a "Show N more" toggle, matching the prototype's
|
||||
* extensions page. Real data produces far longer option lists than the
|
||||
* prototype's hardcoded arrays, so every catalog needs this.
|
||||
*/
|
||||
const FILTER_COLLAPSE_LIMIT = 10;
|
||||
|
||||
/**
|
||||
* The Skills catalog, ported from the design prototype's `skills.tsx`. The
|
||||
* prototype's hardcoded array is replaced by build-time data; the layout,
|
||||
* filters, and interactions are unchanged.
|
||||
*/
|
||||
export function SkillsCatalog({
|
||||
skills,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
}: {
|
||||
skills: SkillItem[];
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
}) {
|
||||
const { colorMode } = useTheme();
|
||||
const [sortMode, setSortMode] = useState<SortMode>("az");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<FilterState>(emptyFilters);
|
||||
const [expandedGroups, setExpandedGroups] = useState<
|
||||
Record<FilterGroupId, boolean>
|
||||
>({ resources: false, files: false, updated: false });
|
||||
const toggleGroupExpanded = (groupId: FilterGroupId) => {
|
||||
setExpandedGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] }));
|
||||
};
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyError, setCopyError] = useState(false);
|
||||
const copyTimer = React.useRef<number | undefined>(undefined);
|
||||
|
||||
React.useEffect(
|
||||
() => () => window.clearTimeout(copyTimer.current),
|
||||
[],
|
||||
);
|
||||
|
||||
const copyInstall = async (skill: SkillItem) => {
|
||||
setCopyError(false);
|
||||
try {
|
||||
await navigator.clipboard.writeText(installCommand(skill));
|
||||
} catch {
|
||||
setCopyError(true);
|
||||
return;
|
||||
}
|
||||
setCopied(true);
|
||||
window.clearTimeout(copyTimer.current);
|
||||
copyTimer.current = window.setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const sortedSkills = useMemo(() => {
|
||||
const filtered = skills.filter((skill) => {
|
||||
const resourcesOk =
|
||||
filters.resources.length === 0 ||
|
||||
filters.resources.includes(resourceOf(skill));
|
||||
const filesOk =
|
||||
filters.files.length === 0 ||
|
||||
filters.files.includes(fileBucketOf(skill.files.length));
|
||||
const updatedOk =
|
||||
filters.updated.length === 0 ||
|
||||
filters.updated.includes(updatedBucketOf(daysSince(skill.lastUpdated)));
|
||||
return resourcesOk && filesOk && updatedOk;
|
||||
});
|
||||
if (sortMode === "newest") {
|
||||
filtered.sort((a, b) => daysSince(a.lastUpdated) - daysSince(b.lastUpdated));
|
||||
} else {
|
||||
filtered.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
return filtered;
|
||||
}, [skills, filters, sortMode]);
|
||||
|
||||
const toggleFilter = (groupId: FilterGroupId, option: string) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[groupId]: toggleValue(prev[groupId], option),
|
||||
}));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters(emptyFilters);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const activeFilterCount =
|
||||
filters.resources.length + filters.files.length + filters.updated.length;
|
||||
const hasActiveFilters = activeFilterCount > 0;
|
||||
const pageCount = Math.max(1, Math.ceil(sortedSkills.length / PAGE_SIZE));
|
||||
const page = Math.min(currentPage, pageCount);
|
||||
const visibleSkills = sortedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
styles={styles}
|
||||
currentPage="skills"
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
searchAriaLabel="Search skills"
|
||||
>
|
||||
<Box className={styles.hero}>
|
||||
<div className={styles.heroInner}>
|
||||
<span className={styles.heroIcon} aria-hidden="true">
|
||||
<SkillsIcon size={36} />
|
||||
</span>
|
||||
<Heading as="h1" size="3" className={styles.heroHeading}>
|
||||
Skills
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted" className={styles.heroText}>
|
||||
Self-contained agent skills that bundle instructions and resources —
|
||||
contributed and curated by the community to extend GitHub Copilot.
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Section id="catalog" paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.catalog}>
|
||||
<aside className={styles.filterNav} aria-label="Filter skills">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filterToggle}
|
||||
aria-expanded={mobileFiltersOpen}
|
||||
onClick={() => setMobileFiltersOpen((open) => !open)}
|
||||
>
|
||||
<span>Filters{hasActiveFilters ? ` (${activeFilterCount})` : ""}</span>
|
||||
<ChevronDownIcon
|
||||
size={16}
|
||||
className={clsx(
|
||||
styles.filterToggleChevron,
|
||||
mobileFiltersOpen && styles.filterToggleChevronOpen,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.filterBody,
|
||||
!mobileFiltersOpen && styles.filterBodyCollapsed,
|
||||
)}
|
||||
>
|
||||
{filterGroups.map((group) => {
|
||||
const isExpandable =
|
||||
group.options.length > FILTER_COLLAPSE_LIMIT;
|
||||
const expanded = expandedGroups[group.id];
|
||||
const visibleOptions =
|
||||
isExpandable && !expanded
|
||||
? group.options.slice(0, FILTER_COLLAPSE_LIMIT)
|
||||
: group.options;
|
||||
return (
|
||||
<div className={styles.filterGroup} key={group.id}>
|
||||
<Text as="h2" size="100" className={styles.filterHeading}>
|
||||
{group.label}
|
||||
</Text>
|
||||
<div className={styles.filterOptions}>
|
||||
{visibleOptions.map((option) => (
|
||||
<div className={styles.filterOption} key={option}>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={filters[group.id].includes(option)}
|
||||
onChange={() => toggleFilter(group.id, option)}
|
||||
/>
|
||||
<FormControl.Label>{option}</FormControl.Label>
|
||||
</FormControl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{isExpandable ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.showMoreButton}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => toggleGroupExpanded(group.id)}
|
||||
>
|
||||
{expanded
|
||||
? "Show less"
|
||||
: `Show ${group.options.length - FILTER_COLLAPSE_LIMIT} more`}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hasActiveFilters ? (
|
||||
<div className={styles.filterActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
hasArrow={false}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear all ({activeFilterCount})
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Box className={styles.catalogMain}>
|
||||
<Box className={styles.toolbar}>
|
||||
<CatalogSortControl
|
||||
ariaLabel="Sort skills"
|
||||
value={sortMode}
|
||||
options={CATALOG_SORT_OPTIONS}
|
||||
styles={styles}
|
||||
onChange={(value) => {
|
||||
setSortMode(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box className={styles.gridFrame} data-mode={colorMode}>
|
||||
<Box className={styles.gridContent}>
|
||||
<Grid
|
||||
className={styles.threeUp}
|
||||
columnGap="none"
|
||||
rowGap="none"
|
||||
enableGutters={false}
|
||||
>
|
||||
{visibleSkills.map((skill) => (
|
||||
<Grid.Column
|
||||
key={skill.id}
|
||||
span={{ xsmall: 12, medium: 6, large: 6 }}
|
||||
className={styles.col}
|
||||
>
|
||||
<Box className={clsx(styles.item, styles.itemHover)}>
|
||||
<Card
|
||||
href={pageHref(`skill/${skill.id}`)}
|
||||
fullWidth
|
||||
ctaVariant="none"
|
||||
backgroundColor="none"
|
||||
className={styles.card}
|
||||
>
|
||||
<Card.Heading as="h2">{skill.title}</Card.Heading>
|
||||
<Card.Description>
|
||||
<span className={styles.cardDescText}>
|
||||
{skill.description}
|
||||
</span>
|
||||
</Card.Description>
|
||||
</Card>
|
||||
<div className={styles.cardActions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
hasArrow={false}
|
||||
leadingVisual={CopyIcon}
|
||||
onClick={() => copyInstall(skill)}
|
||||
>
|
||||
Copy install
|
||||
</Button>
|
||||
<Button
|
||||
as="button"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
void downloadFile(downloadUrl(skill), "SKILL.md")
|
||||
}
|
||||
aria-label={`Download ${skill.title} skill file`}
|
||||
className={styles.iconButton}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href={skillSourceUrl(skill)}
|
||||
variant="secondary"
|
||||
aria-label={`View ${skill.title} skill on GitHub`}
|
||||
className={styles.iconButton}
|
||||
>
|
||||
<MarkGithubIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</Grid.Column>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{sortedSkills.length === 0 ? (
|
||||
<Box className={styles.emptyState}>
|
||||
<Text as="p" variant="muted">
|
||||
No skills match the selected filters.
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.paginationRow}>
|
||||
<Stack direction="horizontal" justifyContent="center" padding="none">
|
||||
<Pagination
|
||||
className={styles.pagination}
|
||||
pageCount={pageCount}
|
||||
currentPage={page}
|
||||
onPageChange={(e, n) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(n);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.ctaFrame}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.ctaFrameInner}>
|
||||
<CTABanner align="center" backgroundColor="subtle">
|
||||
<CTABanner.Heading>
|
||||
Can’t find the skill you need?
|
||||
</CTABanner.Heading>
|
||||
<CTABanner.Description>
|
||||
This library is community-built. Share a skill you use—or
|
||||
request one—to help developers ship faster with GitHub
|
||||
Copilot.
|
||||
</CTABanner.Description>
|
||||
<CTABanner.ButtonGroup>
|
||||
<Button as="a" href={CONTRIBUTE_URL}>
|
||||
Submit a skill
|
||||
</Button>
|
||||
<Button as="a" href={REQUEST_URL}>
|
||||
Request a skill
|
||||
</Button>
|
||||
</CTABanner.ButtonGroup>
|
||||
</CTABanner>
|
||||
</Box>
|
||||
</Section>
|
||||
</Box>
|
||||
|
||||
<div
|
||||
className={styles.toast}
|
||||
role={copyError ? "alert" : "status"}
|
||||
aria-live={copyError ? "assertive" : "polite"}
|
||||
data-visible={copied || copyError ? "true" : undefined}
|
||||
>
|
||||
{copyError
|
||||
? "Unable to copy install command. Please try again."
|
||||
: "Install command copied!"}
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
type SkillsIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function SkillsIcon({ size = 36 }: SkillsIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 96 96"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Skills"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#skills-icon-clip)">
|
||||
<path
|
||||
d="M87.6452 36.2143H60L69.8776 7.27142C71.2028 3.38827 66.5201 0.2653 63.4444 2.98108L5.9258 53.7695C3.16384 56.2082 4.88879 60.7679 8.57336 60.7679H38L29.0183 88.7728C27.7683 92.6706 32.486 95.7075 35.5163 92.9556L90.3343 43.1755C93.0417 40.7169 91.3024 36.2143 87.6452 36.2143Z"
|
||||
stroke="var(--brand-color-text-default)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M24 0C27.3137 -1.51144e-07 30 2.68629 30 6C29.9999 9.31356 27.314 11.9999 24.0005 12C20.6868 12 18.0001 9.31364 18 6C18 2.68637 20.6864 0.000131825 24 0Z"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
<path
|
||||
d="M11 82C14.3137 82 17 84.6863 17 88C16.9999 91.3136 14.314 93.9999 11.0005 94C7.68683 94 5.00008 91.3136 5 88C5 84.6864 7.6864 82.0001 11 82Z"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
<path
|
||||
d="M90 56C93.3137 56 96 58.6863 96 62C95.9999 65.3136 93.314 67.9999 90.0005 68C86.6868 68 84.0001 65.3136 84 62C84 58.6864 86.6864 56.0001 90 56Z"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="skills-icon-clip">
|
||||
<rect width="96" height="96" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import styles from "./styles/SkipLink.module.css";
|
||||
|
||||
/**
|
||||
* Keyboard-only "Skip to main content" link. Rendered as the first focusable
|
||||
* element on every page so keyboard and screen-reader users can bypass the
|
||||
* header navigation and jump straight to the page's <main> region. It stays
|
||||
* visually hidden until it receives focus, then slides into the top-left.
|
||||
*/
|
||||
export function SkipLink({
|
||||
targetId = "main-content",
|
||||
label = "Skip to main content",
|
||||
}: {
|
||||
targetId?: string;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<a className={styles.skipLink} href={`#${targetId}`}>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import React from "react";
|
||||
|
||||
export type CodeLanguage = "bash" | "json" | "markup" | "tsx" | "yaml";
|
||||
|
||||
type TokenKind =
|
||||
| "attr-name"
|
||||
| "boolean"
|
||||
| "comment"
|
||||
| "function"
|
||||
| "keyword"
|
||||
| "number"
|
||||
| "operator"
|
||||
| "placeholder"
|
||||
| "property"
|
||||
| "punctuation"
|
||||
| "string"
|
||||
| "tag"
|
||||
| "variable";
|
||||
|
||||
type TokenRule = {
|
||||
kind: TokenKind;
|
||||
expression: RegExp;
|
||||
};
|
||||
|
||||
type HighlightToken = {
|
||||
content: string;
|
||||
kind?: TokenKind;
|
||||
};
|
||||
|
||||
const rules: Record<CodeLanguage, readonly TokenRule[]> = {
|
||||
bash: [
|
||||
{ kind: "comment", expression: /#.*$/y },
|
||||
{ kind: "string", expression: /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/y },
|
||||
{ kind: "placeholder", expression: /<[\w.*-]+>/y },
|
||||
{ kind: "variable", expression: /\$\{?[\w@#?-]+\}?/y },
|
||||
{
|
||||
kind: "variable",
|
||||
expression: /(?<=^|[\s|(])(?:--?|\/)[\w][\w:-]*(?:=[^\s\\]+)?/y,
|
||||
},
|
||||
{
|
||||
kind: "function",
|
||||
expression:
|
||||
/(?:dotnet|find|gh|git|grep|npm|npx|sed|sort|uniq|brew)\b/y,
|
||||
},
|
||||
{ kind: "operator", expression: /(?:&&|\|\||[|;&])/y },
|
||||
],
|
||||
json: [
|
||||
{ kind: "property", expression: /"(?:\\.|[^"\\])*"(?=\s*:)/y },
|
||||
{ kind: "string", expression: /"(?:\\.|[^"\\])*"/y },
|
||||
{ kind: "boolean", expression: /\b(?:false|null|true)\b/y },
|
||||
{ kind: "number", expression: /-?\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/iy },
|
||||
{ kind: "punctuation", expression: /[{}[\],:]/y },
|
||||
],
|
||||
markup: [
|
||||
{ kind: "comment", expression: /<!--[\s\S]*?-->/y },
|
||||
{ kind: "tag", expression: /<\/?[A-Za-z][\w:-]*/y },
|
||||
{ kind: "attr-name", expression: /[A-Za-z_:][\w:.-]*(?=\s*=)/y },
|
||||
{ kind: "string", expression: /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/y },
|
||||
{ kind: "punctuation", expression: /\/?>/y },
|
||||
],
|
||||
tsx: [
|
||||
{ kind: "comment", expression: /\/\/.*$|\/\*.*?\*\//y },
|
||||
{ kind: "string", expression: /`(?:\\.|[^`\\])*`|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/y },
|
||||
{ kind: "tag", expression: /<\/?[A-Za-z][\w.]*/y },
|
||||
{
|
||||
kind: "keyword",
|
||||
expression:
|
||||
/\b(?:as|async|await|const|else|export|extends|false|for|from|function|if|import|interface|let|new|null|return|true|type|undefined)\b/y,
|
||||
},
|
||||
{ kind: "number", expression: /\b\d+(?:\.\d+)?\b/y },
|
||||
{ kind: "variable", expression: /\b[A-Z][A-Za-z0-9_]*\b/y },
|
||||
{ kind: "operator", expression: /=>|===?|!==?|[+*/%-]/y },
|
||||
{ kind: "punctuation", expression: /[{}[\](),.;:<>]/y },
|
||||
],
|
||||
yaml: [
|
||||
{ kind: "comment", expression: /#.*$/y },
|
||||
{ kind: "punctuation", expression: /(?:---|\.\.\.)/y },
|
||||
{ kind: "property", expression: /[A-Za-z_][\w.-]*(?=\s*:)/y },
|
||||
{ kind: "string", expression: /"(?:\\.|[^"\\])*"|'(?:''|[^'])*'/y },
|
||||
{ kind: "placeholder", expression: /<[\w.*-]+>/y },
|
||||
{ kind: "variable", expression: /\$\{\{.*?\}\}|\$\([\w.-]+\)/y },
|
||||
{ kind: "boolean", expression: /\b(?:false|null|true|~)\b/iy },
|
||||
{ kind: "number", expression: /-?\b\d+(?:\.\d+)?\b/y },
|
||||
{ kind: "function", expression: /\bdotnet\b/y },
|
||||
{ kind: "punctuation", expression: /[:,[\]{}]|-(?=\s)/y },
|
||||
],
|
||||
};
|
||||
|
||||
function matchRule(line: string, cursor: number, rule: TokenRule) {
|
||||
rule.expression.lastIndex = cursor;
|
||||
return rule.expression.exec(line);
|
||||
}
|
||||
|
||||
function tokenizeLine(line: string, language: CodeLanguage): HighlightToken[] {
|
||||
const languageRules = rules[language];
|
||||
const tokens: HighlightToken[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < line.length) {
|
||||
const ruleMatch = languageRules
|
||||
.map((rule) => ({ match: matchRule(line, cursor, rule), rule }))
|
||||
.find(({ match }) => match?.index === cursor);
|
||||
|
||||
if (ruleMatch?.match) {
|
||||
tokens.push({
|
||||
content: ruleMatch.match[0],
|
||||
kind: ruleMatch.rule.kind,
|
||||
});
|
||||
cursor += ruleMatch.match[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
let end = cursor + 1;
|
||||
while (
|
||||
end < line.length &&
|
||||
!languageRules.some((rule) => matchRule(line, end, rule)?.index === end)
|
||||
) {
|
||||
end += 1;
|
||||
}
|
||||
tokens.push({ content: line.slice(cursor, end) });
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function detectCodeLanguage(code: string): CodeLanguage {
|
||||
const source = code.trimStart();
|
||||
if (source.startsWith("{") || source.startsWith("[")) return "json";
|
||||
if (
|
||||
source.startsWith("---") ||
|
||||
/^(?:- (?:task|uses)|description|model|name|tools|version):/m.test(source)
|
||||
) {
|
||||
return "yaml";
|
||||
}
|
||||
if (/^(?:import|export|const|let|function|interface|type)\s/m.test(source)) {
|
||||
return "tsx";
|
||||
}
|
||||
if (/^<[/!?]?[a-z][^>]*>/i.test(source)) return "markup";
|
||||
return "bash";
|
||||
}
|
||||
|
||||
export function SyntaxHighlightedCode({
|
||||
className,
|
||||
code,
|
||||
language = detectCodeLanguage(code),
|
||||
lineClassName,
|
||||
}: {
|
||||
className: string;
|
||||
code: string;
|
||||
language?: CodeLanguage;
|
||||
lineClassName: string;
|
||||
}) {
|
||||
return (
|
||||
<pre className={className} tabIndex={0}>
|
||||
<code>
|
||||
{code.split("\n").map((line, lineIndex) => (
|
||||
<span className={lineClassName} key={lineIndex}>
|
||||
{tokenizeLine(line, language).map((token, tokenIndex) =>
|
||||
token.kind ? (
|
||||
<span className={`token ${token.kind}`} key={tokenIndex}>
|
||||
{token.content}
|
||||
</span>
|
||||
) : (
|
||||
<React.Fragment key={tokenIndex}>
|
||||
{token.content}
|
||||
</React.Fragment>
|
||||
),
|
||||
)}
|
||||
{"\n"}
|
||||
</span>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { ChevronDownIcon, ThreeBarsIcon, XIcon } from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
import { Button } from "@primer/react-brand";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import { contributorsTotal as siteContributorsTotal } from "../../lib/site-data";
|
||||
import mobileStyles from "./styles/TopNav.module.css";
|
||||
import { ContributorsNavButton } from "./ContributorsNavButton";
|
||||
import { LanguageSelect } from "./LanguageSelect";
|
||||
import { TopNavSearch } from "./TopNavSearch";
|
||||
|
||||
const CONTRIBUTING_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md";
|
||||
|
||||
type NavLink = { label: string; href: string; current: boolean };
|
||||
|
||||
/**
|
||||
* The top navigation is intentionally reduced to two tabs shared across every
|
||||
* page: a "Resources" dropdown that gathers all catalog destinations, plus a
|
||||
* standalone "GitHub Copilot Learning Hub" tab. Each page owns its own scoped CSS module,
|
||||
* so the class map is injected via `styles` to keep the existing look.
|
||||
*/
|
||||
export function TopNav({
|
||||
styles,
|
||||
links,
|
||||
libraryLabel = "Resources",
|
||||
learningHubLabel = "Learning Hub",
|
||||
contributorsHref,
|
||||
contributorsTotal = siteContributorsTotal,
|
||||
searchIndex,
|
||||
contributorsCurrent = false,
|
||||
searchAriaLabel = "Search the library",
|
||||
showLanguageSelect = false,
|
||||
}: {
|
||||
styles: Record<string, string | undefined>;
|
||||
links: NavLink[];
|
||||
libraryLabel?: string;
|
||||
learningHubLabel?: string;
|
||||
contributorsHref?: string;
|
||||
contributorsTotal?: number;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsCurrent?: boolean;
|
||||
searchAriaLabel?: string;
|
||||
/** Only pages with a mirrored translation should offer a language switch. */
|
||||
showLanguageSelect?: boolean;
|
||||
}) {
|
||||
const learningHubLink = links.find((link) => link.label === learningHubLabel);
|
||||
const libraryLinks = links.filter((link) => link.label !== learningHubLabel);
|
||||
const libraryActive = libraryLinks.some((link) => link.current);
|
||||
const desktopMenuRef = useRef<HTMLDetailsElement>(null);
|
||||
const mobileMenuRef = useRef<HTMLDetailsElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const closeMenus = () => {
|
||||
if (desktopMenuRef.current) desktopMenuRef.current.open = false;
|
||||
if (mobileMenuRef.current) mobileMenuRef.current.open = false;
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
const activeElement = document.activeElement;
|
||||
const activeMenu = [desktopMenuRef.current, mobileMenuRef.current].find(
|
||||
(menu) => menu?.open && activeElement && menu.contains(activeElement),
|
||||
);
|
||||
if (!activeMenu) return;
|
||||
closeMenus();
|
||||
activeMenu.querySelector("summary")?.focus();
|
||||
};
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (
|
||||
!desktopMenuRef.current?.contains(target) &&
|
||||
!mobileMenuRef.current?.contains(target)
|
||||
) {
|
||||
closeMenus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className={styles.subNavList}>
|
||||
<li className={styles.moreItem}>
|
||||
<details className={styles.moreMenu} ref={desktopMenuRef}>
|
||||
<summary
|
||||
className={clsx(
|
||||
styles.subNavLink,
|
||||
styles.moreTrigger,
|
||||
libraryActive && styles.subNavLinkActive,
|
||||
)}
|
||||
>
|
||||
{libraryLabel}
|
||||
<ChevronDownIcon size={16} className={styles.moreChevron} />
|
||||
</summary>
|
||||
<div className={styles.moreOverlay}>
|
||||
{libraryLinks.map((link) => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
className={styles.moreLink}
|
||||
aria-current={link.current ? "page" : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
{learningHubLink && (
|
||||
<li>
|
||||
<a
|
||||
href={learningHubLink.href}
|
||||
className={clsx(
|
||||
styles.subNavLink,
|
||||
learningHubLink.current && styles.subNavLinkActive,
|
||||
)}
|
||||
aria-current={learningHubLink.current ? "page" : undefined}
|
||||
>
|
||||
{learningHubLink.label}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<details className={mobileStyles.mobileMenu} ref={mobileMenuRef}>
|
||||
<summary
|
||||
className={mobileStyles.mobileTrigger}
|
||||
aria-label={`Open ${libraryLabel} menu`}
|
||||
>
|
||||
<ThreeBarsIcon size={20} className={mobileStyles.triggerBars} />
|
||||
<XIcon size={20} className={mobileStyles.triggerClose} />
|
||||
</summary>
|
||||
<nav className={mobileStyles.mobileOverlay} aria-label={libraryLabel}>
|
||||
{contributorsHref && (
|
||||
<>
|
||||
<div className={mobileStyles.mobileSearch}>
|
||||
<TopNavSearch
|
||||
index={searchIndex}
|
||||
styles={styles}
|
||||
variant="inline"
|
||||
inputAriaLabel={searchAriaLabel}
|
||||
/>
|
||||
</div>
|
||||
<span className={mobileStyles.overlayDivider} aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
<span className={mobileStyles.overlayLabel}>{libraryLabel}</span>
|
||||
{libraryLinks.map((link) => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
className={mobileStyles.mobileLink}
|
||||
aria-current={link.current ? "page" : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
{learningHubLink && (
|
||||
<>
|
||||
<span className={mobileStyles.overlayDivider} aria-hidden="true" />
|
||||
<a
|
||||
href={learningHubLink.href}
|
||||
className={mobileStyles.mobileLink}
|
||||
aria-current={learningHubLink.current ? "page" : undefined}
|
||||
>
|
||||
{learningHubLink.label}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{contributorsHref && (
|
||||
<>
|
||||
<span className={mobileStyles.overlayDivider} aria-hidden="true" />
|
||||
<div className={mobileStyles.mobileActions}>
|
||||
<ContributorsNavButton
|
||||
href={contributorsHref}
|
||||
current={contributorsCurrent}
|
||||
total={contributorsTotal}
|
||||
/>
|
||||
{showLanguageSelect && <LanguageSelect />}
|
||||
<Button
|
||||
as="a"
|
||||
href={CONTRIBUTING_URL}
|
||||
variant="primary"
|
||||
size="medium"
|
||||
>
|
||||
Contribute
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ArrowUpRightIcon, SearchIcon } from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
import { hrefKey, searchPagefind } from "./pagefindSearch";
|
||||
import { type SearchCategory, type SearchItem } from "./searchIndex";
|
||||
import s from "./styles/TopNavSearch.module.css";
|
||||
|
||||
const CATEGORY_ORDER: SearchCategory[] = [
|
||||
"Pages",
|
||||
"Articles",
|
||||
"Agents",
|
||||
"Instructions",
|
||||
"Skills",
|
||||
"Plugins",
|
||||
"Extensions",
|
||||
];
|
||||
|
||||
const MAX_RESULTS = 8;
|
||||
const MAX_PER_GROUP = 4;
|
||||
/** Upper bound on Pagefind hits fetched per query before merge/dedupe. */
|
||||
const MAX_PAGEFIND_HITS = 12;
|
||||
|
||||
type ResultGroup = { category: SearchCategory; items: SearchItem[] };
|
||||
|
||||
/**
|
||||
* Shared top-navigation search. A collapsed magnifier button expands into a
|
||||
* search field (also opened with ⌘K / Ctrl+K) and shows a live dropdown of
|
||||
* matches drawn from the site-wide index, so a visitor can search the whole
|
||||
* library and jump to any page from anywhere. The field, button, and ⌘K hint
|
||||
* reuse each page's scoped `styles`; the popover uses this component's module.
|
||||
*
|
||||
* The term can be controlled via `term`/`onTermChange` so a page that also
|
||||
* filters its own catalog (agents, skills, …) keeps narrowing its list as the
|
||||
* visitor types; omit them and the component manages its own term.
|
||||
*/
|
||||
export function TopNavSearch({
|
||||
index = [],
|
||||
styles,
|
||||
inputAriaLabel = "Search the library",
|
||||
term: controlledTerm,
|
||||
onTermChange,
|
||||
variant = "bar",
|
||||
}: {
|
||||
/** Site-wide search index, injected from build-time data. */
|
||||
index?: SearchItem[];
|
||||
styles: Record<string, string | undefined>;
|
||||
inputAriaLabel?: string;
|
||||
term?: string;
|
||||
onTermChange?: (value: string) => void;
|
||||
/**
|
||||
* `"bar"` (default) is the collapsed magnifier that expands in the desktop
|
||||
* top bar. `"inline"` is the always-open, full-width field used inside the
|
||||
* mobile hamburger menu, where the desktop bar is hidden — so search stays
|
||||
* available at every viewport.
|
||||
*/
|
||||
variant?: "bar" | "inline";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [internalTerm, setInternalTerm] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const listboxId = useId();
|
||||
|
||||
const isControlled = controlledTerm !== undefined;
|
||||
const term = isControlled ? controlledTerm : internalTerm;
|
||||
const setTerm = (value: string) => {
|
||||
if (isControlled) {
|
||||
onTermChange?.(value);
|
||||
} else {
|
||||
setInternalTerm(value);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const trimmed = term.trim();
|
||||
|
||||
// Pagefind covers every built HTML page (Learning Hub articles included), which
|
||||
// the build-time `index` prop does not. It is only available after a build,
|
||||
// so results arrive asynchronously and are merged in when they land.
|
||||
const [pagefindHits, setPagefindHits] = useState<SearchItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trimmed.length === 0) {
|
||||
setPagefindHits([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
searchPagefind(trimmed, MAX_PAGEFIND_HITS).then((hits) => {
|
||||
if (!cancelled) setPagefindHits(hits);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [trimmed]);
|
||||
|
||||
const groups = useMemo<ResultGroup[]>(() => {
|
||||
const query = trimmed.toLowerCase();
|
||||
if (query.length === 0) return [];
|
||||
const staticMatches = index.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(query) ||
|
||||
item.description.toLowerCase().includes(query) ||
|
||||
item.category.toLowerCase().includes(query),
|
||||
);
|
||||
// Static hits win on ties: they carry curated titles and descriptions.
|
||||
const seen = new Set(staticMatches.map((item) => hrefKey(item.href)));
|
||||
const matches = [...staticMatches];
|
||||
for (const hit of pagefindHits) {
|
||||
const key = hrefKey(hit.href);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
matches.push(hit);
|
||||
}
|
||||
let remaining = MAX_RESULTS;
|
||||
const grouped: ResultGroup[] = [];
|
||||
for (const category of CATEGORY_ORDER) {
|
||||
if (remaining <= 0) break;
|
||||
const items = matches
|
||||
.filter((item) => item.category === category)
|
||||
.slice(0, Math.min(MAX_PER_GROUP, remaining));
|
||||
if (items.length === 0) continue;
|
||||
remaining -= items.length;
|
||||
grouped.push({ category, items });
|
||||
}
|
||||
return grouped;
|
||||
}, [trimmed, index, pagefindHits]);
|
||||
|
||||
const flatResults = useMemo(
|
||||
() => groups.flatMap((group) => group.items),
|
||||
[groups],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(-1);
|
||||
}, [trimmed]);
|
||||
|
||||
const resultsOpen = open && focused && trimmed.length > 0;
|
||||
const hasResults = groups.length > 0;
|
||||
const activeDescendant =
|
||||
activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
|
||||
|
||||
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Escape") {
|
||||
setTerm("");
|
||||
setActiveIndex(-1);
|
||||
setFocused(false);
|
||||
if (variant === "bar") {
|
||||
setOpen(false);
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasResults) return;
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((current) =>
|
||||
current >= flatResults.length - 1 ? 0 : current + 1,
|
||||
);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((current) =>
|
||||
current <= 0 ? flatResults.length - 1 : current - 1,
|
||||
);
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
setActiveIndex(0);
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
setActiveIndex(flatResults.length - 1);
|
||||
} else if (event.key === "Enter" && activeIndex >= 0) {
|
||||
event.preventDefault();
|
||||
window.location.assign(flatResults[activeIndex].href);
|
||||
}
|
||||
};
|
||||
|
||||
const renderGroups = () => {
|
||||
if (!hasResults) {
|
||||
return (
|
||||
<p className={s.empty} role="status">
|
||||
No results for “{trimmed}”
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
let itemIndex = 0;
|
||||
return groups.map((group, groupIndex) => {
|
||||
const groupLabelId = `${listboxId}-group-${groupIndex}`;
|
||||
return (
|
||||
<div
|
||||
className={s.group}
|
||||
key={group.category}
|
||||
role="group"
|
||||
aria-labelledby={groupLabelId}
|
||||
>
|
||||
<p id={groupLabelId} className={s.groupLabel}>
|
||||
{group.category}
|
||||
</p>
|
||||
{group.items.map((item) => {
|
||||
const index = itemIndex++;
|
||||
return (
|
||||
<a
|
||||
id={`${listboxId}-option-${index}`}
|
||||
key={`${item.category}-${item.href}-${item.title}`}
|
||||
href={item.href}
|
||||
className={clsx(
|
||||
s.result,
|
||||
activeIndex === index && s.resultActive,
|
||||
)}
|
||||
role="option"
|
||||
aria-selected={activeIndex === index}
|
||||
onFocus={() => setActiveIndex(index)}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
>
|
||||
<span className={s.resultText}>{item.title}</span>
|
||||
<ArrowUpRightIcon size={16} className={s.resultIcon} />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<div className={s.inlineWrap} ref={rootRef}>
|
||||
<div className={s.inlineField}>
|
||||
<SearchIcon size={16} className={s.inlineIcon} />
|
||||
<input
|
||||
type="text"
|
||||
className={s.inlineInput}
|
||||
placeholder="Search"
|
||||
aria-label={inputAriaLabel}
|
||||
role="combobox"
|
||||
aria-expanded={trimmed.length > 0}
|
||||
aria-controls={listboxId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={activeDescendant}
|
||||
autoComplete="off"
|
||||
value={term}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
</div>
|
||||
{trimmed.length > 0 && (
|
||||
<div
|
||||
id={listboxId}
|
||||
className={s.inlineResults}
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
>
|
||||
{renderGroups()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={s.wrap}
|
||||
ref={rootRef}
|
||||
onBlur={(event) => {
|
||||
if (event.currentTarget.contains(event.relatedTarget)) return;
|
||||
setFocused(false);
|
||||
setActiveIndex(-1);
|
||||
if (term.trim() === "") setOpen(false);
|
||||
}}
|
||||
>
|
||||
{open ? (
|
||||
<div className={styles.searchField}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={styles.searchInput}
|
||||
placeholder="Search"
|
||||
aria-label={inputAriaLabel}
|
||||
role="combobox"
|
||||
aria-expanded={resultsOpen}
|
||||
aria-controls={listboxId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={activeDescendant}
|
||||
autoComplete="off"
|
||||
value={term}
|
||||
onFocus={() => setFocused(true)}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
<kbd className={styles.searchKbd}>⌘K</kbd>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={styles.searchButton}
|
||||
aria-label="Search"
|
||||
aria-expanded={false}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<SearchIcon size={16} />
|
||||
</button>
|
||||
)}
|
||||
{resultsOpen && (
|
||||
<div
|
||||
id={listboxId}
|
||||
className={s.results}
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
{renderGroups()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import styles from "./styles/TypingText.module.css";
|
||||
|
||||
type TypingTextProps = {
|
||||
/** The full string to type out. */
|
||||
text: string;
|
||||
/** Class applied to the animated (aria-hidden) span. */
|
||||
className?: string;
|
||||
/** Per-character delay in milliseconds. */
|
||||
speedMs?: number;
|
||||
/** Render a blinking block caret that trails the text while it types and
|
||||
* disappears once the line is complete. */
|
||||
caret?: boolean;
|
||||
/** Class applied to the trailing caret span (only used when `caret`). */
|
||||
caretClassName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Terminal-style typewriter that reveals `text` one character at a time,
|
||||
* starting when the element scrolls into view. An optional blinking block
|
||||
* caret can trail the text while it types (see `caret`); otherwise the cursor
|
||||
* is expected to be a sibling element. The full string is always exposed to
|
||||
* assistive tech through a visually hidden copy, and reduced-motion users see
|
||||
* it immediately.
|
||||
*/
|
||||
export function TypingText({
|
||||
text,
|
||||
className,
|
||||
speedMs = 55,
|
||||
caret = false,
|
||||
caretClassName,
|
||||
}: TypingTextProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [count, setCount] = useState(0);
|
||||
const [started, setStarted] = useState(false);
|
||||
const done = count >= text.length;
|
||||
|
||||
useEffect(() => {
|
||||
const prefersReduced = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
if (prefersReduced) {
|
||||
setCount(text.length);
|
||||
setStarted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
setStarted(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.6 },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!started || done) return;
|
||||
const id = window.setTimeout(() => setCount((c) => c + 1), speedMs);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [started, count, done, speedMs]);
|
||||
|
||||
return (
|
||||
<span ref={ref}>
|
||||
<span className={styles.visuallyHidden}>{text}</span>
|
||||
<span aria-hidden="true" className={className}>
|
||||
{text.slice(0, count)}
|
||||
</span>
|
||||
{caret && started && !done ? (
|
||||
<span aria-hidden="true" className={caretClassName} />
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { ArrowLeftIcon, ArrowRightIcon, PlayIcon, XIcon } from "@primer/octicons-react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { Heading, ThemeProvider, useTheme } from "@primer/react-brand";
|
||||
|
||||
import styles from "./styles/VideoCarousel.module.css";
|
||||
|
||||
export type Video = {
|
||||
id: string;
|
||||
title: string;
|
||||
meta: string;
|
||||
};
|
||||
|
||||
const PER_PAGE = 3;
|
||||
|
||||
/** Modal YouTube player with a portal, focus trap, scroll lock, and Escape close. */
|
||||
function VideoDialog({ video, onClose }: { video: Video; onClose: () => void }) {
|
||||
const { colorMode } = useTheme();
|
||||
const titleId = useId();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
const { overflow } = document.body.style;
|
||||
document.body.style.overflow = "hidden";
|
||||
closeRef.current?.focus();
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab" || !dialogRef.current) return;
|
||||
const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), iframe, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = overflow;
|
||||
previouslyFocused?.focus?.();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<ThemeProvider colorMode={colorMode}>
|
||||
<div className={styles.backdrop} onMouseDown={onClose}>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className={styles.dialog}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className={styles.dialogHeader}>
|
||||
<Heading as="h2" size="6" id={titleId} className={styles.dialogTitle}>
|
||||
{video.title}
|
||||
</Heading>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
className={styles.closeButton}
|
||||
aria-label="Close video"
|
||||
onClick={onClose}
|
||||
>
|
||||
<XIcon size={16} />
|
||||
</button>
|
||||
</header>
|
||||
<div className={styles.videoFrame}>
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${video.id}?autoplay=1&rel=0`}
|
||||
title={video.title}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeProvider>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function VideoCarousel({ videos }: { videos: Video[] }) {
|
||||
const [page, setPage] = useState(0);
|
||||
const [activeVideo, setActiveVideo] = useState<Video | null>(null);
|
||||
const pageCount = Math.ceil(videos.length / PER_PAGE);
|
||||
const changePage = (direction: 1 | -1) => {
|
||||
setPage((current) => (current + direction + pageCount) % pageCount);
|
||||
};
|
||||
const visibleVideos = videos.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE);
|
||||
|
||||
return (
|
||||
<div className={styles.carousel}>
|
||||
<div className={styles.header}>
|
||||
<p className={styles.headerLabel}>
|
||||
{videos.length} {videos.length === 1 ? "video" : "videos"} in this
|
||||
series
|
||||
</p>
|
||||
{pageCount > 1 ? (
|
||||
<div className={styles.nav}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navButton}
|
||||
aria-label="Previous videos"
|
||||
onClick={() => changePage(-1)}
|
||||
>
|
||||
<ArrowLeftIcon size={20} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navButton}
|
||||
aria-label="Next videos"
|
||||
onClick={() => changePage(1)}
|
||||
>
|
||||
<ArrowRightIcon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.track} key={page}>
|
||||
{visibleVideos.map((video) => (
|
||||
<button
|
||||
key={video.id}
|
||||
type="button"
|
||||
className={styles.card}
|
||||
onClick={() => setActiveVideo(video)}
|
||||
aria-label={`Play video: ${video.title}`}
|
||||
>
|
||||
<span className={styles.thumb}>
|
||||
<img
|
||||
className={styles.thumbImage}
|
||||
src={`https://i.ytimg.com/vi/${video.id}/maxresdefault.jpg`}
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = `https://i.ytimg.com/vi/${video.id}/hqdefault.jpg`;
|
||||
}}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
width={1280}
|
||||
height={720}
|
||||
/>
|
||||
<span className={styles.playBadge} aria-hidden="true">
|
||||
<PlayIcon size={24} />
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.cardMeta}>{video.meta}</span>
|
||||
<span className={styles.cardTitle}>{video.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeVideo ? (
|
||||
<VideoDialog video={activeVideo} onClose={() => setActiveVideo(null)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
type WikiIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function WikiIcon({ size = 64 }: WikiIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Community library"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M32 23.666L32 53.666"
|
||||
stroke="var(--brand-color-text-muted)"
|
||||
strokeWidth="2.66667"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M32.0007 53.666L29.7091 51.7564C27.3129 49.7596 24.2925 48.666 21.1733 48.666H9.33398"
|
||||
stroke="var(--brand-color-text-muted)"
|
||||
strokeWidth="2.66667"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M32.0013 53.666L34.2928 51.7564C36.689 49.7596 39.7095 48.666 42.8286 48.666H54.668"
|
||||
stroke="var(--brand-color-text-muted)"
|
||||
strokeWidth="2.66667"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16.6673 4C24.0311 4 30.6673 8.6362 32.0007 15.3333C33.334 8.6362 39.9702 4 47.334 4H60.0007C61.4734 4 62.6673 5.19391 62.6673 6.66667V54C62.6673 55.4728 61.4734 56.6667 60.0007 56.6667H48.334C45.3852 56.6667 42.6394 58.0407 40.8692 60.3251C40.2659 61.1035 39.3855 61.6667 38.4007 61.6667H25.6007C24.6158 61.6667 23.7354 61.1035 23.1321 60.3251C21.3619 58.0407 18.6161 56.6667 15.6673 56.6667H4.00065C2.52789 56.6667 1.33398 55.4728 1.33398 54V6.66667C1.33398 5.19391 2.52789 4 4.00065 4H16.6673Z"
|
||||
stroke="var(--brand-color-text-default)"
|
||||
strokeWidth="2.66667"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M20 16.666C20 19.9797 17.3137 22.666 14 22.666C10.6864 22.6659 8.00013 19.9801 8 16.6665C8 13.3528 10.6864 10.6661 14 10.666C17.3136 10.666 19.9999 13.3524 20 16.666Z"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Shared filter/sort helpers for the catalog pages.
|
||||
*
|
||||
* The prototype hardcoded its catalog data with a precomputed `updatedDays`
|
||||
* field. Real catalog data carries an ISO `lastUpdated` timestamp instead, so
|
||||
* these helpers derive the same buckets the prototype filtered on.
|
||||
*/
|
||||
|
||||
export const updatedBuckets: { label: string; max: number }[] = [
|
||||
{ label: "Past week", max: 7 },
|
||||
{ label: "Past month", max: 30 },
|
||||
{ label: "Past 3 months", max: 90 },
|
||||
{ label: "Past 6 months", max: 180 },
|
||||
{ label: "Older", max: Number.POSITIVE_INFINITY },
|
||||
];
|
||||
|
||||
/** Whole days between `lastUpdated` and now. Unparsable dates sort as oldest. */
|
||||
export function daysSince(lastUpdated: string | undefined): number {
|
||||
if (!lastUpdated) return Number.POSITIVE_INFINITY;
|
||||
const then = Date.parse(lastUpdated);
|
||||
if (Number.isNaN(then)) return Number.POSITIVE_INFINITY;
|
||||
return Math.max(0, Math.floor((Date.now() - then) / 86_400_000));
|
||||
}
|
||||
|
||||
export function updatedBucketOf(days: number): string {
|
||||
return updatedBuckets.find((bucket) => days <= bucket.max)?.label ?? "Older";
|
||||
}
|
||||
|
||||
export const fileBuckets: { label: string; min: number; max: number }[] = [
|
||||
{ label: "1–2 files", min: 0, max: 2 },
|
||||
{ label: "3–5 files", min: 3, max: 5 },
|
||||
{ label: "6–10 files", min: 6, max: 10 },
|
||||
{ label: "11+ files", min: 11, max: Number.POSITIVE_INFINITY },
|
||||
];
|
||||
|
||||
export function fileBucketOf(files: number): string {
|
||||
return (
|
||||
fileBuckets.find((bucket) => files >= bucket.min && files <= bucket.max)
|
||||
?.label ?? "1–2 files"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a value in a filter list, returning a new array. Extracted because
|
||||
* every catalog page repeats the same checkbox toggle behaviour.
|
||||
*/
|
||||
export function toggleValue(current: string[], option: string): string[] {
|
||||
return current.includes(option)
|
||||
? current.filter((value) => value !== option)
|
||||
: [...current, option];
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
import { Heading, Text } from "@primer/react-brand";
|
||||
|
||||
import styles from "../styles/github-copilot-app.module.css";
|
||||
import { pageHref } from "../pageHref";
|
||||
import {
|
||||
CopyBlock,
|
||||
LearningArticleLayout,
|
||||
type TocSection,
|
||||
} from "../LearningArticleLayout";
|
||||
|
||||
const WHAT_ARE_AGENTS_URL =
|
||||
"https://awesome-copilot.github.com/learning-hub/what-are-agents-skills-instructions/";
|
||||
const AUTOMATING_WITH_HOOKS_URL =
|
||||
"https://awesome-copilot.github.com/learning-hub/automating-with-hooks/";
|
||||
const BUILDING_CUSTOM_AGENTS_URL =
|
||||
"https://awesome-copilot.github.com/learning-hub/building-custom-agents/";
|
||||
const TERMINOLOGY_GLOSSARY_URL =
|
||||
"https://awesome-copilot.github.com/learning-hub/github-copilot-terminology-glossary/";
|
||||
const AGENT_CONFIGURATION_REFERENCE_URL =
|
||||
"https://docs.github.com/en/copilot/customizing-copilot/github-copilot-agents/configuration-reference-for-github-copilot-agents";
|
||||
|
||||
const mentalModelRows: { topic: string; agent: string; subagent: string }[] = [
|
||||
{
|
||||
topic: "How it starts",
|
||||
agent: "Selected by the user or configured for the workflow",
|
||||
subagent: "Launched by another agent or orchestrator",
|
||||
},
|
||||
{
|
||||
topic: "Lifetime",
|
||||
agent: "Persists across the main conversation or session",
|
||||
subagent: "Temporary; exists only for the delegated task",
|
||||
},
|
||||
{
|
||||
topic: "Context",
|
||||
agent: "Carries the broader conversation and goals",
|
||||
subagent: "Gets a narrower prompt and its own isolated context",
|
||||
},
|
||||
{
|
||||
topic: "Scope",
|
||||
agent: "Coordinates the whole task",
|
||||
subagent: "Performs one focused piece of work",
|
||||
},
|
||||
{
|
||||
topic: "Output",
|
||||
agent: "Talks directly with the user",
|
||||
subagent: "Reports back to the main agent, which synthesizes the result",
|
||||
},
|
||||
];
|
||||
|
||||
const subagentChanges: { term: string; description: string }[] = [
|
||||
{
|
||||
term: "Context isolation",
|
||||
description:
|
||||
"the subagent gets only the task-relevant prompt, which reduces distraction from earlier conversation history.",
|
||||
},
|
||||
{
|
||||
term: "Focused instructions",
|
||||
description:
|
||||
"the subagent can use a tighter role, such as planner, implementer, reviewer, or researcher.",
|
||||
},
|
||||
{
|
||||
term: "Parallelism",
|
||||
description:
|
||||
"multiple subagents can work at the same time when tasks do not conflict.",
|
||||
},
|
||||
{
|
||||
term: "Controlled synthesis",
|
||||
description:
|
||||
"the parent agent decides what gets brought back into the main conversation.",
|
||||
},
|
||||
{
|
||||
term: "Alternative model selection",
|
||||
description:
|
||||
"the subagent can use a different AI model to perform a task, so while our main agent might be using a generalist model, a subagent could be configured to use a more specialized one for code review or research.",
|
||||
},
|
||||
];
|
||||
|
||||
const subagentUseCases: string[] = [
|
||||
"research before implementation",
|
||||
"compare multiple approaches without polluting the main thread",
|
||||
"run parallel review perspectives, such as correctness, security, and architecture",
|
||||
"split large work into independent tracks with explicit dependencies",
|
||||
"keep an orchestrator agent focused on coordination rather than direct execution",
|
||||
"compare multiple approaches across different models",
|
||||
];
|
||||
|
||||
const featureBuilderFrontmatter = `---
|
||||
name: Feature Builder
|
||||
tools: ['agent', 'read', 'search', 'edit']
|
||||
agents: ['Planner', 'Implementer', 'Reviewer']
|
||||
---`;
|
||||
|
||||
const plannerFrontmatter = `---
|
||||
name: Planner
|
||||
user-invocable: false
|
||||
tools: ['read', 'search']
|
||||
---`;
|
||||
|
||||
const parallelAnalysisPrompt = `Analyze this feature in parallel:
|
||||
1. Research existing code patterns
|
||||
2. Propose an implementation plan
|
||||
3. Review likely security risks
|
||||
Then summarize the findings into one recommendation.`;
|
||||
|
||||
const fleetCommand =
|
||||
"/fleet Update the auth docs, refactor the auth service, and add related tests.";
|
||||
|
||||
const fleetPromptModeCommand =
|
||||
'copilot -p "/fleet Update the auth docs, refactor the auth service, and add related tests." --no-ask-user';
|
||||
|
||||
const subagentsCommand =
|
||||
"/subagents # open the subagents configuration panel";
|
||||
|
||||
const experimentalAgentCommand = `/experimental # toggle experimental features
|
||||
/agent # open the agent picker and select rubber-duck`;
|
||||
|
||||
const fleetBehaviors: string[] = [
|
||||
"the orchestrator plans work items first",
|
||||
"independent tasks can run in parallel",
|
||||
"each subagent gets its own context window",
|
||||
"subagents share the same filesystem, so overlapping writes should be avoided",
|
||||
];
|
||||
|
||||
const nextSteps: {
|
||||
action: string;
|
||||
label: string;
|
||||
href: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
action: "Read",
|
||||
label: "Building Custom Agents",
|
||||
href: BUILDING_CUSTOM_AGENTS_URL,
|
||||
description: "to design coordinator and worker agents.",
|
||||
},
|
||||
{
|
||||
action: "Revisit",
|
||||
label: "What are Agents, Skills, and Instructions",
|
||||
href: WHAT_ARE_AGENTS_URL,
|
||||
description: "for the broader customization model.",
|
||||
},
|
||||
{
|
||||
action: "Keep the",
|
||||
label: "GitHub Copilot Terminology Glossary",
|
||||
href: TERMINOLOGY_GLOSSARY_URL,
|
||||
description: "nearby when comparing terminology across products.",
|
||||
},
|
||||
];
|
||||
|
||||
const articleSections: TocSection[] = [
|
||||
{ id: "start-with-the-mental-model", label: "Start with the mental model" },
|
||||
{
|
||||
id: "what-changes-when-work-moves-to-a-subagent",
|
||||
label: "What changes when work moves to a subagent",
|
||||
},
|
||||
{ id: "when-to-use-subagents", label: "When to use subagents" },
|
||||
{ id: "launch-subagents-in-vs-code", label: "Launch subagents in VS Code" },
|
||||
{
|
||||
id: "launch-subagents-in-copilot-cli",
|
||||
label: "Launch subagents in Copilot CLI",
|
||||
},
|
||||
{
|
||||
id: "orchestration-patterns-that-work-well",
|
||||
label: "Orchestration patterns that work well",
|
||||
},
|
||||
{
|
||||
id: "repository-examples-you-can-inspect",
|
||||
label: "Repository examples you can inspect",
|
||||
},
|
||||
{
|
||||
id: "important-platform-nuance-handoffs-are-not-universal",
|
||||
label: "Important platform nuance: handoffs are not universal",
|
||||
},
|
||||
{ id: "common-questions", label: "Common questions" },
|
||||
{ id: "next-steps", label: "Next steps" },
|
||||
];
|
||||
|
||||
export default function AgentsAndSubagents() {
|
||||
return (
|
||||
<LearningArticleLayout
|
||||
pageHref={pageHref}
|
||||
currentPage="agents-and-subagents"
|
||||
breadcrumbLabel="Agents & Subagents"
|
||||
heroTitle="Agents and Subagents"
|
||||
heroSubtitle="An agent is the primary assistant for a session, while a subagent is a temporary worker it launches for a narrower, specialized task."
|
||||
tocSections={articleSections}
|
||||
upNext={{
|
||||
label: "Understanding Copilot Context",
|
||||
href: "https://awesome-copilot.github.com/learning-hub/understanding-copilot-context/",
|
||||
}}
|
||||
>
|
||||
<section
|
||||
id="start-with-the-mental-model"
|
||||
className={styles.articleSection}
|
||||
>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Start with the mental model
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
This distinction matters more as you move from simple chat prompts to
|
||||
orchestrated agentic workflows.
|
||||
</Text>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Think of the main agent as a project lead and subagents as focused
|
||||
contributors:
|
||||
</Text>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Topic</th>
|
||||
<th>Agent</th>
|
||||
<th>Subagent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mentalModelRows.map((row) => (
|
||||
<tr key={row.topic}>
|
||||
<td>{row.topic}</td>
|
||||
<td>{row.agent}</td>
|
||||
<td>{row.subagent}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
In practice, the main agent keeps the big picture while subagents
|
||||
absorb the noisy intermediate work: research, code inspection,
|
||||
specialized review passes, or independent implementation tracks.
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="what-changes-when-work-moves-to-a-subagent"
|
||||
className={styles.articleSection}
|
||||
>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
What changes when work moves to a subagent
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Subagents are useful because they are not just “the same agent in
|
||||
another tab.” They usually change the shape of the work in a few
|
||||
important ways:
|
||||
</Text>
|
||||
<ul className={styles.pointList}>
|
||||
{subagentChanges.map((change) => (
|
||||
<li key={change.term}>
|
||||
<span className={styles.stepLabel}>{change.term}</span>:{" "}
|
||||
{change.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
That isolation is one of the main reasons subagents can outperform a
|
||||
single monolithic agent on larger tasks.
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section id="when-to-use-subagents" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
When to use subagents
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Subagents work especially well when you need to:
|
||||
</Text>
|
||||
<ul className={styles.checkList}>
|
||||
{subagentUseCases.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
If all of the work happens in one small file and does not need
|
||||
decomposition, a subagent may be unnecessary. The benefit appears when
|
||||
delegation reduces context pressure or lets multiple tracks run
|
||||
independently.
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="launch-subagents-in-vs-code"
|
||||
className={styles.articleSection}
|
||||
>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Launch subagents in VS Code
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
In VS Code, subagents are typically{" "}
|
||||
<strong>agent-initiated</strong>. You usually describe the larger task,
|
||||
and the main agent decides when to delegate a focused subtask. To make
|
||||
that possible, the agent needs access to the subagent tool.
|
||||
</Text>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>1. Enable the agent tool</span>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Use the <code className={styles.inlineCode}>agent</code> tool in
|
||||
frontmatter so the main agent can launch other agents:
|
||||
</Text>
|
||||
<CopyBlock code={featureBuilderFrontmatter} />
|
||||
<Text as="p" size="300" variant="muted">
|
||||
The <code className={styles.inlineCode}>agents</code> property acts
|
||||
as an allowlist for which worker agents this coordinator can call.
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>
|
||||
2. Define worker agents with clear boundaries
|
||||
</span>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Worker agents are often hidden from the picker and reserved for
|
||||
delegation:
|
||||
</Text>
|
||||
<CopyBlock code={plannerFrontmatter} />
|
||||
<Text as="p" size="300" variant="muted">
|
||||
You can also use{" "}
|
||||
<code className={styles.inlineCode}>disable-model-invocation: true</code>{" "}
|
||||
to prevent an agent from being used as a subagent unless another
|
||||
coordinator explicitly allows it.
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>
|
||||
3. Prompt for isolated or parallel work
|
||||
</span>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
You do not always need to say “run a subagent,” but
|
||||
prompts that describe isolated research or parallel tracks make
|
||||
delegation easier. For example:
|
||||
</Text>
|
||||
<CopyBlock code={parallelAnalysisPrompt} />
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>4. Know the nesting rule</span>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
By default, subagents do not keep spawning additional subagents. In
|
||||
VS Code, recursive delegation is controlled by the{" "}
|
||||
<code className={styles.inlineCode}>chat.subagents.allowInvocationsFromSubagents</code>{" "}
|
||||
setting, which is off by default.
|
||||
</Text>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="launch-subagents-in-copilot-cli"
|
||||
className={styles.articleSection}
|
||||
>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Launch subagents in Copilot CLI
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
In GitHub Copilot CLI, the clearest end-user entry point is{" "}
|
||||
<strong><code className={styles.inlineCode}>/fleet</code></strong>
|
||||
{". Fleet acts as an orchestrator that decomposes a larger objective, launches multiple background subagents, respects dependencies, and then synthesizes the final result."}
|
||||
</Text>
|
||||
<CopyBlock code={fleetCommand} />
|
||||
<Text as="p" size="300" variant="muted">
|
||||
For non-interactive execution:
|
||||
</Text>
|
||||
<CopyBlock code={fleetPromptModeCommand} />
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>
|
||||
Prompt mode and repo hooks (v1.0.40+):
|
||||
</span>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
When using{" "}
|
||||
<code className={styles.inlineCode}>copilot -p "..."</code>{" "}
|
||||
(prompt mode), repository hooks are disabled by default for security.
|
||||
If your <code className={styles.inlineCode}>/fleet</code> workflow
|
||||
relies on hooks (e.g., auto-formatting or lint checks after edits),
|
||||
opt in by setting{" "}
|
||||
<code className={styles.inlineCode}>GITHUB_COPILOT_PROMPT_MODE_REPO_HOOKS=true</code>{" "}
|
||||
before running. See{" "}
|
||||
<a href={AUTOMATING_WITH_HOOKS_URL}>Automating with Hooks</a> for
|
||||
details.
|
||||
</Text>
|
||||
</div>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
The important behavior is different from a single chat turn:
|
||||
</Text>
|
||||
<ul className={styles.checkList}>
|
||||
{fleetBehaviors.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
That makes <code className={styles.inlineCode}>/fleet</code> a
|
||||
practical way to launch subagents even if you are not authoring custom
|
||||
agent files yourself.
|
||||
</Text>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Rubber-duck agent</span>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Available in <code className={styles.inlineCode}>/experimental</code>{" "}
|
||||
(v1.0.42+), the <strong>rubber-duck agent</strong> applies a novel
|
||||
multi-model pattern: when you’re working in a GPT-powered
|
||||
session, the rubber-duck agent internally routes certain requests
|
||||
through Claude to provide a second perspective. The idea is similar
|
||||
to rubber-duck debugging — talking through a problem with a
|
||||
different “listener” often surfaces assumptions or blind
|
||||
spots you didn’t notice.
|
||||
</Text>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
In v1.0.64+, you can configure the rubber-duck agent (including its
|
||||
complementary model strategy) directly from{" "}
|
||||
<code className={styles.inlineCode}>/subagents</code>:
|
||||
</Text>
|
||||
<CopyBlock code={subagentsCommand} />
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Or you can still enable experimental features and select it from the
|
||||
agent picker:
|
||||
</Text>
|
||||
<CopyBlock code={experimentalAgentCommand} />
|
||||
<Text as="p" size="300" variant="muted">
|
||||
The <strong>complementary model strategy</strong> lets you specify
|
||||
that the rubber-duck agent should automatically pick a model from a
|
||||
different family than your primary model (e.g., if you’re on
|
||||
Claude, it selects a GPT model, and vice versa). This maximises the
|
||||
diversity of perspectives.
|
||||
</Text>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Because it runs as a sub-agent layer rather than replacing your
|
||||
primary model, you keep your current session model and context while
|
||||
the rubber-duck analysis runs in the background.
|
||||
</Text>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Note:</span>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
This is an experimental feature and may change. Provide feedback
|
||||
via <code className={styles.inlineCode}>/feedback</code> if you
|
||||
find it useful.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="orchestration-patterns-that-work-well"
|
||||
className={styles.articleSection}
|
||||
>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Orchestration patterns that work well
|
||||
</Heading>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Coordinator and worker</span>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
One agent owns the workflow and delegates to narrower specialists
|
||||
such as planner, implementer, and reviewer. This keeps the
|
||||
coordinator lightweight and makes the worker prompts more precise.
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Multi-perspective review</span>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Run parallel subagents for different lenses - correctness, security,
|
||||
code quality, architecture - and combine the results after they
|
||||
finish.
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Research, then act</span>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Use one subagent to gather facts and another to implement with those
|
||||
facts. This pattern is especially helpful when you want the main
|
||||
thread to stay free of exploratory noise.
|
||||
</Text>
|
||||
</div>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
The built-in{" "}
|
||||
<strong><code className={styles.inlineCode}>/research</code></strong>{" "}
|
||||
command uses this orchestrator/subagent model automatically
|
||||
(v1.0.40+): it spawns an orchestrator that breaks the topic into
|
||||
research threads, runs them in parallel as subagents, and synthesizes
|
||||
the findings into a structured report. This means you get deeper and
|
||||
more reliable results than a single-turn query provides — without
|
||||
having to set up the multi-agent pattern yourself.
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="repository-examples-you-can-inspect"
|
||||
className={styles.articleSection}
|
||||
>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Repository examples you can inspect
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
This repository already includes a few useful examples of
|
||||
delegation-related syntax:
|
||||
</Text>
|
||||
<ul className={styles.pointList}>
|
||||
<li>
|
||||
<a href="https://github.com/github/awesome-copilot/blob/main/agents/context7.agent.md"><code className={styles.inlineCode}>agents/context7.agent.md</code></a>{" "}
|
||||
is a concrete example of VS Code-style{" "}
|
||||
<code className={styles.inlineCode}>handoffs</code>. It defines a
|
||||
handoff button that can pass work to another agent after research is
|
||||
complete.
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/github/awesome-copilot/blob/main/agents/rug-orchestrator.agent.md"><code className={styles.inlineCode}>agents/rug-orchestrator.agent.md</code></a>{" "}
|
||||
is a strong coordinator example. It enables the{" "}
|
||||
<code className={styles.inlineCode}>agent</code> tool and restricts
|
||||
delegation with{" "}
|
||||
<code className={styles.inlineCode}>agents: ['SWE', 'QA']</code>.
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/github/awesome-copilot/blob/main/agents/gem-orchestrator.agent.md"><code className={styles.inlineCode}>agents/gem-orchestrator.agent.md</code></a>{" "}
|
||||
shows invocation control with{" "}
|
||||
<code className={styles.inlineCode}>user-invocable</code> and{" "}
|
||||
<code className={styles.inlineCode}>disable-model-invocation</code>{", which is useful when deciding whether an orchestrator should be directly selectable, delegatable, or both."}
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/github/awesome-copilot/blob/main/agents/custom-agent-foundry.agent.md"><code className={styles.inlineCode}>agents/custom-agent-foundry.agent.md</code></a>{" "}
|
||||
documents the VS Code{" "}
|
||||
<code className={styles.inlineCode}>handoffs</code> shape in its
|
||||
guidance section, which is helpful if you want a template before
|
||||
creating your own coordinator workflow.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="important-platform-nuance-handoffs-are-not-universal"
|
||||
className={styles.articleSection}
|
||||
>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Important platform nuance: handoffs are not universal
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
VS Code documentation describes both subagents and the{" "}
|
||||
<code className={styles.inlineCode}>handoffs</code> frontmatter
|
||||
property.{" "}
|
||||
<a href={AGENT_CONFIGURATION_REFERENCE_URL}>GitHub’s custom agent configuration reference</a>{", however, notes that "}
|
||||
<code className={styles.inlineCode}>handoffs</code> and{" "}
|
||||
<code className={styles.inlineCode}>argument-hint</code> are currently
|
||||
ignored for Copilot cloud agent on GitHub.com.
|
||||
</Text>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
That means you should think about delegation features in
|
||||
product-specific terms:
|
||||
</Text>
|
||||
<ul className={styles.pointList}>
|
||||
<li>
|
||||
<span className={styles.stepLabel}>VS Code</span>: supports subagent
|
||||
concepts, allowlists, and handoff-oriented agent composition
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.stepLabel}>Copilot CLI</span>: exposes
|
||||
practical orchestration through commands like{" "}
|
||||
<code className={styles.inlineCode}>/fleet</code>
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.stepLabel}>
|
||||
GitHub.com coding agent / cloud agent
|
||||
</span>{": supports custom agents, but some VS Code-specific frontmatter is intentionally ignored"}
|
||||
</li>
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
If you share agent files across surfaces, document those differences
|
||||
so users know which behaviors are portable and which are
|
||||
editor-specific.
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section id="common-questions" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Common questions
|
||||
</Heading>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>
|
||||
Do users always invoke subagents directly?
|
||||
</span>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
No. Most of the time the main agent launches them when it decides the
|
||||
task benefits from context isolation or parallelism.
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>
|
||||
Can a subagent use a different model or tool set?
|
||||
</span>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Yes, when the delegated worker is a custom agent with its own
|
||||
frontmatter.
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>
|
||||
Are subagents always parallel?
|
||||
</span>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
No. They can run sequentially when one step depends on another, or in
|
||||
parallel when work items are independent.
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>
|
||||
Can I control how many subagents run simultaneously?
|
||||
</span>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Yes. In v1.0.66+, usage-based billing users can configure{" "}
|
||||
<strong>subagent concurrency and depth limits</strong> directly from{" "}
|
||||
<code className={styles.inlineCode}>/settings</code>. The concurrency
|
||||
limit controls how many subagents run in parallel; the depth limit
|
||||
controls how many levels deep delegation can chain (preventing
|
||||
runaway recursive subagent trees). These settings give you
|
||||
predictable control over resource consumption during complex
|
||||
orchestrated tasks.
|
||||
</Text>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="next-steps" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Next steps
|
||||
</Heading>
|
||||
<ul className={styles.checkList}>
|
||||
{nextSteps.map((step) => (
|
||||
<li key={step.label}>
|
||||
{step.action} <a href={step.href}>{step.label}</a>{" "}
|
||||
{step.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</LearningArticleLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import { Heading, Text } from "@primer/react-brand";
|
||||
import { BookIcon, PlayIcon, GlobeIcon, ArrowUpRightIcon } from "@primer/octicons-react";
|
||||
|
||||
import styles from "../styles/github-copilot-app.module.css";
|
||||
import { pageHref } from "../pageHref";
|
||||
import { InstallCommandBar } from "../InstallCommandBar";
|
||||
import { LearningArticleLayout, type TocSection } from "../LearningArticleLayout";
|
||||
import { VideoCarousel, type Video } from "../VideoCarousel";
|
||||
|
||||
const cliIntroVideo = "/media/cli-for-beginners-intro.mp4";
|
||||
|
||||
const articleSections: TocSection[] = [
|
||||
{ id: "what-youll-learn", label: "What you’ll learn" },
|
||||
{ id: "prerequisites", label: "Prerequisites" },
|
||||
{
|
||||
id: "github-copilot-family",
|
||||
label: "Understanding the GitHub Copilot family",
|
||||
},
|
||||
{ id: "course-structure", label: "Course structure" },
|
||||
{ id: "how-this-course-works", label: "How this course works" },
|
||||
{
|
||||
id: "command-reference",
|
||||
label: "GitHub Copilot CLI command reference",
|
||||
},
|
||||
{ id: "getting-help", label: "Getting help" },
|
||||
{ id: "license", label: "License" },
|
||||
{ id: "video-series", label: "Video series" },
|
||||
{ id: "learn-more", label: "Learn more" },
|
||||
];
|
||||
|
||||
/** GitHub's official "Copilot CLI for Beginners" YouTube series. */
|
||||
const videoSeries: Video[] = [
|
||||
{
|
||||
id: "BDxRhhs36ns",
|
||||
title: "Getting started with GitHub Copilot CLI",
|
||||
meta: "Part 1 · GitHub",
|
||||
},
|
||||
{
|
||||
id: "bdIJkGr2NV0",
|
||||
title: "Interactive vs non-interactive modes",
|
||||
meta: "Part 2 · GitHub",
|
||||
},
|
||||
{
|
||||
id: "-Yavis20B4Q",
|
||||
title: "A beginner’s guide to slash commands",
|
||||
meta: "Part 3 · GitHub",
|
||||
},
|
||||
{
|
||||
id: "v8dr7QcIiLU",
|
||||
title: "Plan, delegate, and review",
|
||||
meta: "Part 4 · GitHub",
|
||||
},
|
||||
{
|
||||
id: "DtQjVIRRszM",
|
||||
title: "How to use MCP servers with Copilot CLI",
|
||||
meta: "Part 5 · GitHub",
|
||||
},
|
||||
{
|
||||
id: "-yKALFS5ewY",
|
||||
title: "Using agents, skills, and instructions",
|
||||
meta: "Part 6 · GitHub",
|
||||
},
|
||||
];
|
||||
|
||||
export default function CliForBeginnersOverview() {
|
||||
return (
|
||||
<LearningArticleLayout
|
||||
pageHref={pageHref}
|
||||
currentPage="cli-for-beginners"
|
||||
breadcrumbLabel="Copilot CLI"
|
||||
heroTitle="GitHub Copilot CLI"
|
||||
animateHeroTitle
|
||||
heroSubtitle="GitHub Copilot CLI brings AI assistance to your terminal — ask questions, generate apps, review code, write tests, and debug without leaving it. Start here, then continue into the hands-on workshop track."
|
||||
heroExtra={<InstallCommandBar />}
|
||||
tocSections={articleSections}
|
||||
upNext={{
|
||||
label: "Quick Start",
|
||||
href: pageHref("learning-hub/cli-for-beginners/00-quick-start"),
|
||||
}}
|
||||
>
|
||||
<section id="what-youll-learn" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
What you’ll learn
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
<strong>
|
||||
Learn to supercharge your development workflow with AI-powered
|
||||
command-line assistance.
|
||||
</strong>
|
||||
</Text>
|
||||
<figure className={styles.videoFigure}>
|
||||
<video
|
||||
className={styles.video}
|
||||
src={cliIntroVideo}
|
||||
controls
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
aria-label="Demo of GitHub Copilot CLI assisting from the terminal"
|
||||
/>
|
||||
</figure>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Think of it as having a knowledgeable colleague available 24/7 who can
|
||||
read your code, explain confusing patterns, and help you work faster!
|
||||
</Text>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
This course is designed for:
|
||||
</Text>
|
||||
<ul className={styles.checkList}>
|
||||
<li>
|
||||
<strong>Software Developers</strong> who want to use AI from the
|
||||
command line
|
||||
</li>
|
||||
<li>
|
||||
<strong>Terminal users</strong> who prefer keyboard-driven workflows
|
||||
over IDE integrations
|
||||
</li>
|
||||
<li>
|
||||
<strong>Teams looking to standardize</strong> AI-assisted code review
|
||||
and development practices
|
||||
</li>
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
This hands-on course takes you from zero to productive with GitHub
|
||||
Copilot CLI. You’ll work with a single Python book collection app
|
||||
throughout all chapters, progressively improving it using AI-assisted
|
||||
workflows. By the end, you’ll confidently use AI to review code,
|
||||
generate tests, debug issues, and automate workflows: all from your
|
||||
terminal.
|
||||
</Text>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
<strong>No AI experience required.</strong> If you can use a terminal,
|
||||
you can learn this.
|
||||
</Text>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
<strong>Perfect for:</strong> Developers, students, and anyone who has
|
||||
experience with software development.
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section id="prerequisites" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Prerequisites
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Before starting, ensure you have:
|
||||
</Text>
|
||||
<ul className={styles.checkList}>
|
||||
<li>
|
||||
<strong>GitHub account</strong>: {" "}
|
||||
<a href="https://github.com/signup">Create one free</a>
|
||||
</li>
|
||||
<li>
|
||||
<strong>GitHub Copilot access</strong>:{" "}
|
||||
<a href="https://github.com/features/copilot/plans">
|
||||
Free offering
|
||||
</a>
|
||||
{", "}
|
||||
<a href="https://github.com/features/copilot/plans">
|
||||
Monthly subscription
|
||||
</a>
|
||||
{", or "}
|
||||
<a href="https://education.github.com/pack">
|
||||
Free for students/teachers
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Terminal basics</strong>: Comfortable with {" "}
|
||||
<code className={styles.inlineCode}>cd</code>, {" "}
|
||||
<code className={styles.inlineCode}>ls</code>, running commands
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="github-copilot-family" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Understanding the GitHub Copilot family
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
GitHub Copilot has evolved into a family of AI-powered tools. Here’s
|
||||
where each one lives:
|
||||
</Text>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>Where it runs</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://docs.github.com/copilot/how-tos/copilot-cli/cli-getting-started">
|
||||
<strong>GitHub Copilot CLI</strong>
|
||||
</a>
|
||||
<br />
|
||||
(this course)
|
||||
</td>
|
||||
<td>Your terminal</td>
|
||||
<td>Terminal-native AI coding assistant</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://docs.github.com/copilot">
|
||||
<strong>GitHub Copilot</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td>VS Code, Visual Studio, JetBrains, etc.</td>
|
||||
<td>Agent mode, chat, inline suggestions</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://github.com/copilot">
|
||||
<strong>Copilot on GitHub.com</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td>GitHub</td>
|
||||
<td>Immersive chat about your repos, create agents, and more</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://docs.github.com/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks">
|
||||
<strong>GitHub Copilot coding agent</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td>GitHub</td>
|
||||
<td>Assign issues to agents, get PRs back</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
This course focuses on <strong>GitHub Copilot CLI</strong>, bringing AI
|
||||
assistance directly to your terminal.
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section id="course-structure" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Course structure
|
||||
</Heading>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Chapter</th>
|
||||
<th>Title</th>
|
||||
<th>What you’ll build</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>00</td>
|
||||
<td>
|
||||
<a href={pageHref("learning-hub/cli-for-beginners/00-quick-start")}>Quick Start</a>
|
||||
</td>
|
||||
<td>Installation and verification</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>01</td>
|
||||
<td>
|
||||
<a href={pageHref("learning-hub/cli-for-beginners/01-setup-and-first-steps")}>First Steps</a>
|
||||
</td>
|
||||
<td>Live demos + three interaction modes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>02</td>
|
||||
<td>
|
||||
<a href={pageHref("learning-hub/cli-for-beginners/02-context-and-conversations")}>
|
||||
Context and Conversations
|
||||
</a>
|
||||
</td>
|
||||
<td>Multi-file project analysis</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>03</td>
|
||||
<td>
|
||||
<a href={pageHref("learning-hub/cli-for-beginners/03-development-workflows")}>
|
||||
Development Workflows
|
||||
</a>
|
||||
</td>
|
||||
<td>Code review, debug, test generation</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>04</td>
|
||||
<td>
|
||||
<a href={pageHref("learning-hub/cli-for-beginners/04-agents-and-custom-instructions")}>
|
||||
Create Specialized AI Assistants
|
||||
</a>
|
||||
</td>
|
||||
<td>Custom agents for your workflow</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>05</td>
|
||||
<td>
|
||||
<a href={pageHref("learning-hub/cli-for-beginners/05-skills")}>Automate Repetitive Tasks</a>
|
||||
</td>
|
||||
<td>Skills that load automatically</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>06</td>
|
||||
<td>
|
||||
<a href={pageHref("learning-hub/cli-for-beginners/06-mcp-servers")}>
|
||||
Connect to GitHub, Databases & APIs
|
||||
</a>
|
||||
</td>
|
||||
<td>MCP server integration</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>07</td>
|
||||
<td>
|
||||
<a href={pageHref("learning-hub/cli-for-beginners/07-putting-it-all-together")}>
|
||||
Putting It All Together
|
||||
</a>
|
||||
</td>
|
||||
<td>Complete feature workflows</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="how-this-course-works" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
How this course works
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Each chapter follows the same pattern:
|
||||
</Text>
|
||||
<ol className={styles.stepsList}>
|
||||
<li>
|
||||
<strong>Real-World Analogy</strong>: Understand the concept through
|
||||
familiar comparisons
|
||||
</li>
|
||||
<li>
|
||||
<strong>Core Concepts</strong>: Learn the essential knowledge
|
||||
</li>
|
||||
<li>
|
||||
<strong>Hands-On Examples</strong>: Run actual commands and see
|
||||
results
|
||||
</li>
|
||||
<li>
|
||||
<strong>Assignment</strong>: Practice what you learned
|
||||
</li>
|
||||
<li>
|
||||
<strong>What’s Next</strong>: Preview of the following chapter
|
||||
</li>
|
||||
</ol>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
<strong>Code examples are runnable.</strong> Every copilot text block in
|
||||
this course can be copied and run in your terminal.
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section id="command-reference" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
GitHub Copilot CLI command reference
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
The {" "}
|
||||
<strong>
|
||||
<a href="https://docs.github.com/en/copilot/reference/cli-command-reference">
|
||||
GitHub Copilot CLI command reference
|
||||
</a>
|
||||
</strong>{" "}
|
||||
helps you find commands and keyboard shortcuts to help you use Copilot
|
||||
CLI effectively.
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section id="getting-help" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Getting help
|
||||
</Heading>
|
||||
<ul className={styles.checkList}>
|
||||
<li>
|
||||
<strong>Found a bug?</strong> {" "}
|
||||
<a href="https://github.com/github/copilot-cli-for-beginners/issues">
|
||||
Open an Issue
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Want to contribute?</strong> PRs welcome!
|
||||
</li>
|
||||
<li>
|
||||
<strong>Official Docs:</strong> {" "}
|
||||
<a href="https://docs.github.com/copilot/concepts/agents/about-copilot-cli">
|
||||
GitHub Copilot CLI Documentation
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="license" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
License
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
This project is licensed under the terms of the MIT open source license.
|
||||
Please refer to the {" "}
|
||||
<a href="https://github.com/github/copilot-cli-for-beginners/blob/main/LICENSE">
|
||||
LICENSE
|
||||
</a>{" "}
|
||||
file for the full terms.
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section id="video-series" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Video series
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Prefer to watch and learn? Follow GitHub’s official{" "}
|
||||
<strong>Copilot CLI for beginners</strong> video series. Pick a chapter
|
||||
below to play it right here on the page.
|
||||
</Text>
|
||||
<VideoCarousel videos={videoSeries} />
|
||||
</section>
|
||||
|
||||
<section id="learn-more" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Learn more
|
||||
</Heading>
|
||||
<div className={styles.learnMoreBand}>
|
||||
<a className={styles.learnMoreCard} href="https://github.com/features/copilot/cli">
|
||||
<span className={styles.learnMoreCardIcon} aria-hidden="true">
|
||||
<GlobeIcon size={24} />
|
||||
</span>
|
||||
<span className={styles.learnMoreCardTitle}>
|
||||
GitHub Copilot CLI
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</span>
|
||||
</a>
|
||||
<a className={styles.learnMoreCard} href="https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli">
|
||||
<span className={styles.learnMoreCardIcon} aria-hidden="true">
|
||||
<BookIcon size={24} />
|
||||
</span>
|
||||
<span className={styles.learnMoreCardTitle}>
|
||||
Documentation
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</span>
|
||||
</a>
|
||||
<a className={styles.learnMoreCard} href="https://www.youtube.com/playlist?list=PL0lo9MOBetEHvO-spzKBAITkkTqv4RvNl">
|
||||
<span className={styles.learnMoreCardIcon} aria-hidden="true">
|
||||
<PlayIcon size={24} />
|
||||
</span>
|
||||
<span className={styles.learnMoreCardTitle}>
|
||||
Video series
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</LearningArticleLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Heading, Text } from "@primer/react-brand";
|
||||
|
||||
import styles from "../styles/github-copilot-app.module.css";
|
||||
import { LearningArticleLayout, type TocSection } from "../LearningArticleLayout";
|
||||
import { pageHref } from "../pageHref";
|
||||
|
||||
export type RecipeLink = {
|
||||
label: string;
|
||||
href: string;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
export type CookbookRecipeCard = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
author?: { name: string; href: string };
|
||||
tags: string[];
|
||||
links: RecipeLink[];
|
||||
};
|
||||
|
||||
export type CookbookSection = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
recipes: CookbookRecipeCard[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Cookbook index.
|
||||
*
|
||||
* Ported from the prototype's `pages/cookbook.tsx`, but backed by the real
|
||||
* cookbook data source so live recipes and community samples stay in sync with
|
||||
* `public/data/samples.json`.
|
||||
*/
|
||||
export default function CookbookIndex({
|
||||
sections,
|
||||
}: {
|
||||
sections: CookbookSection[];
|
||||
}) {
|
||||
const articleSections: TocSection[] = sections.map((section) => ({
|
||||
id: section.id,
|
||||
label: section.name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<LearningArticleLayout
|
||||
pageHref={pageHref}
|
||||
currentPage="cookbook"
|
||||
breadcrumbLabel="Cookbook"
|
||||
heroTitle="Cookbook"
|
||||
heroSubtitle="Code samples, recipes, and hands-on examples for building with GitHub Copilot — ready to copy into your own projects and adapt."
|
||||
tocSections={articleSections}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<section key={section.id} id={section.id} className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
{section.name}
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
{section.description}
|
||||
</Text>
|
||||
{section.recipes.map((recipe) => (
|
||||
<div key={recipe.id} className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>{recipe.title}</span>
|
||||
{recipe.badge ? (
|
||||
<Text as="p" size="300" variant="muted">
|
||||
{recipe.badge}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text as="p" size="300" variant="muted">
|
||||
{recipe.description}
|
||||
</Text>
|
||||
{recipe.author ? (
|
||||
<Text as="p" size="300" variant="muted">
|
||||
by{" "}
|
||||
<a href={recipe.author.href} target="_blank" rel="noopener">
|
||||
{recipe.author.name}
|
||||
</a>
|
||||
</Text>
|
||||
) : null}
|
||||
<ul className={styles.checkList}>
|
||||
{recipe.tags.map((tag) => (
|
||||
<li key={tag}>{tag}</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul className={styles.checkList}>
|
||||
{recipe.links.map((link) => (
|
||||
<li key={link.label}>
|
||||
<a
|
||||
href={link.href}
|
||||
target={link.external ? "_blank" : undefined}
|
||||
rel={link.external ? "noopener" : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</LearningArticleLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
import { Heading, Text } from "@primer/react-brand";
|
||||
|
||||
import styles from "../styles/github-copilot-app.module.css";
|
||||
import { pageHref } from "../pageHref";
|
||||
import {
|
||||
CopyBlock,
|
||||
LearningArticleLayout,
|
||||
type TocSection,
|
||||
} from "../LearningArticleLayout";
|
||||
|
||||
const articleSections: TocSection[] = [
|
||||
{ id: "configuration-levels", label: "Configuration Levels" },
|
||||
{ id: "key-configuration-options", label: "Key Configuration Options" },
|
||||
{ id: "repository-level-configuration", label: "Repository-Level Configuration" },
|
||||
{ id: "setting-up-team-configuration", label: "Setting Up Team Configuration" },
|
||||
{ id: "ide-specific-configuration", label: "IDE-Specific Configuration" },
|
||||
{ id: "common-questions", label: "Common Questions" },
|
||||
{ id: "next-steps", label: "Next Steps" },
|
||||
];
|
||||
|
||||
export default function CopilotConfigurationBasics() {
|
||||
return (
|
||||
<LearningArticleLayout
|
||||
pageHref={pageHref}
|
||||
currentPage="copilot-configuration-basics"
|
||||
breadcrumbLabel="Configuration Basics"
|
||||
heroTitle="Copilot Configuration Basics"
|
||||
heroSubtitle="GitHub Copilot’s configuration layers let you tailor its behavior to your preferences, projects, and team standards for consistent results."
|
||||
tocSections={articleSections}
|
||||
upNext={{
|
||||
label: "Defining Custom Instructions",
|
||||
href: "https://awesome-copilot.github.com/learning-hub/defining-custom-instructions/",
|
||||
}}
|
||||
>
|
||||
<section id="configuration-levels" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Configuration Levels
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">GitHub Copilot uses a hierarchical configuration system where settings at different levels can override each other. Understanding this hierarchy helps you apply the right configuration at the right level.</Text>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>User Settings</span>
|
||||
<Text as="p" size="300" variant="muted">User settings apply globally across all your projects and represent your personal preferences. These are stored in your IDE’s user configuration and travel with your IDE profile.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Common user-level settings</span>:</Text>
|
||||
<ul className={styles.checkList}>
|
||||
<li>Enable/disable inline suggestions globally</li>
|
||||
<li>Commit message style preferences</li>
|
||||
<li>Default language preferences</li>
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>When to use</span>: For personal preferences that should apply everywhere you work, like keyboard shortcuts or whether you prefer inline suggestions vs chat.</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Repository Settings</span>
|
||||
<Text as="p" size="300" variant="muted">Repository settings live in your codebase (typically in <code className={styles.inlineCode}>.github/</code> although some editors allow customising the paths that Copilot will use) and are shared with everyone working on the project. These provide the highest level of customization and override both user and workspace settings.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Common repository-level customizations</span>:</Text>
|
||||
<ul className={styles.checkList}>
|
||||
<li>Custom instructions for coding conventions</li>
|
||||
<li>Reusable skills for common tasks</li>
|
||||
<li>Specialized agents for project workflows</li>
|
||||
<li>Custom agents for domain expertise</li>
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>When to use</span>: For repository-wide standards, project-specific best practices, and reusable customizations that should be version-controlled and shared.</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Organisation Settings (GitHub.com only)</span>
|
||||
<Text as="p" size="300" variant="muted">Organisation settings allow administrators to enforce Copilot policies across all repositories within an organization. These settings can include defining custom agents, creating globally applied instructions, enabling or disabling Copilot, managing billing, and setting usage limits. These policies may not be enforced in the IDE, depending on the IDE’s support for organization-level settings, but will apply to Copilot usage on GitHub.com.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>When to use</span>: For enforcing organization-wide policies, ensuring compliance, and providing shared resources across multiple repositories.</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Configuration Precedence</span>
|
||||
<Text as="p" size="300" variant="muted">When multiple configuration levels define the same setting, GitHub Copilot applies them in this order (highest precedence first):</Text>
|
||||
<ol className={styles.stepsList}>
|
||||
<li><span className={styles.stepLabel}>Organisation settings</span> (if applicable)</li>
|
||||
<li><span className={styles.stepLabel}>Repository settings</span> (<code className={styles.inlineCode}>.github/</code>)</li>
|
||||
<li><span className={styles.stepLabel}>User settings</span> (IDE global preferences)</li>
|
||||
</ol>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Example</span>: If your user settings disable Copilot for <code className={styles.inlineCode}>.test.ts</code> files, but repository settings enable custom instructions for test files, the repository settings take precedence and Copilot remains active with the custom instructions applied.</Text>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="key-configuration-options" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Key Configuration Options
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">These settings control GitHub Copilot’s core behavior across all IDEs:</Text>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Inline Suggestions</span>
|
||||
<Text as="p" size="300" variant="muted">Control whether Copilot automatically suggests code completions as you type.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>VS Code example</span>:</Text>
|
||||
<CopyBlock code={`{
|
||||
"github.copilot.enable": {
|
||||
"*": true,
|
||||
"plaintext": false,
|
||||
"markdown": false
|
||||
}
|
||||
}`} label="VS Code example:" />
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Why it matters</span>: Some developers prefer to invoke Copilot explicitly rather than seeing automatic suggestions. You can also enable it only for specific languages.</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Chat Availability</span>
|
||||
<Text as="p" size="300" variant="muted">Control access to GitHub Copilot Chat in your IDE.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>VS Code example</span>:</Text>
|
||||
<CopyBlock code={`{
|
||||
"github.copilot.chat.enabled": true
|
||||
}`} label="VS Code example:" />
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Why it matters</span>: Chat provides a conversational interface for asking questions and getting explanations, complementing inline suggestions.</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Suggestion Trigger Behavior</span>
|
||||
<Text as="p" size="300" variant="muted">Configure how and when Copilot generates suggestions.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>VS Code example</span>:</Text>
|
||||
<CopyBlock code={`{
|
||||
"editor.inlineSuggest.enabled": true,
|
||||
"github.copilot.editor.enableAutoCompletions": true
|
||||
}`} label="VS Code example:" />
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Why it matters</span>: Control whether suggestions appear automatically or only when explicitly requested, balancing helpfulness with potential distraction.</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Language-Specific Settings</span>
|
||||
<Text as="p" size="300" variant="muted">Enable or disable Copilot for specific programming languages.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>VS Code example</span>:</Text>
|
||||
<CopyBlock code={`{
|
||||
"github.copilot.enable": {
|
||||
"typescript": true,
|
||||
"javascript": true,
|
||||
"python": true,
|
||||
"markdown": false
|
||||
}
|
||||
}`} label="VS Code example:" />
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Why it matters</span>: You may want Copilot active for code files but not for documentation or configuration files.</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Excluded Files and Directories</span>
|
||||
<Text as="p" size="300" variant="muted">Prevent Copilot from accessing specific files or directories.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>VS Code example</span>:</Text>
|
||||
<CopyBlock code={`{
|
||||
"github.copilot.advanced": {
|
||||
"debug.filterLogCategories": [],
|
||||
"excludedFiles": [
|
||||
"**/secrets/**",
|
||||
"**/*.env",
|
||||
"**/node_modules/**"
|
||||
]
|
||||
}
|
||||
}`} label="VS Code example:" />
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Why it matters</span>: Exclude sensitive files, generated code, or dependencies from Copilot’s context to improve suggestion relevance and protect confidential information.</Text>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="repository-level-configuration" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Repository-Level Configuration
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>.github/</code> directory in your repository enables team-wide customizations that are version-controlled and shared across all contributors.</Text>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Directory Structure</span>
|
||||
<Text as="p" size="300" variant="muted">A well-organized Copilot configuration directory looks like this:</Text>
|
||||
<CopyBlock code={`.github/
|
||||
├── agents/
|
||||
│ ├── terraform-expert.agent.md
|
||||
│ └── api-reviewer.agent.md
|
||||
├── skills/
|
||||
│ ├── generate-tests/
|
||||
│ │ └── SKILL.md
|
||||
│ └── refactor-component/
|
||||
│ └── SKILL.md
|
||||
└── instructions/
|
||||
├── typescript-conventions.instructions.md
|
||||
└── api-design.instructions.md`} label="A well-organized Copilot configuration directory looks like this:" />
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Monorepo Support</span>
|
||||
<Text as="p" size="300" variant="muted">In monorepos with multiple packages or services, GitHub Copilot CLI discovers customizations at <span className={styles.stepLabel}>every directory level</span> from your working directory up to the git repository root. This means each package or service can have its own <code className={styles.inlineCode}>.github/</code> folder with specialized agents, instructions, skills, and MCP servers, while still inheriting configuration from parent directories.</Text>
|
||||
<CopyBlock code={`my-monorepo/
|
||||
├── .github/
|
||||
│ └── instructions/
|
||||
│ └── shared-conventions.instructions.md ← applies everywhere
|
||||
├── packages/
|
||||
│ ├── api/
|
||||
│ │ └── .github/
|
||||
│ │ └── agents/
|
||||
│ │ └── api-expert.agent.md ← applies in packages/api/
|
||||
│ └── web/
|
||||
│ └── .github/
|
||||
│ └── instructions/
|
||||
│ └── react-conventions.instructions.md ← applies in packages/web/`} label="Monorepo Support" />
|
||||
<Text as="p" size="300" variant="muted">When you work inside <code className={styles.inlineCode}>packages/api/</code>, Copilot loads configuration from <code className={styles.inlineCode}>packages/api/.github/</code>, then <code className={styles.inlineCode}>packages/.github/</code> (if it exists), then the root <code className={styles.inlineCode}>.github/</code>. This layered discovery ensures the right context is active no matter where in the repository you’re working.</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Personal Skills Directory</span>
|
||||
<Text as="p" size="300" variant="muted">In addition to repository-level skills, GitHub Copilot CLI supports <span className={styles.stepLabel}>personal skills directories</span> at <code className={styles.inlineCode}>~/.copilot/skills/</code> and <code className={styles.inlineCode}>~/.agents/skills/</code>. Skills you place in either location are discovered automatically across all your projects, making them ideal for personal workflows and reusable utilities that are not project-specific.</Text>
|
||||
<CopyBlock code={`~/.agents/
|
||||
└── skills/
|
||||
├── my-review-style/
|
||||
│ └── SKILL.md ← available in all sessions
|
||||
└── cleanup-todos/
|
||||
└── SKILL.md`} label="Personal Skills Directory" />
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>~/.agents/skills/</code> path aligns with the VS Code GitHub Copilot for Azure extension’s default skill discovery path, while <code className={styles.inlineCode}>~/.copilot/skills/</code> matches the Copilot CLI configuration directory. Both are supported for personal skills.</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Pinning Model and Effort via .github/copilot/settings.json</span>
|
||||
<Text as="p" size="300" variant="muted"><em>(v1.0.70+)</em> A <span className={styles.stepLabel}>trusted repository</span> can pin the model, reasoning effort level, and context tier for all sessions working in that repository by adding a <code className={styles.inlineCode}>.github/copilot/settings.json</code> file. This is a team governance feature that ensures everyone uses a consistent model configuration without relying on individual user settings:</Text>
|
||||
<CopyBlock code={`{
|
||||
"model": "claude-sonnet-4",
|
||||
"effortLevel": "high",
|
||||
"contextTier": "full"
|
||||
}`} label="Pinning Model and Effort via .github/copilot/settings.json" />
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Supported fields</span>:</Text>
|
||||
<table tabIndex={0}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Description</th>
|
||||
<th>Example values</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code className={styles.inlineCode}>model</code></td>
|
||||
<td>The AI model to use for this repository</td>
|
||||
<td><code className={styles.inlineCode}>"claude-sonnet-4"</code>, <code className={styles.inlineCode}>"gpt-4.1"</code>, <code className={styles.inlineCode}>"claude-sonnet-5"</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code className={styles.inlineCode}>effortLevel</code></td>
|
||||
<td>Reasoning effort level</td>
|
||||
<td><code className={styles.inlineCode}>"low"</code>, <code className={styles.inlineCode}>"medium"</code>, <code className={styles.inlineCode}>"high"</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code className={styles.inlineCode}>contextTier</code></td>
|
||||
<td>How much context to include</td>
|
||||
<td><code className={styles.inlineCode}>"default"</code>, <code className={styles.inlineCode}>"full"</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<Text as="p" size="300" variant="muted">In addition to model and effort settings, this file can also extend the URL, MCP server, and skill deny lists, allowing organizations to enforce access restrictions at the repository level.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Why use this</span>: Pin a model when your team has agreed on the right cost/quality tradeoff for a project. Pin a high effort level for codebases where mistakes are expensive. Deny lists let you block specific MCP servers or URLs that aren’t appropriate for a given project’s security posture.</Text>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Trust requirement:</span>
|
||||
<Text as="p" size="300" variant="muted">The repository must be explicitly trusted by the user for these settings to take effect. This prevents untrusted repositories from changing your model or access restrictions without your knowledge.</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Custom Agents</span>
|
||||
<Text as="p" size="300" variant="muted">Agents are specialized assistants for specific workflows. Place agent definition files in <code className={styles.inlineCode}>.github/agents/</code>.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Example agent</span> (<code className={styles.inlineCode}>terraform-expert.agent.md</code>):</Text>
|
||||
<CopyBlock code={`---
|
||||
description: 'Terraform infrastructure-as-code specialist'
|
||||
tools: ['filesystem', 'terminal']
|
||||
name: 'Terraform Expert'
|
||||
---
|
||||
|
||||
You are an expert in Terraform and cloud infrastructure.
|
||||
Guide users through creating, reviewing, and deploying infrastructure code.`} label="Example agent (terraform-expert.agent.md):" />
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>When to use</span>: Create agents for domain-specific tasks like infrastructure management, API design, or security reviews.</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Reusable Skills</span>
|
||||
<Text as="p" size="300" variant="muted">Skills are self-contained folders that package reusable capabilities. Store them in <code className={styles.inlineCode}>.github/skills/</code>.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Example skill</span> (<code className={styles.inlineCode}>generate-tests/SKILL.md</code>):</Text>
|
||||
<CopyBlock code={`---
|
||||
name: generate-tests
|
||||
description: 'Generate comprehensive unit tests for a component, covering happy path, edge cases, and error conditions'
|
||||
---
|
||||
|
||||
# generate-tests
|
||||
|
||||
Generate unit tests for the selected code that:
|
||||
- Cover all public methods and edge cases
|
||||
- Use our testing conventions from @testing-utils.ts
|
||||
- Include descriptive test names
|
||||
|
||||
See [references/test-patterns.md](references/test-patterns.md) for standard patterns.`} label="Example skill (generate-tests/SKILL.md):" />
|
||||
<Text as="p" size="300" variant="muted">Skills can also bundle reference files, templates, and scripts in their folder, giving the AI richer context than a single file can provide. Unlike the older prompt format, skills can be discovered and invoked by agents automatically.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Dynamic skill retrieval</span> (v1.0.66+): By default, Copilot CLI uses embeddings-based retrieval to automatically surface the most relevant skills for each prompt. You can toggle this behavior with the <code className={styles.inlineCode}>--dynamic-retrieval</code> flag or the <code className={styles.inlineCode}>dynamicRetrieval</code> config setting. To disable embeddings-based retrieval (for example, to force all configured skills to always be loaded):</Text>
|
||||
<CopyBlock code={`copilot --dynamic-retrieval skills=off`} label="Reusable Skills" />
|
||||
<Text as="p" size="300" variant="muted">This setting persists across sessions once saved to your config.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>When to use</span>: For repetitive tasks your team performs regularly, like generating tests, creating documentation, or refactoring patterns.</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Instructions Files</span>
|
||||
<Text as="p" size="300" variant="muted">Instructions provide persistent context that applies automatically when working in specific files or directories. Store them in <code className={styles.inlineCode}>.github/instructions/</code>.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Example instruction</span> (<code className={styles.inlineCode}>typescript-conventions.instructions.md</code>):</Text>
|
||||
<CopyBlock code={`---
|
||||
description: 'TypeScript coding conventions for this project'
|
||||
applyTo: '**.ts, **.tsx'
|
||||
---
|
||||
|
||||
When writing TypeScript code:
|
||||
- Use strict type checking
|
||||
- Prefer interfaces over type aliases for object types
|
||||
- Always handle null/undefined with optional chaining
|
||||
- Use async/await instead of raw promises`} label="Example instruction (typescript-conventions.instructions.md):" />
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>When to use</span>: For project-wide coding standards, architectural patterns, or technology-specific conventions that should influence all suggestions.</Text>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="setting-up-team-configuration" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Setting Up Team Configuration
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">Follow these steps to establish effective team-wide Copilot configuration:</Text>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>1. Create the Configuration Structure</span>
|
||||
<Text as="p" size="300" variant="muted">Start by creating the <code className={styles.inlineCode}>.github/</code> directory in your repository root:</Text>
|
||||
<CopyBlock code={`mkdir -p .github/{agents,skills,instructions}`} label="Start by creating the .github/ directory in your repository root:" />
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>2. Document Your Conventions</span>
|
||||
<Text as="p" size="300" variant="muted">Create instructions that capture your team’s coding standards:</Text>
|
||||
<CopyBlock code={`---
|
||||
description: 'Team coding conventions and best practices'
|
||||
applyTo: '**'
|
||||
---
|
||||
|
||||
Our team follows these practices:
|
||||
- Write self-documenting code with clear names
|
||||
- Add comments only for complex logic
|
||||
- Prefer composition over inheritance
|
||||
- Keep functions small and focused`} label="Create instructions that capture your team’s coding standards:" />
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>3. Build Reusable Skills</span>
|
||||
<Text as="p" size="300" variant="muted">Identify repetitive tasks and create skills for them:</Text>
|
||||
<CopyBlock code={`---
|
||||
name: add-error-handling
|
||||
description: 'Add comprehensive error handling to existing code following team patterns'
|
||||
---
|
||||
|
||||
# add-error-handling
|
||||
|
||||
Add error handling to the selected code:
|
||||
- Catch and handle potential errors
|
||||
- Log errors with context
|
||||
- Provide meaningful error messages
|
||||
- Follow our error handling patterns from @error-utils.ts`} label="Identify repetitive tasks and create skills for them:" />
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>4. Version Control Best Practices</span>
|
||||
<ul className={styles.checkList}>
|
||||
<li><span className={styles.stepLabel}>Commit all <code className={styles.inlineCode}>.github/</code> files</span> to your repository</li>
|
||||
<li><span className={styles.stepLabel}>Use descriptive commit messages</span> when adding or updating customizations</li>
|
||||
<li><span className={styles.stepLabel}>Review changes</span> to ensure they align with team standards</li>
|
||||
<li><span className={styles.stepLabel}>Document</span> each customization with clear descriptions and examples</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>5. Onboard New Team Members</span>
|
||||
<Text as="p" size="300" variant="muted">Make Copilot configuration part of your onboarding process:</Text>
|
||||
<ol className={styles.stepsList}>
|
||||
<li>Point new members to your <code className={styles.inlineCode}>.github/</code> directory</li>
|
||||
<li>Explain which agents and skills exist and when to use them</li>
|
||||
<li>Encourage exploration and contributions</li>
|
||||
<li>Include example usage in your project README</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="ide-specific-configuration" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
IDE-Specific Configuration
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">While repository-level customizations work across all IDEs, you may also need IDE-specific settings:</Text>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>VS Code</span>
|
||||
<Text as="p" size="300" variant="muted">Settings file: <code className={styles.inlineCode}>.vscode/settings.json</code> or global user settings</Text>
|
||||
<CopyBlock code={`{
|
||||
"github.copilot.enable": {
|
||||
"*": true
|
||||
},
|
||||
"github.copilot.chat.enabled": true,
|
||||
"editor.inlineSuggest.enabled": true
|
||||
}`} label="VS Code" />
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Visual Studio</span>
|
||||
<Text as="p" size="300" variant="muted">Settings: Tools → Options → GitHub Copilot</Text>
|
||||
<ul className={styles.checkList}>
|
||||
<li>Configure inline suggestions</li>
|
||||
<li>Set keyboard shortcuts</li>
|
||||
<li>Manage language-specific enablement</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>JetBrains IDEs</span>
|
||||
<Text as="p" size="300" variant="muted">Settings: File → Settings → Tools → GitHub Copilot</Text>
|
||||
<ul className={styles.checkList}>
|
||||
<li>Enable/disable for specific file types</li>
|
||||
<li>Configure suggestion behavior</li>
|
||||
<li>Customize keyboard shortcuts</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>GitHub Copilot CLI</span>
|
||||
<Text as="p" size="300" variant="muted">Configuration file: <code className={styles.inlineCode}>~/.copilot-cli/config.json</code></Text>
|
||||
<CopyBlock code={`{
|
||||
"editor": "vim",
|
||||
"suggestions": true
|
||||
}`} label="GitHub Copilot CLI" />
|
||||
<Text as="p" size="300" variant="muted">CLI settings use <span className={styles.stepLabel}>camelCase</span> naming. Key settings added in recent releases:</Text>
|
||||
<table tabIndex={0}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Setting</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code className={styles.inlineCode}>includeCoAuthoredBy</code></td>
|
||||
<td>Include Co-authored-by trailer in commits</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code className={styles.inlineCode}>effortLevel</code></td>
|
||||
<td>Default reasoning effort level (<code className={styles.inlineCode}>low</code>, <code className={styles.inlineCode}>medium</code>, <code className={styles.inlineCode}>high</code>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code className={styles.inlineCode}>autoUpdatesChannel</code></td>
|
||||
<td>Update channel (<code className={styles.inlineCode}>stable</code>, <code className={styles.inlineCode}>preview</code>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code className={styles.inlineCode}>statusLine</code></td>
|
||||
<td>Show status line in the terminal UI</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code className={styles.inlineCode}>include_gitignored</code></td>
|
||||
<td>Include gitignored files in <code className={styles.inlineCode}>@</code> file search</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code className={styles.inlineCode}>extension_mode</code></td>
|
||||
<td>Control extensibility (agent tools and plugins)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code className={styles.inlineCode}>continueOnAutoMode</code></td>
|
||||
<td>Automatically switch to the auto model on rate limit instead of pausing</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code className={styles.inlineCode}>proxy</code></td>
|
||||
<td>HTTP(S) proxy URL for all outbound CLI requests (e.g., <code className={styles.inlineCode}>http://proxy.example.com:8080</code>) (v1.0.64+)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code className={styles.inlineCode}>sessionLimits</code></td>
|
||||
<td>Restrict credit or turn usage for a session; limits apply across the current conversation and reset on <code className={styles.inlineCode}>/clear</code> (v1.0.66+)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code className={styles.inlineCode}>stayInAutopilot</code></td>
|
||||
<td>Keep the CLI in autopilot mode after an autopilot task completes, instead of returning to interactive mode (v1.0.69+)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Note:</span>
|
||||
<Text as="p" size="300" variant="muted">Older snake_case names (e.g., <code className={styles.inlineCode}>include_gitignored</code>, <code className={styles.inlineCode}>auto_updates_channel</code>) are still accepted for backward compatibility, but camelCase is now the preferred format.</Text>
|
||||
</div>
|
||||
<Text as="p" size="300" variant="muted">In addition to the main config file, GitHub Copilot CLI reads two optional per-project files for repository-specific overrides:</Text>
|
||||
<ul className={styles.pointList}>
|
||||
<li><code className={styles.inlineCode}>.claude/settings.json</code> — committed project settings</li>
|
||||
<li><code className={styles.inlineCode}>.claude/settings.local.json</code> — local overrides (add to <code className={styles.inlineCode}>.gitignore</code> for personal adjustments)</li>
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted">These files follow the same format as <code className={styles.inlineCode}>config.json</code> and are loaded after the global config, so they can tailor CLI behaviour—including hook definitions—per repository without touching <code className={styles.inlineCode}>.github/</code>.</Text>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Important (v1.0.36+):</span>
|
||||
<Text as="p" size="300" variant="muted">Custom agents, skills, and commands placed in <code className={styles.inlineCode}>~/.claude/</code> (the Claude Code user directory) are <span className={styles.stepLabel}>no longer loaded</span> by GitHub Copilot CLI. Only <code className={styles.inlineCode}>~/.claude/settings.json</code> is read for configuration. If you previously stored personal agents or skills in <code className={styles.inlineCode}>~/.claude/</code>, move them to the supported locations: <code className={styles.inlineCode}>~/.copilot/agents/</code> for user-level agents, <code className={styles.inlineCode}>~/.copilot/skills/</code> or <code className={styles.inlineCode}>~/.agents/skills/</code> for personal skills, or <code className={styles.inlineCode}>.github/agents/</code> and <code className={styles.inlineCode}>.github/skills/</code> in your repositories for project-level customizations.</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Model Picker</span>
|
||||
<Text as="p" size="300" variant="muted">The model picker opens in a <span className={styles.stepLabel}>full-screen view</span> with inline reasoning effort adjustment. Use the <span className={styles.stepLabel}>← / →</span> arrow keys to change the reasoning effort level (<code className={styles.inlineCode}>low</code>, <code className={styles.inlineCode}>medium</code>, <code className={styles.inlineCode}>high</code>) directly from the picker without leaving the session. The current reasoning effort level is also displayed in the model header (e.g., <code className={styles.inlineCode}>claude-sonnet-4.6 (high)</code>) so you always know which level is active.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Auto mode and server-side model routing</span> (v1.0.43+): When you select <span className={styles.stepLabel}>Auto</span> as your model, the CLI uses server-side model routing for real-time model selection. Instead of locking in a single model at session start, Auto mode evaluates each request and routes it to the most appropriate model dynamically. This means straightforward questions can be handled by a faster model while complex reasoning tasks are automatically escalated — without you needing to switch models manually.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Model family aliases</span> (v1.0.64+): Instead of typing a full model name, you can use short family aliases in the model setting: <code className={styles.inlineCode}>opus</code>, <code className={styles.inlineCode}>sonnet</code>, <code className={styles.inlineCode}>haiku</code> (Anthropic), and <code className={styles.inlineCode}>gpt</code>, <code className={styles.inlineCode}>gemini</code> (Google/OpenAI). The CLI resolves the alias to the latest available model in that family. This is especially useful in scripts or configuration files where you want to track the best model in a family without hardcoding a version string.</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>CLI Session Commands</span>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/settings</code> command (v1.0.61+) opens an interactive dialog to browse and edit all user settings in one place. Use it to discover available settings, toggle options, and update values without manually editing your config file:</Text>
|
||||
<CopyBlock code={`/settings`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">The settings dialog supports search — type to filter settings by name. Changes take effect immediately.</Text>
|
||||
<Text as="p" size="300" variant="muted"><em>(v1.0.70+)</em> The <code className={styles.inlineCode}>/settings</code> command and the <code className={styles.inlineCode}>/model</code> command both support <span className={styles.stepLabel}><code className={styles.inlineCode}>--repo</code> and <code className={styles.inlineCode}>--local</code> flags</span> for explicitly scoping which layer of settings you want to view or edit:</Text>
|
||||
<CopyBlock code={`/settings --repo # view/edit repository-scoped settings
|
||||
/settings --local # view/edit local (user-level) settings
|
||||
/model --repo # view/edit the model pinned for this repository
|
||||
/model --local # view/edit your personal model preference`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">These flags mirror the <span className={styles.stepLabel}>Repo</span> and <span className={styles.stepLabel}>Repo (local)</span> scope tabs available in the <code className={styles.inlineCode}>/settings</code> dashboard (v1.0.71+), making it easier to manage per-repository vs. user-global configuration without ambiguity. In v1.0.71+, the <code className={styles.inlineCode}>/settings</code> dashboard also shows <span className={styles.stepLabel}>Repo</span> and <span className={styles.stepLabel}>Repo (local)</span> tabs alongside the existing user-level view, giving you a unified place to see which settings are applied at each layer.</Text>
|
||||
<Text as="p" size="300" variant="muted">GitHub Copilot CLI has two commands for managing session state, with distinct behaviours:</Text>
|
||||
<table tabIndex={0}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Command</th>
|
||||
<th>Behaviour</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code className={styles.inlineCode}>/new [prompt]</code></td>
|
||||
<td>Starts a fresh conversation while keeping the current session backgrounded. You can switch back to backgrounded sessions.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code className={styles.inlineCode}>/clear [prompt]</code></td>
|
||||
<td>Abandons the current session entirely and starts a new one. Backgrounded sessions are not affected. MCP servers configured in your project are preserved in the new session.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<Text as="p" size="300" variant="muted">Both commands accept an optional prompt argument to seed the new session with an opening message, for example <code className={styles.inlineCode}>/new Add error handling to the login flow</code>.</Text>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/session rename</code> command renames the current session. When called <span className={styles.stepLabel}>without a name argument</span>, it automatically generates a session name based on the conversation history:</Text>
|
||||
<CopyBlock code={`/session rename # auto-generate a name from conversation history
|
||||
/session rename "My feature" # set a specific name`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">Auto-generated names help you find sessions quickly when switching between multiple backgrounded sessions.</Text>
|
||||
<Text as="p" size="300" variant="muted">You can also name a session at startup with the <code className={styles.inlineCode}>--name</code> flag, and resume it by name later:</Text>
|
||||
<CopyBlock code={`copilot --name "auth-refactor" # start a session with a given name
|
||||
copilot --resume="auth-refactor" # resume that session by name`} label="You can also name a session at startup with the --name flag, and resume it by name later:" />
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/session delete</code> command removes sessions you no longer need:</Text>
|
||||
<CopyBlock code={`/session delete # delete the current session
|
||||
/session delete <id> # delete a session by ID
|
||||
/session delete-all # delete all sessions`} label="The /session delete command removes sessions you no longer need:" />
|
||||
<Text as="p" size="300" variant="muted">You can also press <span className={styles.stepLabel}>x</span> on a highlighted session in the session picker (<code className={styles.inlineCode}>--resume</code>) to delete it directly from the list.</Text>
|
||||
<Text as="p" size="300" variant="muted">In the session picker, press <span className={styles.stepLabel}><code className={styles.inlineCode}>s</code></span> to cycle the sort order: relevance, last used, created, or name. The picker also shows the branch name and idle/in-use status for each session.</Text>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/rewind</code> command opens a timeline picker that lets you roll back the conversation to any earlier point in history, reverting both the conversation and any file changes made after that point. You can also trigger it by pressing <span className={styles.stepLabel}>double-Esc</span>:</Text>
|
||||
<CopyBlock code={`/rewind`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">Use <code className={styles.inlineCode}>/rewind</code> when you want to branch off from a different point in the conversation, rather than just undoing the most recent turn.</Text>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/undo</code> command reverts the last turn—including any file changes the agent made—letting you course-correct without manually undoing edits:</Text>
|
||||
<CopyBlock code={`/undo`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">Use <code className={styles.inlineCode}>/undo</code> when the agent’s last response went in an unwanted direction and you want to try a different approach from that point.</Text>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/fork</code> command (v1.0.45+) copies the current session into a <span className={styles.stepLabel}>new independent session</span> that starts from the same conversation state. The original session continues unchanged — you can switch back to it at any time. This is useful when you want to explore two different approaches to a problem simultaneously. In v1.0.64+, <code className={styles.inlineCode}>/branch</code> is available as an alias for <code className={styles.inlineCode}>/fork</code> (matching Claude Code’s command naming):</Text>
|
||||
<CopyBlock code={`/fork # fork with an auto-generated name
|
||||
/fork "my-experiment" # fork with a custom name (v1.0.47+)
|
||||
/branch # alias for /fork (v1.0.64+)`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">After forking, the new session is immediately active. Both sessions share the same history up to the fork point but accumulate changes independently from that moment forward. Use <code className={styles.inlineCode}>/fork</code> to experiment with a risky refactor without abandoning your current working session. Since v1.0.47, forked sessions display their <span className={styles.stepLabel}>origin session</span> name in the sessions dialog, making it easy to trace which session a fork came from.</Text>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/cd</code> command changes the working directory for the current session. Since v1.0.65, the working directory <span className={styles.stepLabel}>persists when you resume a session</span> — if you restart the CLI and resume, you return to the same directory automatically. Changing directory also triggers discovery of custom agents in the new location, so switching to a different project loads its agents without a restart:</Text>
|
||||
<CopyBlock code={`/cd ~/projects/my-other-repo`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">This is useful when you have multiple backgrounded sessions each focused on a different project directory.</Text>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/worktree</code> command (v1.0.61+, also aliased <code className={styles.inlineCode}>/move</code>) creates a new git worktree and switches into it, moving any uncommitted changes along. This lets you start working on a parallel branch without leaving your current terminal session:</Text>
|
||||
<CopyBlock code={`/worktree my-feature-branch`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">In v1.0.66+, you can pass a task description to <code className={styles.inlineCode}>/worktree</code> to name the branch from the task and immediately run the task as the first prompt in the new worktree — all in one step:</Text>
|
||||
<CopyBlock code={`/worktree fix the login redirect`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">This creates a branch named from your task description and begins working on it immediately, making it easy to spin up parallel work without stopping to think of a branch name.</Text>
|
||||
<Text as="p" size="300" variant="muted">After the command runs, the session is inside the new worktree. Use this when you want to work on a second task in parallel without stashing changes or opening a new terminal. In v1.0.64+ you can also use the experimental <code className={styles.inlineCode}>--worktree</code> flag at startup (<code className={styles.inlineCode}>copilot -w [name]</code>) to create or reuse a worktree under <code className={styles.inlineCode}><repo>.worktrees/</code> before the session begins.</Text>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/every</code> command (also available as <code className={styles.inlineCode}>/loop</code> since v1.0.64) schedules a recurring prompt to run automatically at a specified interval. The companion <code className={styles.inlineCode}>/after</code> command runs a prompt once after a specified delay. Both are useful for self-paced automation — polling for results, periodically summarizing progress, or triggering other slash commands on a timer:</Text>
|
||||
<CopyBlock code={`/every 5m Check if there are any new test failures and summarize them
|
||||
/loop 30s Check if the build is done
|
||||
/after 2h /compact # compact the session after 2 hours
|
||||
/every 1d /chronicle standup # daily standup report via /chronicle`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">The interval can be specified in seconds (<code className={styles.inlineCode}>s</code>), minutes (<code className={styles.inlineCode}>m</code>), or hours (<code className={styles.inlineCode}>h</code>), and both commands can invoke other slash commands as their payload. To see and manage all your scheduled prompts, use <code className={styles.inlineCode}>/every</code> with no argument — it opens the schedule manager. To cancel a running schedule, use <code className={styles.inlineCode}>/every stop</code> or <span className={styles.stepLabel}>Ctrl+C</span>.</Text>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Experimental:</span>
|
||||
<Text as="p" size="300" variant="muted"><code className={styles.inlineCode}>/every</code>, <code className={styles.inlineCode}>/loop</code>, and <code className={styles.inlineCode}>/after</code> are part of the experimental feature set. They appear in the <code className={styles.inlineCode}>/experimental</code> slash command list — enable experimental features if they are not already visible in your current session.</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Note:</span>
|
||||
<Text as="p" size="300" variant="muted">Scheduled prompts run in the background of the current session and use your active model. They share the session context window, so very frequent scheduling with long responses may consume context rapidly. Use <code className={styles.inlineCode}>/compact</code> if context usage becomes a concern.</Text>
|
||||
</div>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/pr auto</code> command <em>(v1.0.66+)</em> starts a self-paced automation loop that drives the current pull request to CI green. Rather than running continuously, it fixes one failing item per run and paces itself around CI checks to avoid redundant work:</Text>
|
||||
<CopyBlock code={`/pr auto # start fixing the current PR until CI passes
|
||||
/pr automerge # continue until the PR is fully merged`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted"><code className={styles.inlineCode}>/pr auto</code> is ideal when you have a PR with failing tests or linting errors — let it work through failures one at a time while you focus on other things. <code className={styles.inlineCode}>/pr automerge</code> extends this further: it continues until all CI checks pass, required reviews are approved, and the PR is successfully merged. Both commands can be monitored and stopped from <code className={styles.inlineCode}>/loop</code> or <code className={styles.inlineCode}>/every</code>, which register the running automation as a scheduleable loop task.</Text>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/delegate</code> command creates a <span className={styles.stepLabel}>delegate PR</span> — a pull request that the coding agent works on autonomously. By default, the delegate PR targets your current branch. Use <code className={styles.inlineCode}>--base</code> <em>(v1.0.69+)</em> to specify a different target base branch:</Text>
|
||||
<CopyBlock code={`/delegate # create a delegate PR targeting the current branch
|
||||
/delegate --base main # create a delegate PR targeting main
|
||||
/delegate --base release/2.0 # target a specific release branch`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">This is useful when you want to hand off a task to the coding agent on a specific branch — for example, backporting a fix to an older release branch or targeting a long-lived feature branch for automated work.</Text>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/share html</code> command exports the current session — including conversation history and any research reports — as a <span className={styles.stepLabel}>self-contained interactive HTML file</span>:</Text>
|
||||
<CopyBlock code={`/share html`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">The exported file contains everything needed to view the session without a network connection and can be shared with teammates or stored for later reference. This complements <code className={styles.inlineCode}>/share</code> (which shares via URL) for cases where an offline or attached format is preferred.</Text>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/chronicle</code> command opens an interactive timeline of everything the agent has done in the current session. It shows file changes, tool calls, and conversation turns in chronological order, letting you review the full arc of the session at a glance:</Text>
|
||||
<CopyBlock code={`/chronicle`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">Chronicle tracks which files were created, modified, or deleted during the session alongside the conversation that led to those changes. Use it to review what happened before a <code className={styles.inlineCode}>/rewind</code>, audit what the agent changed, or share a summary of session activity with teammates.</Text>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/chronicle skills review</code> subcommand <em>(v1.0.66+)</em> opens an interactive review flow for proposed draft skill changes. When the agent has suggested additions or modifications to skills during a session, you can review each draft individually and choose to accept, reject, or defer:</Text>
|
||||
<CopyBlock code={`/chronicle skills review`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">This keeps you in control of skill evolution — the agent can propose skill improvements as it discovers reusable patterns, but nothing is applied until you explicitly approve each change.</Text>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Note:</span>
|
||||
<Text as="p" size="300" variant="muted">Session history, file tracking, and the <code className={styles.inlineCode}>/chronicle</code> command were previously experimental features. As of v1.0.40, they are available to all users without enabling experimental mode.</Text>
|
||||
</div>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/diagnose</code> command (v1.0.64+) analyzes the current session’s logs and surfaces diagnostic information to help troubleshoot unexpected behavior, performance issues, or errors:</Text>
|
||||
<CopyBlock code={`/diagnose`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">Use <code className={styles.inlineCode}>/diagnose</code> when a session is behaving unexpectedly — it inspects session logs and reports what it finds, making it easier to share diagnostics with support or understand what happened internally.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Keyboard shortcuts for queuing messages</span>: Use <span className={styles.stepLabel}>Ctrl+Q</span> or <span className={styles.stepLabel}>Ctrl+Enter</span> to queue a message (send it while the agent is still working). <span className={styles.stepLabel}>Ctrl+D</span> no longer queues messages — it now has its default terminal behavior. If you have muscle memory for Ctrl+D queuing, switch to Ctrl+Q.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Background running tasks</span>: Press <span className={styles.stepLabel}>Ctrl+X → B</span> to move the current running task or shell command to the background. The task continues executing while you can type a new message or review earlier output. This is useful for long-running commands where you want to interact with the agent while waiting for the result.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Shell command history in normal mode</span> (v1.0.65+): The <span className={styles.stepLabel}>↑/↓</span> arrow keys and <span className={styles.stepLabel}>Ctrl+R</span> reverse search now include past shell commands (commands run with <code className={styles.inlineCode}>!</code>) while you are in normal (non-shell) input mode. Previously you had to type <code className={styles.inlineCode}>!</code> to enter shell mode before history worked. Now you can recall and re-run a shell command without switching modes first — useful for quickly repeating a build, test, or diagnostic command from earlier in the session.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Inline image rendering</span> (v1.0.64+): The CLI can display images inline in the terminal when your terminal supports it. If an MCP tool, agent, or attachment returns an image, it is rendered directly in the conversation timeline rather than shown as a file path or URL. This works in terminals with image protocol support (such as iTerm2, Kitty, Wezterm, and tmux with appropriate configuration).</Text>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/ask</code> command lets you ask a quick question without affecting your conversation history. The current session context is preserved, so you can use it for one-off lookups without derailing an ongoing task. Responses are rendered as full markdown, including tables and formatted links:</Text>
|
||||
<CopyBlock code={`/ask What does the \`retry\` utility in src/utils do?`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/refine</code> command <em>(v1.0.70+)</em> rewrites a rough, stream-of-consciousness prompt into a clear, structured one before sending it to the agent:</Text>
|
||||
<CopyBlock code={`/refine`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">Type your rough idea, and <code className={styles.inlineCode}>/refine</code> transforms it into a precise, well-structured prompt. This is especially helpful for complex multi-step tasks where prompt clarity significantly affects output quality — for example, turning “um make the login thing work better with the existing setup” into a focused task description with clear scope and acceptance criteria.</Text>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/env</code> command shows all loaded environment details — instructions, MCP servers, skills, agents, and plugins — in a single view. Use it to verify that the right resources are active for the current session:</Text>
|
||||
<CopyBlock code={`/env`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/context</code> command shows a visualization of the current conversation’s context window usage — how many tokens are consumed and how much headroom remains:</Text>
|
||||
<CopyBlock code={`/context`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/usage</code> command displays session metrics such as the number of tokens consumed, API calls made, and any quota information for the current session. In v1.0.64+, <code className={styles.inlineCode}>/usage</code> also shows per-model token totals when you have used multiple models in a session:</Text>
|
||||
<CopyBlock code={`/usage`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/compact</code> command summarizes the conversation history to free up context window space while preserving the thread of the conversation. Use it when your context is getting full but you do not want to start a fresh session:</Text>
|
||||
<CopyBlock code={`/compact`} label="CLI Session Commands" />
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Note:</span>
|
||||
<Text as="p" size="300" variant="muted">Skills remain loaded and effective after <code className={styles.inlineCode}>/compact</code>. You do not need to re-invoke them after compacting.</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>ACP sessions (v1.0.39+):</span>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/compact</code>, <code className={styles.inlineCode}>/context</code>, <code className={styles.inlineCode}>/usage</code>, and <code className={styles.inlineCode}>/env</code> commands are now available in ACP (Agent Coordination Protocol) sessions, allowing remote ACP clients to surface session details and manage context from within their own automated workflows.</Text>
|
||||
</div>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/statusline</code> command (with <code className={styles.inlineCode}>/footer</code> as an alias) lets you control which items appear in the terminal status bar. You can show or hide individual indicators like the working directory, current branch, effort level, context window usage, quota, and <span className={styles.stepLabel}>active account username</span> (v1.0.43+). The <span className={styles.stepLabel}>changes</span> toggle shows a running count of added/removed lines for the session — useful when tracking the scope of an ongoing edit. In v1.0.65+, there is also an opt-in <span className={styles.stepLabel}>CI check status</span> indicator that shows the passing/running/failing state of CI checks for the current branch — enable it from the <code className={styles.inlineCode}>/statusline</code> menu:</Text>
|
||||
<CopyBlock code={`/statusline # show the statusline configuration menu`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">Toggle the <span className={styles.stepLabel}>username</span> indicator to display which GitHub account is currently active in the footer — helpful when you work with multiple accounts or switch between personal and organization contexts.</Text>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/keep-alive</code> command prevents the system from sleeping while Copilot CLI is active. This is useful during long-running agent sessions on laptops or machines with aggressive sleep settings:</Text>
|
||||
<CopyBlock code={`/keep-alive # toggle keep-alive on or off`} label="CLI Session Commands" />
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Note:</span>
|
||||
<Text as="p" size="300" variant="muted"><code className={styles.inlineCode}>/keep-alive</code> was previously an experimental feature. As of v1.0.36, it is available without enabling experimental mode.</Text>
|
||||
</div>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/allow-all</code> command (also accessible as <code className={styles.inlineCode}>/yolo</code>) enables autopilot mode, where the agent runs all tools without asking for confirmation. It now supports <code className={styles.inlineCode}>on</code>, <code className={styles.inlineCode}>off</code>, and <code className={styles.inlineCode}>show</code> subcommands:</Text>
|
||||
<CopyBlock code={`/allow-all on # enable allow-all mode
|
||||
/allow-all off # disable allow-all mode
|
||||
/allow-all show # check current allow-all status`} label="CLI Session Commands" />
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Note:</span>
|
||||
<Text as="p" size="300" variant="muted"><code className={styles.inlineCode}>/allow-all on</code> permissions persist after <code className={styles.inlineCode}>/clear</code> starts a new session, so you don’t need to re-enable it each time.</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>ACP clients (v1.0.39+):</span>
|
||||
<Text as="p" size="300" variant="muted">ACP clients can also toggle allow-all mode programmatically via session configuration, without issuing a slash command. This is useful for automated pipelines that drive Copilot CLI through the ACP protocol.</Text>
|
||||
</div>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>/autopilot</code> command (v1.0.45+) is a quick in-session toggle that switches between <span className={styles.stepLabel}>interactive mode</span> (where the agent pauses to ask for confirmation before tool use) and <span className={styles.stepLabel}>autopilot mode</span> (where it runs autonomously). Unlike <code className={styles.inlineCode}>/allow-all</code> which specifically controls whether tool permissions are required, <code className={styles.inlineCode}>/autopilot</code> toggles the overall agent mode:</Text>
|
||||
<CopyBlock code={`/autopilot # toggle between interactive and autopilot modes`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">Use <code className={styles.inlineCode}>/autopilot</code> when you want to flip between supervised and unsupervised operation mid-session without typing out the full <code className={styles.inlineCode}>/allow-all on</code> or <code className={styles.inlineCode}>/allow-all off</code> commands.</Text>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Enhanced autopilot (v1.0.64+):</span>
|
||||
<Text as="p" size="300" variant="muted">When autopilot mode is active — including when launched with <code className={styles.inlineCode}>--autopilot</code> at startup or during automatic continuation turns — the agent automatically handles elicitation dialogs, <code className={styles.inlineCode}>ask_user</code> prompts, sampling requests, and permission prompts without surfacing them as interactive dialogs. This means long-running automated sessions can proceed end-to-end without manual confirmation steps.</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Auto allow-all mode (v1.0.69+):</span>
|
||||
<Text as="p" size="300" variant="muted">In addition to the standard allow-all mode (which approves everything), the CLI now supports an <span className={styles.stepLabel}>auto allow-all</span> mode that uses an LLM judge to evaluate each tool request. When enabled, the judge automatically approves requests it evaluates as acceptable, and asks you for manual confirmation only for requests it considers risky. This gives you a middle ground between full autopilot and fully supervised operation — most routine actions proceed automatically while unusual or potentially dangerous actions still surface for your review. As of v1.0.69-3, this mode requires experimental features to be enabled — use <code className={styles.inlineCode}>/experimental on</code> or start the CLI with <code className={styles.inlineCode}>--experimental</code> — then activate it with <code className={styles.inlineCode}>/allow-all auto</code>. The previous <code className={styles.inlineCode}>AUTO_APPROVAL</code> environment variable approach has been removed in favour of experimental mode.</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Read-only gh CLI commands (v1.0.46+):</span>
|
||||
<Text as="p" size="300" variant="muted">Read-only <code className={styles.inlineCode}>gh</code> commands — such as <code className={styles.inlineCode}>gh issue list</code>, <code className={styles.inlineCode}>gh pr view</code>, <code className={styles.inlineCode}>gh run status</code>, and other commands that don’t write to GitHub — are <span className={styles.stepLabel}>automatically approved</span> without a permission prompt. Only commands that write to GitHub (like creating issues, merging PRs) still require explicit approval. This reduces friction during exploratory sessions where you frequently check issue or PR status.</Text>
|
||||
</div>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>--effort</code> flag (shorthand for <code className={styles.inlineCode}>--reasoning-effort</code>) controls how much computational reasoning the model applies to a request:</Text>
|
||||
<CopyBlock code={`gh copilot --effort high "Refactor the authentication module"`} label="CLI Session Commands" />
|
||||
<Text as="p" size="300" variant="muted">Accepted values are <code className={styles.inlineCode}>low</code>, <code className={styles.inlineCode}>medium</code>, and <code className={styles.inlineCode}>high</code>. You can also set a default via the <code className={styles.inlineCode}>effortLevel</code> config setting.</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>CLI Startup Flags</span>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>-C <directory></code> flag changes the working directory before starting, similar to <code className={styles.inlineCode}>git -C</code> (v1.0.42+). This is useful for scripts or aliases that need to start Copilot CLI in a specific project directory without a separate <code className={styles.inlineCode}>cd</code>:</Text>
|
||||
<CopyBlock code={`copilot -C ~/projects/my-repo # start in a different directory
|
||||
copilot -C ~/projects/my-repo -p "..." # combine with prompt mode`} label="CLI Startup Flags" />
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>--mode</code> flag (along with its aliases <code className={styles.inlineCode}>--autopilot</code> and <code className={styles.inlineCode}>--plan</code>) lets you launch the CLI directly in a specific agent mode without waiting for the interactive session to start:</Text>
|
||||
<CopyBlock code={`copilot --mode agent # start in agent mode (autonomous tool use)
|
||||
copilot --autopilot # alias for --mode autopilot (allow-all)
|
||||
copilot --plan # start in plan mode (propose without executing)`} label="CLI Startup Flags" />
|
||||
<Text as="p" size="300" variant="muted">This is useful in scripts or CI pipelines where you want the CLI to immediately begin working in a specific mode without an interactive prompt.</Text>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>--max-autopilot-continues</code> flag controls how many times Copilot can automatically continue in autopilot mode before pausing for confirmation. The default is 5:</Text>
|
||||
<CopyBlock code={`copilot --autopilot --max-autopilot-continues 10 "Refactor the authentication module"`} label="CLI Startup Flags" />
|
||||
<Text as="p" size="300" variant="muted">Set it higher for long-running tasks, or lower for tasks where you want more frequent checkpoints. Setting it to <code className={styles.inlineCode}>0</code> disables automatic continuation entirely.</Text>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>--sandbox</code> and <code className={styles.inlineCode}>--no-sandbox</code> flags <em>(v1.0.70+)</em> turn the OS-level shell sandbox on or off for the current session only, without permanently changing your saved sandbox setting. This is useful with <code className={styles.inlineCode}>-p</code> (prompt mode) when you need to temporarily adjust sandbox behavior for a specific automated task:</Text>
|
||||
<CopyBlock code={`copilot --sandbox -p "Run the full test suite and fix any failures"
|
||||
copilot --no-sandbox -p "Set up development environment with system tools"`} label="CLI Startup Flags" />
|
||||
<Text as="p" size="300" variant="muted">These flags apply only to the current invocation — your persisted sandbox preference remains unchanged.</Text>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>--attachment</code> flag (available in prompt mode, <code className={styles.inlineCode}>-p</code>) lets you attach files — images or native documents — to the initial prompt in non-interactive mode:</Text>
|
||||
<CopyBlock code={`copilot -p "Summarize the architecture shown in these diagrams" \\
|
||||
--attachment arch-overview.png \\
|
||||
--attachment data-flow.pdf`} label="CLI Startup Flags" />
|
||||
<Text as="p" size="300" variant="muted">This is useful in automated pipelines where you want to pass visual or document context (screenshots, design specs, PDF reports) to the model without interactive file selection. Multiple <code className={styles.inlineCode}>--attachment</code> flags can be specified to include several files at once.</Text>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>COPILOT_HOME</code> environment variable sets the Copilot CLI configuration directory. It is the preferred replacement for the <code className={styles.inlineCode}>--config-dir</code> flag, which is deprecated:</Text>
|
||||
<CopyBlock code={`# Preferred — set via environment variable
|
||||
export COPILOT_HOME=~/.my-copilot-config
|
||||
copilot
|
||||
|
||||
# Deprecated — use COPILOT_HOME instead
|
||||
copilot --config-dir ~/.my-copilot-config`} label="CLI Startup Flags" />
|
||||
<Text as="p" size="300" variant="muted">Set <code className={styles.inlineCode}>COPILOT_HOME</code> in your shell profile to use a custom config directory across all sessions. This is especially useful when running multiple Copilot configurations for different projects or teams.</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Shell Completion</span>
|
||||
<Text as="p" size="300" variant="muted">The <code className={styles.inlineCode}>copilot completion</code> subcommand generates a static shell completion script for subcommands, flags, and known option values. Once installed, pressing Tab auto-completes Copilot CLI commands in your terminal.</Text>
|
||||
<CopyBlock code={`# Bash — add to ~/.bashrc
|
||||
eval "$(copilot completion bash)"
|
||||
|
||||
# Zsh — add to ~/.zshrc
|
||||
eval "$(copilot completion zsh)"
|
||||
|
||||
# Fish — add to ~/.config/fish/config.fish
|
||||
copilot completion fish | source`} label="Shell Completion" />
|
||||
<Text as="p" size="300" variant="muted">Or write the script to a file and source it from your shell profile:</Text>
|
||||
<CopyBlock code={`copilot completion bash > ~/.copilot-completion.bash
|
||||
echo 'source ~/.copilot-completion.bash' >> ~/.bashrc`} label="Or write the script to a file and source it from your shell profile:" />
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Tip:</span>
|
||||
<Text as="p" size="300" variant="muted">Reload your shell (<code className={styles.inlineCode}>source ~/.bashrc</code> or open a new terminal) after adding the completion script for changes to take effect.</Text>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="common-questions" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Common Questions
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Q: How do I disable Copilot for specific files?</span></Text>
|
||||
<Text as="p" size="300" variant="muted">A: Use the <code className={styles.inlineCode}>excludedFiles</code> setting in your IDE configuration or create a workspace setting that disables Copilot for specific patterns:</Text>
|
||||
<CopyBlock code={`{
|
||||
"github.copilot.advanced": {
|
||||
"excludedFiles": [
|
||||
"**/secrets/**",
|
||||
"**/*.env",
|
||||
"**/test/fixtures/**"
|
||||
]
|
||||
}
|
||||
}`} label="Common Questions" />
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Q: Can I have different settings per project?</span></Text>
|
||||
<Text as="p" size="300" variant="muted">A: Yes! Use workspace settings (<code className={styles.inlineCode}>.vscode/settings.json</code>) for project-specific preferences that don’t need to be shared, or use repository settings (for example, files in <code className={styles.inlineCode}>.github/agents/</code>, <code className={styles.inlineCode}>.github/skills/</code>, <code className={styles.inlineCode}>.github/instructions/</code>, and <code className={styles.inlineCode}>.github/copilot-instructions.md</code>) for team-wide customizations that should be version-controlled.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Q: How do team settings override personal settings?</span></Text>
|
||||
<Text as="p" size="300" variant="muted">A: Repository-level Copilot configuration (such as <code className={styles.inlineCode}>.github/agents/</code>, <code className={styles.inlineCode}>.github/skills/</code>, <code className={styles.inlineCode}>.github/instructions/</code>, and <code className={styles.inlineCode}>.github/copilot-instructions.md</code>) has the highest precedence, followed by workspace settings, then user settings. This means team-defined instructions and agents will apply even if your personal settings differ, ensuring consistency across the team.</Text>
|
||||
<Text as="p" size="300" variant="muted"><span className={styles.stepLabel}>Q: Where should I put customizations that apply to all my projects?</span></Text>
|
||||
<Text as="p" size="300" variant="muted">A: Use user-level settings in your IDE for personal preferences that should apply everywhere. For customizations specific to a technology or framework (like React conventions), consider creating a collection in the awesome-copilot-hub repository that you can reference across multiple projects.</Text>
|
||||
</section>
|
||||
|
||||
<section id="next-steps" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Next Steps
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">Now that you understand Copilot configuration, explore how to create powerful customizations:</Text>
|
||||
<ul className={styles.checkList}>
|
||||
<li><span className={styles.stepLabel}><a href="https://awesome-copilot.github.com/learning-hub/what-are-agents-skills-instructions/">What are Agents, Skills, and Instructions</a></span> - Understand the customization types you can configure</li>
|
||||
<li><span className={styles.stepLabel}><a href="https://awesome-copilot.github.com/learning-hub/understanding-copilot-context/">Understanding Copilot Context</a></span> - Learn how configuration affects context usage</li>
|
||||
<li><span className={styles.stepLabel}><a href="https://awesome-copilot.github.com/learning-hub/defining-custom-instructions/">Defining Custom Instructions</a></span> - Create persistent context for your projects</li>
|
||||
<li><span className={styles.stepLabel}><a href="https://awesome-copilot.github.com/learning-hub/creating-effective-skills/">Creating Effective Skills</a></span> - Build reusable task folders with bundled assets</li>
|
||||
<li><span className={styles.stepLabel}><a href="https://awesome-copilot.github.com/learning-hub/building-custom-agents/">Building Custom Agents</a></span> - Develop specialized assistants</li>
|
||||
</ul>
|
||||
</section>
|
||||
</LearningArticleLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* Bespoke Learning Hub article for "GitHub Copilot app", ported near-verbatim
|
||||
* from the Brand Engineering prototype's `pages/github-copilot-app.tsx`
|
||||
* (`site-generator/prototypes/awesome-copilot-community-library`).
|
||||
*
|
||||
* Only the following were adapted from the prototype source:
|
||||
* - `PrototypePageProps`/`pageHref` import swapped for the site's `pageHref`.
|
||||
* - Media imports (webpack asset imports in the prototype) replaced with
|
||||
* static paths. The two large source videos
|
||||
* (`copilot-app-centralized-inbox.mp4`, ~23MB and
|
||||
* `copilot-app-delegate-to-agents.mp4`, ~32MB) are not committed to this
|
||||
* repo — TODO: host them externally (e.g. a CDN/GitHub release asset) and
|
||||
* swap `inboxPoster`/`delegateGif` below to real `<video>` sources. Until
|
||||
* then the inbox video renders as its poster image and the delegate clip
|
||||
* renders as its already-ported static webp.
|
||||
*/
|
||||
import { Heading, Text } from "@primer/react-brand";
|
||||
import { BookIcon, PlayIcon, GlobeIcon, ArrowUpRightIcon } from "@primer/octicons-react";
|
||||
|
||||
import styles from "../styles/github-copilot-app.module.css";
|
||||
import { CopyBlock, LearningArticleLayout, ProTip, type TocSection } from "../LearningArticleLayout";
|
||||
import { VideoCarousel, type Video } from "../VideoCarousel";
|
||||
import { pageHref } from "../pageHref";
|
||||
|
||||
const DOWNLOAD_URL = "https://github.com/features/ai/github-app?locale=en-US";
|
||||
|
||||
// TODO: replace with hosted URLs once the large source videos are uploaded
|
||||
// externally; see file header note.
|
||||
const inboxPoster = "/images/learning-hub/github-copilot-app/copilot-app-centralized-inbox-poster.webp";
|
||||
const delegateGif = "/images/learning-hub/github-copilot-app/copilot-app-delegate-to-agents.webp";
|
||||
|
||||
const controlCenterPoints = [
|
||||
"See all your active work at a glance",
|
||||
"Spin up multiple agents working on different tasks simultaneously",
|
||||
"Inspect what each agent is doing in real time",
|
||||
"Redirect agents mid-task or approve their changes",
|
||||
"Let agents handle automation (like merging PRs) while you focus elsewhere",
|
||||
];
|
||||
|
||||
const keyFeatures: { title: string; description: string }[] = [
|
||||
{
|
||||
title: "The My Work view",
|
||||
description:
|
||||
"A single dashboard for active sessions, your inbox of issues and PRs, background automations, and overall status — so you stop checking GitHub, your CLI, and VS Code separately.",
|
||||
},
|
||||
{
|
||||
title: "Automations",
|
||||
description:
|
||||
"Built-in automations run scheduled tasks with the same agentic technology. Use templates out of the box or create your own, running as a plan, an interactive session, or on autopilot.",
|
||||
},
|
||||
{
|
||||
title: "Isolated worktrees",
|
||||
description:
|
||||
"Every session runs in its own git worktree — a real, isolated copy of your branch with its own environment and changes, so multiple agents never step on each other.",
|
||||
},
|
||||
{
|
||||
title: "Canvases",
|
||||
description:
|
||||
"Interactive work surfaces where you and agents collaborate. A canvas shows the actual work — a plan, a PR diff, terminal output, or a live browser — that you can edit, approve, or redirect.",
|
||||
},
|
||||
{
|
||||
title: "Agent Merge",
|
||||
description:
|
||||
"Carries pull requests through the whole workflow: monitors CI, addresses failing tests and lint, tracks required reviewers, and can merge automatically once every condition is met.",
|
||||
},
|
||||
];
|
||||
|
||||
const audiences: { title: string; description: string }[] = [
|
||||
{
|
||||
title: "Teams running parallel agents",
|
||||
description:
|
||||
"If you use agents regularly and need to manage parallel work, the app is a dedicated control center — everything in one place instead of many windows.",
|
||||
},
|
||||
{
|
||||
title: "Non-traditional developers",
|
||||
description:
|
||||
"A more accessible, desktop-first interface for business analysts, product managers, and technical teammates who find VS Code or the CLI overwhelming.",
|
||||
},
|
||||
{
|
||||
title: "Multi-agent workflows",
|
||||
description:
|
||||
"The worktree architecture makes it natural to dispatch multiple agents on different tasks with no manual branch juggling or coordination.",
|
||||
},
|
||||
{
|
||||
title: "GUI-first developers",
|
||||
description:
|
||||
"A visual interface for common tasks that still surfaces the full power of agents, hooks, skills, and custom instructions.",
|
||||
},
|
||||
];
|
||||
|
||||
const comparisonRows: { experience: string; bestFor: string; strength: string }[] = [
|
||||
{
|
||||
experience: "Copilot CLI",
|
||||
bestFor: "Developers in the terminal",
|
||||
strength: "Raw power, scriptable, always available in your shell.",
|
||||
},
|
||||
{
|
||||
experience: "VS Code extension",
|
||||
bestFor: "Coding and real-time AI assistance",
|
||||
strength: "Integrated with your editor, instant feedback.",
|
||||
},
|
||||
{
|
||||
experience: "GitHub.com",
|
||||
bestFor: "Code review and PR management",
|
||||
strength: "Central hub for collaboration, always accessible on web.",
|
||||
},
|
||||
{
|
||||
experience: "Copilot app",
|
||||
bestFor: "Directing parallel agents, visual workflow",
|
||||
strength: "Control center for agentic development and multi-agent management.",
|
||||
},
|
||||
];
|
||||
|
||||
const installSteps = [
|
||||
"Download the installer for your platform.",
|
||||
"Install and launch the app.",
|
||||
"Authenticate with your GitHub account.",
|
||||
"Connect the repositories you want to work in.",
|
||||
];
|
||||
|
||||
const sessionWays: { lead: string; rest: string }[] = [
|
||||
{
|
||||
lead: "From an issue:",
|
||||
rest: "assign it to Copilot and the app opens a session to work on it.",
|
||||
},
|
||||
{
|
||||
lead: "From a prompt:",
|
||||
rest: "open the app and describe what you want done, like “Add dark mode support.”",
|
||||
},
|
||||
{
|
||||
lead: "From your inbox:",
|
||||
rest: "the app syncs your GitHub inbox — click an issue and start a session for it.",
|
||||
},
|
||||
];
|
||||
|
||||
const sessionFlow = [
|
||||
"You describe the work or assign an issue.",
|
||||
"The Copilot app creates an isolated worktree.",
|
||||
"The agent reads your issue, instructions, and codebase.",
|
||||
"It plans and implements a solution.",
|
||||
"You monitor progress in the My Work view.",
|
||||
"You redirect the agent or let it finish.",
|
||||
"Changes are ready for review — a PR or an approval.",
|
||||
];
|
||||
|
||||
const customizations: { title: string; lead: string; code: string }[] = [
|
||||
{ title: "Custom agents", lead: "Markdown agent files in", code: ".github/agents/" },
|
||||
{ title: "Skills", lead: "Specialized task guidance in", code: ".github/skills/" },
|
||||
{
|
||||
title: "Instructions",
|
||||
lead: "Coding standards in",
|
||||
code: ".github/instructions/",
|
||||
},
|
||||
{
|
||||
title: "Hooks",
|
||||
lead: "Automated checks and formatting in",
|
||||
code: ".github/hooks/",
|
||||
},
|
||||
{
|
||||
title: "Setup steps",
|
||||
lead: "Environment setup in",
|
||||
code: ".github/copilot-setup-steps.yml",
|
||||
},
|
||||
];
|
||||
|
||||
const workflows: { title: string; steps: string[] }[] = [
|
||||
{
|
||||
title: "Parallel bug fixes",
|
||||
steps: [
|
||||
"Create a session for “Fix login timeout issue.”",
|
||||
"While it runs, create another for “Fix dark mode button styling.”",
|
||||
"Monitor both in the My Work view.",
|
||||
"Review and merge each PR independently.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Feature development across sprints",
|
||||
steps: [
|
||||
"Connect your issue tracker and pull features from the backlog.",
|
||||
"Create a session for each feature.",
|
||||
"Each agent works independently in its own worktree.",
|
||||
"PRs land without interfering with each other.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Automated PR management",
|
||||
steps: [
|
||||
"Configure Agent Merge in the app settings.",
|
||||
"Choose the automations to enable — run CI, address feedback, merge.",
|
||||
"Create a session to implement a feature.",
|
||||
"Agent Merge monitors the PR and merges when it’s ready.",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const deepLinkSnippet = `# Start a new session in a repo
|
||||
open "ghapp://session/new?repo=owner/repo"
|
||||
|
||||
# Start from a branch or a pull request
|
||||
open "ghapp://session/new?repo=owner/repo&branch=main"
|
||||
open "ghapp://session/new?repo=owner/repo&pr=1234"
|
||||
|
||||
# Start with a kickoff prompt or an initial mode
|
||||
open "ghapp://session/new?repo=owner/repo&prompt=fix%20the%20flaky%20test"
|
||||
open "ghapp://session/new?repo=owner/repo&mode=plan"`;
|
||||
|
||||
const articleSections: TocSection[] = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "key-features", label: "Key features" },
|
||||
{ id: "who-its-for", label: "Who it’s for" },
|
||||
{ id: "how-it-compares", label: "How it compares" },
|
||||
{ id: "getting-started", label: "Getting started" },
|
||||
{ id: "session-flow", label: "How a session works" },
|
||||
{ id: "customizations", label: "Built on your customizations" },
|
||||
{ id: "workflows", label: "Common workflows" },
|
||||
{ id: "video-series", label: "Video series" },
|
||||
{ id: "learn-more", label: "Learn more" },
|
||||
];
|
||||
|
||||
/** GitHub's "Copilot app for Beginners" YouTube series. */
|
||||
const videoSeries: Video[] = [
|
||||
{
|
||||
id: "LsA4vIX_3UY",
|
||||
title: "Meet the GitHub Copilot app: Your new AI desktop assistant",
|
||||
meta: "GitHub",
|
||||
},
|
||||
];
|
||||
|
||||
export default function GithubCopilotApp() {
|
||||
return (
|
||||
<LearningArticleLayout
|
||||
pageHref={pageHref}
|
||||
currentPage="github-copilot-app"
|
||||
breadcrumbLabel="GitHub Copilot app"
|
||||
heroTitle="GitHub Copilot app"
|
||||
animateHeroTitle
|
||||
heroSubtitle="A desktop experience for agent-native development — see multiple agents run in parallel and take control, all in one place."
|
||||
heroCta={{ label: "Download the app", href: DOWNLOAD_URL }}
|
||||
tocSections={articleSections}
|
||||
upNext={{
|
||||
label: "GitHub Copilot Terminology Glossary",
|
||||
href: pageHref("github-copilot-terminology-glossary"),
|
||||
}}
|
||||
>
|
||||
<section id="overview" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
What is the GitHub Copilot app?
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
The Copilot app is a standalone desktop application that serves as a
|
||||
control center for agentic development. Instead of managing agents
|
||||
through GitHub.com pull requests, issues, and CLI windows, the Copilot
|
||||
app brings everything into one unified interface.
|
||||
</Text>
|
||||
<figure className={styles.videoFigure}>
|
||||
<img
|
||||
className={styles.video}
|
||||
src={inboxPoster}
|
||||
alt="The GitHub Copilot app showing a centralized inbox of active work"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width={2000}
|
||||
height={957}
|
||||
/>
|
||||
</figure>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Think of it as a command center where you can:
|
||||
</Text>
|
||||
<ul className={styles.checkList}>
|
||||
{controlCenterPoints.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
The key difference from existing Copilot experiences is that the app
|
||||
is purpose-built for parallel agent work. It handles the complexity of
|
||||
managing multiple isolated environments, branches, and worktrees
|
||||
automatically, so you don’t have to.
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section id="key-features" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Key features
|
||||
</Heading>
|
||||
<ul className={styles.pointList}>
|
||||
{keyFeatures.map((feature) => (
|
||||
<li key={feature.title}>
|
||||
<span className={styles.stepLabel}>{feature.title}</span> —{" "}
|
||||
{feature.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<figure className={styles.videoFigure}>
|
||||
<iframe
|
||||
className={styles.videoEmbedFrame}
|
||||
src="https://www.youtube.com/embed/LsA4vIX_3UY"
|
||||
title="What is the GitHub Copilot app?"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
/>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section id="who-its-for" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Who it’s for
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
The Copilot app isn’t a replacement for existing experiences —
|
||||
it’s another tool in the toolbox that fills a specific gap.
|
||||
</Text>
|
||||
<ul className={styles.pointList}>
|
||||
{audiences.map((audience) => (
|
||||
<li key={audience.title}>
|
||||
<span className={styles.stepLabel}>{audience.title}</span> —{" "}
|
||||
{audience.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<figure className={styles.videoFigure}>
|
||||
<img
|
||||
className={styles.gif}
|
||||
src={delegateGif}
|
||||
alt="The GitHub Copilot app delegating tasks to multiple agents working in parallel"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width={960}
|
||||
height={459}
|
||||
/>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section id="how-it-compares" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
How it compares
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Each Copilot surface is best for a different job. The app is the
|
||||
control center for directing parallel agents.
|
||||
</Text>
|
||||
<ul className={styles.pointList}>
|
||||
{comparisonRows.map((row) => (
|
||||
<li key={row.experience}>
|
||||
<span className={styles.stepLabel}>{row.experience}</span> — best
|
||||
for {row.bestFor.toLowerCase()}. {row.strength}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="getting-started" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Getting started
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
To use the GitHub Copilot app, you’ll need a Copilot Pro, Pro+,
|
||||
Business, or Enterprise plan, a compatible OS (macOS, Windows, or
|
||||
Linux), and connected GitHub repositories.
|
||||
</Text>
|
||||
<ol className={styles.stepsList}>
|
||||
{installSteps.map((step) => (
|
||||
<li key={step}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Start a session three ways</span>
|
||||
<ul className={styles.pointList}>
|
||||
{sessionWays.map((way) => (
|
||||
<li key={way.lead}>
|
||||
<span className={styles.stepLabel}>{way.lead}</span> {way.rest}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
The app supports URL deep links on the{" "}
|
||||
<code className={styles.inlineCode}>ghapp://</code> scheme — handy for
|
||||
starting a session straight from your terminal:
|
||||
</Text>
|
||||
<CopyBlock code={deepLinkSnippet} label="Deep links" />
|
||||
</section>
|
||||
|
||||
<section id="session-flow" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
How a session works
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Each session runs in its own worktree with an isolated environment,
|
||||
and you can run many in parallel.
|
||||
</Text>
|
||||
<ol className={styles.stepsList}>
|
||||
{sessionFlow.map((step) => (
|
||||
<li key={step}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<ProTip>
|
||||
<p className={styles.proTipLead}>
|
||||
Pin the app to your dock and keep the My Work view open — it’s
|
||||
the fastest way to see every running agent at a glance.
|
||||
</p>
|
||||
<p>
|
||||
Connect your issue tracker first. Assigning an issue to Copilot
|
||||
becomes the quickest way to spin up a new, fully-scoped session.
|
||||
</p>
|
||||
</ProTip>
|
||||
|
||||
<section id="customizations" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Built on your customizations
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
The app respects all your existing GitHub Copilot configuration, so
|
||||
agents behave the same way they do everywhere else.
|
||||
</Text>
|
||||
<ul className={styles.pointList}>
|
||||
{customizations.map((item) => (
|
||||
<li key={item.title}>
|
||||
<span className={styles.stepLabel}>{item.title}</span> —{" "}
|
||||
{item.lead}{" "}
|
||||
<code className={styles.inlineCode}>{item.code}</code>.
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="workflows" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Common workflows
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
A few ways teams put the app to work every day.
|
||||
</Text>
|
||||
{workflows.map((workflow) => (
|
||||
<div key={workflow.title} className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>{workflow.title}</span>
|
||||
<ol className={styles.stepsList}>
|
||||
{workflow.steps.map((step) => (
|
||||
<li key={step}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section id="video-series" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Video series
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Prefer to watch and learn? Get a guided tour of the GitHub Copilot app
|
||||
in this video from GitHub. Play it right here on the page.
|
||||
</Text>
|
||||
<VideoCarousel videos={videoSeries} />
|
||||
</section>
|
||||
|
||||
<section id="learn-more" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Learn more
|
||||
</Heading>
|
||||
<div className={styles.learnMoreBand}>
|
||||
<a
|
||||
className={styles.learnMoreCard}
|
||||
href="https://github.com/features/ai/github-app?locale=en-US"
|
||||
>
|
||||
<span className={styles.learnMoreCardIcon} aria-hidden="true">
|
||||
<GlobeIcon size={24} />
|
||||
</span>
|
||||
<span className={styles.learnMoreCardTitle}>
|
||||
GitHub Copilot app
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
className={styles.learnMoreCard}
|
||||
href="https://docs.github.com/en/copilot/concepts/agents/github-copilot-app"
|
||||
>
|
||||
<span className={styles.learnMoreCardIcon} aria-hidden="true">
|
||||
<BookIcon size={24} />
|
||||
</span>
|
||||
<span className={styles.learnMoreCardTitle}>
|
||||
Documentation
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</span>
|
||||
</a>
|
||||
<a className={styles.learnMoreCard} href="https://www.youtube.com/playlist?list=PLNBWjViYXaIY">
|
||||
<span className={styles.learnMoreCardIcon} aria-hidden="true">
|
||||
<PlayIcon size={24} />
|
||||
</span>
|
||||
<span className={styles.learnMoreCardTitle}>
|
||||
Video series
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</LearningArticleLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Heading, Text } from "@primer/react-brand";
|
||||
import { BookIcon, PlayIcon, GlobeIcon, ArrowUpRightIcon } from "@primer/octicons-react";
|
||||
|
||||
import styles from "../styles/github-copilot-app.module.css";
|
||||
import { pageHref } from "../pageHref";
|
||||
import {
|
||||
CopyBlock,
|
||||
LearningArticleLayout,
|
||||
type TocSection,
|
||||
} from "../LearningArticleLayout";
|
||||
import { VideoCarousel, type Video } from "../VideoCarousel";
|
||||
|
||||
const videoSeries: Video[] = [
|
||||
{
|
||||
id: "LsA4vIX_3UY",
|
||||
title: "Meet the GitHub Copilot app: Your new AI desktop assistant",
|
||||
meta: "GitHub",
|
||||
},
|
||||
];
|
||||
|
||||
type GlossaryBlock =
|
||||
| { type: "paragraph"; text: string }
|
||||
| { type: "bullets"; items: string[] }
|
||||
| { type: "ordered"; items: string[] }
|
||||
| { type: "code"; code: string };
|
||||
|
||||
type GlossaryTerm = {
|
||||
id: string;
|
||||
term: string;
|
||||
definition: [GlossaryBlock, ...GlossaryBlock[]];
|
||||
};
|
||||
|
||||
const articleBaseUrl =
|
||||
"https://awesome-copilot.github.com/learning-hub/github-copilot-terminology-glossary/";
|
||||
|
||||
const frontMatterExample = `---
|
||||
name: 'React Component Generator'
|
||||
description: 'Generate modern React components with TypeScript'
|
||||
mode: 'agent'
|
||||
tools: ['codebase']
|
||||
---`;
|
||||
|
||||
const toolsExample = `tools: ['codebase', 'terminalCommand', 'github']`;
|
||||
|
||||
const coreTerms: GlossaryTerm[] = [
|
||||
{
|
||||
id: "agent",
|
||||
term: "Agent",
|
||||
definition: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "A specialized configuration file (`*.agent.md`) that defines a GitHub Copilot persona or assistant with specific expertise, tools, and behavior patterns. In products that support delegation, the agent is usually the primary coordinator or main session persona, while subagents handle narrower delegated tasks.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**When to use**: For recurring workflows that benefit from deep tooling integrations and persistent conversational context.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Learn more**: [What are Agents, Skills, and Instructions](../what-are-agents-skills-instructions/)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "subagent",
|
||||
term: "Subagent",
|
||||
definition: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "A temporary, task-focused agent launched by another agent or orchestrator. A subagent usually gets a narrower prompt, its own isolated context window, and returns a summary back to the main agent instead of staying in the primary conversation.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**When to use**: For isolated research, parallel analysis, specialized review passes, or delegated implementation steps.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Learn more**: [Agents and Subagents](../agents-and-subagents/)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "built-in-tool",
|
||||
term: "Built-in Tool",
|
||||
definition: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "A native capability provided by GitHub Copilot without requiring additional configuration or MCP servers. Examples include code search, file editing, terminal command execution, and web search. Built-in tools are always available and don’t require installation.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Related terms**: [Tools](#tools), [MCP](#mcp-model-context-protocol)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chat-mode",
|
||||
term: "Chat Mode",
|
||||
definition: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Deprecated terminology** - This term is no longer used. Use [Agent](#agent) instead.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Previously, “chat mode” was an alternative term for [Agent](#agent) that described how GitHub Copilot Chat could be transformed into domain-specific assistants. The ecosystem has standardized on “Agent” as the preferred terminology.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**See**: [Agent](#agent)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "collection",
|
||||
term: "Collection",
|
||||
definition: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Note**: Collections are a concept specific to the Awesome GitHub Copilot repository and are not part of standard GitHub Copilot terminology.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "A curated grouping of related skills, instructions, and agents organized around a specific theme or workflow. Collections are defined in YAML files (`*.collection.yml`) in the `collections/` directory and help users discover related customizations together.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Example**: The “Awesome Copilot” collection bundles meta-skills for discovering and generating GitHub Copilot customizations.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Learn more**: [What are Agents, Skills, and Instructions](../what-are-agents-skills-instructions/)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "custom-agent",
|
||||
term: "Custom Agent",
|
||||
definition: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "See [Agent](#agent). The term “custom” emphasizes that these are user-defined configurations rather than GitHub Copilot’s default behavior. Custom agents can be created by anyone and shared via repositories like Awesome GitHub Copilot.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "custom-instruction",
|
||||
term: "Custom Instruction",
|
||||
definition: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "See [Instruction](#instruction). The term “custom” emphasizes that these are user-defined rules rather than GitHub Copilot’s built-in understanding. Custom instructions are particularly useful for codifying team-specific standards and architectural decisions.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const configurationTerms: GlossaryTerm[] = [
|
||||
{
|
||||
id: "front-matter",
|
||||
term: "Front Matter",
|
||||
definition: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "YAML metadata placed at the beginning of Markdown files (between `---` delimiters) that provides structured information about the file and controls its behavior. In this repository, front matter typically includes fields like `name`, `description`, `mode`, `model`, `tools`, and `applyTo`.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "The front matter is what controls:",
|
||||
},
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"**Tool access**: Which built-in and MCP tools the customization can use",
|
||||
"**Model selection**: Which AI model powers the customization",
|
||||
"**Scope**: Where the customization applies (e.g., `applyTo` patterns for instructions)",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Note**: Not all fields are common across all customization types. Refer to the specific documentation for agents, skills, or instructions to see which fields apply to each type.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Example**:",
|
||||
},
|
||||
{
|
||||
type: "code",
|
||||
code: frontMatterExample,
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Used in**: Skills, agents, instructions, and Learning Hub articles.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "handoff",
|
||||
term: "Handoff",
|
||||
definition: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "A VS Code custom-agent frontmatter property (`handoffs`) that defines suggested transitions from one agent to another, often with a pre-filled follow-up prompt. Handoffs are useful for guided workflows such as research -> implementation or planning -> review.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Important**: GitHub’s [custom agent configuration reference](../building-custom-agents/#agent-configuration-reference) says `handoffs` are currently ignored for Copilot cloud agent on GitHub.com, so this concept is not portable across every Copilot surface.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Learn more**: [Agents and Subagents](../agents-and-subagents/), [Building Custom Agents](../building-custom-agents/)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "agentsmd",
|
||||
term: "AGENTS.md",
|
||||
definition: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "An emerging industry standard file format for defining portable AI coding instructions that work across different AI coding tools (GitHub Copilot, Claude, Codex, and others). The `AGENTS.md` file, typically placed in a repository root or `.github/` directory, contains instructions for how AI assistants should interact with your codebase.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Unlike tool-specific customization files (`.agent.md`, `.prompt.md`, `.instructions.md`), `AGENTS.md` aims to provide a standardized, platform-agnostic way to define AI behavior that can be consumed by multiple tools.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Key characteristics**:",
|
||||
},
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"Platform-agnostic format for cross-tool compatibility",
|
||||
"Typically contains project context, coding standards, and architectural guidelines",
|
||||
"Located at repository root or in `.github/` directory",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Learn more**: [AGENTS.md Specification](https://agents.md/)",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Related terms**: [Instruction](#instruction), [Front Matter](#front-matter)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "instruction",
|
||||
term: "Instruction",
|
||||
definition: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "A configuration file (`*.instructions.md`) that provides persistent background context and coding standards that GitHub Copilot reads whenever working on matching files. Instructions contain style guides, framework-specific hints, and repository rules that help Copilot align with your engineering practices automatically.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**When to use**: For long-lived guidance that applies across many sessions, like coding standards or compliance requirements.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Learn more**: [What are Agents, Skills, and Instructions](../what-are-agents-skills-instructions/), [Defining Custom Instructions](../defining-custom-instructions/)",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const skillsTerms: GlossaryTerm[] = [
|
||||
{
|
||||
id: "persona",
|
||||
term: "Persona",
|
||||
definition: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "The identity, tone, and behavioral characteristics defined for an [Agent](#agent). A well-crafted persona helps GitHub Copilot respond consistently and appropriately for specific domains or expertise areas.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Example**: A “Database Performance Expert” persona might prioritize query optimization and explain concepts using database-specific terminology.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Related terms**: [Agent](#agent)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "prompt",
|
||||
term: "Prompt",
|
||||
definition: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Deprecated** — Prompts (`*.prompt.md`) were reusable chat templates that captured specific tasks or workflows, invoked using the `/` command in GitHub Copilot Chat. Prompts have been superseded by [Skills](#skill), which offer the same slash-command invocation plus agent discovery, bundled assets, and cross-platform portability.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "If you have existing prompts, consider migrating them to skills. See [Creating Effective Skills](../creating-effective-skills/) for guidance.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**See**: [Skill](#skill)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "skill",
|
||||
term: "Skill",
|
||||
definition: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "A self-contained folder containing a `SKILL.md` file and optional bundled assets (reference documents, templates, scripts) that packages a reusable capability for GitHub Copilot. Skills follow the open [Agent Skills specification](https://agentskills.io/home) and can be invoked by users via `/command` or discovered and invoked by agents automatically.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Key advantages**:",
|
||||
},
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"**Agent discovery**: Extended frontmatter lets agents find and invoke skills automatically",
|
||||
"**Bundled assets**: Reference files, templates, and scripts provide richer context",
|
||||
"**Cross-platform**: Portable across coding agent systems via the Agent Skills specification",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Example**: A `/generate-tests` skill might include a `SKILL.md` with testing instructions, a `references/test-patterns.md` with common patterns, and a `templates/test-template.ts` starter file.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**When to use**: For standardizing how Copilot responds to recurring tasks, especially when bundled resources improve quality.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Learn more**: [What are Agents, Skills, and Instructions](../what-are-agents-skills-instructions/), [Creating Effective Skills](../creating-effective-skills/)",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const platformTerms: GlossaryTerm[] = [
|
||||
{
|
||||
id: "mcp-model-context-protocol",
|
||||
term: "MCP (Model Context Protocol)",
|
||||
definition: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "A standardized protocol for connecting AI assistants like GitHub Copilot to external data sources, tools, and services. MCP servers act as bridges, allowing Copilot to interact with APIs, databases, file systems, and other resources beyond its built-in capabilities.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Example**: An MCP server might provide access to your company’s internal documentation, AWS resources, or a specific database system.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Learn more**: [Model Context Protocol](https://modelcontextprotocol.io/) | [MCP Specification](https://spec.modelcontextprotocol.io/) | [Understanding MCP Servers](../understanding-mcp-servers/)",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Related terms**: [Tools](#tools), [Built-in Tool](#built-in-tool)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "hook",
|
||||
term: "Hook",
|
||||
definition: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "A shell command or script that runs automatically in response to lifecycle events during a Copilot agent session. Hooks are stored as JSON files in `.github/hooks/` and can trigger on events like session start/end, prompt submission, before/after tool use, and when errors occur. They provide deterministic automation—linting, formatting, governance scanning—that doesn’t depend on the AI remembering to do it.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Example**: A `postToolUse` hook that runs Prettier after the agent edits files, or a `preToolUse` hook that blocks dangerous shell commands.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**When to use**: For deterministic automation that must happen reliably, like formatting code, running linters, or auditing prompts for compliance.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Learn more**: [Automating with Hooks](../automating-with-hooks/)",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Related terms**: [Agent](#agent), [Coding Agent](#coding-agent)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "coding-agent",
|
||||
term: "Coding Agent",
|
||||
definition: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "The autonomous GitHub Copilot agent that works on issues in a cloud environment without continuous human guidance. You assign an issue to Copilot, it spins up a dev environment, implements a solution, runs tests, and opens a pull request for review.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Key characteristics**:",
|
||||
},
|
||||
{
|
||||
type: "bullets",
|
||||
items: [
|
||||
"Runs in an isolated cloud environment",
|
||||
"Uses your repository’s instructions, agents, skills, and hooks",
|
||||
"Always produces a PR—it can’t merge or deploy",
|
||||
"Supports iteration via PR comments",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**When to use**: For well-defined tasks with clear acceptance criteria that can be completed autonomously.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Learn more**: [Using the Copilot Coding Agent](../using-copilot-coding-agent/)",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Related terms**: [Agent](#agent), [Hook](#hook)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "plugin",
|
||||
term: "Plugin",
|
||||
definition: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "An installable package that extends GitHub Copilot CLI with a bundled set of agents, skills, hooks, MCP server configurations, and LSP integrations. Plugins provide a way to distribute and share custom capabilities across projects and teams, with versioning, discovery, and one-command installation via marketplaces.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Example**: Installing `database-data-management@awesome-copilot` to get a database specialist agent, migration skills, and schema validation hooks in a single command.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**When to use**: When you want to share a curated set of Copilot capabilities across multiple projects or team members, or when you want to install community-contributed tooling without manually copying files.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Learn more**: [Installing and Using Plugins](../installing-and-using-plugins/)",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Related terms**: [Agent](#agent), [Skill](#skill), [Hook](#hook)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
term: "Tools",
|
||||
definition: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Capabilities that GitHub Copilot can invoke to perform actions or retrieve information. Tools fall into two categories:",
|
||||
},
|
||||
{
|
||||
type: "ordered",
|
||||
items: [
|
||||
"**Built-in tools**: Native capabilities like `codebase` (code search), `terminalCommand` (running commands), and `web` (web search)",
|
||||
"**MCP tools**: External integrations provided by MCP servers (e.g., database queries, cloud resource management, or API calls)",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Agents and skills can specify which tools they require or recommend in their front matter.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Example front matter**:",
|
||||
},
|
||||
{
|
||||
type: "code",
|
||||
code: toolsExample,
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "**Related terms**: [MCP](#mcp-model-context-protocol), [Built-in Tool](#built-in-tool), [Agent](#agent)",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const contributionNote =
|
||||
"**Have a term you’d like to see added?** Contributions are welcome! See our [Contributing Guidelines](https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md) for how to suggest additions to this glossary.";
|
||||
|
||||
const articleSections: TocSection[] = [
|
||||
{ id: "core-concepts", label: "Core Concepts" },
|
||||
{ id: "configuration-metadata", label: "Configuration & Metadata" },
|
||||
{ id: "skills-interactions", label: "Skills & Interactions" },
|
||||
{ id: "platform-integration", label: "Platform & Integration" },
|
||||
{ id: "video-series", label: "Video series" },
|
||||
{ id: "learn-more", label: "Learn more" },
|
||||
];
|
||||
|
||||
function articleHref(href: string) {
|
||||
if (href.startsWith("#") || href.startsWith("http")) return href;
|
||||
return new URL(href, articleBaseUrl).href;
|
||||
}
|
||||
|
||||
function renderInline(text: string): ReactNode[] {
|
||||
const parts: ReactNode[] = [];
|
||||
const inlinePattern = /`([^`]+)`|\*\*([^*]+)\*\*|\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = inlinePattern.exec(text))) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
if (match[1]) {
|
||||
parts.push(
|
||||
<code className={styles.inlineCode} key={`${match.index}-code`}>
|
||||
{match[1]}
|
||||
</code>,
|
||||
);
|
||||
} else if (match[2]) {
|
||||
parts.push(<strong key={`${match.index}-strong`}>{match[2]}</strong>);
|
||||
} else if (match[3] && match[4]) {
|
||||
parts.push(
|
||||
<a href={articleHref(match[4])} key={`${match.index}-link`}>
|
||||
{match[3]}
|
||||
</a>,
|
||||
);
|
||||
}
|
||||
|
||||
lastIndex = inlinePattern.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function renderBlock(block: GlossaryBlock, key: string) {
|
||||
if (block.type === "paragraph") {
|
||||
return (
|
||||
<Text as="p" size="300" variant="muted" key={key}>
|
||||
{renderInline(block.text)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === "bullets") {
|
||||
return (
|
||||
<ul className={styles.pointList} key={key}>
|
||||
{block.items.map((item) => (
|
||||
<li key={item}>{renderInline(item)}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === "ordered") {
|
||||
return (
|
||||
<ol className={styles.stepsList} key={key}>
|
||||
{block.items.map((item) => (
|
||||
<li key={item}>{renderInline(item)}</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
return <CopyBlock code={block.code} key={key} />;
|
||||
}
|
||||
|
||||
function GlossaryList({ terms }: { terms: GlossaryTerm[] }) {
|
||||
return (
|
||||
<ul className={styles.pointList}>
|
||||
{terms.map((item) => {
|
||||
const [leadBlock, ...detailBlocks] = item.definition;
|
||||
return (
|
||||
<li id={item.id} key={item.id}>
|
||||
<span className={styles.stepLabel}>{item.term}</span> —{" "}
|
||||
{leadBlock.type === "paragraph"
|
||||
? renderInline(leadBlock.text)
|
||||
: renderBlock(leadBlock, `${item.id}-lead`)}
|
||||
{detailBlocks.map((block, index) =>
|
||||
renderBlock(block, `${item.id}-${index}`),
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TerminologyGlossary() {
|
||||
return (
|
||||
<LearningArticleLayout
|
||||
pageHref={pageHref}
|
||||
currentPage="github-copilot-terminology-glossary"
|
||||
breadcrumbLabel="Glossary"
|
||||
heroTitle="Terminology"
|
||||
animateHeroTitle
|
||||
heroSubtitle="Definitions for the terms you’ll meet while exploring agents, skills, instructions, and related GitHub Copilot concepts."
|
||||
tocSections={articleSections}
|
||||
upNext={{
|
||||
label: "GitHub Copilot CLI for Beginners",
|
||||
href: pageHref("cli-for-beginners"),
|
||||
}}
|
||||
>
|
||||
<section id="core-concepts" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Core Concepts
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Use this page as a quick reference when reading articles in the
|
||||
Learning Hub or browsing the repository.
|
||||
</Text>
|
||||
<GlossaryList terms={coreTerms} />
|
||||
<figure className={styles.videoFigure}>
|
||||
<iframe
|
||||
className={styles.videoEmbedFrame}
|
||||
src="https://www.youtube.com/embed/EPyyyB23NUU"
|
||||
title="Introducing the GitHub Copilot coding agent"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
/>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section id="configuration-metadata" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Configuration & Metadata
|
||||
</Heading>
|
||||
<GlossaryList terms={configurationTerms} />
|
||||
<figure className={styles.videoFigure}>
|
||||
<iframe
|
||||
className={styles.videoEmbedFrame}
|
||||
src="https://www.youtube.com/embed/-yKALFS5ewY"
|
||||
title="How to use agents, skills, and instructions in Copilot CLI"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
/>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section id="skills-interactions" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Skills & Interactions
|
||||
</Heading>
|
||||
<GlossaryList terms={skillsTerms} />
|
||||
<figure className={styles.videoFigure}>
|
||||
<iframe
|
||||
className={styles.videoEmbedFrame}
|
||||
src="https://www.youtube.com/embed/LAF-lACf2QY"
|
||||
title="Prompt engineering essentials: Getting better results from LLMs"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
/>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section id="platform-integration" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Platform & Integration
|
||||
</Heading>
|
||||
<GlossaryList terms={platformTerms} />
|
||||
<figure className={styles.videoFigure}>
|
||||
<iframe
|
||||
className={styles.videoEmbedFrame}
|
||||
src="https://www.youtube.com/embed/DtQjVIRRszM"
|
||||
title="How to use MCP servers with GitHub Copilot CLI"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
/>
|
||||
</figure>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
{renderInline(contributionNote)}
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section id="video-series" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Video series
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Prefer to watch and learn? Get a guided tour of the GitHub Copilot app
|
||||
in this video from GitHub. Play it right here on the page.
|
||||
</Text>
|
||||
<VideoCarousel videos={videoSeries} />
|
||||
</section>
|
||||
|
||||
<section id="learn-more" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Learn more
|
||||
</Heading>
|
||||
<div className={styles.learnMoreBand}>
|
||||
<a className={styles.learnMoreCard} href="https://github.com/features/copilot">
|
||||
<span className={styles.learnMoreCardIcon} aria-hidden="true">
|
||||
<GlobeIcon size={24} />
|
||||
</span>
|
||||
<span className={styles.learnMoreCardTitle}>
|
||||
GitHub Copilot
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</span>
|
||||
</a>
|
||||
<a className={styles.learnMoreCard} href="https://docs.github.com/en/copilot/get-started/what-is-github-copilot">
|
||||
<span className={styles.learnMoreCardIcon} aria-hidden="true">
|
||||
<BookIcon size={24} />
|
||||
</span>
|
||||
<span className={styles.learnMoreCardTitle}>
|
||||
Documentation
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</span>
|
||||
</a>
|
||||
<a className={styles.learnMoreCard} href="https://www.youtube.com/playlist?list=PL0lo9MOBetEFc6rN_y9-YKA3plCSUb1NP">
|
||||
<span className={styles.learnMoreCardIcon} aria-hidden="true">
|
||||
<PlayIcon size={24} />
|
||||
</span>
|
||||
<span className={styles.learnMoreCardTitle}>
|
||||
Video series
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</LearningArticleLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* Bespoke Learning Hub article for "Using Automations in the GitHub Copilot app",
|
||||
* ported near-verbatim from the Brand Engineering prototype's
|
||||
* `pages/using-automations-in-copilot-app.tsx`
|
||||
* (`site-generator/prototypes/awesome-copilot-community-library`).
|
||||
*
|
||||
* Only the following were adapted from the prototype source:
|
||||
* - `PrototypePageProps`/`pageHref` import swapped for the site's `pageHref`.
|
||||
* - Media imports replaced with static paths. The large source video
|
||||
* (`copilot-app-delegate-to-agents.mp4`, ~32MB) is not committed to this
|
||||
* repo — TODO: host it externally (e.g. a CDN/GitHub release asset) and swap
|
||||
* `delegateGif` below to a real `<video>` source. Until then the clip renders
|
||||
* as its already-ported static webp.
|
||||
*/
|
||||
import { Heading, Text } from "@primer/react-brand";
|
||||
import { BookIcon, PlayIcon, GlobeIcon, ArrowUpRightIcon } from "@primer/octicons-react";
|
||||
|
||||
import styles from "../styles/github-copilot-app.module.css";
|
||||
import { pageHref } from "../pageHref";
|
||||
import {
|
||||
LearningArticleLayout,
|
||||
type TocSection,
|
||||
} from "../LearningArticleLayout";
|
||||
import { VideoCarousel, type Video } from "../VideoCarousel";
|
||||
|
||||
// TODO: replace with a hosted URL once the large source video is uploaded
|
||||
// externally; see file header note.
|
||||
const delegateGif = "/images/learning-hub/using-automations-in-copilot-app/copilot-app-delegate-to-agents.webp";
|
||||
|
||||
const videoSeries: Video[] = [
|
||||
{
|
||||
id: "usXv9jJWjvI",
|
||||
title: "Automating daily workflows with the GitHub Copilot app and MCP",
|
||||
meta: "GitHub",
|
||||
},
|
||||
{
|
||||
id: "3_i03fGXs9U",
|
||||
title: "Introducing GitHub Agentic Workflows",
|
||||
meta: "GitHub",
|
||||
},
|
||||
{
|
||||
id: "P2qK2BCdi-w",
|
||||
title: "Demo: using /delegate in the GitHub Copilot CLI",
|
||||
meta: "Demo",
|
||||
},
|
||||
{
|
||||
id: "HDEGFNAUkX8",
|
||||
title: "How to automate code reviews and testing with GitHub Copilot",
|
||||
meta: "Guide",
|
||||
},
|
||||
{
|
||||
id: "onVn-lnHZ9s",
|
||||
title: "Demo: end-to-end agentic development with GitHub Copilot",
|
||||
meta: "Demo",
|
||||
},
|
||||
{
|
||||
id: "LsA4vIX_3UY",
|
||||
title: "Meet the GitHub Copilot app: Your new AI desktop assistant",
|
||||
meta: "GitHub",
|
||||
},
|
||||
];
|
||||
|
||||
const createAutomationUrl =
|
||||
"ghapp://automations/new?name=Awesome%20Copilot%20daily%20PR%20summary&trigger=daily&time=09%3A00&prompt=Pulls%20open%20PRs%20via%20gh%20api%2C%20filters%20to%20updates%20in%20the%20last%2024%20hours%2C%20and%20returns%20a%20concise%20summary%20table";
|
||||
|
||||
const whyThisWorks: string[] = [
|
||||
"It is narrowly scoped (one repo, one reporting goal).",
|
||||
"It has explicit command examples in the prompt.",
|
||||
"It runs on a predictable cadence and produces a format that is easy to review.",
|
||||
];
|
||||
|
||||
const typicalRefinements: string[] = [
|
||||
"Tighten scope (for example, only specific labels, teams, or paths)",
|
||||
"Improve output format (table, checklist, short summary)",
|
||||
"Add clear success criteria (what should be included or excluded)",
|
||||
];
|
||||
|
||||
const communityIdeas: string[] = [
|
||||
"Daily status updates for initiative-specific issues",
|
||||
"Review and inbox hygiene tasks",
|
||||
"Recurring personal workflow maintenance",
|
||||
];
|
||||
|
||||
const practicalGuardrails: string[] = [
|
||||
"Keep prompts explicit and scoped to one outcome.",
|
||||
"Give the automation only the capabilities it needs.",
|
||||
"Avoid secrets in prompts; use repository secrets and variables where needed.",
|
||||
"Prefer review-friendly outputs so it is easy to trust and iterate.",
|
||||
];
|
||||
|
||||
const articleSections: TocSection[] = [
|
||||
{
|
||||
id: "start-with-templates-then-customize",
|
||||
label: "Start with templates (then customize)",
|
||||
},
|
||||
{
|
||||
id: "use-the-work-surface-audit-trick",
|
||||
label: "Use the work-surface audit trick",
|
||||
},
|
||||
{
|
||||
id: "create-your-first-automation-in-the-app",
|
||||
label: "Create your first automation in the app",
|
||||
},
|
||||
{
|
||||
id: "example-awesome-copilot-daily-pr-summary",
|
||||
label: "Example: Awesome Copilot daily PR summary",
|
||||
},
|
||||
{
|
||||
id: "iterate-in-chat-to-improve-results",
|
||||
label: "Iterate in chat to improve results",
|
||||
},
|
||||
{
|
||||
id: "real-world-ideas-from-the-community",
|
||||
label: "Real-world ideas from the community",
|
||||
},
|
||||
{ id: "practical-guardrails", label: "Practical guardrails" },
|
||||
{ id: "next-step", label: "Next step" },
|
||||
{ id: "video-series", label: "Video series" },
|
||||
{ id: "learn-more", label: "Learn more" },
|
||||
];
|
||||
|
||||
export default function UsingAutomationsInCopilotApp() {
|
||||
return (
|
||||
<LearningArticleLayout
|
||||
pageHref={pageHref}
|
||||
currentPage="using-automations-in-copilot-app"
|
||||
breadcrumbLabel="Automations"
|
||||
heroTitle="Automations in the GitHub Copilot app"
|
||||
animateHeroTitle
|
||||
heroSubtitle="One of the fastest ways to make the GitHub Copilot app useful daily: save a recurring task once, then run it on a schedule or on demand."
|
||||
tocSections={articleSections}
|
||||
upNext={{
|
||||
label: "Working with Canvas Extensions",
|
||||
href: pageHref("working-with-canvas-extensions"),
|
||||
}}
|
||||
>
|
||||
<section
|
||||
id="start-with-templates-then-customize"
|
||||
className={styles.articleSection}
|
||||
>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Start with templates (then customize)
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
If you are not sure where to begin, start with templates, adapt one to
|
||||
your workflow, and iterate from real runs.
|
||||
</Text>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
When you create a new automation in the Copilot app, browse the
|
||||
built-in templates first. They give you a strong starting point for
|
||||
both prompt structure and scope.
|
||||
</Text>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Common examples include:
|
||||
</Text>
|
||||
<ul className={styles.checkList}>
|
||||
<li>
|
||||
<span className={styles.stepLabel}>Triage incoming issues</span> (for
|
||||
example, label issues as{" "}
|
||||
<code className={styles.inlineCode}>bug</code>,{" "}
|
||||
<code className={styles.inlineCode}>enhancement</code>, or{" "}
|
||||
<code className={styles.inlineCode}>other</code>)
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.stepLabel}>Fix failing tests nightly</span>{" "}
|
||||
(attempt a fix and open a draft pull request)
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.stepLabel}>
|
||||
Prepare weekly release notes
|
||||
</span>{" "}
|
||||
(draft and open a pull request on schedule)
|
||||
</li>
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Even if none of these are an exact match, templates are the quickest
|
||||
way to avoid a blank-page start.
|
||||
</Text>
|
||||
<figure className={styles.videoFigure}>
|
||||
<img
|
||||
className={styles.video}
|
||||
src={delegateGif}
|
||||
alt="Demo of delegating work to agents in the GitHub Copilot app"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="use-the-work-surface-audit-trick"
|
||||
className={styles.articleSection}
|
||||
>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Use the work-surface audit trick
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
A practical way to discover useful automations is to ask Copilot to
|
||||
audit your work surfaces and suggest candidates.
|
||||
</Text>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
If you have MCP servers configured (for example, WorkIQ for Microsoft
|
||||
365 or a Slack MCP server), try a prompt like this in a regular chat
|
||||
first:
|
||||
</Text>
|
||||
<blockquote className={styles.prompt}>
|
||||
If available, use WorkIQ (Teams/Outlook) and a Slack MCP server to
|
||||
review my recent messages and calendar. Identify where I’m missing
|
||||
follow-ups or repeating work, and suggest a short list of useful
|
||||
automations.
|
||||
</blockquote>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
This often produces several concrete automation ideas in one pass. Then
|
||||
turn the best one into a saved automation.
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="create-your-first-automation-in-the-app"
|
||||
className={styles.articleSection}
|
||||
>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Create your first automation in the app
|
||||
</Heading>
|
||||
<ol className={styles.stepsList}>
|
||||
<li>
|
||||
Open <span className={styles.stepLabel}>Automations</span> in the
|
||||
Copilot app sidebar.
|
||||
</li>
|
||||
<li>
|
||||
Click <span className={styles.stepLabel}>New automation</span>.
|
||||
</li>
|
||||
<li>Start from a template or from scratch.</li>
|
||||
<li>Give it a clear name and a specific prompt.</li>
|
||||
<li>Choose when it runs (manual, hourly, daily, or weekly).</li>
|
||||
<li>Optionally set mode, model, and reasoning effort.</li>
|
||||
<li>
|
||||
Use <span className={styles.stepLabel}>Create and run</span> for the
|
||||
first run so you can immediately inspect output and refine.
|
||||
</li>
|
||||
</ol>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
If your first version is only 70% right, that is normal. The fastest
|
||||
path is to iterate from a real run.
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="example-awesome-copilot-daily-pr-summary"
|
||||
className={styles.articleSection}
|
||||
>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Example: Awesome Copilot daily PR summary
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Here is a real in-app automation used on the{" "}
|
||||
<code className={styles.inlineCode}>github/awesome-copilot</code>{" "}
|
||||
repository:
|
||||
</Text>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>Awesome Copilot daily PR summary</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Interval</td>
|
||||
<td>Daily at 09:00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Mode</td>
|
||||
<td>Autopilot</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>What it does</td>
|
||||
<td>
|
||||
Pulls open PRs via{" "}
|
||||
<code className={styles.inlineCode}>gh api</code>, filters to
|
||||
updates in the last 24 hours, and returns a concise summary
|
||||
table
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
<a href={createAutomationUrl}>Create Automation</a>
|
||||
</Text>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Why this works well:
|
||||
</Text>
|
||||
<ul className={styles.checkList}>
|
||||
{whyThisWorks.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
This is a strong starter pattern you can copy for issue triage, release
|
||||
prep, review tracking, or team digests.
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="iterate-in-chat-to-improve-results"
|
||||
className={styles.articleSection}
|
||||
>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Iterate in chat to improve results
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
After each run, open a chat and refine the automation prompt directly.
|
||||
Typical refinements:
|
||||
</Text>
|
||||
<ul className={styles.checkList}>
|
||||
{typicalRefinements.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
The goal is not a perfect first prompt. The goal is a useful automation
|
||||
that gets better with each run.
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="real-world-ideas-from-the-community"
|
||||
className={styles.articleSection}
|
||||
>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Real-world ideas from the community
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Teams are already using recurring automations for practical work such
|
||||
as:
|
||||
</Text>
|
||||
<ul className={styles.checkList}>
|
||||
{communityIdeas.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Use these as patterns, then tailor them to your own repo, rituals, and
|
||||
communication style.
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section id="practical-guardrails" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Practical guardrails
|
||||
</Heading>
|
||||
<ul className={styles.checkList}>
|
||||
{practicalGuardrails.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="next-step" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Next step
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Open <span className={styles.stepLabel}>Automations</span>, pick one
|
||||
template, and convert one recurring task you currently do manually into
|
||||
a daily run. That single win usually makes the next automation obvious.
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section id="video-series" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Video series
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Prefer to watch and learn? Explore these related videos from GitHub on
|
||||
automating workflows, delegating to agents, and agentic development.
|
||||
Play any video right here on the page.
|
||||
</Text>
|
||||
<VideoCarousel videos={videoSeries} />
|
||||
</section>
|
||||
|
||||
<section id="learn-more" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Learn more
|
||||
</Heading>
|
||||
<div className={styles.learnMoreBand}>
|
||||
<a className={styles.learnMoreCard} href="https://github.com/features/copilot">
|
||||
<span className={styles.learnMoreCardIcon} aria-hidden="true">
|
||||
<GlobeIcon size={24} />
|
||||
</span>
|
||||
<span className={styles.learnMoreCardTitle}>
|
||||
GitHub Copilot
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</span>
|
||||
</a>
|
||||
<a className={styles.learnMoreCard} href="https://docs.github.com/en/copilot/concepts/coding-agent">
|
||||
<span className={styles.learnMoreCardIcon} aria-hidden="true">
|
||||
<BookIcon size={24} />
|
||||
</span>
|
||||
<span className={styles.learnMoreCardTitle}>
|
||||
Documentation
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</span>
|
||||
</a>
|
||||
<a className={styles.learnMoreCard} href="https://www.youtube.com/playlist?list=PLNBWjViYXaIY">
|
||||
<span className={styles.learnMoreCardIcon} aria-hidden="true">
|
||||
<PlayIcon size={24} />
|
||||
</span>
|
||||
<span className={styles.learnMoreCardTitle}>
|
||||
Video series
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</LearningArticleLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { Heading, Text } from "@primer/react-brand";
|
||||
import { BookIcon, PlayIcon, GlobeIcon, ArrowUpRightIcon } from "@primer/octicons-react";
|
||||
|
||||
import styles from "../styles/github-copilot-app.module.css";
|
||||
import { pageHref } from "../pageHref";
|
||||
import {
|
||||
LearningArticleLayout,
|
||||
type TocSection,
|
||||
} from "../LearningArticleLayout";
|
||||
import { VideoCarousel, type Video } from "../VideoCarousel";
|
||||
|
||||
const videoSeries: Video[] = [
|
||||
{
|
||||
id: "LsA4vIX_3UY",
|
||||
title: "Meet the GitHub Copilot app: Your new AI desktop assistant",
|
||||
meta: "GitHub",
|
||||
},
|
||||
{
|
||||
id: "onVn-lnHZ9s",
|
||||
title: "Demo: end-to-end agentic development with GitHub Copilot",
|
||||
meta: "Demo",
|
||||
},
|
||||
{
|
||||
id: "GPML5a2jZCY",
|
||||
title: "How to get the most out of the Copilot coding agent",
|
||||
meta: "Guide",
|
||||
},
|
||||
{
|
||||
id: "a1BR6K3E4zs",
|
||||
title: "When to use the coding agent versus agent mode",
|
||||
meta: "GitHub",
|
||||
},
|
||||
{
|
||||
id: "boviC841YWs",
|
||||
title: "The latest in managing and auditing GitHub Copilot agents",
|
||||
meta: "GitHub",
|
||||
},
|
||||
{
|
||||
id: "-yKALFS5ewY",
|
||||
title: "How to use agents, skills, and instructions in Copilot CLI",
|
||||
meta: "Copilot CLI",
|
||||
},
|
||||
];
|
||||
|
||||
const agentDescriptionPoints: string[] = [
|
||||
"The tasks they specialize in (for example, “Terraform Expert” or “LaunchDarkly Flag Manager”).",
|
||||
"Which tools or MCP servers they can invoke.",
|
||||
"Optional instructions that guide the conversation style or guardrails.",
|
||||
];
|
||||
|
||||
const agentReachPoints: string[] = [
|
||||
"You have a recurring workflow that benefits from deep tooling integrations.",
|
||||
"You want Copilot to proactively execute commands or fetch context via MCP.",
|
||||
"You need persona-level guardrails that persist throughout a coding session.",
|
||||
"You want a coordinator that can delegate narrower work to subagents.",
|
||||
];
|
||||
|
||||
const skillReachPoints: string[] = [
|
||||
"You want to standardize how Copilot responds to a recurring task.",
|
||||
"You need bundled resources (templates, schemas, scripts) to complete the task.",
|
||||
"You want agents to discover and invoke the capability automatically.",
|
||||
"You prefer to drive the conversation, but with guardrails and rich context.",
|
||||
];
|
||||
|
||||
const instructionReachPoints: string[] = [
|
||||
"You need persistent guidance that applies across many sessions.",
|
||||
"You are codifying architecture decisions or compliance requirements.",
|
||||
"You want Copilot to understand patterns without manually pasting context.",
|
||||
];
|
||||
|
||||
const teamOutcomes: string[] = [
|
||||
"Consistent onboarding for new developers.",
|
||||
"Repeatable operations tasks with reduced context switching.",
|
||||
"Tailored experiences for specialized domains (security, infrastructure, data science, etc.).",
|
||||
];
|
||||
|
||||
const articleSections: TocSection[] = [
|
||||
{ id: "agents", label: "Agents" },
|
||||
{ id: "skills", label: "Skills" },
|
||||
{ id: "instructions", label: "Instructions" },
|
||||
{
|
||||
id: "how-the-artifacts-work-together",
|
||||
label: "How the artifacts work together",
|
||||
},
|
||||
{ id: "next-steps", label: "Next steps" },
|
||||
{ id: "video-series", label: "Video series" },
|
||||
{ id: "learn-more", label: "Learn more" },
|
||||
];
|
||||
|
||||
export default function AgentsSkillsInstructions() {
|
||||
return (
|
||||
<LearningArticleLayout
|
||||
pageHref={pageHref}
|
||||
currentPage="what-are-agents-skills-instructions"
|
||||
breadcrumbLabel="Agents, Skills & Instructions"
|
||||
heroTitle="Fundamentals"
|
||||
animateHeroTitle
|
||||
heroSubtitle="Understand the core primitives that shape how Copilot behaves — what each artifact does, how it’s packaged here, and when to use it."
|
||||
tocSections={articleSections}
|
||||
upNext={{
|
||||
label: "Using Automations in the GitHub Copilot app",
|
||||
href: pageHref("using-automations-in-copilot-app"),
|
||||
}}
|
||||
>
|
||||
<section id="agents" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Agents
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Agents are configuration files (
|
||||
<code className={styles.inlineCode}>*.agent.md</code>) that describe:
|
||||
</Text>
|
||||
<ul className={styles.checkList}>
|
||||
{agentDescriptionPoints.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
When you assign an issue to Copilot or open the{" "}
|
||||
<span className={styles.stepLabel}>Agents</span> panel in VS Code,
|
||||
these configurations let you swap in a specialized assistant. Each
|
||||
agent in this repo lives under{" "}
|
||||
<code className={styles.inlineCode}>agents/</code> and includes
|
||||
metadata about the tools it depends on.
|
||||
</Text>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
In products that support delegation, a primary agent can also launch
|
||||
temporary subagents for focused work such as planning, research, or
|
||||
review. See{" "}
|
||||
<a href={pageHref("agents-and-subagents")}>Agents and Subagents</a>{" "}
|
||||
for the coordination model.
|
||||
</Text>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>When to reach for an agent</span>
|
||||
<ul className={styles.checkList}>
|
||||
{agentReachPoints.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<figure className={styles.videoFigure}>
|
||||
<iframe
|
||||
className={styles.videoEmbedFrame}
|
||||
src="https://www.youtube.com/embed/1GVBRhDI5No"
|
||||
title="How the GitHub Copilot coding agent works"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
/>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section id="skills" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Skills
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Skills are self-contained folders that package reusable capabilities
|
||||
for GitHub Copilot. Each skill lives in its own directory and contains
|
||||
a <code className={styles.inlineCode}>SKILL.md</code> file along with
|
||||
optional bundled assets such as reference documents, templates, and
|
||||
scripts.
|
||||
</Text>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
A <code className={styles.inlineCode}>SKILL.md</code> defines:
|
||||
</Text>
|
||||
<ul className={styles.checkList}>
|
||||
<li>
|
||||
A <span className={styles.stepLabel}>name</span> (used as a{" "}
|
||||
<code className={styles.inlineCode}>/command</code> in VS Code Chat
|
||||
and for agent discovery).
|
||||
</li>
|
||||
<li>
|
||||
A <span className={styles.stepLabel}>description</span> that tells
|
||||
agents and users when the skill is relevant.
|
||||
</li>
|
||||
<li>Detailed instructions for how the skill should be executed.</li>
|
||||
<li>References to any bundled assets the skill needs.</li>
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Skills follow the open{" "}
|
||||
<a href="https://agentskills.io/home">Agent Skills specification</a>,
|
||||
making them portable across coding agent systems beyond GitHub
|
||||
Copilot.
|
||||
</Text>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Why skills over prompts</span>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Skills replace the earlier prompt file (
|
||||
<code className={styles.inlineCode}>*.prompt.md</code>) pattern and
|
||||
offer several advantages:
|
||||
</Text>
|
||||
<ul className={styles.pointList}>
|
||||
<li>
|
||||
<span className={styles.stepLabel}>Agent discovery</span>: Skills
|
||||
include extended frontmatter that lets agents find and invoke them
|
||||
automatically—prompts could only be triggered manually via a
|
||||
slash command.
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.stepLabel}>Richer context</span>: Skills
|
||||
can bundle reference files, scripts, templates, and other assets
|
||||
alongside their instructions, giving the AI much more to work
|
||||
with.
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.stepLabel}>Cross-platform portability</span>: The
|
||||
Agent Skills specification is supported across multiple coding
|
||||
agent systems, so your investment travels with you.
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.stepLabel}>Slash command support</span>: Like
|
||||
prompts, skills can still be invoked via{" "}
|
||||
<code className={styles.inlineCode}>/command</code> in VS Code
|
||||
Chat.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>When to reach for a skill</span>
|
||||
<ul className={styles.checkList}>
|
||||
{skillReachPoints.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<figure className={styles.videoFigure}>
|
||||
<iframe
|
||||
className={styles.videoEmbedFrame}
|
||||
src="https://www.youtube.com/embed/JRDN_-4E9ts"
|
||||
title="How to extend Copilot code review with MCP and custom skills"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
/>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section id="instructions" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Instructions
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Instructions (
|
||||
<code className={styles.inlineCode}>*.instructions.md</code>) provide
|
||||
background context that Copilot reads whenever it works on matching
|
||||
files. They often contain:
|
||||
</Text>
|
||||
<ul className={styles.checkList}>
|
||||
<li>
|
||||
Coding standards or style guides (naming conventions, testing
|
||||
strategy).
|
||||
</li>
|
||||
<li>
|
||||
Framework-specific hints (Angular best practices, .NET analyzers to
|
||||
suppress).
|
||||
</li>
|
||||
<li>
|
||||
Repository-specific rules (“never commit secrets”,
|
||||
“feature flags must live in{" "}
|
||||
<code className={styles.inlineCode}>flags/</code>”).
|
||||
</li>
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Instructions sit under{" "}
|
||||
<code className={styles.inlineCode}>instructions/</code> and can be
|
||||
scoped globally, per language, or per directory using glob patterns.
|
||||
They help Copilot align with your engineering playbook automatically.
|
||||
</Text>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>
|
||||
When to reach for instructions
|
||||
</span>
|
||||
<ul className={styles.checkList}>
|
||||
{instructionReachPoints.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<figure className={styles.videoFigure}>
|
||||
<iframe
|
||||
className={styles.videoEmbedFrame}
|
||||
src="https://www.youtube.com/embed/0jEzUhU8bLc"
|
||||
title="Your codebase, your rules: Customizing Copilot with context engineering"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
/>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="how-the-artifacts-work-together"
|
||||
className={styles.articleSection}
|
||||
>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
How the artifacts work together
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Think of these artifacts as complementary layers:
|
||||
</Text>
|
||||
<ol className={styles.stepsList}>
|
||||
<li>
|
||||
<span className={styles.stepLabel}>Instructions</span> lay the
|
||||
groundwork with long-lived guardrails.
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.stepLabel}>Skills</span> let you trigger
|
||||
rich, reusable workflows on demand—and let agents discover
|
||||
those workflows automatically.
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.stepLabel}>Agents</span> bring the most
|
||||
opinionated behavior, bundling tools and instructions into a single
|
||||
persona.
|
||||
</li>
|
||||
</ol>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
By combining all three, teams can achieve:
|
||||
</Text>
|
||||
<ul className={styles.checkList}>
|
||||
{teamOutcomes.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="next-steps" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Next steps
|
||||
</Heading>
|
||||
<ul className={styles.checkList}>
|
||||
<li>
|
||||
Explore the rest of the{" "}
|
||||
<span className={styles.stepLabel}>Fundamentals</span> track for
|
||||
deeper dives on chat modes, collections, and MCP servers.
|
||||
</li>
|
||||
<li>
|
||||
Browse the <a href={pageHref("agents")}>Awesome Agents</a>,{" "}
|
||||
<a href={pageHref("skills")}>Skills</a>, and{" "}
|
||||
<a href={pageHref("instructions")}>Instructions</a> directories for
|
||||
inspiration.
|
||||
</li>
|
||||
<li>
|
||||
Try generating your own artifacts, then add them to the repo to keep
|
||||
the Learning Hub evolving.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="video-series" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Video series
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Prefer to watch and learn? Explore this playlist from GitHub on
|
||||
agents, skills, and instructions. Play any video right here on the
|
||||
page.
|
||||
</Text>
|
||||
<VideoCarousel videos={videoSeries} />
|
||||
</section>
|
||||
|
||||
<section id="learn-more" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Learn more
|
||||
</Heading>
|
||||
<div className={styles.learnMoreBand}>
|
||||
<a className={styles.learnMoreCard} href="https://github.com/features/copilot">
|
||||
<span className={styles.learnMoreCardIcon} aria-hidden="true">
|
||||
<GlobeIcon size={24} />
|
||||
</span>
|
||||
<span className={styles.learnMoreCardTitle}>
|
||||
GitHub Copilot
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</span>
|
||||
</a>
|
||||
<a className={styles.learnMoreCard} href="https://docs.github.com/en/copilot/concepts/agents">
|
||||
<span className={styles.learnMoreCardIcon} aria-hidden="true">
|
||||
<BookIcon size={24} />
|
||||
</span>
|
||||
<span className={styles.learnMoreCardTitle}>
|
||||
Documentation
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</span>
|
||||
</a>
|
||||
<a className={styles.learnMoreCard} href="https://www.youtube.com/playlist?list=PL0lo9MOBetEFc6rN_y9-YKA3plCSUb1NP">
|
||||
<span className={styles.learnMoreCardIcon} aria-hidden="true">
|
||||
<PlayIcon size={24} />
|
||||
</span>
|
||||
<span className={styles.learnMoreCardTitle}>
|
||||
Video series
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</LearningArticleLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* Bespoke Learning Hub article for "Working with Canvas Extensions", ported
|
||||
* near-verbatim from the Brand Engineering prototype's
|
||||
* `pages/working-with-canvas-extensions.tsx`
|
||||
* (`site-generator/prototypes/awesome-copilot-community-library`).
|
||||
*
|
||||
* Only the following were adapted from the prototype source:
|
||||
* - `PrototypePageProps`/`pageHref` import swapped for the site's `pageHref`.
|
||||
* - Media imports replaced with static paths. The large source video
|
||||
* (`copilot-app-canvas.mp4`, ~20MB) is not committed to this repo — TODO:
|
||||
* host it externally (e.g. a CDN/GitHub release asset) and swap
|
||||
* `canvasPoster` below to a real `<video>` source. Until then the canvas demo
|
||||
* renders as a static webp poster.
|
||||
*/
|
||||
import { Heading, Text } from "@primer/react-brand";
|
||||
import { BookIcon, PlayIcon, GlobeIcon, ArrowUpRightIcon } from "@primer/octicons-react";
|
||||
|
||||
import styles from "../styles/github-copilot-app.module.css";
|
||||
import { pageHref } from "../pageHref";
|
||||
import {
|
||||
CopyBlock,
|
||||
LearningArticleLayout,
|
||||
type TocSection,
|
||||
} from "../LearningArticleLayout";
|
||||
import { VideoCarousel, type Video } from "../VideoCarousel";
|
||||
|
||||
// TODO: replace with a hosted URL once the large source video is uploaded
|
||||
// externally; see file header note.
|
||||
const canvasPoster = "/images/learning-hub/working-with-canvas-extensions/canvases-light.webp";
|
||||
|
||||
const videoSeries: Video[] = [
|
||||
{
|
||||
id: "UzYm0kyVY9s",
|
||||
title: "Exploring the agent-first GitHub Copilot desktop app",
|
||||
meta: "GitHub Checkout",
|
||||
},
|
||||
{
|
||||
id: "LsA4vIX_3UY",
|
||||
title: "Meet the GitHub Copilot app: Your new AI desktop assistant",
|
||||
meta: "GitHub",
|
||||
},
|
||||
{
|
||||
id: "LwqUp4Dc1mQ",
|
||||
title: "Extending AI agents: A live demo of the GitHub MCP Server",
|
||||
meta: "Demo",
|
||||
},
|
||||
{
|
||||
id: "HN47tveqfQU",
|
||||
title: "End-to-end development with GitHub Copilot and an MCP server",
|
||||
meta: "Demo",
|
||||
},
|
||||
{
|
||||
id: "onVn-lnHZ9s",
|
||||
title: "Demo: end-to-end agentic development with GitHub Copilot",
|
||||
meta: "Demo",
|
||||
},
|
||||
{
|
||||
id: "usXv9jJWjvI",
|
||||
title: "Automating daily workflows with the GitHub Copilot app and MCP",
|
||||
meta: "GitHub",
|
||||
},
|
||||
];
|
||||
|
||||
const bidirectionalPoints = [
|
||||
"You can interact through UI controls (buttons, forms, filters, cards, etc.)",
|
||||
"The agent can call canvas capabilities to update that same state",
|
||||
"You can iterate quickly by asking the agent to add or revise capabilities",
|
||||
];
|
||||
|
||||
const workflowPoints = [
|
||||
"Triage boards",
|
||||
"Planning documents",
|
||||
"Live browser-assisted workflows",
|
||||
"Release coordination surfaces",
|
||||
];
|
||||
|
||||
const structurePoints: { lead: string; rest: string }[] = [
|
||||
{
|
||||
lead: "package.json",
|
||||
rest: "for metadata and dependencies",
|
||||
},
|
||||
{
|
||||
lead: "extension.mjs",
|
||||
rest: "(or another entry module) for canvas behavior and capabilities",
|
||||
},
|
||||
{
|
||||
lead: "Optional UI files",
|
||||
rest: "(index.html, assets) for richer panel controls",
|
||||
},
|
||||
{
|
||||
lead: "Optional persisted artifacts",
|
||||
rest: "/state files",
|
||||
},
|
||||
];
|
||||
|
||||
const contractPoints: { lead: string; rest: string }[] = [
|
||||
{
|
||||
lead: "Define clear canvas actions and schemas",
|
||||
rest: "in createCanvas(...)",
|
||||
},
|
||||
{
|
||||
lead: "Keep action names verb-oriented and predictable",
|
||||
rest: "(get_*, apply_*, sync_*)",
|
||||
},
|
||||
{
|
||||
lead: "Return structured state from handlers",
|
||||
rest: "so both the UI and agent remain in sync",
|
||||
},
|
||||
];
|
||||
|
||||
const examples: { title: string; description: string }[] = [
|
||||
{
|
||||
title: "Backlog Swipe Triage",
|
||||
description:
|
||||
"swipe-based issue triage surface for fast backlog decisions.",
|
||||
},
|
||||
{
|
||||
title: "Release Notes Showcase",
|
||||
description: "release notes authoring and review canvas pattern.",
|
||||
},
|
||||
{
|
||||
title: "Chromium Control Canvas",
|
||||
description:
|
||||
"advanced canvas that coordinates panel controls with a real headful Chromium window.",
|
||||
},
|
||||
{
|
||||
title: "Agent Arcade",
|
||||
description:
|
||||
"retro arcade canvas with agent-callable controls for choosing or restarting mini-games while agents work.",
|
||||
},
|
||||
];
|
||||
|
||||
const iteratingPoints = [
|
||||
"Add or rename capabilities as your workflow evolves",
|
||||
"Simplify controls that are rarely used",
|
||||
"Add guardrails around sensitive actions",
|
||||
"Keep capability names clear and action-oriented",
|
||||
];
|
||||
|
||||
const nextSteps = [
|
||||
"Review the GitHub Copilot app overview for broader session and workflow concepts.",
|
||||
"Browse the Canvas Extensions page for discoverable extensions.",
|
||||
"Fork one of the example extension folders above and adapt it to your own workflow.",
|
||||
];
|
||||
|
||||
const triagePrompt = `/create-canvas Create an issue triage canvas with list filtering, label editing, and quick-priority actions. Add capabilities for get_issues, update_priority, and apply_label.`;
|
||||
|
||||
const releasePrompt = `/create-canvas Create a release checklist canvas that tracks milestones and owners. Add capabilities for add_item, assign_owner, mark_done, and export_summary.`;
|
||||
|
||||
const planningPrompt = `/create-canvas Create a markdown planning canvas that combines my open PRs and issues, and lets me launch and track agent sessions from the canvas.`;
|
||||
|
||||
const articleSections: TocSection[] = [
|
||||
{ id: "what-canvases-can-do", label: "What canvases can do" },
|
||||
{ id: "create-a-canvas", label: "Create a canvas" },
|
||||
{ id: "prompt-patterns", label: "Prompt patterns" },
|
||||
{ id: "choose-scope", label: "Choose scope" },
|
||||
{ id: "extension-structure", label: "Extension structure" },
|
||||
{ id: "best-practices", label: "Best practices" },
|
||||
{ id: "examples", label: "Examples" },
|
||||
{ id: "iterating", label: "Iterating" },
|
||||
{ id: "next-steps", label: "Next steps" },
|
||||
{ id: "video-series", label: "Video series" },
|
||||
{ id: "learn-more", label: "Learn more" },
|
||||
];
|
||||
|
||||
export default function WorkingWithCanvasExtensions() {
|
||||
return (
|
||||
<LearningArticleLayout
|
||||
pageHref={pageHref}
|
||||
currentPage="working-with-canvas-extensions"
|
||||
breadcrumbLabel="Canvas Extensions"
|
||||
heroTitle="Canvas Extensions"
|
||||
animateHeroTitle
|
||||
heroSubtitle="Shared, interactive work surfaces in the GitHub Copilot app that move progress out of chat into an artifact people and agents can update."
|
||||
tocSections={articleSections}
|
||||
upNext={{
|
||||
label: "Get started with the GitHub Copilot app",
|
||||
href: pageHref("github-copilot-app"),
|
||||
}}
|
||||
>
|
||||
<section id="what-canvases-can-do" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
What canvases can do
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
This guide explains what canvases can do, how to create one with{" "}
|
||||
<code className={styles.inlineCode}>/create-canvas</code>, and how to
|
||||
use patterns from this repository as reference implementations.
|
||||
</Text>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
A canvas is a bidirectional surface:
|
||||
</Text>
|
||||
<ul className={styles.checkList}>
|
||||
{bidirectionalPoints.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
This makes canvases especially useful for workflows where visibility
|
||||
and steering matter, for example:
|
||||
</Text>
|
||||
<ul className={styles.checkList}>
|
||||
{workflowPoints.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
<figure className={styles.videoFigure}>
|
||||
<img
|
||||
className={styles.video}
|
||||
src={canvasPoster}
|
||||
alt="Demo of using canvas extensions in the GitHub Copilot app"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section id="create-a-canvas" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Create a canvas with{" "}
|
||||
<code className={styles.inlineCode}>/create-canvas</code>
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
In the GitHub Copilot app, create canvases from an active session
|
||||
using the <code className={styles.inlineCode}>/create-canvas</code>{" "}
|
||||
skill.
|
||||
</Text>
|
||||
<ol className={styles.stepsList}>
|
||||
<li>Open or start an agent session.</li>
|
||||
<li>
|
||||
In the prompt box, run{" "}
|
||||
<code className={styles.inlineCode}>/create-canvas</code> and
|
||||
describe:
|
||||
<ul className={styles.pointList}>
|
||||
<li>the workflow you want</li>
|
||||
<li>what people should do in the UI</li>
|
||||
<li>what the agent should do via callable capabilities</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Let the agent generate the extension and open it in the right panel.</li>
|
||||
<li>Continue iterating by asking for capability or UI changes.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section id="prompt-patterns" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Prompt patterns that work well
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Use explicit capability language in your prompt:
|
||||
</Text>
|
||||
<CopyBlock code={triagePrompt} label="Triage canvas" />
|
||||
<CopyBlock code={releasePrompt} label="Release checklist" />
|
||||
<CopyBlock code={planningPrompt} label="Planning canvas" />
|
||||
</section>
|
||||
|
||||
<section id="choose-scope" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Choose scope: project or personal
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
When creating a canvas extension, choose where it should live:
|
||||
</Text>
|
||||
<ul className={styles.pointList}>
|
||||
<li>
|
||||
<span className={styles.stepLabel}>Project scope</span> —{" "}
|
||||
<code className={styles.inlineCode}>.github/extensions</code>{" "}
|
||||
(shared with the repository team)
|
||||
</li>
|
||||
<li>
|
||||
<span className={styles.stepLabel}>User scope</span> —{" "}
|
||||
<code className={styles.inlineCode}>~/.copilot/extensions</code>{" "}
|
||||
(personal to your machine)
|
||||
</li>
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Use project scope when the workflow is team-relevant, and user scope
|
||||
for personal experiments or private workflows.
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section id="extension-structure" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Typical extension structure
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Canvas extensions can vary, but most include:
|
||||
</Text>
|
||||
<ul className={styles.pointList}>
|
||||
{structurePoints.map((item) => (
|
||||
<li key={item.lead}>
|
||||
<code className={styles.inlineCode}>{item.lead}</code> {item.rest}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="best-practices" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Best practices
|
||||
</Heading>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>
|
||||
Choose storage scope intentionally
|
||||
</span>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Default canvas state is often session-scoped. If you only need state
|
||||
for the current session, keep it in session storage paths such as:
|
||||
</Text>
|
||||
<ul className={styles.pointList}>
|
||||
<li>
|
||||
<code className={styles.inlineCode}>
|
||||
<copilot_home>/session-state/<sessionId>/files/<whatever>
|
||||
</code>
|
||||
</li>
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
If you want data to persist across multiple sessions for the same
|
||||
extension, use extension-scoped storage such as:
|
||||
</Text>
|
||||
<ul className={styles.pointList}>
|
||||
<li>
|
||||
<code className={styles.inlineCode}>
|
||||
<copilot_home>/extensions/<extensionId>/<whatever>
|
||||
</code>
|
||||
</li>
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
This split keeps ephemeral workflow data separate from longer-lived
|
||||
user data.
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>
|
||||
Use joinSession handlers as your canvas-agent contract
|
||||
</span>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Treat <code className={styles.inlineCode}>joinSession</code> +{" "}
|
||||
<code className={styles.inlineCode}>createCanvas</code> as the
|
||||
contract between UI interactions and agent-callable actions:
|
||||
</Text>
|
||||
<ul className={styles.pointList}>
|
||||
{contractPoints.map((item) => (
|
||||
<li key={item.lead}>
|
||||
<span className={styles.stepLabel}>{item.lead}</span>{" "}
|
||||
{item.rest}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="examples" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Examples from this repository
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Use these extension folders as concrete references:
|
||||
</Text>
|
||||
<div className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>Reference implementations</span>
|
||||
<ul className={styles.pointList}>
|
||||
{examples.map((example) => (
|
||||
<li key={example.title}>
|
||||
<span className={styles.stepLabel}>{example.title}</span> —{" "}
|
||||
{example.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
These examples show different complexity levels, from focused workflow
|
||||
boards to richer UI + automation integrations.
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section id="iterating" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Iterating after first creation
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Treat the first{" "}
|
||||
<code className={styles.inlineCode}>/create-canvas</code> result as
|
||||
version one. Then refine in-place:
|
||||
</Text>
|
||||
<ul className={styles.checkList}>
|
||||
{iteratingPoints.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
The fastest loop is: use the canvas, note friction, and ask the agent
|
||||
for a targeted update.
|
||||
</Text>
|
||||
</section>
|
||||
|
||||
<section id="next-steps" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Next steps
|
||||
</Heading>
|
||||
<ul className={styles.checkList}>
|
||||
{nextSteps.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="video-series" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Video series
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
Prefer to watch and learn? Explore these related videos from GitHub on
|
||||
the Copilot app, MCP servers, and extending agents. Play any video
|
||||
right here on the page.
|
||||
</Text>
|
||||
<VideoCarousel videos={videoSeries} />
|
||||
</section>
|
||||
|
||||
<section id="learn-more" className={styles.articleSection}>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
Learn more
|
||||
</Heading>
|
||||
<div className={styles.learnMoreBand}>
|
||||
<a className={styles.learnMoreCard} href="https://github.com/features/copilot">
|
||||
<span className={styles.learnMoreCardIcon} aria-hidden="true">
|
||||
<GlobeIcon size={24} />
|
||||
</span>
|
||||
<span className={styles.learnMoreCardTitle}>
|
||||
GitHub Copilot
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</span>
|
||||
</a>
|
||||
<a className={styles.learnMoreCard} href="https://docs.github.com/en/copilot/building-copilot-extensions/about-building-copilot-extensions">
|
||||
<span className={styles.learnMoreCardIcon} aria-hidden="true">
|
||||
<BookIcon size={24} />
|
||||
</span>
|
||||
<span className={styles.learnMoreCardTitle}>
|
||||
Documentation
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</span>
|
||||
</a>
|
||||
<a className={styles.learnMoreCard} href="https://www.youtube.com/playlist?list=PLNBWjViYXaIY">
|
||||
<span className={styles.learnMoreCardIcon} aria-hidden="true">
|
||||
<PlayIcon size={24} />
|
||||
</span>
|
||||
<span className={styles.learnMoreCardTitle}>
|
||||
Video series
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</LearningArticleLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Locale table for the site's language selector.
|
||||
*
|
||||
* These codes mirror the `i18n.locales` list in `astro.config.mjs` and the
|
||||
* locale directory names used by mirrored Learning Hub content
|
||||
* (`website/src/content/docs/<locale>/…`). The default locale is served at the
|
||||
* site root with no prefix (`prefixDefaultLocale: false`).
|
||||
*/
|
||||
|
||||
export type SiteLocale = {
|
||||
/** Locale code / URL prefix, e.g. `es-es`. */
|
||||
code: string;
|
||||
/** Name of the language, written in that language. */
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_LOCALE = "en";
|
||||
|
||||
export const SITE_LOCALES: SiteLocale[] = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "es-es", label: "Español" },
|
||||
{ code: "ja-jp", label: "日本語" },
|
||||
{ code: "ko-kr", label: "한국어" },
|
||||
{ code: "pt-br", label: "Português do Brasil" },
|
||||
{ code: "zh-cn", label: "简体中文" },
|
||||
];
|
||||
|
||||
const LOCALE_CODES = new Set(SITE_LOCALES.map((locale) => locale.code));
|
||||
|
||||
/**
|
||||
* Split a base-relative path into its locale prefix (if any) and the remainder.
|
||||
* The default locale has no prefix, so an unprefixed path reports as default.
|
||||
*/
|
||||
export function splitLocale(relativePath: string): {
|
||||
locale: string;
|
||||
rest: string;
|
||||
} {
|
||||
const trimmed = relativePath.replace(/^\/+/, "");
|
||||
const [first, ...restParts] = trimmed.split("/");
|
||||
if (first && LOCALE_CODES.has(first) && first !== DEFAULT_LOCALE) {
|
||||
return { locale: first, rest: restParts.join("/") };
|
||||
}
|
||||
return { locale: DEFAULT_LOCALE, rest: trimmed };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { PrototypePageProps } from "./pageHref";
|
||||
|
||||
export type AwesomeCopilotPage =
|
||||
| "agents"
|
||||
| "extensions"
|
||||
| "instructions"
|
||||
| "learning-hub-copilot-app"
|
||||
| "plugins"
|
||||
| "skills"
|
||||
| "hooks"
|
||||
| "workflows"
|
||||
| "tools";
|
||||
|
||||
const destinations = [
|
||||
{ label: "Agents", page: "agents" },
|
||||
{ label: "Instructions", page: "instructions" },
|
||||
{ label: "Learning Hub", page: "learning-hub-copilot-app" },
|
||||
{ label: "Skills", page: "skills" },
|
||||
{ label: "Plugins", page: "plugins" },
|
||||
{ label: "Extensions", page: "extensions" },
|
||||
{ label: "Hooks", page: "hooks" },
|
||||
{ label: "Workflows", page: "workflows" },
|
||||
{ label: "Tools", page: "tools" },
|
||||
] as const;
|
||||
|
||||
export function getAwesomeCopilotNavLinks(
|
||||
pageHref: PrototypePageProps["pageHref"],
|
||||
currentPage?: AwesomeCopilotPage,
|
||||
) {
|
||||
return destinations.map((destination) => ({
|
||||
label: destination.label,
|
||||
href: pageHref(destination.page),
|
||||
current: destination.page === currentPage,
|
||||
}));
|
||||
}
|
||||