Crear Aplicación Canvas
Una forma completa y preconfigurada de construir extensiones de canvas para aplicaciones Copilot. Existe porque los canvases hechos a mano tropiezan una y otra vez con los mismos problemas: un bucle de repintado con innerHTML que devora las pulsaciones de teclas, un estado que vive solo en un panel, estilos improvisados que ignoran el tema del anfitrión e iconos inconsistentes. El kit soluciona los cuatro.
Qué es un canvas (y cuándo construir uno)
Un canvas es una superficie interactiva que el agente abre en un panel lateral mediante open_canvas. Tanto el agente como el usuario actúan sobre el mismo estado a través de los mismos manejadores de acciones. Recurre a un canvas cuando el texto del chat o un diff no son suficientes: paneles de control en vivo, editores, hojas de cálculo, rastreadores, vistas kanban/tablero, previsualizaciones de documentos, flujos de trabajo específicos de una herramienta.
Si el usuario solo necesita una herramienta de agente no visual, crea una herramienta de extensión normal — no un canvas.
Nivel de renderizado — decide primero
| Nivel | Cuándo usarlo | Cómo |
|---|---|---|
| Cadena HTML estática | Contenido de solo lectura o casi estático; sin entradas que proteger | El andamio estándar del runtime (extensions_manage scaffold kind:canvas). |
| Preact + htm + este kit | Cualquier cosa interactiva: entradas, actualizaciones en vivo, listas, formularios, estado compartido | Este kit. Opción por defecto para canvases reales. |
La razón más importante para usar el kit: Preact hace diff del DOM, por lo que una inserción de estado en vivo desde el agente no interrumpe el foco, la posición del cursor ni el texto a medio escribir en una entrada. El patrón innerHTML = ... que usan la mayoría de los canvases tempranos repinta todo el árbol y pierde las pulsaciones de teclado en cada inserción. No hagas eso.
El modelo (léelo antes de programar)
extension.mjs ── el ÚNICO archivo que importa el SDK de Copilot (adaptador delgado)
canvas.mjs ── tu canvas: id, esquema, carga/guardado de estado, manejadores de acciones (sin SDK)
canvas-kit/ ── el kit (copiado textualmente; no editar)
web/index.html ── shell: carga /kit/theme.css y ./app.mjs
web/app.mjs ── tu vista Preact
- El estado es compartido y duradero. Se almacena mediante una id de dominio resuelta a partir de la entrada abierta (
resolveDomainId), no porinstanceId— abre el mismo dominio en dos paneles y ambos muestran los mismos datos. La persistencia pasa poruserStore(extName, file)→$COPILOT_HOME/extensions/<nombre>/artifacts/<dominio>.json. - El agente y la interfaz de usuario comparten manejadores. Una acción invocada por el agente y la misma acción invocada desde un botón ejecutan el manejador idéntico y producen la misma mutación de estado. Escribe la lógica una vez, en
canvas.mjs. - Las actualizaciones en vivo son automáticas. El kit sirve
GET /state,GET /events(SSE) yPOST /action.mountCanvaslos conecta; cada cambio de estado se propaga a todos los paneles abiertos. server.mjsestá libre de SDK por lo que todo el runtime se puede probar con Node HTTP simple (consultatest/http.test.mjs). Mantén las llamadas al SDK solo enextension.mjs.
Dos tipos de estado en la vista — mantenlos separados
- Estado de dominio compartido llega a través de SSE y se vuelve a renderizar en cada inserción. Trátalo como solo lectura en la vista; cámbialo solo llamando a
invoke(action, input). - Estado local de la interfaz de usuario (el valor de una entrada en borrador, la pestaña/filtro activo) vive en
useStatey nunca se debe enviar al servidor hasta que el usuario lo confirme.
Resultados de acciones y errores — cómo se muestran los fallos
Ambos llamadores (agente e IU) alcanzan el mismo manejador, y el runtime envuelve cada invocación en el mismo envoltorio, por lo tanto, modela los fallos deliberadamente:
- Devuelve un objeto simple pequeño para éxito (
{ id, status },{ count }).POST /actionresponde{ ok: true, result }. - Lanza para un fallo grave (entrada incorrecta, id faltante). El runtime responde
{ ok: false, code, message }— HTTP 400 para un error conocido del kit (p. ej., una acción desconocida) y 500 para unthrowdel manejador — y elinvokedemountCanvasrechaza con unCanvasActionErrorque lleva esecode/message, por lo que el manejador de un botón puede usartry/catch. En el lado del agente,extension.mjsmapea el mismo error a unCanvasError. Un lanzamiento es mostrado, nunca un bloqueo. - Para un fallo esperado y transitorio (una obtención remota inestable en una consulta periódica en segundo plano), no lances — captúralo en
state.errory devuelve para que la tarjeta de error se muestre mientras la consulta permanece silenciosa (consulta la plantilla de datosrefresh).
Iconos — GitHub Lucide oficial, siempre
El kit distribuye el conjunto exacto de Lucide que github-app utiliza ([email protected], byte-idéntico). Úsalo para cada icono; no escribas SVGs a mano ni uses una CDN.
import { Icon } from "/kit/client.mjs";
html`<${Icon} name="circle-check" size=${16} />`
Contrato: los nombres están en kebab-case (circle-check, trash-2, list-todo). viewBox es fijo 0 0 24 24, stroke-width es 2 — nunca pases strokeWidth. Usa tamaños 12 · 14 · 16 · 20 · 24 · 32. Coincide con las elecciones de glifos propias de github-app cuando haya una correspondencia obvia (p. ej., open=circle-dot, done=circle-check, bookmark=bookmark). iconNames() / hasIcon(name) están disponibles si necesitas validar.
Tema — tokens Primer, con valores de respaldo
Enlaza /kit/theme.css y construye sobre sus clases ck-* (ck-card, ck-btn, ck-btn-primary, ck-input, ck-badge, ck-tabs, ck-status, ck-empty, …). Se asignan a los tokens Primer que el runtime inyecta en el documento canvas, cada uno con un valor de respaldo hardcodeado para GitHub-dark, por lo que un canvas se ve bien incluso si falta un token. Agrega CSS específico del canvas encima; no reestilices los primitivos.
Las insignias son genéricas. Usa las variantes semánticas ck-badge-success, ck-badge-accent, ck-badge-muted, ck-badge-danger, ck-badge-attention y mapea tus estados a ellas en la vista (la referencia hace open → success, decided → accent, parked → muted). No agregues nombres de clase de estado específicos de la aplicación al tema compartido.
Primitivas de carga y error (no las crees a mano):
ck-spinner— animación de giro; combínalo con un cargador Lucide, p. ej.html\<${Icon} name="loader-circle" class="ck-spinner" />``.ck-skeleton— bloque de marcador de posición brillante para contenido que aún se está cargando.ck-callout/ck-callout ck-error— una nota en línea / superficie de error para una obtención fallida, p. ej.html\<${Icon} name="circle-x" size=${16} />${state.error}``.
La seguridad de movimiento es global. theme.css ya incluye un guardián @media (prefers-reduced-motion: reduce) que neutraliza todas las animaciones y transiciones (spinners/skeletons del kit y cualquier otra que agregues). No lo repitas por canvas — pero mantén el movimiento decorativo, nunca la única señal.
API de vista — hooks, ayudantes, fragmentos
/kit/client.mjs reexporta el conjunto completo de hooks de Preact, no solo useState: useEffect, useRef, useMemo, useCallback, useReducer, useContext, useLayoutEffect, useImperativeHandle. Llama a los hooks incondicionalmente en la parte superior de un componente — antes de cualquier return temprano (p. ej., la rama de carga if (!state) return … viene después de tus hooks).
También exporta ayudantes de formato e id para que dejes de reinventarlos:
import { nid, relativeTime, compactNumber, percent } from "/kit/client.mjs";
nid(); // "lq3k9f2a" — id único corto
relativeTime(iso); // "hace 5m" / "hace 3h" / "hace 2d" / una fecha
compactNumber(2_300_000); // "2.3M"
percent(1.25); // "+1.25%"
nid también se puede importar en el lado del servidor en canvas.mjs mediante import { nid } from "./canvas-kit/format.mjs" (el generador hace esto).
Los formateadores tienen en cuenta la configuración regional — relativeTime termina en un toLocaleDateString(), y compactNumber/percent pasan por Number.prototype.toLocaleString, por lo que su salida depende de la configuración regional del anfitrión ("1.5K" vs "1,5 K", 2025-01-31 vs 31/01/2025). Mantén los valores crudos en el estado duradero (una cadena ISO, un Number) y formatea en la vista. Nunca almacenes una cadena formateada y nunca la analices de vuelta — el formateo es una preocupación de presentación, y un valor formateado bajo una configuración regional no se puede releer de manera confiable bajo otra.
Fragment NO se exporta. Para devolver nodos hermanos sin un contenedor, usa la sintaxis de etiqueta vacía incorporada de htm — html`<><${A} /><${B} /></>` — o simplemente devuelve un array de vnodes. No uses import { Fragment }; no está ahí.
Canvases de datos externos — obtención + actualización automática
La mayoría de los canvases reales no son listas CRUD ingresadas por el usuario; obtienen datos de la red y se actualizan. La forma:
-
fetch()vive en el manejador de acciones (o un ayudante que este llama), nunca en la vista. Siempre vincúlalo con un tiempo de espera para que un servicio ascendente lento no pueda colgar el panel: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()); }La obtención se ejecuta en el lado del servidor en el runtime de bucle invertido, por lo que la plantilla
datagenerada lo protege con una verificación SSRF: permite solohttp/httpsa un anfitrión público y rechaza loopback, link-local (incl. metadatos de nube) y rangos privados — incluyendo cada dirección a la que se resuelve el nombre del anfitrión. Cualquier cosa que le devuelvas al agente (p. ej., a través delist_items) sigue siendo texto influenciado por un atacante, por lo que mantén la fuente que tú elijas y trata el contenido obtenido como no confiable (represéntalo como texto, nuncainnerHTML). -
Expón una acción
refreshque obtenga y escriba el resultado en el estado compartido. Captura los fallos enstate.errory devuelve (no lances) para que un tic de consulta periódica en segundo plano permanezca silencioso mientras se muestra la tarjeta de error. Captura la entrada antes delawait, y escribela de vuelta con unsetfuncional para que una acción concurrente (otra consulta, unset_source) no sea sobrescrita:refresh: { inputSchema: { type: "object", properties: {}, additionalProperties: false }, handler: async ({ state, set }) => { const sourceUrl = state.sourceUrl; // capturar antes de await try { const items = await fetchItems(sourceUrl); set((cur) => cur.sourceUrl !== sourceUrl // descartar una respuesta desactualizada ? cur : { ...cur, items, error: null, lastRefresh: new Date().toISOString() }); return { count: items.length }; } catch (err) { set((cur) => ({ ...cur, error: `No se pudo cargar: ${err.message}`, lastRefresh: new Date().toISOString() })); return { ok: false, error: String(err.message) }; } }, }, -
Consulta periódica con el kit, no con un
setIntervalcrudo.pollWhileVisible(tick, seconds)ejecutaticken un intervalo solo mientras el panel es visible (por lo que un canvas en segundo plano deja de golpear la red) y devuelve una función de limpieza — unuseEffectde retorno directo:import { pollWhileVisible } from "/kit/client.mjs"; // intervalo vinculado al estado duradero; se reconstruye cuando cambia: useEffect(() => pollWhileVisible(() => invoke("refresh"), state.autoRefreshSec || 0), [state.autoRefreshSec]);Para un intervalo fijo puedes omitir el efecto por completo:
mountCanvas({ view, poll: { action: "refresh", seconds: 60, immediate: true } }).
Sella toda esta forma con node scripts/new-canvas.mjs <nombre> --template data.
Estrategia de dominio — tablero compartido vs perfil personal
El estado se almacena mediante la id de dominio que resolveDomainId devuelve. Dos intenciones, maquinaria idéntica (fileFor sanitiza la id a un nombre de archivo — cópialo textualmente):
- Tablero compartido (la mayoría de los canvases): clave basada en un tema que tanto el agente como el usuario nombran.
resolveDomainId: (input) => (input?.domain ? String(input.domain) : "default"). Cualquiera que abradomain: "tech"ve el mismo tablero. - Almacenamiento personal: clave basada en una identidad, no en un tema.
resolveDomainId: (input) => (input?.profile ? String(input.profile) : "default"), con la propiedad del esquema de entrada llamadaprofile. Úsalo para datos por alumno/usuario que no deben mezclarse entre temas.
Elige la palabra (domain vs profile) deliberadamente — es todo el contrato de quién comparte qué.
Patrones y limitaciones (conócelos)
- Catálogo empaquetado. Para datos de semilla seleccionados a mano (cursos, ajustes preestablecidos), envía un
catalog.mjsseparado: datos simples más un constructor de estilobuildCourse(...)que asigna ids posicionales deterministas (es-u1-l2) para que el mismo catálogo siempre produzca los mismos ids. Mantén los datos masivos concisos con pequeños ayudantes de constructor. statusLinese calcula una vez, al abrir. El texto de estado del panel se establece enopenInstancey no se vuelve a enviar cuando una acción muta el estado, por lo que un conteo enstatusLinese vuelve obsoleto. Muestra los conteos en vivo dentro del cuerpo del canvas (que se vuelve a renderizar en cada inserción SSE) y tratastatusLinecomo un resumen solo al abrir.- Acciones con carga rica. Los
inputSchemade las acciones tienen por defectoadditionalProperties: falsea propósito: el esquema es el contrato que el agente completa, y un esquema estricto evita que el modelo introduzca campos no validados o con errores tipográficos — solo las propiedades que declaras llegan a tu manejador. Cuando la interfaz de usuario necesita genuinamente pasar al manejador un objeto completo (p. ej., el artículo que se guarda) en lugar de campos escalares, estableceadditionalProperties: trueen elinputSchemade esa acción y desnormaliza lo que necesitas en el estado duradero en el manejador. Actívalo deliberadamente, por acción — no como un valor predeterminado general. - Diseño de instalación / resolución de SDK. Una carpeta de extensión enviada contiene solo
copilot-extension.json({ "name", "version": 1 }— sinpackage.json),extension.mjs,canvas.mjs,web/, y elcanvas-kit/anidado. El kit es ESM simple que Node ejecuta directamente — sin empaquetador, sin transpilación, sin paso de compilación, sinpackage.jsonen la extensión (eltest/smoke.test.mjsestampado se ejecuta connodesin más). El anfitrión resuelve@github/copilot-sdk/extensionpor ti;extension.mjses el único archivo que lo importa. No agregues unpackage.jsono un paso de compilación a un canvas enviado.
Construir un canvas — la ruta más rápida
- Séllalo (un canvas funcional, con el kit ya anidado, incluye una prueba de humo):
node scripts/new-ca
…
[truncado para traducción]