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
| Niveau | Quand l'utiliser | Comment |
|---|---|---|
| Chaîne HTML statique | Contenu en lecture seule ou quasi statique ; pas de champs de saisie à protéger | Le scaffold vanille de l'environnement d'exécution (extensions_manage scaffold kind:canvas). |
| Preact + htm + ce kit | Tout 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 parinstanceId— ouvrez le même domaine dans deux panneaux et ils affichent les mêmes données. La persistance passe paruserStore(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), etPOST /action.mountCanvasles câble ; chaque changement d'état se diffuse vers tous les panneaux ouverts. server.mjsest sans SDK donc tout l'environnement d'exécution est testable avec Node HTTP brut (voirtest/http.test.mjs). Gardez les appels SDK uniquement dansextension.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
useStateet 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 /actionré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 unthrowdu gestionnaire — etmountCanvas'sinvokerejette avec unCanvasActionErrorportant cecode/message, donc un gestionnaire de bouton peuttry/catchle capturer. Côté agent,extension.mjsmappe la même erreur vers unCanvasError. 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.erroret retournez pour que la carte d'erreur s'affiche pendant que la sonde reste silencieuse (voir le modèle de donnéesrefresh).
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 exemplehtml\<${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 :
-
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é
datale protège avec une vérification SSRF : il autorise uniquementhttp/httpsvers 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 vialist_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, jamaisinnerHTML). -
Exposez une action
refreshqui extrait et écrit le résultat dans l'état partagé. Capturez les échecs dansstate.erroret 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 leawait, et réécrivez avec unsetfonctionnel pour qu'une action concurrente (une autre sonde, unset_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) }; } }, }, -
Sondez avec le kit, pas avec un
setIntervalbrut.pollWhileVisible(tick, seconds)exécutetickà 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 — unuseEffectreturn 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 ouvredomain: "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éeprofile. 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.mjsséparé : des données simples plus un constructeur de stylebuildCourse(...)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. statusLineest calculée une fois, à l'ouverture. La chaîne de statut du panneau est définie suropenInstanceet n'est pas repoussée lorsqu'une action modifie l'état, donc un compte dansstatusLinedevient obsolète. Affichez les comptes en direct à l'intérieur du corps du canvas (qui se réaffiche à chaque poussée SSE) et traitezstatusLinecomme un résumé uniquement à l'ouverture.- Actions à charge utile riche. Les
inputSchemades actions par défaut ontadditionalProperties: falseexprè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éfinissezadditionalProperties: truesurinputSchemade 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 depackage.json),extension.mjs,canvas.mjs,web/, et lecanvas-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 depackage.jsondans l'extension (letest/smoke.test.mjsestampillé s'exécute avec un simplenode). L'hôte résout@github/copilot-sdk/extensionpour vous ;extension.mjsest le seul fichier qui l'importe. N'ajoutez pas depackage.jsonou d'étape de build à un canvas livré.
Construire un canvas — le chemin le plus rapide
- Estampez-le (un canvas fonctionnel, kit déjà imbriqué, un test de validation inclus) :
node scripts/new-canvas.mjs mon-canvas --template data - Ajoutez vos outils (vos propres actions) dans
canvas.mjs. - Construisez la vue dans
web/app.mjs. - Testez — exécutez
node test/smoke.test.mjsounode 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-apppour 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.