캔버스 앱 만들기
Copilot App 캔버스 확장을 구축하기 위한 올인원 방식입니다. 이 키트가 필요한 이유는 직접 만든 캔버스가 항상 동일한 문제에 부딪히기 때문입니다: 키 입력을 삼키는 innerHTML 리페인트 루프, 하나의 패널에만 존재하는 상태, 호스트 테마를 무시하는 임시 스타일링, 일관성 없는 아이콘. 이 키트는 네 가지 문제를 모두 해결합니다.
캔버스란 무엇이며, 언제 만들어야 하는가
캔버스는 에이전트가 open_canvas를 통해 사이드 패널에 여는 대화형 표면입니다. 에이전트와 사용자는 동일한 액션 핸들러를 통해 동일한 상태에 대해 작업합니다. 채팅 텍스트나 diff만으로는 부족할 때 캔버스를 사용하세요: 라이브 대시보드, 편집기, 스프레드시트, 트래커, 칸반/보드 뷰, 문서 미리보기, 도구별 워크플로우.
사용자에게 시각적 요소가 필요 없는 에이전트 도구만 필요하다면, 캔버스 대신 일반 확장 도구를 만드세요.
렌더링 계층 — 먼저 결정하세요
| 계층 | 사용 시기 | 방법 |
|---|---|---|
| 정적 HTML 문자열 | 읽기 전용 또는 거의 정적인 콘텐츠; 보호할 입력 필드 없음 | 런타임의 기본 스캐폴드(extensions_manage scaffold kind:canvas). |
| Preact + htm + 이 키트 | 대화형 요소: 입력, 라이브 업데이트, 목록, 폼, 공유 상태 | 이 키트. 실제 캔버스의 기본 선택. |
키트를 사용하는 가장 중요한 이유: Preact가 DOM을 diff 방식으로 업데이트하므로, 에이전트의 라이브 상태 푸시가 입력 필드의 포커스, 캐럿 위치, 또는 입력 중인 텍스트를 방해하지 않습니다. 초기 캔버스가 주로 사용하는 innerHTML = ... 패턴은 전체 트리를 다시 그리므로 매 푸시마다 키 입력이 손실됩니다. 그러지 마세요.
모델 (코딩 전에 읽어보세요)
extension.mjs ── Copilot SDK를 임포트하는 유일한 파일 (얇은 어댑터)
canvas.mjs ── 당신의 캔버스: id, 스키마, 상태 로드/저장, 액션 핸들러 (SDK 없음)
canvas-kit/ ── 키트 (그대로 복사; 편집하지 마세요)
web/index.html ── 셸: /kit/theme.css와 ./app.mjs를 로드
web/app.mjs ── 당신의 Preact 뷰
- 상태는 공유되고 지속됩니다. 열린 입력에서 확인된 도메인 id에 의해 키가 지정되며(
resolveDomainId),instanceId가 아닙니다 — 동일한 도메인을 두 패널에서 열면 동일한 데이터를 보여줍니다. 지속성은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) throw합니다. 런타임은
{ ok: false, code, message }로 응답합니다 — 알려진 키트 오류(예: 알 수 없는 액션)의 경우 HTTP 400, 핸들러throw의 경우 500 — 그리고mountCanvas의invoke는 해당code/message를 담은CanvasActionError로 거부하므로 버튼 핸들러가try/catch할 수 있습니다. 에이전트 측에서는extension.mjs가 동일한 오류를CanvasError에 매핑합니다. throw는 표면화되며, 충돌이 아닙니다. - 예상되는 일시적 실패 (백그라운드 폴링에서 불안정한 업스트림 가져오기)의 경우, throw하지 말고
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 자체 글리프 선택과 일치시키세요 (예: 열기=circle-dot, 완료=circle-check, 북마크=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— 실패한 fetch를 위한 인라인 알림/오류 표면 (예: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-kit/format.mjs에서 import { nid } from "./canvas-kit/format.mjs"를 통해 서버 측 canvas.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 } 하지 마세요. 존재하지 않습니다.
외부 데이터 캔버스 — fetch + 자동 새로고침
대부분의 실제 캔버스는 사용자가 입력한 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()); }fetch는 루프백 런타임에서 서버 측에서 실행되므로, 생성된
data템플릿은 SSRF 검사로 보호합니다:http/https를 공개 호스트에만 허용하고 루프백, 링크-로컬(클라우드 메타데이터 포함) 및 개인 범위(호스트 이름이 확인하는 모든 주소 포함)를 거부합니다. 에이전트에 다시 피드하는 모든 것(예:list_items를 통해)은 여전히 공격자에 의해 영향을 받을 수 있는 텍스트이므로, 당신이 선택한 소스를 유지하고 가져온 콘텐츠를 신뢰할 수 없는 것으로 취급하세요(텍스트로 렌더링하고, 절대innerHTML로 렌더링하지 마세요). -
refresh액션을 노출하여 fetch하고 결과를 공유 상태에 씁니다. 실패를state.error에 캡처하고 반환하여(throw하지 말고) 백그라운드 폴링 틱이 오류 카드가 표시되는 동안 조용히 유지되도록 합니다.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: `로드할 수 없음: ${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]);고정 간격의 경우 effect를 완전히 건너뛸 수 있습니다:
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"를 여는 사람은 누구나 동일한 보드를 봅니다. - 개인 저장소: 주제가 아닌 ID에서 키를 가져옵니다.
resolveDomainId: (input) => (input?.profile ? String(input.profile) : "default"), 입력 스키마 속성 이름은profile입니다. 학습자별/사용자별 데이터에 대해 사용하세요. 주제 간에 데이터가 섞여서는 안 됩니다.
명사(domain vs profile)를 의도적으로 선택하세요 — 이것이 누가 무엇을 공유하는지에 대한 전체 계약입니다.
패턴 및 제한 사항 (알아두세요)
- 번들 카탈로그. 선별된 시드 데이터(과정, 프리셋)의 경우, 별도의
catalog.mjs를 제공하세요: 일반 데이터와 결정적 위치 ID(es-u1-l2)를 할당하는buildCourse(...)-스타일 빌더를 포함하여 동일한 카탈로그가 항상 동일한 ID를 생성하도록 합니다. 작은 생성자 헬퍼를 사용하여 대량 데이터를 간결하게 유지하세요. statusLine은 열 때 한 번 계산됩니다. 패널의 상태 문자열은openInstance에서 설정되며, 액션이 상태를 변경할 때 다시 푸시되지 않으므로statusLine의 count는 오래됩니다. 캔버스 본문 내부에서 라이브 count를 표시하세요 (모든 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