Skip to content

Commit 1e9bce0

Browse files
jongioCopilot
andauthored
feat(code-tutor): wire canvas-kit deep links into code-review fixes (#19)
The vendored canvas-kit (2026-07-04.1) shipped deep-link builders that no extension used. Code Tutor's "Request fix session" button only copied the prompt to the clipboard and set a flag, leaving the user to ask Copilot manually, even though a finding's fixPrompt is described as a ready-to-run prompt for a new session. Findings now render a real "Fix in a new session" deep-link anchor (ghapp://session/new, mode=autopilot) when the board knows its GitHub owner/repo, with a graceful fallback to the original copy-prompt button when it does not. - canvas.mjs: set_codebase gains an optional repo (owner/repo), validated with the kit's isRepoFullName and stored with the same lenient fallback as its sibling fields so a bogus value never yields a dead link. - web/app.mjs: build the link with buildSessionDeepLink and thread repo through ReviewTab into FindingCard; anchor uses target=_blank. - web/styles.css: one rule so the anchor reads as a button. - extension.mjs: refresh stale kit-version comments. - test/smoke.test.mjs: valid repo stored, invalid repo ignored. - README.md: document the CTA and the optional repo. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 628d8d3 commit 1e9bce0

6 files changed

Lines changed: 71 additions & 14 deletions

File tree

extensions/code-tutor/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ A GitHub Copilot App **canvas extension**: the agent and the user share the same
1212
- **Points at real code**: every topic and finding links to a file and line range. Click a reference to expand the actual source with language-aware syntax highlighting (read straight from disk under the codebase root).
1313
- **Mark your understanding** per topic: Understood, Not understood, Revisit, or New. A progress ring tracks how much you have understood.
1414
- **Ask and clarify**: ask questions per topic or globally; the agent answers in the panel.
15-
- **Code review**: flags good / ok / bad spots (perf, wrong data structures, suboptimal algorithms) with a one-click "Request fix session" the agent can pick up.
15+
- **Code review**: flags good / ok / bad spots (perf, wrong data structures, suboptimal algorithms). When the board knows its GitHub `owner/repo`, each issue gets a one-click **Fix in a new session** deep link (`ghapp://session/new`) that opens a dedicated Copilot session to run the fix; otherwise it copies a ready-to-run prompt for the agent to pick up.
1616
- **Freshness tracking**: fingerprints the code (git HEAD + newest file mtime) at analysis time, re-checks on a visibility-gated timer, and shows a "code changed, refresh" banner plus an always-available Refresh button. Code Tutor never re-analyzes on its own; analysis is the agent's job, so the Refresh button injects a re-analysis prompt into the current Copilot session.
1717

1818
## Layout
@@ -41,7 +41,9 @@ Copy this folder into `.github/extensions/code-tutor` (in-repo) or
4141
`$COPILOT_HOME/extensions/code-tutor` (personal), then run `extensions_reload` and
4242
open it with `open_canvas` (`canvasId: "code-tutor"`). Point it at a codebase by
4343
asking Copilot to analyze the repo; it calls `set_codebase` (with a `root` so
44-
code references resolve) and then authors the topics, references and findings.
44+
code references resolve, and optionally `repo` as `owner/repo` to enable the
45+
"Fix in a new session" deep links) and then authors the topics, references and
46+
findings.
4547

4648
## Keeping the kit current
4749

extensions/code-tutor/canvas.mjs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { homedir } from "node:os";
1818
import { resolve, isAbsolute, sep, join } from "node:path";
1919
import { userStore } from "./canvas-kit/storage.mjs";
2020
import { nid } from "./canvas-kit/format.mjs";
21+
import { isRepoFullName } from "./canvas-kit/deeplinks.mjs";
2122
import {
2223
conceptKeyFor,
2324
looksCodebaseSpecific,
@@ -413,6 +414,12 @@ export const canvasConfig = {
413414
properties: {
414415
label: { type: "string", description: "Display name, e.g. the repo or project name." },
415416
root: { type: "string", description: "Absolute or repo-relative root path that was analyzed." },
417+
repo: {
418+
type: "string",
419+
description:
420+
"GitHub \"owner/repo\" this codebase belongs to. When set, findings offer a one-click " +
421+
"\"Fix in a new session\" deep link that opens a dedicated Copilot session to run the fix.",
422+
},
416423
summary: { type: "string", description: "1-3 sentence overview of what the codebase does." },
417424
fileCount: { type: "number", description: "Approximate number of source files analyzed." },
418425
languages: {
@@ -425,9 +432,19 @@ export const canvasConfig = {
425432
},
426433
handler: async ({ state, set, input }) => {
427434
const root = trimmed(input.root) || state.codebase?.root || "";
435+
// Only keep a repo that passes github-app's own owner/repo validation, so
436+
// a bogus value never reaches buildSessionDeepLink (which would drop it and
437+
// yield a null link). An omitted/invalid input preserves the prior repo.
438+
const inputRepo = trimmed(input.repo);
439+
const repo = isRepoFullName(inputRepo)
440+
? inputRepo
441+
: isRepoFullName(state.codebase?.repo)
442+
? state.codebase.repo
443+
: null;
428444
const codebase = {
429445
label: trimmed(input.label) || state.codebase?.label || "This codebase",
430446
root,
447+
repo,
431448
summary: trimmed(input.summary) || state.codebase?.summary || "",
432449
fileCount: Number.isFinite(input.fileCount) ? Math.max(0, input.fileCount | 0) : state.codebase?.fileCount ?? null,
433450
languages: Array.isArray(input.languages)

extensions/code-tutor/extension.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ let runtime = null;
2424
const AI_TIMEOUT_MS = 90_000;
2525
const fastAI = createFastAI({ model: "gpt-5.4-mini", timeoutMs: AI_TIMEOUT_MS });
2626

27-
// ---- host AI capability (canvas-kit 2026-06-27.1) --------------------------
27+
// ---- host AI capability (canvas-kit host model: ai + askAgent) -------------
2828
// Two ways to reach the model. Both are handed to the kit via runtime.setHost(...)
2929
// so SDK-free canvas.mjs handlers can call ctx.ai(...) / ctx.askAgent(...).
3030
//
@@ -279,7 +279,7 @@ session = await joinSession({ canvases: [canvas] });
279279

280280
// Expose the host model to SDK-free canvas.mjs handlers as ctx.ai / ctx.askAgent.
281281
// The intercepts above use `host` directly; this makes the SAME capability
282-
// available to any plain handler too (canvas-kit 2026-06-27.1).
282+
// available to any plain handler too (via the kit's runtime.setHost host model).
283283
runtime.setHost(host);
284284

285285
// Eagerly warm the dedicated fast-AI runtime so the learner's FIRST question or

extensions/code-tutor/test/smoke.test.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,16 @@ try {
126126
assert.equal(s.codebase.fileCount, 12);
127127
});
128128

129+
await test("set_codebase stores a valid owner/repo and ignores an invalid one", async () => {
130+
// A valid full name is recorded so findings can offer a session deep link.
131+
await post(open.url, "set_codebase", { repo: "jongio/copilot-extensions" });
132+
assert.equal((await getState(open.url)).codebase.repo, "jongio/copilot-extensions");
133+
// A malformed repo must NOT overwrite the good one (lenient fallback), so a
134+
// bad value never reaches buildSessionDeepLink and yields a dead link.
135+
await post(open.url, "set_codebase", { repo: "not a repo/../x" });
136+
assert.equal((await getState(open.url)).codebase.repo, "jongio/copilot-extensions");
137+
});
138+
129139
let topicId;
130140
await test("add_topic caches the generic eli5 level + auto-detects it", async () => {
131141
const { body } = await post(open.url, "add_topic", {

extensions/code-tutor/web/app.mjs

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// (active tab, filters, expanded panels, draft text, live slider position) lives
66
// in useState; Preact's DOM diffing keeps a live push from clobbering it.
77

8-
import { html, mountCanvas, useState, useEffect, useRef, Icon, relativeTime, pollWhileVisible } from "/kit/client.mjs";
8+
import { html, mountCanvas, useState, useEffect, useRef, Icon, relativeTime, pollWhileVisible, buildSessionDeepLink } from "/kit/client.mjs";
99
import { tokenize, toLines, languageFor } from "./highlight.mjs";
1010

1111
const LEVELS = ["eli5", "curious", "engineer", "wizard"];
@@ -524,9 +524,16 @@ function TopicCard({ topic, level, questions, open, onToggle, invoke }) {
524524
}
525525

526526
// ---- findings --------------------------------------------------------------
527-
function FindingCard({ f, topicTitle, invoke }) {
527+
function FindingCard({ f, topicTitle, invoke, repo }) {
528528
const [showPrompt, setShowPrompt] = useState(false);
529529
const m = QUALITY_META[f.quality] ?? QUALITY_META.ok;
530+
// A validated ghapp:// deep link that opens a NEW, dedicated Copilot session to
531+
// run this finding's ready-made fix prompt. Null unless the board knows its
532+
// GitHub owner/repo (set via set_codebase) AND the finding carries a fixPrompt;
533+
// buildSessionDeepLink returns null for anything it can't form into a safe link,
534+
// so we fall back to copy-the-prompt below.
535+
const fixLink =
536+
repo && f.fixPrompt ? buildSessionDeepLink({ repo, prompt: f.fixPrompt, mode: "autopilot" }) : null;
530537
return html`
531538
<div class=${`cs-finding ${f.quality}`}>
532539
<div class="cs-finding-head">
@@ -550,12 +557,27 @@ function FindingCard({ f, topicTitle, invoke }) {
550557
<div class="cs-finding-actions">
551558
${f.fixPrompt
552559
? html`
553-
<button class="ck-btn ck-btn-sm ck-btn-primary" onClick=${async () => {
554-
copy(f.fixPrompt);
555-
await invoke("set_fix_status", { id: f.id, status: "requested" });
556-
}}>
557-
<${Icon} name="wrench" size=${14} />Request fix session
558-
</button>
560+
${fixLink
561+
? html`
562+
<a
563+
class="ck-btn ck-btn-sm ck-btn-primary"
564+
href=${fixLink}
565+
target="_blank"
566+
rel="noopener noreferrer"
567+
title="Open a new Copilot session that fixes this"
568+
onClick=${() => invoke("set_fix_status", { id: f.id, status: "requested" })}
569+
>
570+
<${Icon} name="wrench" size=${14} />Fix in a new session
571+
</a>
572+
`
573+
: html`
574+
<button class="ck-btn ck-btn-sm ck-btn-primary" onClick=${async () => {
575+
copy(f.fixPrompt);
576+
await invoke("set_fix_status", { id: f.id, status: "requested" });
577+
}}>
578+
<${Icon} name="wrench" size=${14} />Request fix session
579+
</button>
580+
`}
559581
<button class="ck-btn ck-btn-sm" onClick=${() => setShowPrompt((v) => !v)}>
560582
<${Icon} name=${showPrompt ? "chevron-down" : "chevron-right"} size=${14} />${showPrompt ? "Hide" : "Show"} prompt
561583
</button>
@@ -572,7 +594,9 @@ function FindingCard({ f, topicTitle, invoke }) {
572594
</button>
573595
</div>
574596
${f.fixStatus === "requested"
575-
? html`<div class="ck-caption"><${Icon} name="info" size=${12} /> Prompt copied - ask Copilot to start a session, or it'll pick this up.</div>`
597+
? html`<div class="ck-caption"><${Icon} name="info" size=${12} /> ${fixLink
598+
? "Opening a fix session in a new Copilot window - confirm it there."
599+
: "Prompt copied - ask Copilot to start a session, or it'll pick this up."}</div>`
576600
: null}
577601
</div>
578602
`;
@@ -680,6 +704,7 @@ function LearnTab({ state, level, openIds, toggle, invoke }) {
680704

681705
function ReviewTab({ state, invoke }) {
682706
const findings = state.findings ?? [];
707+
const repo = state.codebase?.repo || null;
683708
const byId = Object.fromEntries((state.topics ?? []).map((t) => [t.id, t.title]));
684709
if (!findings.length) {
685710
return html`
@@ -704,7 +729,7 @@ function ReviewTab({ state, invoke }) {
704729
<h3>${m.label}</h3>
705730
<span class="cs-count">${group.length}</span>
706731
</div>
707-
<div class="cs-list">${group.map((f) => html`<${FindingCard} key=${f.id} f=${f} topicTitle=${byId[f.topicId]} invoke=${invoke} />`)}</div>
732+
<div class="cs-list">${group.map((f) => html`<${FindingCard} key=${f.id} f=${f} topicTitle=${byId[f.topicId]} repo=${repo} invoke=${invoke} />`)}</div>
708733
</div>
709734
`;
710735
})}

extensions/code-tutor/web/styles.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,9 @@ button.cs-chip:hover { border-color: var(--ck-accent); color: var(--ck-fg); }
303303
.cs-suggest strong { color: var(--ck-accent); }
304304
.cs-fixprompt { font-family: var(--ck-mono); font-size: var(--fs-code); white-space: pre-wrap; background: var(--ck-bg-inset); border: 1px solid var(--ck-border); border-radius: var(--r-sm); padding: 9px 11px; line-height: 1.5; }
305305
.cs-finding-actions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
306+
/* "Fix in a new session" is a ghapp:// deep-link anchor styled as a kit button;
307+
strip the default underline so it reads as a button, not a link. */
308+
.cs-finding-actions a.ck-btn { text-decoration: none; }
306309

307310
/* ---- empty states -------------------------------------------------------- */
308311
.cs-empty { text-align: center; padding: 30px 16px; color: var(--ck-muted); }

0 commit comments

Comments
 (0)