Create Canvas App
Copilot App キャンバス拡張機能を構築するための、バッテリー同梱型の方法です。
手作業でキャンバスを作成すると、常に同じ壁にぶつかります。innerHTML による再描画ループでキー入力が失われる、状態が一つのパネルにしか存在しない、アドホックなスタイリングがホストテーマを無視する、アイコンが一貫しない、といった問題です。このキットはこれら四つすべてを修正します。
キャンバスとは(そして、いつ構築するか)
キャンバスとは、エージェントが open_canvas でサイドパネルに開くインタラクティブなサーフェスです。エージェントとユーザーの両方が、同じアクションハンドラを通じて同じ状態に対して操作します。チャットテキストや差分だけでは不十分な場合にキャンバスを活用してください。ライブダッシュボード、エディタ、スプレッドシート、トラッカー、かんばん/ボードビュー、ドキュメントプレビュー、ツール固有のワークフローなどです。
ユーザーが非視覚的なエージェントツールだけを必要とする場合は、代わりに通常の拡張ツールを構築してください。キャンバスではありません。
レンダリング階層 — 最初に決定する
| 階層 | 使用する場合 | 方法 |
|---|---|---|
| 静的HTML文字列 | 読み取り専用またはほぼ静的なコンテンツ。保護する入力がない場合 | ランタイムのバニラスキャフォールド(extensions_manage scaffold kind:canvas) |
| Preact + htm + このキット | インタラクティブなものすべて:入力、ライブ更新、リスト、フォーム、共有状態 | このキット。本番キャンバスでのデフォルトの選択肢。 |
このキットを使用する最も重要な理由:Preact がDOMを差分更新するため、エージェントからのライブ状態プッシュがフォーカス、キャレット位置、または入力中に半分入力されたテキストを上書きしません。初期のキャンバスのほとんどが使用する innerHTML = ... パターンは、ツリー全体を再描画し、プッシュのたびにキー入力を失います。そうしないでください。
モデル(コーディング前にこれを読むこと)
extension.mjs ── Copilot SDK をインポートする**唯一の**ファイル(薄いアダプター)
canvas.mjs ── あなたのキャンバス:id、スキーマ、状態のロード/セーブ、アクションハンドラ(SDK不要)
canvas-kit/ ── キット(そのままコピー、編集しない)
web/index.html ── シェル:/kit/theme.css と ./app.mjs をロード
web/app.mjs ── あなたのPreactビュー
- 状態は共有され、永続化されます。 状態はインスタンスIDではなく、オープン入力から解決されたドメインID(
resolveDomainId)によってキー設定されます。同じドメインを二つのパネルで開くと、同じデータが表示されます。永続化はuserStore(extName, file)→$COPILOT_HOME/extensions/<name>/artifacts/<domain>.jsonを通じて行われます。 - エージェントとUIはハンドラを共有します。 エージェントによって呼び出されたアクションと、ボタンから呼び出された同じアクションは、同一のハンドラを実行し、同一の状態変更を生成します。ロジックは一度だけ
canvas.mjsに記述してください。 - ライブ更新は自動です。 キットは
GET /state、GET /events(SSE)、POST /actionを提供します。mountCanvasがそれらを配線し、すべての状態変更はすべてのオープンパネルにファンアウトされます。 server.mjsはSDK不要であるため、ランタイム全体を通常のNode HTTPでテストできます(test/http.test.mjsを参照)。SDK呼び出しはextension.mjsのみに保持してください。
ビュー内の二種類の状態 — 分離して保持する
- 共有ドメイン状態はSSE経由で到着し、プッシュのたびに再レンダリングされます。ビュー内では読み取り専用として扱い、
invoke(action, input)を呼び出すことによってのみ変更してください。 - ローカルUI状態(ドラフト入力値、アクティブタブ/フィルター)は
useStateに保持し、ユーザーがコミットするまでサーバーにプッシュしてはいけません。
アクションの結果とエラー — 失敗の表面化方法
呼び出し元(エージェントとUIの両方)は同じハンドラにアクセスし、ランタイムはすべての呼び出しを同じエンベロープでラップするため、意図的に障害をモデル化します。
- 成功の場合は、小さなプレーンオブジェクト(
{ id, status }、{ count })を返します。POST /actionは{ ok: true, result }で応答します。 - 深刻な障害(不正な入力、IDの欠落)の場合はスローします。ランタイムは
{ ok: false, code, message }で応答します。既知のキットエラー(例:不明なアクション)の場合はHTTP 400、ハンドラのthrowの場合は500 です。そしてmountCanvasのinvokeはCanvasActionErrorを拒否し、そのcode/messageを伝えるため、ボタンハンドラはtry/catchで処理できます。エージェント側では、extension.mjsが同じエラーをCanvasErrorにマッピングします。スローは表面化され、決してクラッシュではありません。 - 予期される一時的な障害(バックグラウンドポーリングでの信頼性の低い上流フェッチ)の場合は、スローせず、
state.errorにキャプチャして返します。これにより、ポーリングが静かに行われている間、エラーカードが表示されます(データテンプレートのrefreshを参照)。
アイコン — 常に公式GitHub Lucide
キットはgithub-appが出荷するものとまったく同じLucideセット([email protected]、バイト単位で同一)をベンダー提供します。すべてのアイコンにこれを使用し、手書きのSVGを書いたりCDNをプルしたりしないでください。
import { Icon } from "/kit/client.mjs";
html`<${Icon} name="circle-check" size=${16} />`
契約:名前はケバブケース(circle-check、trash-2、list-todo)です。viewBox は固定で 0 0 24 24、stroke-width は 2 です — 決して strokeWidth を渡さないでください。サイズは 12 · 14 · 16 · 20 · 24 · 32 を使用します。明らかなマッピングがある場合は、github-app自身のグリフ選択に合わせてください(例:open=circle-dot、done=circle-check、bookmark=bookmark)。検証が必要な場合は iconNames() / hasIcon(name) が利用可能です。
テーマ — Primerトークン、フォールバック付き
/kit/theme.css をリンクし、その ck-* クラス(ck-card、ck-btn、ck-btn-primary、ck-input、ck-badge、ck-tabs、ck-status、ck-empty、…)の上に構築します。これらはランタイムがキャンバスドキュメントに注入するPrimerトークンにマッピングされ、それぞれにハードコードされたGitHubダークフォールバックがあるため、トークンが欠落していてもキャンバスは正しく表示されます。その上にキャンバス固有のCSSを追加します。プリミティブのスタイルを変更しないでください。
バッジは汎用的です。 意味論的バリアント ck-badge-success、ck-badge-accent、ck-badge-muted、ck-badge-danger、ck-badge-attention を使用し、あなたのステータスをビューでそれらにマッピングします(リファレンスは open → success, decided → accent, parked → muted を行います)。アプリ固有のステータスクラス名を共有テーマに追加しないでください。
ローディングとエラーのプリミティブ(これらは手作業で作成しないでください):
ck-spinner— スピンアニメーション。Lucideローダーと組み合わせます。例:html\<${Icon} name="loader-circle" class="ck-spinner" />``。ck-skeleton— まだロード中のコンテンツのための、きらめくプレースホルダーブロック。ck-callout/ck-callout ck-error— フェッチ失敗のためのインラインノーティス/エラーサーフェス。例:html\<${Icon} name="circle-x" size=${16} />${state.error}``。
モーションの安全性はグローバルです。 theme.css はすでに @media (prefers-reduced-motion: reduce) ガードを出荷しており、すべてのアニメーションとトランジション(キットのスピナー/スケルトンや追加したもの)を無効化します。キャンバスごとに繰り返す必要はありません — ただし、モーションは装飾的に保ち、決して唯一のシグナルにしないでください。
ビューAPI — フック、ヘルパー、フラグメント
/kit/client.mjs は、useState だけでなく、ベンダー提供のPreactフックセット全体を再エクスポートします:useEffect、useRef、useMemo、useCallback、useReducer、useContext、useLayoutEffect、useImperativeHandle。フックはコンポーネントの先頭で無条件に呼び出してください — 早期の return(例:フックの後の if (!state) return … ローディングブランチ)の前に。
また、フォーマットとIDヘルパーをエクスポートしているので、それらを再発明する必要はありません。
import { nid, relativeTime, compactNumber, percent } from "/kit/client.mjs";
nid(); // "lq3k9f2a" — 短いユニークID
relativeTime(iso); // "5分前" / "3時間前" / "2日前" / 日付
compactNumber(2_300_000); // "2.3M"
percent(1.25); // "+1.25%"
nid はサーバーサイドの canvas.mjs でも import { nid } from "./canvas-kit/format.mjs" でインポート可能です(ジェネレーターはこれを行います)。
フォーマッターはロケール対応です — relativeTime は toLocaleDateString() で終わり、compactNumber/percent は Number.prototype.toLocaleString を通るため、その出力はホストロケールに依存します("1.5K" vs "1,5 K"、2025-01-31 vs 31/01/2025)。永続状態には生の値(ISO文字列、Number)を保持し、ビューでフォーマットしてください。フォーマットされた文字列を保存したり、解析して戻したりしないでください。フォーマットはプレゼンテーションの関心事であり、あるロケールでフォーマットされた値は、別のロケールで確実に再読み取りできません。
Fragment はエクスポートされません。 ラッパーなしで兄弟ノードを返すには、htmの組み込み空タグ構文 — html`<><${A} /><${B} /></>` — を使用するか、単にvnodeの配列を返してください。import { Fragment } しないでください。存在しません。
外部データキャンバス — フェッチ + 自動更新
ほとんどの本番キャンバスは、ユーザーが入力するCRUDリストではなく、ネットワークからプルして更新します。形式は次のとおりです。
-
fetch()はアクションハンドラ(またはそれが呼び出すヘルパー)に存在し、ビューには決して存在しません。 常にタイムアウトとバインドして、遅い上流がパネルをハングさせないようにします。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()); }フェッチはループバックランタイム上でサーバーサイドで実行されるため、生成された
dataテンプレートはSSRFチェックでこれをガードします。http/httpsをパブリックホストにのみ許可し、ループバック、リンクローカル(クラウドメタデータを含む)、プライベート範囲 — ホスト名が解決するすべてのアドレスを含む — を拒否します。エージェントにフィードバックするもの(例:list_items経由)は依然として攻撃者に影響されるテキストであるため、あなたが選択したソースを維持し、フェッチされたコンテンツを信頼できないものとして扱います(テキストとしてレンダリングし、決してinnerHTMLではレンダリングしません)。 -
refreshアクションを公開して、フェッチして結果を共有状態に書き込みます。失敗をstate.errorにキャプチャし、スローせずに返します。これにより、バックグラウンドのポーリングティックが静かに行われている間、エラーカードが表示されます。awaitの前に入力をキャプチャし、関数型setで書き戻して、並行アクション(別のポーリング、set_source)が上書きされないようにします。refresh: { inputSchema: { type: "object", properties: {}, additionalProperties: false }, handler: async ({ state, set }) => { const sourceUrl = state.sourceUrl; // awaitの前にキャプチャ try { const items = await fetchItems(sourceUrl); set((cur) => cur.sourceUrl !== sourceUrl // 古い応答を破棄 ? cur : { ...cur, items, error: null, lastRefresh: new Date().toISOString() }); return { count: items.length }; } catch (err) { set((cur) => ({ ...cur, error: `Couldn't load: ${err.message}`, lastRefresh: new Date().toISOString() })); return { ok: false, error: String(err.message) }; } }, }, -
キットでポーリングを行い、生の
setIntervalは使用しません。pollWhileVisible(tick, seconds)は、パネルが表示されている間のみインターバルでtickを実行し(バックグラウンドのキャンバスはネットワークへのアクセスを停止します)、クリーンアップ関数を返します —useEffectの戻り値としてそのまま使用できます。import { pollWhileVisible } from "/kit/client.mjs"; // 永続状態にバインドされたインターバル。変更時に再構築される: useEffect(() => pollWhileVisible(() => invoke("refresh"), state.autoRefreshSec || 0), [state.autoRefreshSec]);固定インターバルの場合は、エフェクトを完全にスキップできます:
mountCanvas({ view, poll: { action: "refresh", seconds: 60, immediate: true } })。
この全体の形式を node scripts/new-canvas.mjs <name> --template data でスタンプします。
ドメイン戦略 — 共有ボード vs パーソナルプロファイル
状態は resolveDomainId が返すドメインIDによってキー設定されます。二つの意図、同一のメカニズム(fileFor はIDをファイル名にサニタイズします — そのままコピーしてください)。
- 共有ボード(ほとんどのキャンバス):エージェントとユーザーの両方が名前を付けるトピックからキーを取得します。
resolveDomainId: (input) => (input?.domain ? String(input.domain) : "default")。誰でもdomain: "tech"を開くと同じボードを表示します。 - パーソナルストア:トピックではなく、アイデンティティからキーを取得します。
resolveDomainId: (input) => (input?.profile ? String(input.profile) : "default")。入力スキーマプロパティはprofileという名前です。トピック間で漏れてはいけない学習者別/ユーザー別データに使用します。
名詞(domain と profile)を意図的に選択してください。それが誰が何を共有するかに関する契約全体です。
パターンと制限事項(これらを知っておくこと)
- バンドルされたカタログ。 厳選されたシードデータ(コース、プリセット)には、別の
catalog.mjsを出荷します。プレーンデータと、決定論的な位置ID(es-u1-l2)を割り当てるbuildCourse(...)スタイルのビルダーを使用します。これにより、同じカタログは常に同じIDを生成します。小さなコンストラクタヘルパーを使用して、バルクデータを簡潔に保ちます。 statusLineはオープン時に一度だけ計算されます。 パネルのステータス文字列はopenInstanceで設定され、アクションが状態を変更しても再プッシュされません。そのため、statusLine内のカウントは古くなります。ライブカウントは、すべてのSSEプッシュで再レンダリングされるキャンバス本体内部に表示し、statusLineはオープン時のみのサマリーとして扱います。- リッチペイロードアクション。 アクション
inputSchemaはデフォルトでadditionalProperties: falseになります。これは、スキーマがエージェントが入力する契約であり、厳格なスキーマにより、モデルが未検証のフィールドやタイプミスのフィールドを密輸するのを防ぎます。宣言したプロパティのみがハンドラに到達します。UIがハンドラにスカラーフィールドではなくオブジェクト全体(例:保存される記事)を渡す必要がある場合は、そのアクションのinputSchemaでadditionalProperties: trueを設定し、ハンドラ内で必要なものを永続状態に非正規化します。アクションごとに意図的に、包括的なデフォルトとしてではなく、設定を反転させてください。 - インストールレイアウト / SDK解決。 出荷される拡張機能フォルダには
copilot-extension.json({ "name", "version": 1 }—package.jsonなし)、extension.mjs、canvas.mjs、web/、およびネストされたcanvas-kit/のみが含まれます。キットはNodeが直接実行するプレーンなESMです — 拡張機能にバンドラー、トランスパイル、ビルドステップ、package.jsonはありません(スタンプされたtest/smoke.test.mjsはベアのnodeで実行されます)。ホストは@github/copilot-sdk/extensionを解決します。extension.mjsだけがそれをインポートする唯一のファイルです。出荷されるキャンバスにpackage.jsonやビルドステップを追加しないでください。
キャンバスの構築 — 最速のパス
- スタンプする(動作するキャンバスが一つ、キットがすでにネストされ、スモークテストが含まれています):
node scripts/new-ca
…
[以降の翻訳部分については、原文が不完全なため、ここまでとします]