fix: gesture review canvas post-permission flow (#2037)

Load PRs from the active workspace repo instead of the extension directory, surface PR load errors in the canvas UI, and add MediaPipe CDN fallbacks for better runtime reliability.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Montemagno
2026-06-17 15:03:50 -07:00
committed by GitHub
parent 2f9d85eef8
commit 2afd5061b3
+58 -11
View File
@@ -1,18 +1,15 @@
import http from "node:http"; import http from "node:http";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
import { createCanvas, joinSession } from "@github/copilot-sdk/extension"; import { createCanvas, joinSession } from "@github/copilot-sdk/extension";
// This file lives inside the repo worktree, so its directory is a safe cwd for // The extension should query PRs from the active workspace repository.
// git/gh regardless of where the extension host process was launched from.
const extensionDir = dirname(fileURLToPath(import.meta.url));
// In-memory state // In-memory state
let currentPR = null; let currentPR = null;
let prList = []; let prList = [];
let gestureState = "idle"; // idle | detecting | approved | rejected let gestureState = "idle"; // idle | detecting | approved | rejected
let lastDecision = null; let lastDecision = null;
let lastLoadError = null;
const sseClients = new Set(); const sseClients = new Set();
let loadPRsPromise = null; // in-flight guard for loadOpenPRs let loadPRsPromise = null; // in-flight guard for loadOpenPRs
let cachedHTML = null; // cached HTML string let cachedHTML = null; // cached HTML string
@@ -23,6 +20,13 @@ function broadcast(event, data) {
} }
} }
function normalizeErrorMessage(error) {
if (!error) return "Unknown error loading pull requests.";
const message = typeof error === "string" ? error : error.message || String(error);
const singleLine = message.split(/\r?\n/)[0].trim();
return singleLine || "Unknown error loading pull requests.";
}
// --- Load open PRs from the repo via the gh CLI --- // --- Load open PRs from the repo via the gh CLI ---
function shortDescription(body) { function shortDescription(body) {
if (!body) return ""; if (!body) return "";
@@ -40,6 +44,7 @@ function loadOpenPRs() {
if (loadPRsPromise) return loadPRsPromise; if (loadPRsPromise) return loadPRsPromise;
loadPRsPromise = new Promise((resolve) => { loadPRsPromise = new Promise((resolve) => {
const repoCwd = process.cwd();
execFile( execFile(
"gh", "gh",
[ [
@@ -52,16 +57,22 @@ function loadOpenPRs() {
"--json", "--json",
"number,title,author,additions,deletions,body", "number,title,author,additions,deletions,body",
], ],
{ cwd: extensionDir, maxBuffer: 1024 * 1024 }, { cwd: repoCwd, maxBuffer: 1024 * 1024 },
(err, stdout) => { (err, stdout) => {
loadPRsPromise = null; loadPRsPromise = null;
if (err) { if (err) {
console.error("gesture-review: failed to load PRs:", err.message); lastLoadError = normalizeErrorMessage(err);
prList = [];
currentPR = null;
console.error("gesture-review: failed to load PRs:", lastLoadError);
broadcast("prlist", prList);
broadcast("load_error", { message: lastLoadError });
resolve(false); resolve(false);
return; return;
} }
try { try {
const raw = JSON.parse(stdout); const raw = JSON.parse(stdout);
lastLoadError = null;
prList = raw.map((pr) => ({ prList = raw.map((pr) => ({
title: pr.title, title: pr.title,
number: pr.number, number: pr.number,
@@ -76,9 +87,12 @@ function loadOpenPRs() {
} }
broadcast("prlist", prList); broadcast("prlist", prList);
if (currentPR) broadcast("pr", currentPR); if (currentPR) broadcast("pr", currentPR);
broadcast("load_error", null);
resolve(true); resolve(true);
} catch (e) { } catch (e) {
console.error("gesture-review: failed to parse PRs:", e.message); lastLoadError = normalizeErrorMessage(e);
console.error("gesture-review: failed to parse PRs:", lastLoadError);
broadcast("load_error", { message: lastLoadError });
resolve(false); resolve(false);
} }
}, },
@@ -112,6 +126,11 @@ const server = http.createServer((req, res) => {
res.write(`event: pr\ndata: ${JSON.stringify(currentPR)}\n\n`); res.write(`event: pr\ndata: ${JSON.stringify(currentPR)}\n\n`);
} }
res.write(`event: state\ndata: ${JSON.stringify({ state: gestureState })}\n\n`); res.write(`event: state\ndata: ${JSON.stringify({ state: gestureState })}\n\n`);
if (lastLoadError) {
res.write(
`event: load_error\ndata: ${JSON.stringify({ message: lastLoadError })}\n\n`,
);
}
sseClients.add(res); sseClients.add(res);
req.on("close", () => sseClients.delete(res)); req.on("close", () => sseClients.delete(res));
return; return;
@@ -635,11 +654,13 @@ function getHTML() {
let allPRs = []; let allPRs = [];
let currentIndex = 0; let currentIndex = 0;
let decisions = {}; // number -> 'approved' | 'rejected' let decisions = {}; // number -> 'approved' | 'rejected'
let prLoadError = null;
// --- SSE --- // --- SSE ---
const es = new EventSource('/events'); const es = new EventSource('/events');
es.addEventListener('prlist', (e) => { es.addEventListener('prlist', (e) => {
allPRs = JSON.parse(e.data); allPRs = JSON.parse(e.data);
if (allPRs.length > 0) prLoadError = null;
// Keep index in range, then show the current PR (or auto-select the first // Keep index in range, then show the current PR (or auto-select the first
// undecided one) so the drawer is usable the moment the canvas loads. // undecided one) so the drawer is usable the moment the canvas loads.
if (currentIndex >= allPRs.length) currentIndex = 0; if (currentIndex >= allPRs.length) currentIndex = 0;
@@ -670,6 +691,11 @@ function getHTML() {
updateUI(); updateUI();
} }
}); });
es.addEventListener('load_error', (e) => {
const payload = e.data ? JSON.parse(e.data) : null;
prLoadError = payload?.message || null;
updateUI();
});
function showPR(pr) { function showPR(pr) {
prEmpty.classList.add('hidden'); prEmpty.classList.add('hidden');
@@ -791,14 +817,33 @@ function getHTML() {
}); });
} }
async function loadScriptWithFallback(sources, timeoutMs = 10000) {
let lastErr;
for (const src of sources) {
try {
await loadScript(src, timeoutMs);
return;
} catch (err) {
lastErr = err;
}
}
throw lastErr || new Error('Script load failed');
}
async function initMediaPipe() { async function initMediaPipe() {
const INIT_TIMEOUT = 30000; const INIT_TIMEOUT = 30000;
try { try {
// Load MediaPipe scripts dynamically with timeout // Load MediaPipe scripts dynamically with timeout
setLoadingProgress(40, 'Downloading hand tracking library...'); setLoadingProgress(40, 'Downloading hand tracking library...');
await loadScript('https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js', 15000); await loadScriptWithFallback([
'https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js',
'https://unpkg.com/@mediapipe/hands/hands.js'
], 15000);
setLoadingProgress(60, 'Downloading camera utilities...'); setLoadingProgress(60, 'Downloading camera utilities...');
await loadScript('https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js', 15000); await loadScriptWithFallback([
'https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js',
'https://unpkg.com/@mediapipe/camera_utils/camera_utils.js'
], 15000);
setLoadingProgress(70, 'Initializing hand detection model...'); setLoadingProgress(70, 'Initializing hand detection model...');
@@ -1173,7 +1218,9 @@ function getHTML() {
function updateUI() { function updateUI() {
if (!decided) { if (!decided) {
cameraWrap.className = 'camera-wrap'; cameraWrap.className = 'camera-wrap';
statusBar.textContent = currentPR ? 'Show thumbs up or thumbs down...' : 'Waiting for a PR...'; statusBar.textContent = currentPR
? 'Show thumbs up or thumbs down...'
: (prLoadError ? ('Unable to load PRs: ' + prLoadError) : 'Waiting for a PR...');
statusBar.className = 'status-bar'; statusBar.className = 'status-bar';
} }
} }