Criar App de Canvas
Uma maneira completa de construir extensões de canvas para o Copilot App. Ele existe porque canvases feitos manualmente esbarram nos mesmos problemas: um loop de repintura innerHTML que engole pressionamentos de tecla, estado que vive apenas em um painel, estilos ad-hoc que ignoram o tema do host e ícones inconsistentes. O kit resolve todos os quatro.
O que é um canvas (e quando construir um)
Um canvas é uma superfície interativa que o agente abre em um painel lateral via open_canvas. Tanto o agente quanto o usuário atuam no mesmo estado através dos mesmos manipuladores de ação. Recorra a um canvas quando o texto do chat ou um diff não for suficiente: painéis ao vivo, editores, planilhas, rastreadores, visualizações kanban/board, pré-visualizações de documentos, fluxos de trabalho específicos de ferramentas.
Se o usuário precisar apenas de uma ferramenta de agente não visual, construa uma ferramenta de extensão normal — não um canvas.
Camada de renderização — decida primeiro
| Camada | Quando usar | Como |
|---|---|---|
| String HTML estática | Conteúdo somente leitura ou quase estático; sem entradas a proteger | O scaffold vanilla do runtime (extensions_manage scaffold kind:canvas). |
| Preact + htm + este kit | Qualquer coisa interativa: entradas, atualizações ao vivo, listas, formulários, estado compartilhado | Este kit. Escolha padrão para canvases reais. |
A razão mais importante para usar o kit: Preact difere o DOM, então um push de estado ao vivo do agente não destrói o foco, a posição do cursor ou o texto parcialmente digitado em uma entrada. O padrão innerHTML = ... que a maioria dos canvases iniciais usa repinta toda a árvore e perde pressionamentos de tecla a cada push. Não faça isso.
O modelo (leia isto antes de codificar)
extension.mjs ── o ÚNICO arquivo que importa o Copilot SDK (adaptador fino)
canvas.mjs ── seu canvas: id, schema, salvar/carregar estado, manipuladores de ação (sem SDK)
canvas-kit/ ── o kit (copiado ipsis litteris; não edite)
web/index.html ── shell: carrega /kit/theme.css e ./app.mjs
web/app.mjs ── sua visualização Preact
- O estado é compartilhado e durável. Ele é indexado por um domain id resolvido a partir da entrada aberta (
resolveDomainId), não porinstanceId— abra o mesmo domínio em dois painéis e eles mostram os mesmos dados. A persistência passa poruserStore(extName, file)→$COPILOT_HOME/extensions/<nome>/artifacts/<dominio>.json. - Agente e Interface compartilham manipuladores. Uma ação invocada pelo agente e a mesma ação invocada a partir de um botão executam o manipulador idêntico e produzem a mutação de estado idêntica. Escreva a lógica uma vez, em
canvas.mjs. - Atualizações ao vivo são automáticas. O kit serve
GET /state,GET /events(SSE) ePOST /action.mountCanvasos conecta; cada mudança de estado se propaga para todos os painéis abertos. server.mjsnão tem SDK para que todo o runtime seja testável com Node HTTP puro (vejatest/http.test.mjs). Mantenha as chamadas de SDK apenas emextension.mjs.
Dois tipos de estado na visualização — mantenha-os separados
- Estado de domínio compartilhado chega via SSE e é re-renderizado a cada push. Trate-o como somente leitura na visualização; altere-o apenas chamando
invoke(action, input). - Estado da interface local (um valor de entrada de rascunho, a guia/filtro ativo) vive em
useStatee nunca deve ser enviado ao servidor até que o usuário o confirme.
Resultados de ação e erros — como as falhas aparecem
Ambos os chamadores (agente e interface) atingem o mesmo manipulador, e o runtime envolve cada invocação no mesmo envelope, então modele as falhas deliberadamente:
- Retorne um pequeno objeto simples para sucesso (
{ id, status },{ count }).POST /actionresponde com{ ok: true, result }. - Lance para uma falha grave (entrada inválida, id ausente). O runtime responde com
{ ok: false, code, message }— HTTP 400 para um erro conhecido do kit (ex.: uma ação desconhecida) e 500 para umthrowdo manipulador — e oinvokedomountCanvasrejeita com umCanvasActionErrorcarregando essecode/message, para que um manipulador de botão possatry/catchisso. No lado do agente,extension.mjsmapeia o mesmo erro para umCanvasError. Umthrowé superficializado, nunca uma quebra. - Para uma falha esperada e transitória (uma busca upstream instável em uma sondagem em segundo plano), não lance — capture-a em
state.errore retorne para que o cartão de erro apareça enquanto a sondagem permanece silenciosa (veja o templatedatarefresh).
Ícones — GitHub Lucide oficial, sempre
O kit fornece o conjunto exato de Lucide que o github-app usa ([email protected], bytes idênticos). Use-o para todos os ícones; não escreva SVGs manualmente ou puxe um CDN.
import { Icon } from "/kit/client.mjs";
html`<${Icon} name="circle-check" size=${16} />`
Contrato: nomes são em kebab-case (circle-check, trash-2, list-todo). viewBox é fixo 0 0 24 24, stroke-width é 2 — nunca passe strokeWidth. Use tamanhos 12 · 14 · 16 · 20 · 24 · 32. Corresponda às próprias escolhas de glifo do github-app quando houver um mapeamento óbvio (ex.: open=circle-dot, done=circle-check, bookmark=bookmark). iconNames() / hasIcon(name) estão disponíveis se você precisar validar.
Tema — tokens Primer, com fallbacks
Vincule /kit/theme.css e construa com base em suas classes ck-* (ck-card, ck-btn, ck-btn-primary, ck-input, ck-badge, ck-tabs, ck-status, ck-empty, …). Elas mapeiam os tokens Primer que o runtime injeta no documento do canvas, cada um com um fallback codificado para o tema escuro do GitHub, para que um canvas pareça correto mesmo se um token estiver ausente. Adicione CSS específico do canvas por cima; não reestilize os primitivos.
Badges são genéricos. Use as variantes semânticas ck-badge-success, ck-badge-accent, ck-badge-muted, ck-badge-danger, ck-badge-attention e mapeie seus status para elas na visualização (a referência faz open → success, decided → accent, parked → muted). Não adicione nomes de classe de status específicos do aplicativo ao tema compartilhado.
Primitivos de carregamento e erro (não crie estes manualmente):
ck-spinner— animação de giro; emparelhe com um loader Lucide, ex.:html\<${Icon} name="loader-circle" class="ck-spinner" />``.ck-skeleton— bloco de placeholder cintilante para conteúdo que ainda está carregando.ck-callout/ck-callout ck-error— uma notificação / superfície de erro embutida para uma busca com falha, ex.:html\<${Icon} name="circle-x" size=${16} />${state.error}``.
Segurança de movimento é global. theme.css já envia um guard @media (prefers-reduced-motion: reduce) que neutraliza todas as animações e transições (spinners/skeletons do kit e quaisquer que você adicione). Você não repete isso por canvas — mas mantenha o movimento decorativo, nunca o único sinal.
API da visualização — hooks, helpers, fragmentos
/kit/client.mjs re-exporta o conjunto completo de hooks Preact fornecidos, não apenas useState: useEffect, useRef, useMemo, useCallback, useReducer, useContext, useLayoutEffect, useImperativeHandle. Chame hooks incondicionalmente no topo de um componente — antes de qualquer return antecipado (ex.: a ramificação de carregamento if (!state) return … vem depois de seus hooks).
Ele também exporta helpers de formatação + id para que você pare de reinventá-los:
import { nid, relativeTime, compactNumber, percent } from "/kit/client.mjs";
nid(); // "lq3k9f2a" — id único curto
relativeTime(iso); // "5m atrás" / "3h atrás" / "2d atrás" / uma data
compactNumber(2_300_000); // "2,3M"
percent(1.25); // "+1,25%"
nid também é importável no lado do servidor em canvas.mjs via import { nid } from "./canvas-kit/format.mjs" (o gerador faz isso).
Os formatadores são sensíveis à localidade — relativeTime termina em um toLocaleDateString(), e compactNumber/percent passam por Number.prototype.toLocaleString, então sua saída depende da localidade do host ("1,5K" vs "1.5K", 2025-01-31 vs 31/01/2025). Mantenha os valores brutos no estado durável (uma string ISO, um Number) e formate na visualização. Nunca armazene uma string formatada e nunca analise uma de volta — a formatação é uma preocupação de apresentação, e um valor formatado sob uma localidade não pode ser lido de forma confiável sob outra.
Fragment NÃO é exportado. Para retornar nós irmãos sem um wrapper, use a sintaxe de tag vazia embutida do htm — html`<><${A} /><${B} /></>` — ou apenas retorne um array de vnodes. Não faça import { Fragment }; ele não está lá.
Canvases com dados externos — busca + atualização automática
A maioria dos canvases reais não são listas CRUD inseridas pelo usuário; eles puxam da rede e atualizam. A forma:
-
fetch()vive no manipulador de ação (ou um helper que ele chama), nunca na visualização. Sempre vincule-o com um timeout para que um upstream lento não possa travar o painel: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()); }A busca executa no lado do servidor no runtime de loopback, então o template
datagerado a protege com uma verificação SSRF: ele permite apenashttp/httpspara um host público e rejeita loopback, link-local (incl. metadados de nuvem) e intervalos privados — incluindo cada endereço para o qual o hostname resolve. Qualquer coisa que você alimente de volta ao agente (ex.: vialist_items) ainda é texto influenciado por atacantes, então mantenha a fonte que você escolhe e trate o conteúdo buscado como não confiável (renderize-o como texto, nuncainnerHTML). -
Exponha uma ação
refreshque busca e escreve o resultado no estado compartilhado. Capture falhas emstate.errore retorne (não lance) para que um tique de sondagem em segundo plano permaneça silencioso enquanto o cartão de erro aparece. Capture a entrada antes doawait, e escreva de volta com umsetfuncional para que uma ação concorrente (outra sondagem, umset_source) não seja sobrescrita:refresh: { inputSchema: { type: "object", properties: {}, additionalProperties: false }, handler: async ({ state, set }) => { const sourceUrl = state.sourceUrl; // capture antes do await try { const items = await fetchItems(sourceUrl); set((cur) => cur.sourceUrl !== sourceUrl // descarta uma resposta obsoleta ? cur : { ...cur, items, error: null, lastRefresh: new Date().toISOString() }); return { count: items.length }; } catch (err) { set((cur) => ({ ...cur, error: `Não foi possível carregar: ${err.message}`, lastRefresh: new Date().toISOString() })); return { ok: false, error: String(err.message) }; } }, }, -
Sonde com o kit, não com um
setIntervalbruto.pollWhileVisible(tick, seconds)executatickem um intervalo apenas enquanto o painel estiver visível (para que um canvas em segundo plano pare de atingir a rede) e retorna uma função de limpeza — umuseEffectreturn imediato:import { pollWhileVisible } from "/kit/client.mjs"; // intervalo vinculado ao estado durável; reconstruído quando muda: useEffect(() => pollWhileVisible(() => invoke("refresh"), state.autoRefreshSec || 0), [state.autoRefreshSec]);Para um intervalo fixo, você pode pular o efeito completamente:
mountCanvas({ view, poll: { action: "refresh", seconds: 60, immediate: true } }).
Estampe toda esta forma com node scripts/new-canvas.mjs <nome> --template data.
Estratégia de domínio — quadro compartilhado vs perfil pessoal
O estado é indexado pelo id de domínio que resolveDomainId retorna. Duas intenções, maquinário idêntico (fileFor sanitiza o id para um nome de arquivo — copie-o ipsis litteris):
- Quadro compartilhado (a maioria dos canvases): indexado por um tópico que o agente e o usuário nomeiam.
resolveDomainId: (input) => (input?.domain ? String(input.domain) : "default"). Qualquer um que abrirdomain: "tech"vê o mesmo quadro. - Armazenamento pessoal: indexado por uma identidade, não um tópico.
resolveDomainId: (input) => (input?.profile ? String(input.profile) : "default"), com a propriedade do schema de entrada nomeadaprofile. Use isso para dados por aluno / por usuário que não devem vazar entre tópicos.
Escolha o substantivo (domain vs profile) deliberadamente — é todo o contrato para quem compartilha o quê.
Padrões e limitações (conheça estes)
- Catálogo empacotado. Para dados iniciais selecionados (cursos, predefinições), envie um
catalog.mjsseparado: dados simples mais um construtor do tipobuildCourse(...)que atribui ids posicionais determinísticos (es-u1-l2) para que o mesmo catálogo sempre produza os mesmos ids. Mantenha os dados em massa concisos com pequenos helpers de construtor. statusLineé calculado uma vez, na abertura. A string de status do painel é definida emopenInstancee não é re-enviada quando uma ação muta o estado, então uma contagem emstatusLinefica obsoleta. Mostre contagens ao vivo dentro do corpo do canvas (que é re-renderizado a cada push SSE) e tratestatusLinecomo um resumo apenas no momento da abertura.- Ações com payload rico.
inputSchemadas ações padrão temadditionalProperties: falsepropositalmente: o schema é o contrato que o agente preenche, e um schema estrito impede o modelo de contrabandear campos não validados/com erro de digitação — apenas as propriedades que você declara alcançam seu manipulador. Quando a interface genuinamente precisa passar um objeto inteiro para o manipulador (ex.: o artigo sendo salvo) em vez de campos escalares, definaadditionalProperties: truenoinputSchemadaquela ação e desnormalize o que você precisa no estado durável no manipulador. Ative-o deliberadamente, por ação — não como um padrão geral. - Layout de instalação / resolução do SDK. Uma pasta de extensão enviada contém apenas
copilot-extension.json({ "name", "version": 1 }— sempackage.json),extension.mjs,canvas.mjs,web/e ocanvas-kit/aninhado. O kit é ESM puro que o Node executa diretamente — sem bundler, sem transpilação, sem etapa de build, sempackage.jsonna extensão (otest/smoke.test.mjsincluso é executado comnodesimples). O host resolve@github/copilot-sdk/extensionpara você;extension.mjsé o único arquivo que o importa. Não adicione umpackage.jsonou uma etapa de build a um canvas enviado.
Construa um canvas — caminho mais rápido
- Gere-o (um canvas funcional, kit já aninhado, um teste de fumaça incluído):
node scripts/new-canvas.mjs <nome> --template data - Ajuste
canvas.mjs: definaid,openInstance(cominputSchema), e, pelo menos, um manipulador de ação. Vejaexamples/para referência. - Escreva
web/app.mjs:useEffectpara invocar na montagem e renderizar comhtml. - Adicione ícones: use
<${Icon} name=... />do/kit/client.mjs. - Teste:
node test/smoke.test.mjs(HTTP sem SDK) e uma instalação realextensions_manage install <caminho>.
Estrutura de suporte & teste
-
Testes sem SDK: O kit exporta
test/http.test.mjsque inicia oserver.mjscom um canvas fictício e envia requisições HTTP reais contra ele. Execute comnode --test test/http.test.mjs. Nenhum Copilot SDK está envolvido; isso testa a lógica do canvas, serialização de estado e roteamento de ações em isolamento.Extensões geradas também recebem um
test/smoke.test.mjsque verifica a sanidade da estrutura: importacanvas.mjs, verificaopenInstancee action handlers, tudo sem envolver a rede. Execute-o comnode --test test/smoke.test.mjs. -
Exemplos (em
examples/): um canvas mínimo e um com dados externos. Copie-os como ponto de partida.
Canal de feedback
Problemas, ideias ou pedidos de recursos? Abra uma issue no repositório do kit.