Communitygithub.com

create-canvas-kit

Un ensemble complet pour créer des extensions canvas d'applications Copilot, installable avec la CLI skills (npx skills add).

Qu'est-ce que create-canvas-kit ?

Ce skill, `create-canvas-kit`, fournit un kit de développement pour construire des extensions canvas interactives pour l'assistant de codage GitHub Copilot. Son objectif principal est de résoudre les problèmes courants liés aux implémentations de canvas artisanales, tels que les boucles de repaint `innerHTML` qui détruisent les frappes au clavier, le confinement de l'état à un seul panneau, le style ad-hoc ignorant le thème de l'hôte et l'utilisation d'icônes incohérentes. En se basant sur Preact et htm, il permet un rendu DOM différencié, garantissant que le focus, la position du curseur et la saisie en cours ne sont pas perdus lors des mises à jour d'état en direct. Le kit met également l'accent sur un état partagé et durable entre les panneaux, des gestionnaires d'actions unifiés pour l'agent et l'interface utilisateur, et une architecture testable avec Node.js brut. Il comprend des composants d'interface utilisateur pré-construits utilisant les tokens Primer et les icônes GitHub officielles, ainsi que des utilitaires pour la gestion de l'état, le formatage localisé et des modèles d'extraction de données externes avec rafraîchissement automatique et respect des préférences de mouvement réduit.

Compatible avec✓Claude Code✓Codex CLI~Cursor
npx skills add jongio/skills

Demander à votre IA préférée

Ouvre une nouvelle conversation avec cette compétence d'agent déjà préchargée.

Documentation

Créer une application Canvas

Une façon clé en main de construire des extensions canvas d'applications Copilot. Elle existe parce que les canvas artisanaux se heurtent toujours aux mêmes obstacles : une boucle de repeint innerHTML qui engloutit les frappes, un état qui vit uniquement dans un seul panneau, un style ad-hoc qui ignore le thème de l'hôte, et des icônes incohérentes. Le kit résout ces quatre problèmes.

Ce qu'est un canvas (et quand en construire un)

Un canvas est une surface interactive que l'agent ouvre dans un panneau latéral via open_canvas. L'agent et l'utilisateur agissent tous deux sur le même état via les mêmes gestionnaires d'actions. Utilisez un canvas lorsque le texte du chat ou un diff ne suffit pas : tableaux de bord en direct, éditeurs, feuilles de calcul, trackers, vues kanban/board, aperçus de documents, workflows spécifiques à un outil.

Si l'utilisateur a seulement besoin d'un outil d'agent non visuel, construisez plutôt un outil d'extension normal — pas un canvas.

Niveau de rendu — décidez d'abord

NiveauQuand l'utiliserComment
Chaîne HTML statiqueContenu en lecture seule ou quasi statique ; pas de champs de saisie à protégerLe scaffold vanille de l'environnement d'exécution (extensions_manage scaffold kind:canvas).
Preact + htm + ce kitTout ce qui est interactif : champs de saisie, mises à jour en direct, listes, formulaires, état partagéCe kit. Choix par défaut pour les vrais canvas.

La raison la plus importante d'utiliser le kit : Preact différencie le DOM, donc une poussée d'état en direct de l'agent ne détruit pas le focus, la position du curseur ou le texte à moitié saisi dans un champ. Le modèle innerHTML = ... que la plupart des premiers canvas utilisent repeint tout l'arbre et perd les frappes à chaque poussée. Ne faites pas cela.

Le modèle (lisez ceci avant de coder)

extension.mjs   ── le SEUL fichier qui importe le SDK Copilot (adaptateur fin)
canvas.mjs      ── votre canvas : id, schéma, chargement/sauvegarde d'état, gestionnaires d'actions (sans SDK)
canvas-kit/     ── le kit (copié textuellement ; ne pas modifier)
web/index.html  ── coque : charge /kit/theme.css et ./app.mjs
web/app.mjs     ── votre vue Preact
  • L'état est partagé et durable. Il est indexé par un id de domaine résolu à partir de l'entrée ouverte (resolveDomainId), pas par instanceId — ouvrez le même domaine dans deux panneaux et ils affichent les mêmes données. La persistance passe par userStore(extName, file) → $COPILOT_HOME/extensions/<name>/artifacts/<domain>.json.
  • L'agent et l'interface utilisateur partagent les gestionnaires. Une action invoquée par l'agent et la même action invoquée depuis un bouton exécutent le gestionnaire identique et produisent la mutation d'état identique. Écrivez la logique une fois, dans canvas.mjs.
  • Les mises à jour en direct sont automatiques. Le kit sert GET /state, GET /events (SSE), et POST /action. mountCanvas les câble ; chaque changement d'état se diffuse vers tous les panneaux ouverts.
  • server.mjs est sans SDK donc tout l'environnement d'exécution est testable avec Node HTTP brut (voir test/http.test.mjs). Gardez les appels SDK uniquement dans extension.mjs.

Deux types d'état dans la vue — gardez-les séparés

  • L'état de domaine partagé arrive via SSE et est réaffiché à chaque poussée. Traitez-le comme en lecture seule dans la vue ; modifiez-le uniquement en appelant invoke(action, input).
  • L'état d'interface utilisateur local (une valeur de saisie provisoire, l'onglet/filtre actif) vit dans useState et ne doit jamais être poussé vers le serveur tant que l'utilisateur ne l'a pas validé.

Résultats d'actions & erreurs — comment les échecs se manifestent

Les deux appelants (agent et interface utilisateur) utilisent le même gestionnaire, et l'environnement d'exécution enveloppe chaque invocation dans la même enveloppe, donc modélisez les échecs délibérément :

  • Retournez un petit objet simple en cas de succès ({ id, status }, { count }). POST /action répond { ok: true, result }.
  • Lancez une exception en cas d'échec grave (mauvaise entrée, id manquant). L'environnement d'exécution répond { ok: false, code, message } — HTTP 400 pour une erreur de kit connue (par exemple, une action inconnue) et 500 pour un throw du gestionnaire — et mountCanvas's invoke rejette avec un CanvasActionError portant ce code/message, donc un gestionnaire de bouton peut try/catch le capturer. Côté agent, extension.mjs mappe la même erreur vers un CanvasError. Une exception est exposée, jamais un plantage.
  • Pour un échec attendu et transitoire (une extraction amont instable sur une sonde d'arrière-plan), ne lancez pas — capturez-le dans state.error et retournez pour que la carte d'erreur s'affiche pendant que la sonde reste silencieuse (voir le modèle de données refresh).

Icônes — Lucide officiel de GitHub, toujours

Le kit fournit l'ensemble Lucide exact que github-app utilise ([email protected], identique octet par octet). Utilisez-le pour chaque icône ; n'écrivez pas de SVG à la main et n'utilisez pas de CDN.

import { Icon } from "/kit/client.mjs";
html`<${Icon} name="circle-check" size=${16} />`

Contrat : les noms sont en kebab-case (circle-check, trash-2, list-todo). viewBox est fixé à 0 0 24 24, stroke-width est 2 — ne passez jamais strokeWidth. Utilisez les tailles 12 · 14 · 16 · 20 · 24 · 32. Faites correspondre les choix de glyphes de github-app lorsqu'il y a un mappage évident (par exemple, ouvert=circle-dot, terminé=circle-check, signet=bookmark). iconNames() / hasIcon(name) sont disponibles si vous avez besoin de valider.

Thème — Tokens Primer, avec solutions de repli

Lie le fichier /kit/theme.css et construis sur ses classes ck-* (ck-card, ck-btn, ck-btn-primary, ck-input, ck-badge, ck-tabs, ck-status, ck-empty, …). Elles correspondent aux tokens Primer que l'environnement d'exécution injecte dans le document canvas, chacune avec une solution de repli codée en dur pour le thème sombre de GitHub, donc un canvas a l'air correct même si un token est manquant. Ajoutez du CSS spécifique au canvas par-dessus ; ne refaites pas le style des primitives.

Les badges sont génériques. Utilisez les variantes sémantiques ck-badge-success, ck-badge-accent, ck-badge-muted, ck-badge-danger, ck-badge-attention et mapper vos statuts vers elles dans la vue (la référence fait open → success, decided → accent, parked → muted). N'ajoutez pas de noms de classe de statut spécifiques à l'application au thème partagé.

Primitives de chargement et d'erreur (ne les faites pas à la main) :

  • ck-spinner — animation de rotation ; associez-la à un chargeur Lucide, par exemple html\<${Icon} name="loader-circle" class="ck-spinner" />``.
  • ck-skeleton — bloc d'espace réservé scintillant pour le contenu encore en chargement.
  • ck-callout / ck-callout ck-error — une notification en ligne / surface d'erreur pour un échec d'extraction, par ex. html\<${Icon} name="circle-x" size=${16} />${state.error}``.

La sécurité du mouvement est globale. theme.css contient déjà un @media (prefers-reduced-motion: reduce) qui neutralise toutes les animations et transitions (spinners/squelettes du kit et tous ceux que vous ajoutez). Vous ne le répétez pas par canvas — mais gardez le mouvement décoratif, jamais le seul signal.

API de la vue — hooks, helpers, fragments

/kit/client.mjs réexporte l'ensemble complet des hooks Preact vendus, pas seulement useState : useEffect, useRef, useMemo, useCallback, useReducer, useContext, useLayoutEffect, useImperativeHandle. Appelez les hooks inconditionnellement en haut d'un composant — avant tout return anticipé (par exemple, la branche de chargement if (!state) return … vient après vos hooks).

Il exporte également les helpers de formatage et d'id pour que vous arrêtiez de les réinventer :

import { nid, relativeTime, compactNumber, percent } from "/kit/client.mjs";
nid();                       // "lq3k9f2a" — id unique court
relativeTime(iso);           // "5m ago" / "3h ago" / "2d ago" / une date
compactNumber(2_300_000);    // "2.3M"
percent(1.25);               // "+1.25%"

nid est également importable côté serveur dans canvas.mjs via import { nid } from "./canvas-kit/format.mjs" (le générateur fait ceci).

Les formatteurs sont sensibles à la locale — relativeTime se termine par un toLocaleDateString(), et compactNumber/percent passent par Number.prototype.toLocaleString, donc leur sortie dépend de la locale de l'hôte ("1.5K" vs "1,5 K", 2025-01-31 vs 31/01/2025). Gardez les valeurs brutes dans l'état durable (une chaîne ISO, un Number) et formatez dans la vue. Ne stockez jamais une chaîne formatée et ne la reparsenez jamais — le formatage est une préoccupation de présentation, et une valeur formatée sous une locale ne peut pas être relue de manière fiable sous une autre.

Fragment N'EST PAS exporté. Pour retourner des nœuds frères sans wrapper, utilisez la syntaxe de balise vide intégrée de htm — html`<><${A} /><${B} /></>` — ou retournez simplement un tableau de vnodes. N'utilisez pas import { Fragment } ; il n'est pas là.

Canvas de données externes — extraction + rafraîchissement automatique

La plupart des canvas réels ne sont pas des listes CRUD saisies par l'utilisateur ; ils extraient des données du réseau et les rafraîchissent. La structure :

  1. fetch() vit dans le gestionnaire d'action (ou un helper qu'il appelle), jamais dans la vue. Liez-le toujours avec un délai d'attente pour qu'un amont lent ne puisse pas bloquer le panneau :

    async function fetchItems(url) {
      const res = await fetch(url, { signal: AbortSignal.timeout(12000) });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return mapItems(await res.json());
    }
    

    L'extraction s'exécute côté serveur sur l'environnement d'exécution en boucle locale, donc le modèle de données généré data le protège avec une vérification SSRF : il autorise uniquement http/https vers un hôte public et rejette les plages de boucle locale, de liaison locale (incl. les métadonnées cloud) et privées — y compris chaque adresse vers laquelle le nom d'hôte résout. Tout ce que vous renvoyez à l'agent (par exemple via list_items) est toujours un texte influencé par l'attaquant, donc gardez la source comme vous la choisissez et traitez le contenu extrait comme non fiable (rendez-le sous forme de texte, jamais innerHTML).

  2. Exposez une action refresh qui extrait et écrit le résultat dans l'état partagé. Capturez les échecs dans state.error et retournez (ne lancez pas) pour qu'un tick de sonde d'arrière-plan reste silencieux pendant que la carte d'erreur s'affiche. Capturez l'entrée avant le await, et réécrivez avec un set fonctionnel pour qu'une action concurrente (une autre sonde, un set_source) ne soit pas écrasée :

    refresh: {
      inputSchema: { type: "object", properties: {}, additionalProperties: false },
      handler: async ({ state, set }) => {
        const sourceUrl = state.sourceUrl;          // capturer avant await
        try {
          const items = await fetchItems(sourceUrl);
          set((cur) => cur.sourceUrl !== sourceUrl  // abandonner une réponse obsolète
            ? cur
            : { ...cur, items, error: null, lastRefresh: new Date().toISOString() });
          return { count: items.length };
        } catch (err) {
          set((cur) => ({ ...cur, error: `Impossible de charger : ${err.message}`, lastRefresh: new Date().toISOString() }));
          return { ok: false, error: String(err.message) };
        }
      },
    },
    
  3. Sondez avec le kit, pas avec un setInterval brut. pollWhileVisible(tick, seconds) exécute tick à un intervalle uniquement lorsque le panneau est visible (donc un canvas en arrière-plan cesse d'atteindre le réseau) et renvoie une fonction de nettoyage — un useEffect return prêt à l'emploi :

    import { pollWhileVisible } from "/kit/client.mjs";
    // intervalle lié à l'état durable ; se reconstruit lorsqu'il change :
    useEffect(() => pollWhileVisible(() => invoke("refresh"), state.autoRefreshSec || 0),
              [state.autoRefreshSec]);
    

    Pour un intervalle fixe, vous pouvez ignorer complètement l'effet : mountCanvas({ view, poll: { action: "refresh", seconds: 60, immediate: true } }).

Estampez toute cette structure avec node scripts/new-canvas.mjs <name> --template data.

Stratégie de domaine — tableau partagé vs profil personnel

L'état est indexé par l'id de domaine que resolveDomainId renvoie. Deux intentions, une machinerie identique (fileFor nettoie l'id en nom de fichier — copiez-le textuellement) :

  • Tableau partagé (la plupart des canvas) : indexez par un sujet que l'agent et l'utilisateur nomment tous deux. resolveDomainId: (input) => (input?.domain ? String(input.domain) : "default"). Quiconque ouvre domain: "tech" voit le même tableau.
  • Stockage personnel : indexez par une identité, pas par un sujet. resolveDomainId: (input) => (input?.profile ? String(input.profile) : "default"), avec la propriété du schéma d'entrée nommée profile. Utilisez ceci pour les données par apprenant / par utilisateur qui ne doivent pas se mélanger entre les sujets.

Choisissez le nom (domain vs profile) délibérément — c'est tout le contrat pour savoir qui partage quoi.

Modèles et limitations (connaissez-les)

  • Catalogue groupé. Pour des données de départ organisées (cours, préréglages), fournissez un catalog.mjs séparé : des données simples plus un constructeur de style buildCourse(...) qui assigne des ids positionnels déterministes (es-u1-l2) pour que le même catalogue produise toujours les mêmes ids. Gardez les données volumineuses concises avec de minuscules helpers de constructeur.
  • statusLine est calculée une fois, à l'ouverture. La chaîne de statut du panneau est définie sur openInstance et n'est pas repoussée lorsqu'une action modifie l'état, donc un compte dans statusLine devient obsolète. Affichez les comptes en direct à l'intérieur du corps du canvas (qui se réaffiche à chaque poussée SSE) et traitez statusLine comme un résumé uniquement à l'ouverture.
  • Actions à charge utile riche. Les inputSchema des actions par défaut ont additionalProperties: false exprès : le schéma est le contrat que l'agent remplit, et un schéma strict empêche le modèle de faire passer en contrebande des champs non validés/avec des fautes de frappe — seules les propriétés que vous déclarez atteignent votre gestionnaire. Lorsque l'interface utilisateur a vraiment besoin de passer au gestionnaire un objet entier (par exemple l'article en cours d'enregistrement) plutôt que des champs scalaires, définissez additionalProperties: true sur inputSchema de cette action et dénormalisez ce dont vous avez besoin dans l'état durable dans le gestionnaire. Activez-le délibérément, par action — pas comme valeur par défaut globale.
  • Structure d'installation / Résolution SDK. Un dossier d'extension livré contient uniquement copilot-extension.json ({ "name", "version": 1 } — pas de package.json), extension.mjs, canvas.mjs, web/, et le canvas-kit/ imbriqué. Le kit est du ESM simple que Node exécute directement — pas de bundler, pas de transpilation, pas d'étape de build, pas de package.json dans l'extension (le test/smoke.test.mjs estampillé s'exécute avec un simple node). L'hôte résout @github/copilot-sdk/extension pour vous ; extension.mjs est le seul fichier qui l'importe. N'ajoutez pas de package.json ou d'étape de build à un canvas livré.

Construire un canvas — le chemin le plus rapide

  1. Estampez-le (un canvas fonctionnel, kit déjà imbriqué, un test de validation inclus) :
    node scripts/new-canvas.mjs mon-canvas --template data
    
  2. Ajoutez vos outils (vos propres actions) dans canvas.mjs.
  3. Construisez la vue dans web/app.mjs.
  4. Testez — exécutez node test/smoke.test.mjs ou node server.mjs (voir la section test ci-dessous).

Test local (sans ouvrir Copilot)

Le kit est livré avec une infrastructure de test intégrée utilisant Node HTTP brut. Voir test/http.test.mjs pour un exemple complet de test d'extraction, de poussée SSE et d'invocation d'action via HTTP.

// test/http.test.mjs
import { describe, it } from "node:test";
import { strict as assert } from "node:assert";

// ... importez votre canvas, créez un serveur, testez les routes

Exécutez : node --test test/*.test.mjs

Prochaines étapes

  • Lancez npx create-canvas-app pour une nouvelle application canvas complète.
  • Consultez la documentation de l'API canvas de Copilot pour des cas d'utilisation avancés.
  • Rejoignez la communauté des développeurs Copilot pour partager des modèles et poser des questions.

Skills associés