Rebuild website on Primer Brand design (#2703)

* feat(website): rebuild site on Primer Brand design prototype

Replace the Astro + Starlight site with the Brand Engineering design
prototype, ported component-for-component onto plain Astro + React
islands. The prototype is treated as the authority on markup and
styling; dynamic data is injected into its components rather than the
components being reinterpreted.

Framework:
- Remove @astrojs/starlight entirely, along with its document shell,
  search, footer and language selector. BaseLayout.astro now owns the
  document head, CSP, social meta and analytics.
- Add @primer/react-brand and @astrojs/react. Alias the package to its
  ESM build in vite.resolve, since the default CJS entrypoint breaks
  named-export detection during SSR, and mark it noExternal so its
  stylesheet imports resolve.
- Promote pagefind to an explicit devDependency; it was previously
  pulled in transitively by Starlight.

Pages: home, the five catalogs, the five detail routes, contributors,
Playbook index and articles, and the cookbook are all rendered by
ported prototype components inside a shared PageShell.

Detail pages share a DetailChassis (hero, breadcrumbs, sticky TOC with
scroll-spy, prev/next) while keeping what makes each type distinct: a
file switcher over bundled skill assets, an included-items grid and
external provenance for plugins, and a hero-scale preview for
extensions.

i18n: resolve translated Playbook entries via Astro.currentLocale
inside the shared article route instead of separate [locale] routes.
The explicit routes collided with the i18n fallback routes, so
translated articles were being shadowed by their English originals and
900 nonsensical double-locale pages were emitted.

Search: TopNavSearch now queries the Pagefind index client-side in
addition to the static resource index, merging on href and degrading
to the static index in dev, where no index has been built.

Also delete the superseded vanilla-TS page renderers and Astro
partials, which the ported React components fully replace.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* fix(website): resolve accessibility violations in ported detail pages

Fixes the three axe violations surfaced by the a11y audit after the
Primer Brand redesign:

- aria-prohibited-attr: `aria-label` was set on roleless `div`s in
  InstructionDetail and PluginDetail. The "Applies to" list now uses
  list/listitem roles; plugin provenance uses a group role.
- scrollable-region-focusable: the install command `code` element
  overflows horizontally but was not keyboard reachable. Added
  tabIndex to all three render sites.
- color-contrast: the Playbook "New" label used the brand's
  success-fg on success-subtle, reaching only 4.09:1 in light mode.
  Stepped one down the same green ramp for 6.14:1. Scoped to light
  mode; dark mode already passed and its green-7 is near-black.

Also corrects the stale route list in the audit script: /hooks/,
/workflows/ and /tools/ have never existed as pages.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* fix(website): collapse long filter facets and unpin detail-page footer

Two issues surfaced by review of the redesigned site:

Catalog facets rendered every option. The prototype's filter groups were
built from small hardcoded arrays, but real data produces 193 tool options
on /agents/ and 245 "Applies to" values on /instructions/. The sidebar grew
to ~10,000px and stretched the whole catalog row, pushing the (already
present) pagination control far below the fold so it read as missing.

Adopt the prototype's own solution for this, which it had already applied to
the extensions page: collapse groups past 10 options behind a "Show N more"
toggle, and cap .filterOptions with an internal scroll area. Ported verbatim
to the agents, instructions, skills, and plugins catalogs.

Detail pages scroll inside .scrollHost rather than the document, but the
footer came from PageShell, outside that element, so it stayed pinned over
the content instead of appearing at the end. The prototype renders its
footer inside the scroll host; PageShell now takes a renderFooter flag so
DetailChassis can do the same. LearningArticleLayout already did this.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* fix(website): source contributor count from .all-contributorsrc

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* Removing playwright-mcp files

* fix(website): resolve article text colour inside themed scope

Injected markdown inherited color from body, which sits outside the
ThemeProvider and always resolved the light-mode token, making body
copy unreadable in dark mode.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* fix(website): drop 'In this article' TOC from resource detail pages

Resource detail pages (agent, instruction, skill, plugin, extension) are
not articles, and their markdown headings do not form a meaningful
outline. Playbook and cookbook articles keep their TOC.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* style(website): thin the sidebar scrollbar on detail pages

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* fix(website): use themed link colour for markdown links in dark mode

Raw markdown-injected <a> elements previously fell back to the browser's
default blue/purple link colours, which are harsh against the dark-mode
background. Route them through --brand-color-text-link-rest (and the
pressed/hover token) instead.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* fix(website): apply ThemeProvider to Playbook article layout so dark mode works

LearningArticleLayout replaces PageShell for Playbook articles but never
wrapped itself in a ThemeProvider, so its useTheme() call always fell back
to Primer's light default regardless of the site's actual theme preference.
Split the component into a thin ThemeProvider wrapper plus the existing
implementation (now LearningArticleLayoutBody), matching the pattern already
used by PageShell.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* fix(website): align raw markdown code blocks with prototype styling

Astro's default Shiki config bakes in the fixed 'github-dark' theme's
literal colors, ignoring the site's actual light/dark mode - this made
plain markdown-fenced code blocks (as opposed to the prototype's own
SyntaxHighlightedCode component) always render a hardcoded dark box
regardless of theme.

Switch shikiConfig to the 'css-variables' theme so highlighted tokens
resolve through --astro-code-* custom properties instead, then map
those to the same brand color tokens the prototype's codeBlock uses
(canvas-subtle background, border-muted border, brand text/link/accent
colors for tokens). Raw markdown code blocks now match the prototype's
bordered, canvas-subtle surface in both color modes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* fix(website): show real contributor count on every page shell

The contributor badge rendered 0 on Playbook, Cookbook, home and custom
pages, and reverted to 0 on hydration everywhere else.

Two causes:
- Shells that bypass PageShell (LearningArticleLayout, PlaybookIndex,
  PlaybookArticleBody, CookbookIndex, HomePage, TopNav, Custom) defaulted
  contributorsTotal to 0 instead of the site-data value.
- site-data read .all-contributorsrc with node:fs at module scope. Those
  shells are client:load hydrated, so the read threw in the browser and
  the count reset to 0 after hydration.

The count is now read once in astro.config.mjs and inlined through
vite.define as __CONTRIBUTORS_TOTAL__, so it is a literal in both the
server render and the client bundle.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* style(website): soften catalog filter list scrollbars

The filter option lists only set scrollbar-width: thin, so they rendered
the platform default scrollbar. They now use the same muted, transparent
track treatment as the article sidebar, which resolves through
--brand-color-border-muted in both colour modes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* refactor(website): remove Access from tools panel from plugins catalog

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* feat(website): make Copilot app deep link the default plugin install

Plugin detail pages exposed only a copyable CLI command. They now lead
with a ghapp://plugins/install deep link in the same split-button
ActionMenu the other detail pages use, keeping the CLI command available
as a Copy action in the menu.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* fix(website): migrate markdown remark plugins to unified processor

Astro 7 deprecates markdown.remarkPlugins in favour of passing a
unified() processor from @astrojs/markdown-remark. Moves the GitHub
admonitions plugin into markdown.processor, clearing the startup
deprecation warning. shikiConfig stays at the markdown level as it is
not part of UnifiedProcessorOptions.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* fix(website): fail the build when the contributor manifest is unreadable

Silently falling back to 0 is how the contributor badge regressed before,
so a missing or malformed .all-contributorsrc now throws in production
builds and warns in dev instead of shipping a wrong count.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* Fix codespell and CodeQL findings in website components

- Fix real typos flagged by codespell: 'Couldn&apos;t' -> 'Couldn't'
  (plain apostrophe, matching convention elsewhere in JSX) and
  'Unparseable' -> 'Unparsable' in catalogFilters.ts
- DetailChassis.tsx: replace sequential HTML entity unescaping with a
  single-pass replace to avoid double-unescape/injection risk flagged
  by CodeQL
- SyntaxHighlightedCode.tsx: make the markup HTML comment regex match
  newlines so multi-line comments cannot break out of the token
  (Bad HTML filtering regexp)
- pagefindSearch.ts: strip HTML tags in a loop until stable so nested/
  malformed markup can't survive a single-pass strip (Incomplete
  multi-character sanitization)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* fix(website): harden detail heading sanitization

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* github-app settings

* Fix light-theme flash before dark mode applies on page load

ThemeProvider colorMode="auto" from @primer/react-brand only resolves
the real OS colour-scheme preference inside a useEffect, so its first
render is always light. Add a synchronous inline script in <head> that
reads prefers-color-scheme and stamps data-color-mode onto <html>
before first paint, using the same attribute Primer Brand's CSS
already keys off. React's own data-color-mode on the inner element
takes over once it hydrates.

Fixes #2820

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* Fix light-mode scrollbar rendering in dark mode on Playbook articles

.scrollHost (the article scroll region shared by Playbook articles,
cookbook recipes, and extension detail pages) never declared a
color-scheme, so the browser always painted its native scrollbar using
light-mode chrome regardless of the site's active theme. Set
color-scheme: light dark as a baseline and pin it explicitly to the
resolved data-mode, and add scrollbar-color so Firefox picks up the
themed thumb colour too.

Fixes #2822

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* Widen and center extension card preview image

The Canvas Extensions overview card thumbnail was fixed at 360px, leaving
uneven left/right margins within the card. Let it grow up to 440px and
center it with margin-inline: auto so the image is balanced within the
card.

Fixes #2823

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* Link Submit CTAs to each resource's contribution section

The bottom "Submit" CTA on every catalog page pointed at the top of
CONTRIBUTING.md, forcing readers to scroll and hunt for the relevant
"how to contribute" instructions for that specific resource type.
Point each catalog's CTA directly at the section that documents how to
contribute that resource: agents, instructions, plugins, and skills
link to their dedicated docs/README.*.md "How to Contribute" section,
and canvas extensions link to CONTRIBUTING.md's "Adding Canvas
Extensions" section (extensions have no dedicated README doc).

Fixes #2824

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* Center-align "View on GitHub" source link with its icon

The source-link label sat on the default inline text baseline instead
of being vertically centered against the GitHub mark icon next to it,
so the two visually drifted apart. Wrap the icon and label in an
inline-flex container with align-items: center so they share the same
vertical center.

Fixes #2825

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* Fix mobile resource and Playbook article layout issues

Clean up the shared resource detail and Playbook article styling so mobile pages no longer show unintended divider lines or overflow horizontally.

- remove article-section divider borders so content separates with spacing instead of white/muted horizontal rules
- drop the detail-page breadcrumb divider on mobile to match the Playbook mobile treatment
- constrain raw markdown pre/code blocks to their column and let long code scroll horizontally
- allow metadata chips to wrap inside the mobile sidebar instead of widening the page

Fixes #2827
Fixes #2828
Fixes #2829

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* Only show language selector on pages with real translations

Most of the site is English-only, but the language selector rendered
unconditionally on every page even though only the
learning-hub/copilot-workshops/app track has mirrored translations.

Add hasTranslations() to playbook-routes.ts, thread a showLanguageSelect
prop through PageShell, TopNav and LearningArticleLayout, and compute
it from the article's englishId in learning-hub/[...slug].astro so only
translated Playbook articles show the selector.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* Add Workshop recommended card to Playbook index

The copilot-workshops hands-on tracks (VS Code, CLI, App, Cloud harnesses) existed and rendered correctly at direct URLs, but had no entry point in the new Playbook index UI -- the Articles grid intentionally excludes nested multi-page tracks by design, and the only prior link was buried in body prose.

Adds a 'Workshop' recommended card (following the existing cli-for-beginners precedent) linking to /learning-hub/copilot-workshops/, and registers the route in pageHref.ts.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* Add a Workshop content-type filter to the Playbook

The hands-on workshop tracks (CLI for Beginners, and the copilot-workshops harnesses) had no shared way to discover them from the Articles grid -- each had a recommended card, but no consistent categorization.

Tags both workshop landing pages 'workshop', adds a new 'Workshop' Kind/content-type facet (derived the same way as the existing Terminology/Tutorial/Example kinds), and includes both landing pages in the Articles grid dataset so filtering by Content type > Workshop surfaces exactly the two tracks.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* Fix P0/P1 items from Brand Engineering audit (brand-experience#458)

- P0: html lang now reflects the actually-rendered locale for a page,
  not the requested URL locale (BaseLayout.astro, playbook-routes.ts,
  PlaybookArticle.astro)
- P0: markdown tables get tabindex=0 via enhanceMarkdownA11y, which
  Playbook articles were never running (PlaybookArticle.astro)
- P0: long inline content and breadcrumbs no longer clip on narrow
  viewports (dotnet-upgrade.module.css, github-copilot-app.module.css)
- P1: strip the markdown document's own leading H1 so detail pages
  don't render the title twice (detail-page.ts)
- P1: document the hero secondary-CTA hex and InstructionsCatalog
  illustration colors as intentional prototype-fidelity values rather
  than defects (styles.module.css)
- P1: add explicit Heading size props in PlaybookIndex.tsx

Ref: github/brand-experience#458

* Rename Playbook to Learning Hub across UI and code

Renames PlaybookIndex.tsx -> LearningHubIndex.tsx, PlaybookArticle.astro
-> LearningHubArticle.astro, PlaybookArticleBody.tsx ->
LearningHubArticleBody.tsx, PlaybookIcon.tsx -> LearningHubIcon.tsx,
lib/playbook-routes.ts -> lib/learning-hub-routes.ts, and
lib/playbook-article.ts -> lib/learning-hub-article.ts. Updates all
call sites, UI copy, TopNav's playbookLabel prop, nav/search entries,
and home page CTA data to use Learning Hub terminology. URLs under
/learning-hub/* are unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* Port github-copilot-app as bespoke Learning Hub article

Adopts the prototype's github-copilot-app.tsx directly as a dedicated React
component instead of rendering it through the generic markdown pipeline,
preserving its video carousel, comparison list, learn-more band, and other
bespoke JSX exactly as designed.

- New GithubCopilotApp.tsx under components/brand/learning-hub/, ported
  near-verbatim from the prototype (data-wiring only: pageHref import swap,
  media paths).
- The two large source videos (~23MB, ~32MB) are not committed; the article
  falls back to their poster/webp images with a TODO to host them externally.
- learning-hub-bespoke-articles.ts lists slugs that bypass the generic
  LearningHubArticle.astro pipeline.
- [...slug].astro branches to the bespoke component for "github-copilot-app"
  (English only for now); all other slugs and locales keep the generic path.

* Port bespoke Learning Hub articles

Add bespoke Learning Hub article components for the remaining prototype-backed
article pages, wire the cookbook index to the data-driven port, and route the
CLI overview page through the new bespoke component.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* Resolve merge validation blockers in astro config

* Regenerate README docs after merging origin/main

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* Fix Learning Hub Get started pages disappearing after opening (#2960)

The bespoke Learning Hub article components (GithubCopilotApp,
AgentsAndSubagents, CopilotConfigurationBasics,
GithubCopilotTerminologyGlossary, UsingAutomationsInCopilotApp,
WhatAreAgentsSkillsInstructions, WorkingWithCanvasExtensions,
CliForBeginnersOverview, CookbookIndex) received the `pageHref` helper
as a function prop passed into a `client:load` React island. Functions
cannot survive Astro's client-hydration prop serialization, so the
prop arrived as `undefined` on the client. The page rendered correctly
server-side (visible briefly), then React threw
`TypeError: pageHref is not a function` during hydration and unmounted
the tree, making the page appear to disappear.

Fixed by having each component import the `pageHref` singleton
directly (as AgentDetail, HomePage, and the other catalog components
already do) instead of receiving it as a prop, and removed the now
unnecessary `pageHref={pageHref}` prop and its import from the two
Astro routes that render these islands.

Verified in the dev server: all 9 previously affected routes now
hydrate without error, and `npm run build` completes successfully
(1097 pages).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* Strip stray trailing divider from Learning Hub articles (#2961)

Several Learning Hub markdown articles end their body with a trailing
--- thematic break, left over from an authoring template. Rendered
as-is it produced an unlabeled <hr> sitting directly above the page
footer, with no heading/section boundary to justify it.

Fix this generically in buildArticleSections() by stripping a single
trailing <hr> from the rendered HTML before it's split into sections,
rather than hand-editing every affected content file. This also
protects future articles authored with the same trailing-rule habit.
Legitimate mid-content dividers (e.g. between CLI lesson steps) are
left untouched.

* Remove emojis from Learning Hub article nav (#2962)

CLI for Beginners lessons decorate their ## headings with emoji
(e.g. "🎯 Learning Objectives"), which is fine inline in the article
body but read as stray glyphs once surfaced as plain-text labels in
the "In this article" nav — other Learning Hub pages don't
emoji-decorate their nav, so this was inconsistent.

Add a shared stripEmoji() helper and apply it to the nav labels built
from markdown headings in LearningHubArticle.astro. The article body
headings themselves are untouched.

* Fix hero code blocks blending into background (#2963)

Detail page hero install-command code blocks (SyntaxHighlightedCode via
heroExtras) used the same background token as the hero surface itself
(--brand-color-canvas-subtle), making them visually indistinguishable
except for a thin border.

Add a scoped .heroContent .codeBlock override in both shared style
modules (dotnet-upgrade.module.css for catalog Detail pages/Cookbook,
github-copilot-app.module.css for Learning Hub) that sets the
background to --brand-color-canvas-default instead, so hero code
blocks stand apart from the hero background.

The base .codeBlock rule is left untouched since body-content code
blocks (e.g. Cookbook recipe steps) correctly rely on canvas-subtle
being distinct from the page's canvas-default background.

* Tie color-scheme to dark mode for native scrollbars (#2964)

Native scrollbars (the page scrollbar, and any overflow container
without custom scrollbar styling, e.g. code block <pre> elements) are
painted by the browser based on the color-scheme CSS property, not our
design tokens. Only a handful of scoped containers (.scrollHost,
.filterOptions, .sidebarSticky) had custom scrollbar-color rules; the
page scrollbar and plain code-block overflow scrollbars had no
color-scheme set at all, so they rendered with light OS-default
scrollbars even when data-color-mode="dark" was set on <html>.

Add a global rule tying html's color-scheme to the same
data-color-mode attribute BaseLayout.astro already stamps on <html>,
so every native scrollbar (and other UA-drawn form controls) follows
the site's theme.

* Unify article/detail page typography, tables, and dividers (#2965)

Raw markdown injected into .articleSection (Skill/Agent/Instruction/
Plugin/Extension Detail pages via dotnet-upgrade.module.css, and
Learning Hub articles via github-copilot-app.module.css) had no styling
for headings, tables, horizontal rules, blockquotes, or inline code, so
it fell back to inconsistent browser UA defaults -- most visibly,
tables rendered with no borders or header emphasis at all.

Add a shared set of rules to both CSS modules, scoped under
.articleSection, that:
- map raw h1-h6 onto the brand type scale (font, weight, line-height)
- give hr a full-width divider matching the border-muted token
- style blockquote with a left accent bar
- render inline `code` (outside <pre>) as a small chip, matching
  .inlineCode used elsewhere for hand-authored tokens
- style table/th/td with bordered cells and a canvas-subtle header row,
  scrolling horizontally on narrow viewports

Also fix skills/acquire-codebase-knowledge/SKILL.md: a stray blank
line inside the "Bundled Assets" table split it into a lone header row
and a second block with no header, so GFM table parsing stopped after
the first row and the remaining rows rendered as literal pipe-delimited
text instead of a table -- this was the exact bug shown in the issue
screenshot.

* fix: align previous/next navigation links horizontally on Detail pages

Detail page Previous/Up next links were stacked vertically. Change
.nextUp to a row layout (space-between) so Previous sits left-aligned
and Up next right-aligned on the same row, with a max-width: 40rem
media query reverting to a stacked column layout on small screens.

Use an explicit .nextUpNext modifier class (applied to the "Up next"
link specifically) with margin-inline-start: auto instead of a
positional :last-child selector, so the link right-aligns correctly
even when it is the only link present (e.g. the first item in a
catalog, which has no Previous link).

Fixes #2966

* fix: align callout blocks with article content width

The .proTip callout (Note/Tip/Caution admonitions in Learning Hub
articles, and the "Maintained outside this repository" notice on
external Plugin Detail pages) had its own 32px horizontal margin.

The prototype places this callout as a sibling of .articleSection
(which has no horizontal padding of its own), using that margin to
align its edges with the section's own 32px padding. Our port always
renders it nested *inside* an already-padded .articleSection, so the
extra margin doubled up with that padding, making the callout visibly
narrower than the surrounding paragraph/table text -- most obvious on
narrow viewports.

Remove the horizontal margin (keep only the bottom spacing) in both
dotnet-upgrade.module.css and github-copilot-app.module.css so the
callout's internal padding lines up with the article text on both
edges, at all viewport widths.

Fixes #2967

* fix: standardize mobile card padding across resource pages

Agents, Instructions, Skills, and Plugins catalog cards (.item) used
the desktop 60px padding at every viewport, including phone widths,
because their @media (max-width: 47.99rem) block never reduced it --
unlike the Extension catalog, which drops to a 32px inset at that
breakpoint. This made cards on those four catalogs look inconsistently
over-indented on mobile compared to Extension cards.

Add the same `.item { padding: var(--base-size-32); }` override to the
mobile media query in agents.module.css, instructions.module.css, and
skills.module.css. plugins.module.css already had an override, but
with an asymmetric 32px/24px padding -- normalized it to the same 32px
on all sides used everywhere else.

Fixes #2968

* fix(website): address PR review feedback

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* fix(website): preserve legacy resource links

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* fix(website): bump transitive js-yaml to patched versions

Force astro's js-yaml dependency to 4.3.2 and front-matter/gray-matter's to 3.15.2 via npm overrides, fixing GHSA-52cp-r559-cp3m and GHSA-5p4m-2wfm-xmqj (quadratic-complexity DoS via YAML merge keys and !!omap resolution). Addresses github/vuln-mgmt#209786.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* fix(website): address redesign follow-up issues

Fix light-mode favicon contrast with an adaptive SVG mark, allow YouTube embeds through the site CSP, and align resource catalog sort controls with the Playbook article sort treatment across Agents, Instructions, Skills, Plugins, and Extensions.

Addresses #3006, #3007, #3008.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* fix(website): address PR review feedback

Respect reduced-motion preferences in detail TOC scrolling, restore default search indexes for bespoke Playbook pages, add pagination scroll/focus handling to the Agents catalog, sanitize contributor and cookbook URLs before rendering external links, index extension records in Pagefind, and restore install affordances for marketplace-backed external extensions.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* fix(website): revert Learning Hub naming from Playbook rename

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* chore(website): update stale Playbook comments to Learning Hub

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* fix(website): align source label in detail sidebar

Fixes #3207

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

* fix(website): address security review feedback

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6
This commit is contained in:
Aaron Powell
2026-09-16 10:50:43 +10:00
committed by GitHub
co-authored by Copilot App
parent fb4eb04fcb
commit 9ce814859e
194 changed files with 31234 additions and 13694 deletions
-129
View File
@@ -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>
-108
View File
@@ -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>
-257
View File
@@ -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>
-116
View File
@@ -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>
</>
)}
-120
View File
@@ -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} />}
-38
View File
@@ -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>
-516
View File
@@ -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>
-170
View File
@@ -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&rsquo;t find the agent you need?
</CTABanner.Heading>
<CTABanner.Description>
This library is community-built. Share an agent you use&mdash;or
request one&mdash;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>
);
}
+291
View File
@@ -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&rsquo;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(/&lt;|&gt;|&quot;|&#39;|&amp;/g, (entity) => {
switch (entity) {
case "&lt;":
return "<";
case "&gt;":
return ">";
case "&quot;":
return '"';
case "&#39;":
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 };
}
+22
View File
@@ -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&rsquo;t see what you&rsquo;re looking for?
</CTABanner.Heading>
<CTABanner.Description>
This collection is community-built. Share your own canvas
extension
<br />
&mdash;or request one&mdash;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>
);
}
+273
View File
@@ -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&rsquo;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&rsquo;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&rsquo;t find the instructions you need?
</CTABanner.Heading>
<CTABanner.Description>
This library is community-built. Share an instruction file you
use&mdash;or request one&mdash;
<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>
);
}
+148
View File
@@ -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&rsquo;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: "24 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&rsquo;t find the plugin you need?
</CTABanner.Heading>
<CTABanner.Description>
This marketplace is community-built. Share a plugin you use
<br />
&mdash;or request one&mdash;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&rsquo;t find the skill you need?
</CTABanner.Heading>
<CTABanner.Description>
This library is community-built. Share a skill you use&mdash;or
request one&mdash;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>
);
}
+21
View File
@@ -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>
);
}
+202
View File
@@ -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>
);
}
+54
View File
@@ -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: "12 files", min: 0, max: 2 },
{ label: "35 files", min: 3, max: 5 },
{ label: "610 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 ?? "12 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 &ldquo;the same agent in
another tab.&rdquo; 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 &ldquo;run a subagent,&rdquo; 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 &quot;...&quot;</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&rsquo;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 &mdash; talking through a problem with a
different &ldquo;listener&rdquo; often surfaces assumptions or blind
spots you didn&rsquo;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&rsquo;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 &mdash; 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&rsquo;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 youll 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 beginners 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 youll 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. Youll work with a single Python book collection app
throughout all chapters, progressively improving it using AI-assisted
workflows. By the end, youll 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. Heres
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 youll 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 &amp; 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>Whats 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 GitHubs 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&rsquo;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&rsquo;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&rsquo;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&rsquo;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&rsquo;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&rsquo;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&rsquo;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&rsquo;t appropriate for a given project&rsquo;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&rsquo;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&rsquo;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> &mdash; committed project settings</li>
<li><code className={styles.inlineCode}>.claude/settings.local.json</code> &mdash; 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&mdash;including hook definitions&mdash;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 &mdash; 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 &mdash; 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&mdash;including any file changes the agent made&mdash;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&rsquo;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 &mdash; 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&rsquo;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> &mdash; 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 &mdash; 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}>&lt;repo&gt;.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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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> &mdash; 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 &mdash; 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 &mdash; including conversation history and any research reports &mdash; 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 &mdash; 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&rsquo;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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; for example, turning &ldquo;um make the login thing work better with the existing setup&rdquo; 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 &mdash; instructions, MCP servers, skills, agents, and plugins &mdash; 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&rsquo;s context window usage &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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&rsquo;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 &mdash; including when launched with <code className={styles.inlineCode}>--autopilot</code> at startup or during automatic continuation turns &mdash; 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 &mdash; 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 &mdash; use <code className={styles.inlineCode}>/experimental on</code> or start the CLI with <code className={styles.inlineCode}>--experimental</code> &mdash; 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 &mdash; 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&rsquo;t write to GitHub &mdash; 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 &lt;directory&gt;</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 &mdash; 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 &mdash; images or native documents &mdash; 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&rsquo;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 its 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 its 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&rsquo;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&rsquo;s for
</Heading>
<Text as="p" size="300" variant="muted">
The Copilot app isn&rsquo;t a replacement for existing experiences
it&rsquo;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&rsquo;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&rsquo;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 dont 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 Copilots 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 Copilots 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**: GitHubs [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 companys 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 doesnt 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 repositorys instructions, agents, skills, and hooks",
"Always produces a PR—it cant 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 youd 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&rsquo;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 &amp; 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 &amp; 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 &amp; 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 Im 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&rsquo;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&mdash;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 (&ldquo;never commit secrets&rdquo;,
&ldquo;feature flags must live in{" "}
<code className={styles.inlineCode}>flags/</code>&rdquo;).
</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&mdash;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}>
&lt;copilot_home&gt;/session-state/&lt;sessionId&gt;/files/&lt;whatever&gt;
</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}>
&lt;copilot_home&gt;/extensions/&lt;extensionId&gt;/&lt;whatever&gt;
</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>
);
}
+44
View File
@@ -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,
}));
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Local replacement for the prototype's `PrototypePageProps`.
*
* The prototype resolved page links through a host-supplied `pageHref` helper.
* On this site, routes are real URLs, so `pageHref` maps a prototype page slug
* to the equivalent site route (honouring the configured base path).
*/
const BASE = import.meta.env.BASE_URL ?? "/";
/** Prototype page slug → site route. Slugs not listed fall through as-is. */
const ROUTES: Record<string, string> = {
"": "",
agents: "agents/",
instructions: "instructions/",
skills: "skills/",
plugins: "plugins/",
extensions: "extensions/",
contributors: "contributors/",
agent: "agents/",
"learning-hub-copilot-app": "learning-hub/",
cookbook: "learning-hub/cookbook/",
"github-copilot-app": "learning-hub/github-copilot-app/",
"working-with-canvas-extensions": "learning-hub/working-with-canvas-extensions/",
"using-automations-in-copilot-app":
"learning-hub/using-automations-in-copilot-app/",
"what-are-agents-skills-instructions":
"learning-hub/what-are-agents-skills-instructions/",
"agents-and-subagents": "learning-hub/agents-and-subagents/",
"copilot-configuration-basics": "learning-hub/copilot-configuration-basics/",
"github-copilot-terminology-glossary":
"learning-hub/github-copilot-terminology-glossary/",
"cli-for-beginners": "learning-hub/cli-for-beginners/",
"copilot-workshops": "learning-hub/copilot-workshops/",
"dotnet-upgrade": "agent/dotnet-upgrade/",
};
export type PageHref = (page?: string) => string;
const withBase = (path: string) =>
`${BASE.endsWith("/") ? BASE : `${BASE}/`}${path}`.replace(/\/{2,}/g, "/");
/**
* Resolve a prototype page slug (or an explicit path) to a site URL.
* Passing no argument returns the site root.
*/
export const pageHref: PageHref = (page) => {
if (!page) return withBase("");
const mapped = ROUTES[page];
if (mapped !== undefined) return withBase(mapped);
// Unknown slug: treat as an explicit path so new pages work without a map entry.
return withBase(page.replace(/^\//, "").replace(/\/?$/, "/"));
};
export type PrototypePageProps = { pageHref: PageHref };
@@ -0,0 +1,152 @@
/**
* Client-side Pagefind adapter.
*
* Pagefind is generated by a post-build step (see
* `src/integrations/pagefind-resources.ts`), so `/pagefind/pagefind.js` only
* exists in a real build. The import is therefore lazy and guarded: in `astro
* dev` it simply fails and search degrades to the static in-memory index.
*/
import { pageHref } from "./pageHref";
import type { SearchCategory, SearchItem } from "./searchIndex";
type PagefindResultData = {
url: string;
excerpt?: string;
meta?: Record<string, string | undefined>;
};
/** Strip HTML tags, repeating until no more tags remain so a malformed or
* nested markup fragment (e.g. `<<script>script>`) can't survive a single pass. */
function stripTags(html: string): string {
let text = html;
let previous: string;
do {
previous = text;
text = text.replace(/<[^>]*>/g, " ");
} while (text !== previous);
return text;
}
type PagefindModule = {
options?: (opts: Record<string, unknown>) => Promise<void>;
init?: () => Promise<void>;
debouncedSearch?: (
term: string,
options?: unknown,
debounceMs?: number,
) => Promise<{ results: { data: () => Promise<PagefindResultData> }[] } | null>;
search: (
term: string,
options?: unknown,
) => Promise<{ results: { data: () => Promise<PagefindResultData> }[] }>;
};
/** Site base path, matching the value bootstrapped by `BaseLayout.astro`. */
function basePath(): string {
const fromDocument =
typeof document !== "undefined"
? document.documentElement.dataset.basePath
: undefined;
const base = fromDocument || pageHref();
return base.endsWith("/") ? base : `${base}/`;
}
let modulePromise: Promise<PagefindModule | null> | null = null;
/** Load (once) the Pagefind bundle, or resolve to `null` when unavailable. */
export function loadPagefind(): Promise<PagefindModule | null> {
if (modulePromise) return modulePromise;
modulePromise = (async () => {
if (typeof window === "undefined") return null;
try {
const url = `${basePath()}pagefind/pagefind.js`;
const mod: PagefindModule = await import(/* @vite-ignore */ url);
await mod.options?.({ baseUrl: basePath() });
await mod.init?.();
return mod;
} catch {
// No index (dev server, or a build that skipped Pagefind) — the caller
// falls back to the static index.
return null;
}
})();
return modulePromise;
}
const CATEGORY_BY_SEGMENT: Record<string, SearchCategory> = {
agent: "Agents",
agents: "Agents",
instruction: "Instructions",
instructions: "Instructions",
skill: "Skills",
skills: "Skills",
plugin: "Plugins",
plugins: "Plugins",
extension: "Extensions",
extensions: "Extensions",
"learning-hub": "Articles",
};
/** Derive a result category from the result URL, relative to the base path. */
function categoryOf(pathname: string): SearchCategory {
const base = basePath();
const relative = pathname.startsWith(base)
? pathname.slice(base.length)
: pathname.replace(/^\//, "");
const segments = relative.split("/").filter(Boolean);
if (segments.length === 0) return "Pages";
// A bare section landing page (e.g. /skills/) is a destination, not a record.
if (segments.length === 1 && !CATEGORY_BY_SEGMENT[segments[0]]) return "Pages";
if (segments.length === 1) return "Pages";
return CATEGORY_BY_SEGMENT[segments[0]] ?? "Pages";
}
/** Pagefind custom records carry a `Title — Agent` style suffix; strip it. */
function cleanTitle(title: string): string {
return title.replace(/\s+\s+(Agent|Instruction|Skill|Hook|Workflow|Plugin|Tool|Extension)$/u, "");
}
/** Normalised key used to dedupe against the static index. */
export function hrefKey(href: string): string {
try {
const url = new URL(href, window.location.origin);
return url.pathname.replace(/\/+$/, "").toLowerCase() || "/";
} catch {
return href.replace(/\/+$/, "").toLowerCase() || "/";
}
}
/** Run a Pagefind query and adapt the hits to `SearchItem`s. */
export async function searchPagefind(
term: string,
limit: number,
): Promise<SearchItem[]> {
const pagefind = await loadPagefind();
if (!pagefind) return [];
try {
const response = pagefind.debouncedSearch
? await pagefind.debouncedSearch(term, undefined, 180)
: await pagefind.search(term);
// `debouncedSearch` resolves to null when superseded by a newer query.
if (!response) return [];
const data = await Promise.all(
response.results.slice(0, limit).map((result) => result.data()),
);
const items: SearchItem[] = [];
for (const entry of data) {
if (!entry?.url) continue;
const title = cleanTitle(entry.meta?.title?.trim() || entry.url);
if (!title) continue;
items.push({
title,
description: entry.excerpt ? stripTags(entry.excerpt) : "",
category: categoryOf(entry.url.split(/[?#]/)[0]),
href: entry.url,
});
}
return items;
} catch {
return [];
}
}
@@ -0,0 +1,25 @@
export function httpUrl(url?: string | null): string | undefined {
if (!url) return undefined;
try {
const parsed = new URL(url);
return parsed.protocol === "http:" || parsed.protocol === "https:"
? url
: undefined;
} catch {
return undefined;
}
}
export async function downloadFile(url: string, filename: string): Promise<void> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download ${url}: ${response.status}`);
}
const objectUrl = URL.createObjectURL(await response.blob());
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(objectUrl);
}
@@ -0,0 +1,5 @@
export function getScrollBehavior(): ScrollBehavior {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
? "auto"
: "smooth";
}
+133
View File
@@ -0,0 +1,133 @@
/**
* Search index adapter.
*
* The prototype shipped a hand-written `searchIndex.ts`. Here the same shape is
* produced from the site's generated `public/data/search-index.json` plus the
* fixed top-level destinations, so `TopNavSearch` searches the real library.
*/
export type SearchCategory =
| "Pages"
| "Articles"
| "Agents"
| "Instructions"
| "Skills"
| "Plugins"
| "Extensions";
export type SearchItem = {
title: string;
description: string;
category: SearchCategory;
/** Resolved site URL for the result. */
href: string;
};
/** Generated record shape from `eng/generate-website-data.mjs`. */
export type GeneratedSearchRecord = {
type: string;
id: string;
title: string;
description?: string;
path?: string;
};
const CATEGORY_BY_TYPE: Record<string, SearchCategory> = {
agent: "Agents",
instruction: "Instructions",
skill: "Skills",
plugin: "Plugins",
extension: "Extensions",
article: "Articles",
};
const DETAIL_ROUTE_BY_TYPE: Record<string, string> = {
agent: "agent",
instruction: "instruction",
skill: "skill",
plugin: "plugin",
extension: "extension",
};
/** Top-level destinations so search always surfaces the main sections. */
export const staticPages = (base: string): SearchItem[] => {
const at = (path: string) => `${base}${path}`.replace(/\/{2,}/g, "/");
return [
{
title: "Home",
description:
"The Awesome GitHub Copilot library home — browse agents, instructions, skills, plugins, and extensions.",
category: "Pages",
href: at("/"),
},
{
title: "Agents",
description:
"Ready-to-use custom agents for GitHub Copilot — specialized assistants for focused tasks.",
category: "Pages",
href: at("/agents/"),
},
{
title: "Instructions",
description:
"Repository and language instruction files that steer GitHub Copilot toward your conventions.",
category: "Pages",
href: at("/instructions/"),
},
{
title: "Skills",
description:
"Self-contained skill folders that bundle instructions and resources together.",
category: "Pages",
href: at("/skills/"),
},
{
title: "Plugins",
description:
"Installable plugin packages that group related agents, hooks, and skills.",
category: "Pages",
href: at("/plugins/"),
},
{
title: "Extensions",
description:
"Interactive canvas extensions that enrich the GitHub Copilot app experience.",
category: "Pages",
href: at("/extensions/"),
},
{
title: "Learning Hub",
description:
"Articles and guides for getting the most from every agent, skill, and instruction.",
category: "Pages",
href: at("/learning-hub/"),
},
{
title: "Contributors",
description: "The people who build and maintain the community library.",
category: "Pages",
href: at("/contributors/"),
},
];
};
/** Convert generated records into `TopNavSearch` items. */
export function buildSearchIndex(
records: GeneratedSearchRecord[],
base = "/",
): SearchItem[] {
const at = (path: string) => `${base}${path}`.replace(/\/{2,}/g, "/");
const items: SearchItem[] = [];
for (const record of records) {
const category = CATEGORY_BY_TYPE[record.type];
if (!category) continue;
const route = DETAIL_ROUTE_BY_TYPE[record.type];
items.push({
title: record.title,
description: record.description ?? "",
category,
href: route ? at(`/${route}/${record.id}/`) : at("/"),
});
}
return [...staticPages(base), ...items];
}
@@ -0,0 +1,20 @@
/* The hero community icon links directly to the full contributors page. */
.wrapper {
display: inline-flex;
justify-content: center;
}
.trigger {
display: inline-flex;
padding: 0;
border: 0;
background: none;
line-height: 0;
cursor: pointer;
border-radius: var(--brand-borderRadius-full);
}
.trigger:focus-visible {
outline: var(--brand-borderWidth-thick) solid var(--brand-color-focus);
outline-offset: var(--base-size-4);
}
@@ -0,0 +1,76 @@
.trigger {
display: inline-flex;
align-items: center;
gap: var(--base-size-6);
block-size: var(--base-size-32);
padding-inline: var(--base-size-12);
border: 0;
border-radius: var(--brand-borderRadius-full);
background-color: transparent;
color: var(--brand-color-text-default);
font-family: var(--brand-fontStack-sansSerif);
font-size: var(--brand-text-size-100);
font-weight: var(--brand-text-weight-semibold);
line-height: 1;
text-decoration: none;
cursor: pointer;
transition: background-color 0.16s ease;
}
.trigger:hover {
background-color: var(--brand-color-canvas-subtle);
}
.trigger:focus-visible {
outline: var(--brand-borderWidth-thick) solid var(--brand-color-focus);
outline-offset: var(--base-size-2);
}
.heart {
display: inline-flex;
align-items: center;
color: var(--brand-color-success-fg);
line-height: 1;
transform-origin: center;
}
/* The heart only beats while the contributors button (heart + count) is
hovered or keyboard-focused, so the motion is intentional, not ambient. */
.trigger:hover .heart,
.trigger:focus-visible .heart {
animation: heartbeat 3s ease-in-out infinite;
}
/* Lub-dub pulse: two quick beats early in the cycle, then a long rest
before it repeats reads like a resting human heartbeat. */
@keyframes heartbeat {
0% {
transform: scale(1);
}
10% {
transform: scale(1.5);
}
20% {
transform: scale(1);
}
30% {
transform: scale(1.35);
}
42% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
.countLabel {
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
.trigger:hover .heart,
.trigger:focus-visible .heart {
animation: none;
}
}
@@ -0,0 +1,42 @@
/* "Skip to main content" link. Off-screen until focused, then slides into the
top-left corner on top of everything else. All colors and sizes use Primer
Brand tokens so it adapts across light and dark color modes. */
.skipLink {
position: fixed;
top: var(--base-size-8);
left: var(--base-size-8);
z-index: 1000;
display: inline-block;
padding: var(--base-size-8) var(--base-size-16);
background-color: var(--brand-color-canvas-default);
color: var(--brand-color-text-link-rest);
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-default);
border-radius: var(--brand-borderRadius-medium);
font-family: var(--brand-fontStack-sansSerif);
font-size: var(--brand-text-size-200);
font-weight: var(--brand-text-weight-600);
line-height: var(--brand-text-lineHeight-200);
text-decoration: none;
/* Park the link above the viewport until it is focused. */
transform: translateY(calc(-100% - var(--base-size-16)));
transition: transform 0.16s ease-in-out;
}
.skipLink:focus,
.skipLink:focus-visible {
transform: translateY(0);
outline: var(--brand-borderWidth-thick) solid var(--brand-color-focus);
outline-offset: var(--base-size-2);
}
/* The skip target is a large region; focusing it should not paint a full-width
ring the scroll + context change already signals the jump. */
:global(#main-content:focus) {
outline: none;
}
@media (prefers-reduced-motion: reduce) {
.skipLink {
transition: none;
}
}
@@ -0,0 +1,195 @@
/*
* Mobile navigation. Above 768px the desktop `.subNavList` (Resources dropdown
* + Learning Hub tab) is shown and this hamburger is hidden; at/below 768px each
* page module hides `.subNavList`, and this disclosure takes over as the mobile
* menu. Styling mirrors the desktop `.moreOverlay` / `.moreLink` dropdown so the
* two navs feel like one system.
*/
.mobileMenu {
display: none;
position: relative;
align-items: center;
/* Sit at the far right of the header row (after the heart + Contribute
actions) so the right-anchored dropdown opens inward and stays on screen. */
order: 5;
margin-inline-end: var(--base-size-16);
}
.mobileTrigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--base-size-40);
height: var(--base-size-40);
color: var(--brand-color-text-default);
background-color: transparent;
border: var(--brand-borderWidth-thin) solid transparent;
border-radius: var(--brand-borderRadius-medium);
cursor: pointer;
list-style: none;
user-select: none;
transition:
color 0.2s ease,
background-color 0.2s ease;
}
.mobileTrigger::-webkit-details-marker {
display: none;
}
.mobileTrigger:hover {
background-color: var(--brand-color-canvas-subtle);
}
.mobileTrigger:focus-visible {
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
outline-offset: var(--base-size-2);
}
.triggerBars {
display: inline-flex;
}
.triggerClose {
display: none;
}
.mobileMenu[open] .triggerBars {
display: none;
}
.mobileMenu[open] .triggerClose {
display: inline-flex;
}
.mobileOverlay {
position: absolute;
/* Meet the top bar flush: the panel's top edge lands on the top bar's own
bottom border, so nav and menu read as one connected surface (no gap). */
inset-block-start: 100%;
/* Break out of the right-anchored menu so the panel spans the full mobile
viewport: the trigger sits 16px + the top bar's 1px border from the screen
edge, so pull the panel's end edge out by that much and size it to 100vw. */
inset-inline-end: calc(
-1 * (var(--base-size-16) + var(--brand-borderWidth-thin))
);
z-index: 30;
inline-size: 100vw;
max-inline-size: 100vw;
display: flex;
flex-direction: column;
gap: var(--base-size-2);
padding: var(--base-size-8);
background-color: var(--brand-color-canvas-subtle);
border-block: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
box-shadow: var(--brand-SubNav-shadow);
}
.overlayLabel {
padding-block: var(--base-size-8) var(--base-size-4);
padding-inline: var(--base-size-12);
font-family: var(--brand-fontStack-monospace);
font-size: var(--brand-text-size-100);
font-weight: var(--brand-text-weight-medium);
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--brand-color-text-muted);
}
/* Site-wide search relocated into the mobile menu (the desktop top-bar search
is hidden below 48rem). Sits at the top of the panel so search is the first
thing available when the menu opens. */
.mobileSearch {
padding-block: var(--base-size-4);
padding-inline: var(--base-size-12);
}
.mobileLink {
padding-block: var(--base-size-8);
padding-inline: var(--base-size-12);
font-family: var(--brand-fontStack-sansSerif);
font-size: var(--brand-text-size-200);
font-weight: var(--brand-text-weight-normal);
line-height: var(--brand-text-lineHeight-200);
color: var(--brand-color-text-default);
text-decoration: none;
border-radius: var(--brand-borderRadius-medium);
transition:
color 0.2s ease,
background-color 0.2s ease;
}
.mobileLink:hover {
color: var(--brand-color-text-default);
background-color: var(--brand-color-canvas-subtle);
}
.mobileLink:focus-visible {
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
outline-offset: calc(var(--base-size-2) * -1);
}
.mobileLink[aria-current="page"],
.mobileLink[aria-current="page"]:hover {
color: var(--brand-color-text-default);
font-weight: var(--brand-text-weight-semibold);
background-color: var(--brand-color-canvas-subtle);
}
.overlayDivider {
height: var(--brand-borderWidth-thin);
margin-block: var(--base-size-8);
margin-inline: var(--base-size-12);
background-color: var(--brand-color-border-muted);
}
/* Action cluster relocated from the header bar on mobile: the contributors
heart and the Contribute button, stacked (heart above, Contribute below)
under the nav links so the full-width panel reads top-to-bottom. */
.mobileActions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--base-size-12);
padding-block: var(--base-size-8);
padding-inline: var(--base-size-12);
}
/* The contributors heart button carries its own inline padding for the header
pill; in the stacked mobile menu that indents the heart past the nav links.
Drop the leading pad here so the heart lines up flush with the links, the
RESOURCES label and the site title (issue #18). The Contribute button's box
already sits on that same content edge. */
.mobileActions :global(button[class*="trigger"]) {
padding-inline-start: 0;
}
/* On the tablet band the top bar is inset from the screen edge by the page
gutter, so the right-anchored breakout above (which only clears the trigger's
16px + border) leaves the full-width panel hanging off the left of the screen.
Pull the end edge out by the gutter as well so the 100vw panel stays aligned
to the viewport here too. Phones (<48rem) have no gutter and keep the base
value above. */
@media screen and (min-width: 48rem) and (max-width: 74.99rem) {
.mobileOverlay {
inset-inline-end: calc(
-1 *
(
var(--page-gutter) + var(--base-size-16) +
var(--brand-borderWidth-thin)
)
);
}
}
/* Show the hamburger across phones AND the tablet band (up to 74.99rem/1199px):
the full desktop nav row doesn't fit below ~1200px it wraps to two rows and
opening search pushes the GitHub wordmark off-screen (issue #19). Each page
module hides its desktop `.subNavList` / `.topBarActions` over the same range,
so this disclosure takes over as the nav through the whole band. */
@media screen and (max-width: 74.99rem) {
.mobileMenu {
display: inline-flex;
}
}
@@ -0,0 +1,177 @@
/* Dropdown styling for the shared top-navigation search. The search field,
* button, and K hint reuse each page's own scoped classes (passed in via
* `styles`); only the results popover lives here so it looks identical on
* every page. */
.wrap {
position: relative;
display: inline-flex;
}
.results {
position: absolute;
z-index: 30;
inset-block-start: calc(100% + var(--base-size-8));
inset-inline-end: 0;
inline-size: 22rem;
max-inline-size: min(22rem, 90vw);
display: flex;
flex-direction: column;
gap: var(--base-size-16);
padding: var(--base-size-16);
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted);
background-color: var(--brand-color-canvas-muted);
box-shadow: var(--brand-SubNav-shadow);
text-align: start;
animation: topNavSearchResultsIn 0.16s ease;
}
@keyframes topNavSearchResultsIn {
from {
opacity: 0;
transform: translateY(calc(var(--base-size-6) * -1));
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.results {
animation: none;
}
}
.group {
display: flex;
flex-direction: column;
}
.groupLabel {
margin: 0 0 var(--base-size-4);
font-family: var(--brand-fontStack-sansSerif);
font-size: var(--brand-text-size-100);
font-weight: var(--brand-text-weight-normal);
color: var(--brand-color-text-muted);
}
.result {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--base-size-12);
padding-block: var(--base-size-8);
border-block-start: var(--brand-borderWidth-thin) dashed
var(--brand-color-border-subtle);
color: var(--brand-color-text-default);
text-decoration: none;
transition: color 0.15s ease;
}
.result:first-of-type {
border-block-start: 0;
}
.result:focus-visible {
outline: var(--brand-borderWidth-thick) solid var(--brand-color-focus);
outline-offset: calc(var(--base-size-2) * -1);
border-radius: var(--brand-borderRadius-small);
}
.resultText {
font-family: var(--brand-fontStack-sansSerif);
font-size: var(--brand-text-size-100);
font-weight: var(--brand-text-weight-semibold);
color: inherit;
}
.resultIcon {
flex-shrink: 0;
color: var(--brand-color-text-muted);
}
.result:hover .resultText,
.result:focus-visible .resultText,
.resultActive .resultText,
.result:hover .resultIcon,
.result:focus-visible .resultIcon,
.resultActive .resultIcon {
color: var(--brand-color-text-link-rest);
}
.resultActive {
background-color: var(--brand-color-canvas-default);
}
.empty {
margin: 0;
font-family: var(--brand-fontStack-sansSerif);
font-size: var(--brand-text-size-100);
color: var(--brand-color-text-muted);
}
/* Inline variant: an always-open, full-width search used inside the mobile
* hamburger menu (where the desktop top-bar search is hidden). The field and
* results flow in normal document order so search reads as the first item of
* the menu, keeping an equivalent search UX at every viewport. */
.inlineWrap {
display: flex;
flex-direction: column;
gap: var(--base-size-8);
inline-size: 100%;
}
.inlineField {
display: flex;
align-items: center;
gap: var(--base-size-8);
inline-size: 100%;
block-size: var(--base-size-40);
padding-inline: var(--base-size-12);
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted);
border-radius: var(--brand-borderRadius-medium);
background-color: var(--brand-color-canvas-default);
color: var(--brand-color-text-muted);
}
.inlineField:focus-within {
border-color: var(--brand-color-focus);
}
.inlineIcon {
flex-shrink: 0;
color: var(--brand-color-text-muted);
}
.inlineInput {
flex: 1;
min-inline-size: 0;
border: 0;
padding: 0;
background: transparent;
font-family: var(--brand-fontStack-sansSerif);
font-size: var(--brand-text-size-200);
font-weight: var(--brand-text-weight-normal);
line-height: var(--brand-text-lineHeight-200);
color: var(--brand-color-text-default);
}
.inlineInput::placeholder {
color: var(--brand-color-text-muted);
}
.inlineInput:focus {
outline: none;
}
.inlineResults {
display: flex;
flex-direction: column;
gap: var(--base-size-16);
padding: var(--base-size-12);
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted);
border-radius: var(--brand-borderRadius-medium);
background-color: var(--brand-color-canvas-muted);
text-align: start;
}
@@ -0,0 +1,11 @@
.visuallyHidden {
position: absolute;
inline-size: var(--base-size-4);
block-size: var(--base-size-4);
padding: 0;
margin: calc(-1 * var(--base-size-4));
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
@@ -0,0 +1,339 @@
/* Video series carousel mirrors the "Get started" carousel on the Copilot
Learning Hub page (bordered band, header with prev/next, connected 3-up cards),
adapted to hold YouTube video thumbnails that open in a modal player. */
.carousel {
display: flex;
flex-direction: column;
background-color: var(--brand-color-canvas-default);
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--base-size-16);
padding: var(--base-size-16) var(--base-size-24);
background-color: var(--brand-color-canvas-muted);
}
.header .headerLabel {
margin: 0;
font-family: var(--brand-fontStack-monospace);
font-size: var(--brand-text-size-100);
font-weight: var(--brand-text-weight-regular);
letter-spacing: var(--brand-text-letterSpacing-100);
text-transform: uppercase;
color: var(--brand-color-text-muted);
}
.nav {
display: flex;
align-items: center;
gap: var(--base-size-24);
}
.navButton {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--base-size-4);
border: 0;
background: transparent;
color: var(--brand-color-text-default);
cursor: pointer;
border-radius: var(--brand-borderRadius-small);
transition: color 0.2s ease;
}
.navButton:hover {
color: var(--brand-color-text-default);
}
.navButton:focus-visible {
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
outline-offset: var(--base-size-2);
}
.track {
display: flex;
flex-wrap: wrap;
overflow: hidden;
border-block-start: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
animation: videoPageFade 0.28s var(--brand-animation-easing-default, ease);
}
@keyframes videoPageFade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.track {
animation: none;
}
}
.card {
flex: 0 0 calc(100% / 3);
display: flex;
flex-direction: column;
gap: var(--base-size-16);
padding: var(--base-size-24);
margin: 0;
text-align: start;
border: 0;
background-color: var(--brand-color-canvas-default);
cursor: pointer;
transition: background-color var(--brand-animation-duration-extended)
var(--brand-animation-easing-default);
}
/* Keep an integer number of cards visible so the row never cuts a card
mid-width. Steps 3-up -> 2-up -> 1-up as the container narrows. */
@media screen and (max-width: 56.24rem) {
.card {
flex-basis: calc(100% / 2);
}
/* Full row is now 2-up: reset the 3-up edge rule and add a right edge only
when the trailing card doesn't complete a pair. */
.card:last-child:not(:nth-child(3n)) {
border-inline-end: 0;
}
.card:last-child:not(:nth-child(2n)) {
border-inline-end: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
}
@media screen and (max-width: 37.49rem) {
.card {
flex-basis: 100%;
}
/* 1-up: every card fills the width, so the carousel border is the right edge. */
.card:last-child:not(:nth-child(2n)),
.card:last-child:not(:nth-child(3n)) {
border-inline-end: 0;
}
}
.card:not(:first-child) {
border-inline-start: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
/* When the last row isn't full (e.g. a single video), the trailing card still
needs a right edge so it reads as a complete card rather than an open band.
Skip it when the card already fills to the container edge (a full 3-up row),
where the carousel's own border supplies the right edge. */
.card:last-child:not(:nth-child(3n)) {
border-inline-end: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
.card:is(:hover, :focus-visible) {
background-color: var(--brand-color-canvas-subtle);
}
.card:focus-visible {
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
outline-offset: calc(-1 * var(--base-size-4));
}
.thumb {
position: relative;
display: block;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted);
border-radius: var(--brand-borderRadius-medium);
background-color: var(--brand-color-canvas-subtle);
}
.thumbImage {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform var(--brand-animation-duration-extended)
var(--brand-animation-easing-default);
}
.card:is(:hover, :focus-visible) .thumbImage {
transform: scale(1.03);
}
.playBadge {
position: absolute;
inset-block-start: 50%;
inset-inline-start: 50%;
transform: translate(-50%, -50%);
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: var(--base-size-48);
block-size: var(--base-size-48);
border-radius: var(--brand-borderRadius-full);
color: var(--base-color-scale-white-0);
background-color: color-mix(
in srgb,
var(--base-color-scale-black-0) 68%,
transparent
);
transition: background-color 0.2s ease, transform 0.2s ease;
}
.card:is(:hover, :focus-visible) .playBadge {
background-color: var(--base-color-scale-green-5);
transform: translate(-50%, -50%) scale(1.06);
}
.cardMeta {
margin: 0;
font-family: var(--brand-fontStack-monospace);
font-size: var(--brand-text-size-100);
font-weight: var(--brand-text-weight-regular);
letter-spacing: var(--brand-text-letterSpacing-100);
text-transform: uppercase;
color: var(--brand-color-text-muted);
}
.cardTitle {
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-family: var(--brand-fontStack-sansSerif);
font-size: var(--brand-text-size-300);
font-weight: var(--brand-text-weight-600, 600);
line-height: var(--brand-text-lineHeight-300);
color: var(--brand-color-text-default);
}
/* ---- Modal player ---------------------------------------------------- */
.backdrop {
position: fixed;
inset-block-start: 0;
inset-inline: 0;
/* Use the dynamic viewport height so the dialog stays centred in the visible
area on mobile, where the browser chrome shrinks the layout viewport. */
block-size: 100dvh;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: var(--base-size-24);
background-color: color-mix(
in srgb,
var(--base-color-scale-black-0) 72%,
transparent
);
animation: videoBackdropIn 0.18s ease-out both;
}
@keyframes videoBackdropIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.dialog {
display: flex;
flex-direction: column;
inline-size: min(60rem, 100%);
max-block-size: 100%;
overflow: hidden;
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted);
background-color: var(--brand-color-canvas-default);
box-shadow: var(--brand-River-visual-shadow);
animation: videoDialogIn 0.22s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes videoDialogIn {
from {
opacity: 0;
transform: translateY(var(--base-size-8));
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dialogHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--base-size-16);
padding: var(--base-size-16) var(--base-size-24);
border-block-end: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
.dialogTitle {
margin: 0;
font-family: var(--brand-fontStack-sansSerif);
font-size: var(--brand-text-size-300);
font-weight: var(--brand-text-weight-600, 600);
line-height: var(--brand-text-lineHeight-300);
color: var(--brand-color-text-default);
}
.closeButton {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--base-size-8);
appearance: none;
-webkit-appearance: none;
border: var(--brand-borderWidth-thick) solid var(--brand-color-border-default);
border-radius: var(--brand-borderRadius-medium);
background-color: var(--brand-color-canvas-subtle);
line-height: 1;
color: var(--brand-color-text-muted);
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
.closeButton:hover {
color: var(--brand-color-text-default);
border-color: var(--brand-color-border-muted);
background-color: var(--brand-color-canvas-default);
}
.closeButton:focus-visible {
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
outline-offset: var(--base-size-2);
}
.videoFrame {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background-color: var(--base-color-scale-black-0);
}
.videoFrame iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,216 @@
.hero {
inline-size: 100%;
min-block-size: 17rem;
display: flex;
justify-content: center;
border-block-end: var(--brand-borderWidth-thick) solid
var(--brand-color-border-muted);
}
.heroInner,
.sectionInner {
inline-size: var(--brand-breakpoint-xlarge);
max-inline-size: 100%;
margin-inline: auto;
border-inline: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
.heroInner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--base-size-24);
padding-block: var(--base-size-80);
padding-inline: var(--base-size-24);
background-color: var(--brand-color-canvas-subtle);
text-align: center;
}
.heroHeading {
margin: 0;
}
.heroText {
max-inline-size: var(--brand-breakpoint-small);
margin: 0;
}
.section {
border-block-end: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
.grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
list-style: none;
margin: 0;
padding: 0;
}
.gridCell {
min-inline-size: 0;
border-inline-end: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
border-block-end: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
.gridCell:nth-child(6n) {
border-inline-end: 0;
}
.contributorLink {
display: flex;
padding: var(--base-size-24);
align-items: center;
justify-content: center;
flex-direction: column;
gap: var(--base-size-8);
color: var(--brand-color-text-default);
text-align: center;
text-decoration: none;
transition: background-color var(--brand-animation-duration-fast)
var(--brand-animation-easing-default);
}
.contributorLink:hover {
background-color: var(--brand-color-canvas-subtle);
}
.contributorLink:focus-visible {
position: relative;
z-index: 1;
outline: var(--brand-borderWidth-thick) solid
var(--brand-color-focus);
outline-offset: calc(var(--brand-borderWidth-thick) * -1);
}
.contributorName,
.contributorLogin {
max-inline-size: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contributorName {
font-family: var(--brand-fontStack-sansSerif);
font-size: var(--brand-text-size-200);
font-weight: var(--brand-text-weight-semibold);
}
.contributorLogin {
font-family: var(--brand-fontStack-sansSerif);
font-size: var(--brand-text-size-100);
color: var(--brand-color-text-muted);
}
.backToTop {
position: fixed;
inset-block-end: var(--base-size-32);
inset-inline-end: var(--base-size-32);
z-index: 30;
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: var(--brand-control-large-size);
block-size: var(--brand-control-large-size);
padding: 0;
border: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
border-radius: var(--brand-borderRadius-full);
background-color: var(--brand-color-canvas-subtle);
color: var(--brand-color-text-default);
box-shadow: var(--brand-SubNav-shadow);
cursor: pointer;
opacity: 0;
transform: translateY(var(--base-size-8));
pointer-events: none;
transition:
opacity var(--brand-animation-duration-fast)
var(--brand-animation-easing-default),
transform var(--brand-animation-duration-fast)
var(--brand-animation-easing-default);
}
.backToTopVisible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.backToTop:hover {
background-color: var(--brand-color-canvas-default);
}
.backToTop:focus-visible {
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
outline-offset: var(--base-size-2);
}
@media screen and (max-width: 119.99rem) {
.heroInner,
.sectionInner {
inline-size: auto;
max-inline-size: none;
margin-inline: var(--page-gutter);
}
.heroInner {
flex: 1 1 auto;
padding-block: var(--page-pad-block);
}
}
@media (max-width: 63.99rem) {
.grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.gridCell {
border-inline-end: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
.gridCell:nth-child(4n) {
border-inline-end: 0;
}
}
@media (max-width: 47.99rem) {
.heroInner,
.sectionInner {
margin-inline: 0;
}
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.gridCell {
border-inline-end: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
.gridCell:nth-child(2n) {
border-inline-end: 0;
}
.contributorLink {
padding: var(--base-size-16);
}
}
@media (prefers-reduced-motion: reduce) {
.contributorLink {
transition: none;
}
.backToTop {
transition: none;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
/* File switcher for skill bundles.
Skills are folders, so the content region is a browser rather than a single
article. The tab strip reuses the prototype's pill-tab treatment
(`.subNavLink` / `.subNavLinkActive` in dotnet-upgrade.module.css) via
`composes`, so there is no new visual language here only the layout the
prototype has no equivalent for. */
.fileTabs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--base-size-8);
margin: 0;
padding: 0;
list-style: none;
}
.fileTab {
composes: subNavLink from "./dotnet-upgrade.module.css";
border: 0;
cursor: pointer;
font-family: var(--brand-fontStack-monospace);
}
.fileTabActive {
composes: subNavLinkActive from "./dotnet-upgrade.module.css";
}
/* Meta row above the rendered file: path, size, per-file GitHub link. */
.fileBar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--base-size-12);
padding-block-end: var(--base-size-12);
border-block-end: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
.filePath {
font-family: var(--brand-fontStack-monospace);
font-size: var(--brand-text-size-100);
color: var(--brand-color-text-muted);
overflow-wrap: anywhere;
}
.fileActions {
display: flex;
align-items: center;
gap: var(--base-size-8);
}
.fileImage {
max-inline-size: 100%;
block-size: auto;
border-radius: var(--brand-borderRadius-medium);
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,798 @@
.page {
min-height: 100vh;
/*
* Responsive page gutters (Figma "card" spec). A fixed side inset and block
* padding per breakpoint tier, exposed as variables so every section band
* below can share them:
* <768px L/R 20px · T/B 40px
* 7681279px L/R 91px · T/B 40px
* 12801919px L/R 152px · T/B 60px
* 1920px variables stop applying native centered-1280 layout
* (91/152/60 have no base-size token, so they are raw px.)
*/
--page-gutter: var(--base-size-20);
--page-pad-block: var(--base-size-40);
}
@media screen and (min-width: 48rem) and (max-width: 79.99rem) {
.page {
--page-gutter: 91px;
}
}
@media screen and (min-width: 80rem) and (max-width: 119.99rem) {
.page {
--page-gutter: 152px;
--page-pad-block: 60px;
}
}
/* Combined top navigation: brand mark, title, divider, content-type tabs,
and trailing actions, framed by the same gridlines as the hero. Ported from
the catalog pages. */
.topBar {
width: 100%;
display: flex;
justify-content: center;
border-block-end: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
.topBarInner {
width: 1280px;
max-width: 100%;
margin: 0;
padding: 0;
display: flex;
align-items: stretch;
border-inline-start: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
border-inline-end: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
.brandMark {
display: flex;
align-items: center;
padding-inline-start: var(--base-size-24);
padding-inline-end: var(--base-size-24);
color: var(--brand-color-text-default);
border-inline-end: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
.brandMark:focus-visible {
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
outline-offset: var(--base-size-4);
}
.topBarActions {
display: flex;
align-items: center;
gap: var(--base-size-12);
padding-inline-start: var(--base-size-16);
padding-inline-end: var(--base-size-24);
}
.searchButton {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: var(--base-size-32);
block-size: var(--base-size-32);
padding: 0;
border: 0;
border-radius: var(--brand-borderRadius-medium);
background-color: transparent;
color: var(--brand-color-text-muted);
cursor: pointer;
transition: color 0.2s ease, background-color 0.2s ease;
}
.searchButton:hover {
color: var(--brand-color-text-default);
background-color: var(--base-color-scale-gray-1);
}
.page[data-mode="dark"] .searchButton:hover {
background-color: var(--base-color-scale-gray-6);
}
.searchButton:focus-visible {
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
outline-offset: var(--base-size-2);
}
.searchField {
display: inline-flex;
align-items: center;
gap: var(--base-size-8);
inline-size: 12rem;
max-inline-size: 100%;
block-size: var(--base-size-32);
padding-inline: var(--base-size-12);
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted);
border-radius: var(--brand-borderRadius-medium);
background-color: var(--brand-color-canvas-default);
color: var(--brand-color-text-muted);
overflow: hidden;
transform-origin: right center;
animation: searchFieldExpand 0.18s ease;
transition: border-color 0.2s ease;
}
@keyframes searchFieldExpand {
from {
inline-size: var(--base-size-32);
opacity: 0.4;
}
to {
inline-size: 12rem;
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.searchField {
animation: none;
}
}
.searchField:focus-within {
border-color: var(--brand-color-focus);
}
.searchInput {
flex: 1;
min-inline-size: 0;
border: 0;
padding: 0;
background: transparent;
font-family: var(--brand-fontStack-sansSerif);
font-size: var(--brand-text-size-100);
font-weight: var(--brand-text-weight-normal);
line-height: var(--brand-text-lineHeight-100);
color: var(--brand-color-text-default);
}
.searchInput::placeholder {
color: var(--brand-color-text-muted);
}
.searchInput:focus {
outline: none;
}
.searchKbd {
flex-shrink: 0;
display: inline-flex;
align-items: center;
padding-inline: var(--base-size-6);
padding-block: var(--base-size-2);
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted);
border-radius: var(--brand-borderRadius-small);
font-family: var(--brand-fontStack-sansSerif);
font-size: var(--brand-text-size-100);
font-weight: var(--brand-text-weight-normal);
line-height: 1;
color: var(--brand-color-text-muted);
}
.subNavTitle {
display: flex;
align-items: center;
gap: var(--base-size-8);
text-decoration: none;
padding-block: var(--base-size-16);
padding-inline: var(--base-size-24);
font-family: var(--brand-fontStack-sansSerif);
font-size: var(--brand-text-size-200);
font-weight: var(--brand-text-weight-medium);
line-height: var(--brand-text-lineHeight-200);
color: var(--brand-color-text-default);
white-space: nowrap;
border-inline-end: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
.subNavList {
flex: 1 1 auto;
margin: 0;
padding-block: var(--base-size-16);
padding-inline: var(--base-size-16);
list-style: none;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--base-size-8);
}
.subNavLink {
display: inline-flex;
align-items: center;
margin-block: 0;
padding-block: var(--base-size-4);
padding-inline: var(--base-size-16);
font-family: var(--brand-fontStack-sansSerif);
font-size: var(--brand-text-size-100);
font-weight: var(--brand-text-weight-normal);
line-height: var(--brand-text-lineHeight-200);
color: var(--brand-color-text-muted);
text-decoration: none;
border-radius: var(--brand-borderRadius-full);
background-color: transparent;
transition:
color 0.2s ease,
background-color 0.2s ease;
}
.subNavLink:hover {
color: var(--brand-color-text-default);
background-color: var(--brand-color-canvas-subtle);
}
.subNavLink:focus-visible {
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
outline-offset: var(--base-size-4);
}
.subNavTitle:focus-visible {
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
outline-offset: calc(var(--base-size-2) * -1);
}
.subNavLinkActive,
.subNavLinkActive:hover {
color: var(--brand-color-text-default);
background-color: var(--base-color-scale-gray-1);
}
.page[data-mode="dark"] .subNavLinkActive,
.page[data-mode="dark"] .subNavLinkActive:hover {
background-color: var(--base-color-scale-gray-6);
}
/* "More" overflow dropdown, trigger styled as a tab */
.moreItem {
display: flex;
align-items: center;
}
.moreMenu {
position: relative;
display: flex;
align-items: center;
}
.moreTrigger {
gap: var(--base-size-4);
cursor: pointer;
list-style: none;
user-select: none;
}
.moreTrigger::-webkit-details-marker {
display: none;
}
.moreChevron {
transition: transform 0.2s ease;
}
.moreMenu[open] .moreChevron {
transform: rotate(180deg);
}
.moreOverlay {
position: absolute;
inset-block-start: calc(100% + var(--base-size-4));
inset-inline-start: 0;
z-index: 20;
min-inline-size: 12rem;
display: flex;
flex-direction: column;
padding: var(--base-size-8);
background-color: var(--brand-color-canvas-default);
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted);
border-radius: var(--brand-borderRadius-medium);
box-shadow: var(--brand-SubNav-shadow);
}
.moreLink {
padding-block: var(--base-size-8);
padding-inline: var(--base-size-12);
font-family: var(--brand-fontStack-sansSerif);
font-size: var(--brand-text-size-100);
font-weight: var(--brand-text-weight-normal);
line-height: var(--brand-text-lineHeight-200);
color: var(--brand-color-text-muted);
text-decoration: none;
white-space: nowrap;
border-radius: var(--brand-borderRadius-medium);
transition:
color 0.2s ease,
background-color 0.2s ease;
}
.moreLink:hover {
color: var(--brand-color-text-default);
background-color: var(--brand-color-canvas-subtle);
}
.moreLink:focus-visible {
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
outline-offset: calc(var(--base-size-2) * -1);
}
/* Active dropdown item: mark the current Resources page so it stays
indicated when the menu is reopened. */
.moreLink[aria-current="page"],
.moreLink[aria-current="page"]:hover {
color: var(--brand-color-text-default);
font-weight: var(--brand-text-weight-semibold);
background-color: var(--brand-color-canvas-subtle);
}
/*
* Because gridlines stretch outside the native Grid, we use a wrapper frame
* that draws the shared connecting lines around the card grid.
* Adapted from templates/marketing/overview/styles.module.css.
*/
.cardGridFrame {
width: 100%;
border-block-start: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
border-block-end: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
.cardGridContent {
width: 100%;
}
.cardGridColumn {
display: flex;
}
.cardGridItem {
position: relative;
width: 100%;
height: 100%;
/* 28px + the Primer Card's 32px inner padding = 60px, matching the catalog
card padding used across the rest of the site. */
padding: var(--base-size-28);
border-block-start: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
/* Subtle hover/focus fill covers the full cell rectangle, out to the gridlines,
like Primer Brand's Card surface. The rounded focus ring below sits inset
within it (see the ::before layer). */
.cardGridColumnArrowHover .cardGridItem {
transition: background-color var(--brand-animation-duration-extended)
var(--brand-animation-easing-default);
}
.cardGridColumnArrowHover .cardGridItem:is(:hover, :focus-within) {
background-color: var(--brand-color-canvas-subtle);
}
/* Ring layer: inset from the gridline boundary by the cell padding, so a gutter
sits between the ring and the gridlines, with medium (8px) corners, matching
Primer Brand's Card, whose focus ring hugs the Card box inset within its grid
cell. A transparent overlay (pointer-events: none) that only paints the ring
on focus; the subtle fill above stays on the full cell. */
.cardGridItem::before {
content: "";
position: absolute;
inset: var(--base-size-28);
border-radius: var(--brand-borderRadius-medium);
pointer-events: none;
}
.cardGridColumn:first-child .cardGridItem {
border-block-start: 0;
}
.resourceCard {
min-block-size: 100%;
}
/* Suppress the Card's built-in ring so it doesn't stack with the surface ring. */
.cardGridItem :global([class*="Card__outer"]):focus-within {
outline: none;
}
/* On card-link focus, draw the ring on the inset layer (offset 0, medium 8px
corners), so it hugs the card inset within the filled cell. Fires on mouse or
keyboard focus of the card link (action buttons keep their own rings). */
.cardGridItem:has(:global([class*="Card__link"]):focus)::before {
outline: var(--brand-borderWidth-thick) solid var(--brand-color-focus);
}
/*
* Gridline frame around the Hero: full-bleed horizontal rules top and bottom,
* with vertical rules inset to the Section content edges two horizontal and
* two vertical lines forming a connected box, matching the CTA frame below.
*/
.heroFrame {
width: 100%;
border-block-start: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
border-block-end: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
.heroFrameInner {
background-color: var(--brand-color-canvas-subtle);
border-inline: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
padding-block: var(--base-size-80);
}
/* Zero the Hero's native block padding so the 60px frame padding above sets the
* exact, symmetric top/bottom whitespace. */
.heroFrameInner :global([class*="Hero-module__Hero___"]) {
padding-block: 0;
}
/* Pin the Hero title to 48px (base-size-48 = 3rem). Cap the line box just past
"The community library" so the title wraps to two lines after that word
instead of splitting mid-word. */
.heroFrameInner :global([class*="Hero-module__Hero-heading___"]) {
font-size: var(--base-size-48);
max-width: 33.5rem;
margin-inline: auto;
animation: heroRiseUp 0.6s ease-out 0.09s both;
}
/* Hero icon, title, and description rise up from below and fade in on load, in
a subtle stagger matching the Agents page hero entrance. */
@keyframes heroRiseUp {
from {
opacity: 0;
transform: translateY(var(--base-size-24));
}
to {
opacity: 1;
transform: translateY(0);
}
}
.heroRiseIcon {
animation: heroRiseUp 0.6s ease-out both;
}
.heroFrameInner :global([class*="Hero-module__Hero-description___"]) {
animation: heroRiseUp 0.6s ease-out 0.18s both;
}
@media (prefers-reduced-motion: reduce) {
.heroRiseIcon,
.heroFrameInner :global([class*="Hero-module__Hero-heading___"]),
.heroFrameInner :global([class*="Hero-module__Hero-description___"]) {
animation: none;
}
}
/* Dark-mode override for the Hero secondary CTA ("Become a contributor"): use
* the requested solid #202020 fill instead of the default translucent white.
* Not a token: --brand-button-secondary-bgColor-rest resolves to a
* translucent overlay (rgba(255,255,255,0.12) in dark mode), which would
* visually regress from the prototype's opaque fill this hex is a
* deliberate, verbatim match to the approved design, not an oversight. */
:global([data-color-mode="dark"]) .heroFrameInner
:global([class*="Button--secondary"]) {
background-color: #202020;
}
/* Light-mode override for the Hero secondary CTA: requested solid #F0F0F0
* fill, same rationale as above. */
:global([data-color-mode="light"]) .heroFrameInner
:global([class*="Button--secondary"]) {
background-color: #f0f0f0;
}
/*
* Lay the heading out as a row: title on the left with the count label sitting
* just after it (not pushed to the far card edge).
*/
.cardHeadingRow {
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--base-size-12);
}
/* Keep the count label at full width; let the title take the remaining space. */
.cardHeadingRow :global([class*="Token"]) {
flex-shrink: 0;
}
/*
* Gridline frame around the closing CTA banner: full-bleed horizontal rules
* top and bottom, with vertical rules inset to the banner (Section content)
* edges mirrors the connected gridline aesthetic of the card grid above.
*/
.ctaFrame {
width: 100%;
border-block-start: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
border-block-end: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
.ctaFrameInner {
border-inline: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
/*
* CTABanner caps its own width at --brand-breakpoint-xlarge and centers itself,
* so on wide viewports the subtle background stops short of the frame's vertical
* gridlines, leaving a gap. Let the banner (and its subtle background) fill the
* full frame edge to edge; the inner content keeps its own max-width and stays
* centered.
*/
.ctaFrameInner :global([class*="CTABanner-module__CTABanner___"]) {
max-width: none;
}
/*
* CTABanner.Logo caps its SVG at 40px tall; render the learning icon at its
* intended 64px instead.
*/
.ctaFrameInner :global([class*="CTABanner-logo"]) > svg {
height: var(--base-size-64);
}
/*
* Constrain the CTA body copy so the first line wraps after "get the most
* out" instead of stretching the full banner width. Centered to match the
* banner's align="center" layout.
*/
.ctaFrameInner :global([class*="CTABanner-description"]) {
max-width: 40rem;
margin-inline: auto;
}
/* The branded page divider sits flush above the footer. */
.dividerFrame {
width: 100%;
}
/*
* Render the Card's arrow-only CTA as a circular chevron affordance, matching
* the reference design. The Card link still wraps the whole card, so the
* chevron stays accessible and clickable. Colour tracks the Primer Brand Card
* default: the arrow uses the link accent, not the green accent-primary.
*/
.resourceCard :global([class*="Card__actionIcon--arrowOnly"]) {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: var(--base-size-40);
block-size: var(--base-size-40);
overflow: hidden;
color: var(--brand-Link-color-accent);
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-default);
border-radius: var(--brand-borderRadius-full);
}
/* Tablet nav: the full desktop nav row (tabs + search + heart + Contribute)
doesn't fit from 48rem up to this breakpoint it wraps to two rows, and
opening search shoves the wordmark off-screen (issue #19). Collapse to the
hamburger across this band too; the wordmark grows to fill so the menu
button sits at the far right. */
@media screen and (min-width: 48rem) and (max-width: 74.99rem) {
.subNavList {
display: none;
}
.topBarActions {
display: none;
}
.searchButton,
.searchField {
display: none;
}
.subNavTitle {
flex: 1 1 auto;
border-inline-end: 0;
}
}
@media screen and (max-width: 47.99rem) {
.subNavList {
display: none;
}
/* Inset the hero content 20px from the frame's vertical gridlines so the
icon, title, description, and buttons keep breathing room off the lines
(and the buttons pick up the same side margin as the CTA banner below). */
.heroFrameInner {
padding-inline: var(--base-size-20);
}
/* Shrink the community icon to sit proportionally with the smaller mobile
hero type (down from the 56px desktop size). */
.heroRiseIcon svg {
width: var(--base-size-44);
height: var(--base-size-44);
}
/* Mobile hero heading: step the title down to the 28px brand type scale
(Heading, size-700) so it sits smaller and fits narrow viewports. */
.heroFrameInner :global([class*="Hero-module__Hero-heading___"]) {
font-size: var(--brand-text-size-700);
line-height: 1.2;
max-width: none;
}
/* Mobile hero description: step the body copy down to the 14px brand scale
(size-100) to match the smaller title. */
.heroFrameInner :global([class*="Hero-module__Hero-description___"]) {
font-size: var(--brand-text-size-100);
}
/* Mobile hero actions: stack the two CTAs and stretch them to equal
(full) width so they read as a matched pair mirroring the CTA banner's
stacked, equal-width buttons below. */
.heroFrameInner :global([class*="Hero-module__Hero-actions___"]) {
flex-direction: column;
align-items: stretch;
width: 100%;
}
.heroFrameInner
:global([class*="Hero-module__Hero-actions___"])
:global([class*="Button-module__Button___"]) {
width: 100%;
}
/* Restore the vertical gridlines on the single-column card grid: the desktop
lines come from the cards' inline borders (added at >=768px), so on mobile
the stack has none. Add them back, inset to the 20px content gutter so the
framed box's side rules stay continuous with the hero/CTA above and below. */
.cardGridItem {
border-inline: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
.subNavTitle {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--brand-text-size-100);
/* Align the wordmark with the hero's left gridline: the full-bleed header's
inner border sits at x=0, so inset by the page gutter less that border. */
padding-inline-start: calc(
var(--page-gutter) - var(--brand-borderWidth-thin)
);
padding-inline-end: var(--base-size-12);
border-inline-end: 0;
}
/* Keep the GitHub mark on the wordmark at every viewport so the page still
reads as official; it stays fixed while the title text ellipsizes. */
.subNavTitle svg {
flex-shrink: 0;
}
.brandMark {
padding-inline-start: var(--base-size-16);
padding-inline-end: var(--base-size-16);
}
/* The heart (contributors) and Contribute action move into the hamburger
dropdown on mobile (see TopNav), so hide the header action cluster here.
The search field is already hidden below. */
.topBarActions {
display: none;
}
.searchButton,
.searchField {
display: none;
}
}
/* Two-column band: from tablet (768px) up to just below xlarge (1280px), the
grid renders two columns (span small:6 holds through the large breakpoint).
Draw the vertical rule on both edges of every card and clear the top border
on the first row (items 1 and 2). */
@media screen and (min-width: 48rem) and (max-width: 79.99rem) {
.cardGridItem {
padding: var(--base-size-28);
border-inline: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
/* two columns: reset the top border on the first two items */
.cardGridColumn:nth-child(2) .cardGridItem {
border-block-start: 0;
}
}
/* Three-column band: the grid only becomes three columns at xlarge (1280px),
where span drops to 4. */
@media screen and (min-width: 80rem) {
.cardGridItem {
padding: var(--base-size-28);
border-block-start: 0;
border-inline-end: 0;
border-inline-start: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
/* rows 2+ (3 columns, so row 2 starts at item 4) draw the inter-row line,
which also serves as the bottom line of the cards in the row above */
.cardGridColumn:nth-child(n + 4) .cardGridItem {
border-block-start: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
/* close the right edge of each row (every 3rd card and the last one) */
.cardGridColumn:nth-child(3n) .cardGridItem,
.cardGridColumn:last-child .cardGridItem {
border-inline-end: var(--brand-borderWidth-thin) solid
var(--brand-color-border-muted);
}
}
/*
* Responsive gutters (below 1920px). Neutralise the native Section/Grid
* 1280px-centered layout and inset each band's framed content by the tiered
* --page-gutter, so the vertical gridlines track the content edge at every
* width. At >=1920px this block drops out and the original layout returns.
*/
@media screen and (max-width: 119.99rem) {
.heroFrame :global([class*="Section__container"]),
.ctaFrame :global([class*="Section__container"]),
.dividerFrame :global([class*="Section__container"]) {
max-width: none;
}
/* Header bar: inset its side gridlines to the same gutter so the vertical
rules stay continuous with the hero/content frame below. Only from 768px
up on phones the collapsed bar (title + heart + Contribute) needs the
full width, so it stays full-bleed there. It is a centered flex child, so
grow-to-fill minus symmetric gutter margins (never width:100% + margin,
which would overflow). */
.heroFrameInner,
.ctaFrameInner {
margin-inline: var(--page-gutter);
}
.heroFrameInner {
padding-block: var(--page-pad-block);
}
.cardGridContent {
padding-inline: var(--page-gutter);
}
.cardGridContent :global([class*="Grid-module__Grid_"]) {
max-width: none;
}
.dividerFrame :global([class*="Section__container"]) > * {
padding-inline: var(--page-gutter);
}
}
/* Header bar gridline alignment: 7681919px only (phones keep the full-bleed
collapsed bar). Grow-to-fill minus symmetric gutter margins. */
@media screen and (min-width: 48rem) and (max-width: 119.99rem) {
.topBarInner {
width: auto;
max-width: none;
flex: 1 1 auto;
margin-inline: var(--page-gutter);
}
}
@@ -0,0 +1,140 @@
import React from "react";
import { getScrollBehavior } from "./scrollBehavior";
const MIN_HERO_READING = 464;
const TWO_COLUMN_QUERY = "(min-width: 75rem)";
type AgentDetailClasses = {
hero: string;
heroInner: string;
heroContent: string;
};
export function useAgentDetailHeroPin(
contentScrollRef: React.RefObject<HTMLDivElement | null>,
classes: AgentDetailClasses,
) {
React.useEffect(() => {
const scrollHost = contentScrollRef.current;
if (!scrollHost) return;
const page = scrollHost.parentElement;
const measure = () => {
const scrollbar = scrollHost.offsetWidth - scrollHost.clientWidth;
page?.style.setProperty("--dotnet-scrollbar", `${scrollbar}px`);
const hero = page?.getElementsByClassName(classes.hero)[0];
const frame = page?.getElementsByClassName(classes.heroInner)[0];
const content = page?.getElementsByClassName(classes.heroContent)[0];
if (!(hero instanceof HTMLElement) || !(frame instanceof HTMLElement)) {
return;
}
const heroRect = hero.getBoundingClientRect();
const frameRect = frame.getBoundingClientRect();
const frameStart = Math.round((frameRect.left - heroRect.left) * 100) / 100;
const frameWidth = Math.round(frameRect.width * 100) / 100;
page?.style.setProperty("--dotnet-frame-start", `${frameStart}px`);
page?.style.setProperty("--dotnet-frame-width", `${frameWidth}px`);
if (!(content instanceof HTMLElement)) return;
const contentRect = content.getBoundingClientRect();
const crumbBand = Math.round((contentRect.top - heroRect.top) * 100) / 100;
const contentBand = Math.round((heroRect.bottom - contentRect.top) * 100) / 100;
const twoColumn = window.matchMedia(TWO_COLUMN_QUERY).matches;
const shouldPin = twoColumn && scrollHost.clientHeight - contentBand >= MIN_HERO_READING;
const pinnedHeight = shouldPin ? contentBand : 0;
if (page) page.dataset.heroPin = shouldPin ? "true" : "false";
page?.style.setProperty("--dotnet-hero-crumb", `${shouldPin ? crumbBand : 0}px`);
page?.style.setProperty("--dotnet-hero-pinned", `${pinnedHeight}px`);
};
measure();
window.addEventListener("resize", measure);
const hero = page?.getElementsByClassName(classes.hero)[0];
const observer =
hero instanceof HTMLElement && typeof ResizeObserver !== "undefined"
? new ResizeObserver(measure)
: undefined;
if (hero instanceof HTMLElement) observer?.observe(hero);
return () => {
window.removeEventListener("resize", measure);
observer?.disconnect();
};
}, [classes, contentScrollRef]);
}
export function useAgentDetailProgress(
contentScrollRef: React.RefObject<HTMLDivElement | null>,
lastSectionId: string,
setShowBackToTop: React.Dispatch<React.SetStateAction<boolean>>,
setHeroBurst: React.Dispatch<React.SetStateAction<boolean>>,
) {
const burstRef = React.useRef(false);
React.useEffect(() => {
const scrollHost = contentScrollRef.current;
if (!scrollHost) return;
const page = scrollHost.parentElement;
let lastHeading: HTMLElement | null = null;
const update = () => {
if (!lastHeading) {
const section = document.getElementById(lastSectionId);
lastHeading = (section?.querySelector("h2") as HTMLElement | null) ?? section;
}
const internalScroll = window.matchMedia(TWO_COLUMN_QUERY).matches;
const rootRect = scrollHost.getBoundingClientRect();
const regionTop = rootRect.top + window.scrollY;
const scrollTop = internalScroll
? scrollHost.scrollTop
: Math.max(0, window.scrollY - regionTop);
const viewportHeight = internalScroll ? rootRect.height : window.innerHeight;
let finish = internalScroll
? scrollHost.scrollHeight - scrollHost.clientHeight
: Math.max(1, scrollHost.scrollHeight - viewportHeight);
if (lastHeading) {
const headingOffset = internalScroll
? lastHeading.getBoundingClientRect().top - rootRect.top + scrollHost.scrollTop
: lastHeading.getBoundingClientRect().top + window.scrollY - regionTop;
finish = Math.max(1, headingOffset - viewportHeight * 0.6);
}
const progress = finish > 0 ? Math.min(1, scrollTop / finish) : 0;
const eased = Math.max(progress > 0 ? Math.pow(progress, 0.5) : 0, 0.055);
page?.style.setProperty("--dotnet-progress", String(eased));
setShowBackToTop(scrollTop > 200);
const reached = progress >= 1;
if (reached !== burstRef.current) {
burstRef.current = reached;
setHeroBurst(reached);
}
};
update();
scrollHost.addEventListener("scroll", update, { passive: true });
window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
return () => {
scrollHost.removeEventListener("scroll", update);
window.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, [contentScrollRef, lastSectionId, setHeroBurst, setShowBackToTop]);
return React.useCallback(() => {
const scrollHost = contentScrollRef.current;
if (!scrollHost) return;
if (window.matchMedia(TWO_COLUMN_QUERY).matches) {
scrollHost.scrollTo({ top: 0, behavior: getScrollBehavior() });
} else {
window.scrollTo({ top: 0, behavior: getScrollBehavior() });
}
}, [contentScrollRef]);
}
@@ -1,25 +0,0 @@
---
export type Breadcrumb = {
paths: { name: string; href?: string }[];
title: string;
};
const { paths, title } = Astro.props as Breadcrumb;
---
<nav class="detail-breadcrumbs" aria-label="Breadcrumb">
{
paths.map((path, index) => (
<>
{path.href ? (
<a href={path.href}>{path.name}</a>
) : (
<span>{path.name}</span>
)}
{index < paths.length - 1 && <span aria-hidden="true">/</span>}
</>
))
}
<span aria-hidden="true">/</span>
<span aria-current="page">{title}</span>
</nav>
@@ -1,145 +0,0 @@
---
import { getFileMeta } from "../../lib/file-types";
export interface BrowserFile {
name: string;
path: string;
size?: number;
}
export interface FileBrowserProps {
files: BrowserFile[];
/** Full repo path of the primary file, e.g. skills/foo/SKILL.md or hooks/foo/README.md */
primaryPath: string;
/** Display name of the primary file, e.g. SKILL.md or README.md */
primaryName: string;
/** Pre-rendered HTML for the primary file (shown by default) */
primaryHtml: string;
/** GitHub blob base, e.g. https://github.com/github/awesome-copilot/blob/main */
githubBase: string;
}
const { files, primaryPath, primaryName, primaryHtml, githubBase } =
Astro.props as FileBrowserProps;
// Primary file first, then everything else alphabetically by name.
const sortedFiles = [...files].sort((a, b) => {
if (a.path === primaryPath) return -1;
if (b.path === primaryPath) return 1;
return a.name.localeCompare(b.name);
});
const otherFiles = sortedFiles.filter((f) => f.path !== primaryPath);
const rootFiles = otherFiles.filter((f) => !f.name.includes("/"));
// Group nested files by their top-level folder for optgroup rendering.
const folderGroups = new Map<string, BrowserFile[]>();
for (const file of otherFiles) {
const slash = file.name.indexOf("/");
if (slash === -1) continue;
const folder = file.name.slice(0, slash);
const bucket = folderGroups.get(folder) ?? [];
bucket.push(file);
folderGroups.set(folder, bucket);
}
const sortedFolders = [...folderGroups.keys()].sort((a, b) =>
a.localeCompare(b)
);
const hasMultipleFiles = sortedFiles.length > 1;
function optionAttrs(file: BrowserFile) {
const meta = getFileMeta(file.name);
return {
value: file.path,
"data-file-name": file.name,
"data-file-lang": meta.lang,
"data-file-kind": meta.kind,
};
}
---
<section
class="skill-file-browser"
aria-label="Bundle files"
data-file-browser
data-github-base={githubBase}
data-primary-file={primaryPath}
>
<div class="skill-file-view">
<header class="skill-file-view-header">
{
hasMultipleFiles ? (
<div class="skill-file-picker">
<label class="skill-file-picker-label" for="file-browser-select">
File
</label>
<select
id="file-browser-select"
class="skill-file-select"
data-file-select
aria-label="Select a file to view"
>
<option {...optionAttrs({ name: primaryName, path: primaryPath })} selected>
{primaryName}
</option>
{rootFiles.map((file) => (
<option {...optionAttrs(file)}>{file.name}</option>
))}
{sortedFolders.map((folder) => (
<optgroup label={`${folder}/`}>
{folderGroups.get(folder)!.map((file) => (
<option {...optionAttrs(file)}>
{file.name.slice(folder.length + 1)}
</option>
))}
</optgroup>
))}
</select>
</div>
) : (
<code class="skill-file-current" data-current-file-name>
{primaryName}
</code>
)
}
<div class="skill-file-view-actions">
<button
type="button"
class="btn btn-secondary btn-small"
data-action="copy-file"
title="Copy file contents"
>
<svg
viewBox="0 0 16 16"
width="14"
height="14"
fill="currentColor"
aria-hidden="true"
><path
d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"
></path><path
d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"
></path></svg
>
Copy
</button>
<a
class="btn btn-secondary btn-small"
data-file-github
href={`${githubBase}/${primaryPath}`}
target="_blank"
rel="noopener"
title="View file on GitHub">GitHub</a
>
</div>
</header>
<div
class="skill-file-content article-content"
data-file-content
set:html={primaryHtml}
/>
<div class="skill-file-status" data-file-status hidden></div>
</div>
</section>
-13
View File
@@ -1,13 +0,0 @@
---
export type HeaderItem = {
title: string;
description?: string;
};
const { item } = Astro.props as { item: HeaderItem };
---
<header class="detail-header">
<h1>{item.title}</h1>
{item.description && <p class="detail-description">{item.description}</p>}
</header>
@@ -1,94 +0,0 @@
---
export interface PluginIncludedItem {
kind: string;
path: string;
title?: string;
detailUrl?: string | null;
}
export interface IncludedItemsProps {
items: PluginIncludedItem[];
pluginPath: string;
githubBase: string;
}
const { items, pluginPath, githubBase } = Astro.props as IncludedItemsProps;
const KIND_LABELS: Record<string, string> = {
agent: "Agents",
skill: "Skills",
instruction: "Instructions",
hook: "Hooks",
prompt: "Commands",
extension: "Extensions",
mcp: "MCP Servers",
};
const KIND_ORDER = ["agent", "skill", "instruction", "hook", "prompt", "extension", "mcp"];
function githubHref(itemPath: string): string {
const normalized = itemPath.replace(/^\.\/+/, "").replace(/\/+$/, "");
// Repo-root-relative paths (e.g. extensions/foo, plugins/foo) are used as-is;
// plugin-relative paths are resolved against the plugin folder.
const isRepoRelative = /^[a-z0-9._-]+\//i.test(normalized) &&
(normalized.startsWith("extensions/") ||
normalized.startsWith("plugins/") ||
normalized.startsWith("agents/") ||
normalized.startsWith("skills/") ||
normalized.startsWith("instructions/") ||
normalized.startsWith("hooks/"));
const repoPath = isRepoRelative ? normalized : `${pluginPath}/${normalized}`;
// GitHub uses /blob/ for files and /tree/ for directories; /tree/<ref>/<file>
// returns 404. Detect files by a trailing extension on the last path segment.
const lastSegment = repoPath.split("/").pop() ?? "";
const isFile = /\.[a-z0-9]+$/i.test(lastSegment);
const base = isFile ? githubBase.replace("/tree/", "/blob/") : githubBase;
return `${base}/${repoPath}`;
}
const grouped = KIND_ORDER.map((kind) => ({
kind,
label: KIND_LABELS[kind] ?? kind,
entries: (items ?? []).filter((item) => item.kind === kind),
})).filter((group) => group.entries.length > 0);
---
{
grouped.length > 0 && (
<section class="detail-section included-items" aria-labelledby="included-heading">
<h2 id="included-heading">Included items</h2>
<p class="included-items-hint">
This plugin bundles the following resources. Installing the plugin brings
them all in together.
</p>
{grouped.map((group) => (
<div class="included-group">
<h3 class="included-group-title">{group.label}</h3>
<ul class="included-list">
{group.entries.map((item) => {
const href = item.detailUrl ?? githubHref(item.path);
const isExternal = !item.detailUrl;
return (
<li class="included-entry">
<a
class="included-link"
href={href}
{...(isExternal
? { target: "_blank", rel: "noopener" }
: {})}
>
<span class="included-kind-badge">{item.kind}</span>
<span class="included-title">{item.title || item.path}</span>
{isExternal && (
<span class="included-external-hint">GitHub ↗</span>
)}
</a>
</li>
);
})}
</ul>
</div>
))}
</section>
)
}
@@ -1,61 +0,0 @@
---
const { vscodeUrl, insidersUrl, rawMarkdown } = Astro.props as { vscodeUrl: string; insidersUrl: string; rawMarkdown?: string };
---
<div class="install-dropdown" data-install-menu>
<a
class="btn btn-primary install-btn-main"
href={vscodeUrl}
target="_blank"
rel="noopener"
>
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"><path d="M12 5v14M5 12h14"></path></svg
>
Install
</a>
<button
type="button"
class="btn btn-primary install-btn-toggle"
aria-label="More install options"
aria-haspopup="true"
aria-expanded="false"
data-install-toggle
>
<svg
viewBox="0 0 16 16"
width="12"
height="12"
fill="currentColor"
aria-hidden="true"
><path
d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z"
></path></svg
>
</button>
<div class="install-dropdown-menu" role="menu">
<a href={vscodeUrl} target="_blank" rel="noopener" role="menuitem"
>Install in VS Code</a
>
<a href={insidersUrl} target="_blank" rel="noopener" role="menuitem"
>Install in VS Code Insiders</a
>
<div class="dropdown-divider" role="separator" aria-hidden="true"></div>
<a href="#" role="menuitem" data-action="download">Download file</a>
{
rawMarkdown && (
<a href="#" role="menuitem" data-action="copy-markdown">
Copy markdown
</a>
)
}
</div>
</div>
-17
View File
@@ -1,17 +0,0 @@
---
const githubUrl = Astro.props.githubUrl as string;
const markdownHtml = Astro.props.markdownHtml as string;
---
<div class="detail-main">
{markdownHtml ? (
<section class="detail-section" aria-labelledby="doc-heading">
<h2 id="doc-heading">Documentation</h2>
<div class="article-content" set:html={markdownHtml} />
</section>
) : (
<section class="detail-section">
<p class="detail-empty">Documentation preview is unavailable. View the file <a href={githubUrl} target="_blank" rel="noopener">on GitHub</a>.</p>
</section>
)}
</div>
@@ -1,173 +0,0 @@
---
/**
* Install actions for plugins and canvas extensions.
*
* Renders a split-button dropdown that deep-links into VS Code, VS Code
* Insiders, and the GitHub Copilot app, plus an optional CLI command block.
* The dropdown open/close is wired by resource-detail.ts via `data-install-menu`.
*/
export interface PluginInstallProps {
isExternal?: boolean;
/** awesome-copilot plugin id. */
pluginId?: string | null;
/** Upstream marketplace source (owner/repo or git URL) for external installs. */
externalSource?: string | null;
/** CLI install command (internal only). */
installCommand?: string | null;
/** Heading shown above the buttons. */
label?: string;
/** Note shown for external installs. */
note?: string;
}
const {
isExternal = false,
pluginId = null,
externalSource = null,
installCommand = null,
label,
note,
} = Astro.props as PluginInstallProps;
const MARKETPLACE = "awesome-copilot";
let vscodeUrl: string | null = null;
let insidersUrl: string | null = null;
let ghappUrl: string | null = null;
if (isExternal) {
if (pluginId) {
ghappUrl = `ghapp://plugins/install?source=${encodeURIComponent(
`${pluginId}@${MARKETPLACE}`
)}`;
}
if (externalSource) {
vscodeUrl = `vscode://chat-plugin/install?source=${encodeURIComponent(
externalSource
)}`;
insidersUrl = `vscode-insiders://chat-plugin/install?source=${encodeURIComponent(
externalSource
)}`;
}
} else if (pluginId) {
const plugin = encodeURIComponent(pluginId);
vscodeUrl = `vscode://chat-plugin/install?source=github/awesome-copilot&plugin=${plugin}`;
insidersUrl = `vscode-insiders://chat-plugin/install?source=github/awesome-copilot&plugin=${plugin}`;
ghappUrl = `ghapp://plugins/install?source=${encodeURIComponent(
`${pluginId}@${MARKETPLACE}`
)}`;
}
const ghappLabel = isExternal
? "Open in GitHub Copilot app"
: "Install in GitHub Copilot app";
const primaryUrl = ghappUrl ?? vscodeUrl ?? insidersUrl;
const primaryLabel =
primaryUrl === ghappUrl
? ghappLabel
: primaryUrl === vscodeUrl
? "Install in VS Code"
: "Install in VS Code Insiders";
const primaryIsGhapp = primaryUrl === ghappUrl;
const heading = label ?? (isExternal ? "Install this plugin" : "Install");
---
<div class="skill-install">
<p class="skill-install-label">{heading}</p>
{note && <p class="skill-install-note">{note}</p>}
{
primaryUrl && (
<div class="install-dropdown" data-install-menu>
<a
class="btn btn-primary install-btn-main"
href={primaryUrl}
{...(primaryIsGhapp ? {} : { target: "_blank", rel: "noopener" })}
>
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 5v14M5 12h14" />
</svg>
{primaryLabel}
</a>
<button
type="button"
class="btn btn-primary install-btn-toggle"
aria-label="More install options"
aria-haspopup="true"
aria-expanded="false"
data-install-toggle
>
<svg
viewBox="0 0 16 16"
width="12"
height="12"
fill="currentColor"
aria-hidden="true"
>
<path d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z" />
</svg>
</button>
<div class="install-dropdown-menu" role="menu">
{ghappUrl && (
<a href={ghappUrl} role="menuitem">
{ghappLabel}
</a>
)}
{ghappUrl && (vscodeUrl || insidersUrl) && (
<div class="dropdown-divider" role="separator" aria-hidden="true" />
)}
{vscodeUrl && (
<a href={vscodeUrl} target="_blank" rel="noopener" role="menuitem">
Install in VS Code
</a>
)}
{insidersUrl && (
<a href={insidersUrl} target="_blank" rel="noopener" role="menuitem">
Install in VS Code Insiders
</a>
)}
</div>
</div>
)
}
{
installCommand && (
<>
<p class="skill-install-label">Or install with the CLI</p>
<div class="skill-install-command" data-install-command={installCommand}>
<code tabindex="0">{installCommand}</code>
<button
type="button"
class="skill-install-copy"
data-action="copy-install"
title="Copy install command"
aria-label="Copy install command"
>
<svg
viewBox="0 0 16 16"
width="15"
height="15"
fill="currentColor"
aria-hidden="true"
>
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z" />
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" />
</svg>
</button>
</div>
</>
)
}
</div>
@@ -1,9 +0,0 @@
---
const { markdown } = Astro.props as { markdown?: string };
---
{
markdown && (
<textarea data-raw-markdown hidden aria-hidden="true" readonly set:text={markdown}></textarea>
)
}
-104
View File
@@ -1,104 +0,0 @@
---
import type { SidebarChipsProps } from "./SidebarChips.astro";
import SidebarChips from "./SidebarChips.astro";
export interface SidebarProps {
item: any;
lastUpdated?: string;
frontmatterText?: string;
githubUrl: string;
chips?: SidebarChipsProps[];
}
const {
item,
lastUpdated,
frontmatterText,
githubUrl,
chips,
} = Astro.props as SidebarProps;
---
<aside class="detail-sidebar" aria-label="Details">
<section class="detail-card detail-actions-card" data-actions>
<slot name="install" />
<div class="detail-actions-secondary">
<button class="btn btn-secondary" type="button" data-action="share">
<svg
viewBox="0 0 24 24"
width="15"
height="15"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
><circle cx="18" cy="5" r="3"></circle><circle cx="6" cy="12" r="3"
></circle><circle cx="18" cy="19" r="3"></circle><path
d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98"></path></svg
>
Share
</button>
<a
class="btn btn-secondary"
href={githubUrl}
target="_blank"
rel="noopener"
>
<svg
viewBox="0 0 24 24"
width="15"
height="15"
fill="currentColor"
aria-hidden="true"
><path
d="M12 2C6.48 2 2 6.48 2 12c0 4.42 2.87 8.17 6.84 9.5.5.09.68-.22.68-.48 0-.24-.01-.87-.01-1.71-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.89 1.53 2.34 1.09 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02.8-.22 1.65-.33 2.5-.33.85 0 1.7.11 2.5.33 1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.69-4.57 4.94.36.31.68.92.68 1.85 0 1.34-.01 2.42-.01 2.75 0 .27.18.58.69.48A10.01 10.01 0 0022 12c0-5.52-4.48-10-10-10z"
></path></svg
>
View on GitHub
</a>
</div>
</section>
<section class="detail-card">
<h2>Details</h2>
<dl class="detail-meta">
{
chips?.map((chip) => (
<SidebarChips
title={chip.title}
items={chip.items}
filterBase={chip.filterBase}
filterParam={chip.filterParam}
/>
))
}
<div>
<dt>Source</dt>
<dd><code class="detail-path">{item.path}</code></dd>
</div>
{
lastUpdated && (
<div>
<dt>Last updated</dt>
<dd>{lastUpdated}</dd>
</div>
)
}
</dl>
</section>
{
frontmatterText && (
<section class="detail-card">
<details class="detail-frontmatter">
<summary>Frontmatter</summary>
<pre>
<code>{frontmatterText}</code>
</pre>
</details>
</section>
)
}
</aside>
@@ -1,51 +0,0 @@
---
export type SidebarChipsProps = {
title: string;
items: string[];
/**
* When both `filterBase` and `filterParam` are provided, each chip is
* rendered as a link to the resource's list page pre-filtered by that value
* (e.g. `/hooks/?tag=testing`), turning read-only metadata into navigation.
*/
filterBase?: string;
filterParam?: string;
};
const { title, items, filterBase, filterParam } =
Astro.props as SidebarChipsProps;
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const linkable = Boolean(filterBase && filterParam);
const chipHref = (value: string) =>
`${filterBase}?${filterParam}=${encodeURIComponent(value)}`;
---
<div>
<dt>{title}</dt>
<dd>
{
items.length ? (
<div class="detail-chips">
{items.map((item) =>
linkable ? (
<a
class={`resource-tag resource-tag-link tag-${slug}`}
href={chipHref(item)}
aria-label={`Filter by ${title.toLowerCase()}: ${item}`}
>
{item}
</a>
) : (
<span class={`resource-tag tag-${slug}`}>{item}</span>
)
)}
</div>
) : (
<span class="detail-none">Not specified</span>
)
}
</dd>
</div>
+27 -11
View File
@@ -1,17 +1,33 @@
import { defineCollection, z } from "astro:content";
import { docsLoader } from "@astrojs/starlight/loaders";
import { docsSchema } from "@astrojs/starlight/schema";
import { glob } from "astro/loaders";
/**
* Learning Hub content collection. Previously loaded via Starlight's `docsLoader`;
* now a plain glob loader over the same directory so the markdown source of
* truth and all existing URLs are unchanged.
*/
const docs = defineCollection({
loader: docsLoader(),
schema: docsSchema({
extend: z.object({
authors: z.array(z.string()).optional(),
estimatedReadingTime: z.string().optional(),
tags: z.array(z.string()).optional(),
relatedArticles: z.array(z.string()).optional(),
prerequisites: z.array(z.string()).optional(),
}),
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/docs" }),
schema: z.object({
title: z.string(),
description: z.string().optional(),
sidebar: z.unknown().optional(),
template: z.string().optional(),
editUrl: z.union([z.string(), z.boolean()]).optional(),
lastUpdated: z.union([z.string(), z.date(), z.boolean()]).optional(),
prev: z.unknown().optional(),
next: z.unknown().optional(),
pagefind: z.boolean().optional(),
head: z.array(z.unknown()).optional(),
tableOfContents: z.unknown().optional(),
banner: z.unknown().optional(),
hero: z.unknown().optional(),
draft: z.boolean().optional(),
authors: z.array(z.string()).optional(),
estimatedReadingTime: z.string().optional(),
tags: z.array(z.string()).optional(),
relatedArticles: z.array(z.string()).optional(),
prerequisites: z.array(z.string()).optional(),
}),
});
@@ -4,6 +4,9 @@ description: "A source-faithful mirror of the companion GitHub Copilot CLI for B
authors:
- GitHub Copilot Learning Hub Team
lastUpdated: 2026-03-20
tags:
- workshop
- cli
---
![GitHub Copilot CLI for Beginners](/images/learning-hub/copilot-cli-for-beginners/overview/copilot-banner.png)
@@ -1,8 +1,11 @@
---
title: "Hands-on with GitHub Copilot's agents"
description: "A hands-on workshop with four harnesses (VS Code, Copilot CLI, Copilot app, and cloud agent) built around a shared Tailspin Toys backlog."
authors:
- GitHub Copilot Learning Hub Team
lastUpdated: 2026-08-03
tags:
- workshop
---
The recent additions to the capabilities of GitHub Copilot provide powerful tools to the developer across the entire software development lifecycle (SDLC). This includes working with issues and pull requests on GitHub, interacting with external services, and of course code creation. This lab explores the functionality, providing real-world use cases and tips on how to get the most out of the tools.

Some files were not shown because too many files have changed in this diff Show More