diff --git a/eng/generate-website-data.mjs b/eng/generate-website-data.mjs
index 59723d1b2..e1316f0b0 100755
--- a/eng/generate-website-data.mjs
+++ b/eng/generate-website-data.mjs
@@ -670,6 +670,76 @@ function generatePluginsData(gitDates) {
/**
* Generate canvas extensions metadata
*/
+function getExtensionAssetInfo(extensionDir, relPath, ref) {
+ const assetDir = path.join(extensionDir, "assets");
+
+ if (!fs.existsSync(assetDir)) {
+ return null;
+ }
+
+ const imageExtensions = new Set([
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".webp",
+ ".gif",
+ ]);
+
+ const preferredNames = [
+ "preview.png",
+ "preview.jpg",
+ "preview.jpeg",
+ "preview.webp",
+ "preview.gif",
+ "screenshot.png",
+ "screenshot.jpg",
+ "screenshot.jpeg",
+ "screenshot.webp",
+ "screenshot.gif",
+ "image.png",
+ "image.jpg",
+ "image.jpeg",
+ "image.webp",
+ "image.gif",
+ ];
+
+ for (const candidate of preferredNames) {
+ const candidatePath = path.join(assetDir, candidate);
+ if (fs.existsSync(candidatePath)) {
+ const assetPath = `${relPath}/assets/${candidate}`;
+ const encodedAssetPath = assetPath
+ .split("/")
+ .map((segment) => encodeURIComponent(segment))
+ .join("/");
+ return {
+ assetPath,
+ imageUrl: `https://raw.githubusercontent.com/github/awesome-copilot/${ref}/${encodedAssetPath}`,
+ };
+ }
+ }
+
+ const files = fs
+ .readdirSync(assetDir)
+ .filter((file) => imageExtensions.has(path.extname(file).toLowerCase()))
+ .sort((a, b) => a.localeCompare(b));
+
+ if (files.length === 0) {
+ return null;
+ }
+
+ const assetFile = files[0];
+ const assetPath = `${relPath}/assets/${assetFile}`;
+ const encodedAssetPath = assetPath
+ .split("/")
+ .map((segment) => encodeURIComponent(segment))
+ .join("/");
+
+ return {
+ assetPath,
+ imageUrl: `https://raw.githubusercontent.com/github/awesome-copilot/${ref}/${encodedAssetPath}`,
+ };
+}
+
function generateExtensionsData(gitDates, commitSha) {
const extensions = [];
@@ -683,12 +753,20 @@ function generateExtensionsData(gitDates, commitSha) {
for (const dir of extensionDirs) {
const relPath = `extensions/${dir.name}`;
+ const assetInfo = getExtensionAssetInfo(
+ path.join(EXTENSIONS_DIR, dir.name),
+ relPath,
+ commitSha
+ );
+
extensions.push({
id: dir.name,
name: formatDisplayName(dir.name),
path: relPath,
ref: commitSha,
lastUpdated: getDirectoryLastUpdated(gitDates, relPath),
+ imageUrl: assetInfo?.imageUrl || null,
+ assetPath: assetInfo?.assetPath || null,
});
}
diff --git a/extensions/backlog-swipe-triage/README.md b/extensions/backlog-swipe-triage/README.md
new file mode 100644
index 000000000..291e6ea90
--- /dev/null
+++ b/extensions/backlog-swipe-triage/README.md
@@ -0,0 +1,8 @@
+# Backlog Swipe Triage
+
+Swipe-driven backlog triage canvas for reviewing open issues, applying quick decisions, and starting implementation sessions.
+
+## Assets
+
+- `assets/preview.png` — preferred screenshot path for the triage experience.
+- `assets/swipe-canvas-triage.png` — existing reference screenshot kept for compatibility.
diff --git a/extensions/backlog-swipe-triage/assets/preview.png b/extensions/backlog-swipe-triage/assets/preview.png
new file mode 100644
index 000000000..ee411be08
Binary files /dev/null and b/extensions/backlog-swipe-triage/assets/preview.png differ
diff --git a/extensions/backlog-swipe-triage/assets/swipe-canvas-triage.png b/extensions/backlog-swipe-triage/assets/swipe-canvas-triage.png
new file mode 100644
index 000000000..ee411be08
Binary files /dev/null and b/extensions/backlog-swipe-triage/assets/swipe-canvas-triage.png differ
diff --git a/extensions/backlog-swipe-triage/extension.mjs b/extensions/backlog-swipe-triage/extension.mjs
new file mode 100644
index 000000000..f60fb07f9
--- /dev/null
+++ b/extensions/backlog-swipe-triage/extension.mjs
@@ -0,0 +1,2169 @@
+import { createServer } from "node:http";
+import { joinSession, createCanvas } from "@github/copilot-sdk/extension";
+import { promises as fs } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+
+const servers = new Map();
+const extensionDir = fileURLToPath(new URL(".", import.meta.url));
+const artifactsDir = path.join(extensionDir, "artifacts");
+const stateFile = path.join(artifactsDir, "backlog-triage-state.json");
+const decisions = ["assign_agent", "needs_info", "not_now", "close", "ignore"];
+const execFileAsync = promisify(execFile);
+const MAX_SYNC_ISSUES = 200;
+const defaultFilters = {
+ timeWindow: "any",
+ labels: [],
+ assignees: [],
+ query: "",
+ sortBy: "updated-desc",
+};
+const filterSchema = {
+ type: "object",
+ properties: {
+ timeWindow: { type: "string", enum: ["any", "1d", "3d", "7d", "14d", "30d", "90d"] },
+ labels: { type: "array", items: { type: "string" } },
+ assignees: { type: "array", items: { type: "string" } },
+ query: { type: "string" },
+ sortBy: { type: "string", enum: ["updated-desc", "updated-asc", "created-desc", "created-asc", "title-asc", "random"] },
+ },
+ additionalProperties: false,
+};
+let activeSession = null;
+const MAX_REQUEST_BODY_BYTES = 1024 * 1024;
+
+let storage = { boards: {} };
+let storageLoaded = false;
+let persistStorageQueue = Promise.resolve();
+
+async function ensureStorageLoaded() {
+ if (storageLoaded) {
+ return;
+ }
+ await fs.mkdir(artifactsDir, { recursive: true });
+ try {
+ const raw = await fs.readFile(stateFile, "utf8");
+ storage = JSON.parse(raw);
+ } catch (error) {
+ if (error && error.code !== "ENOENT") {
+ throw error;
+ }
+ storage = { boards: {} };
+ }
+ storageLoaded = true;
+}
+
+async function persistStorage() {
+ await fs.mkdir(artifactsDir, { recursive: true });
+ const snapshot = JSON.stringify(storage, null, 2);
+ persistStorageQueue = persistStorageQueue
+ .catch(() => undefined)
+ .then(async () => {
+ const tempStateFile = `${stateFile}.tmp-${process.pid}-${Date.now()}`;
+ await fs.writeFile(tempStateFile, snapshot, "utf8");
+ await fs.rename(tempStateFile, stateFile);
+ });
+ await persistStorageQueue;
+}
+
+function normalizeText(value, fallback = "") {
+ return typeof value === "string" ? value.trim() : fallback;
+}
+
+function escapeHtml(value) {
+ return normalizeText(value).replace(/[&<>"']/g, (char) => {
+ if (char === "&") return "&";
+ if (char === "<") return "<";
+ if (char === ">") return ">";
+ if (char === '"') return """;
+ return "'";
+ });
+}
+
+function normalizeStringArray(values) {
+ if (!Array.isArray(values)) {
+ return [];
+ }
+ return values.map((value) => normalizeText(value)).filter(Boolean);
+}
+
+function normalizeFilters(raw, fallback = defaultFilters) {
+ const merged = raw && typeof raw === "object" ? { ...fallback, ...raw } : { ...fallback };
+ const legacyAssignee = normalizeText(merged.assignee);
+ return {
+ timeWindow: ["any", "1d", "3d", "7d", "14d", "30d", "90d"].includes(merged.timeWindow) ? merged.timeWindow : "any",
+ labels: normalizeStringArray(merged.labels),
+ assignees: legacyAssignee ? [legacyAssignee] : normalizeStringArray(merged.assignees),
+ query: normalizeText(merged.query).toLowerCase(),
+ sortBy: ["updated-desc", "updated-asc", "created-desc", "created-asc", "title-asc", "random"].includes(merged.sortBy)
+ ? merged.sortBy
+ : "updated-desc",
+ };
+}
+
+function parseDateToMs(value) {
+ const timestamp = Date.parse(value || "");
+ return Number.isFinite(timestamp) ? timestamp : 0;
+}
+
+function getTimeWindowMs(timeWindow) {
+ if (timeWindow === "1d") return 1 * 24 * 60 * 60 * 1000;
+ if (timeWindow === "3d") return 3 * 24 * 60 * 60 * 1000;
+ if (timeWindow === "7d") return 7 * 24 * 60 * 60 * 1000;
+ if (timeWindow === "14d") return 14 * 24 * 60 * 60 * 1000;
+ if (timeWindow === "30d") return 30 * 24 * 60 * 60 * 1000;
+ if (timeWindow === "90d") return 90 * 24 * 60 * 60 * 1000;
+ return 0;
+}
+
+function getIssueLabels(issue) {
+ return Array.isArray(issue?.labels) ? issue.labels.map((label) => normalizeText(label?.name).toLowerCase()).filter(Boolean) : [];
+}
+
+function getIssueAssignees(issue) {
+ return Array.isArray(issue?.assignees)
+ ? issue.assignees.map((assignee) => normalizeText(assignee?.login).toLowerCase()).filter(Boolean)
+ : [];
+}
+
+function issueMatchesFilters(issue, filters) {
+ const now = Date.now();
+ const cutoffWindow = getTimeWindowMs(filters.timeWindow);
+ if (cutoffWindow > 0) {
+ const updatedAtMs = parseDateToMs(issue.updatedAt);
+ if (!updatedAtMs || now - updatedAtMs > cutoffWindow) {
+ return false;
+ }
+ }
+
+ const issueLabels = getIssueLabels(issue);
+ const requiredLabels = filters.labels.map((label) => label.toLowerCase());
+ if (requiredLabels.length > 0) {
+ if (!requiredLabels.some((label) => issueLabels.includes(label))) {
+ return false;
+ }
+ }
+
+ const assigneeFilters = normalizeStringArray(filters.assignees).map((assignee) => assignee.toLowerCase());
+ if (assigneeFilters.length > 0) {
+ const assignees = getIssueAssignees(issue);
+ const isUnassignedMatch = assigneeFilters.includes("unassigned") && assignees.length === 0;
+ const hasNamedMatch = assigneeFilters.some((wanted) => wanted !== "unassigned" && assignees.includes(wanted));
+ if (!isUnassignedMatch && !hasNamedMatch) {
+ return false;
+ }
+ }
+
+ if (filters.query) {
+ const haystack = `${normalizeText(issue.title)} ${normalizeText(issue.body || "")}`.toLowerCase();
+ if (!haystack.includes(filters.query)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function sortIssues(issues, sortBy) {
+ const sorted = [...issues];
+ if (sortBy === "random") {
+ for (let i = sorted.length - 1; i > 0; i -= 1) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [sorted[i], sorted[j]] = [sorted[j], sorted[i]];
+ }
+ return sorted;
+ }
+ sorted.sort((left, right) => {
+ if (sortBy === "created-asc") {
+ return parseDateToMs(left.createdAt) - parseDateToMs(right.createdAt);
+ }
+ if (sortBy === "created-desc") {
+ return parseDateToMs(right.createdAt) - parseDateToMs(left.createdAt);
+ }
+ if (sortBy === "updated-asc") {
+ return parseDateToMs(left.updatedAt) - parseDateToMs(right.updatedAt);
+ }
+ if (sortBy === "title-asc") {
+ return normalizeText(left.title).localeCompare(normalizeText(right.title));
+ }
+ return parseDateToMs(right.updatedAt) - parseDateToMs(left.updatedAt);
+ });
+ return sorted;
+}
+
+function normalizeItem(raw, index) {
+ const idFromInput = normalizeText(raw?.id);
+ const title = normalizeText(raw?.title, `Item ${index + 1}`);
+ const id = idFromInput || title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || `item-${index + 1}`;
+ return {
+ id,
+ title,
+ description: normalizeText(raw?.description),
+ details: normalizeText(raw?.details),
+ repo: normalizeText(raw?.repo),
+ number: normalizeText(raw?.number),
+ url: normalizeText(raw?.url),
+ labels: normalizeStringArray(raw?.labels),
+ assignees: normalizeStringArray(raw?.assignees),
+ createdAt: normalizeText(raw?.createdAt),
+ updatedAt: normalizeText(raw?.updatedAt),
+ author: normalizeText(raw?.author),
+ };
+}
+
+function getOrCreateBoard(boardId) {
+ if (!storage.boards[boardId]) {
+ storage.boards[boardId] = {
+ id: boardId,
+ title: "Backlog Triage",
+ items: [],
+ decisions: {},
+ workStatus: {},
+ filters: { ...defaultFilters },
+ updatedAt: new Date().toISOString(),
+ };
+ }
+ if (!storage.boards[boardId].workStatus || typeof storage.boards[boardId].workStatus !== "object") {
+ storage.boards[boardId].workStatus = {};
+ }
+ return storage.boards[boardId];
+}
+
+function setBoardItems(board, items, replace = true) {
+ const normalized = Array.isArray(items) ? items.map((item, index) => normalizeItem(item, index)) : [];
+ const repoFromItems = normalized.find((item) => normalizeText(item.repo));
+ if (repoFromItems) {
+ board.repo = repoFromItems.repo;
+ }
+ if (replace) {
+ board.items = normalized;
+ } else {
+ const existingById = new Map(board.items.map((item) => [item.id, item]));
+ for (const item of normalized) {
+ existingById.set(item.id, item);
+ }
+ board.items = [...existingById.values()];
+ }
+ board.updatedAt = new Date().toISOString();
+}
+
+function applyBoardDecision(board, itemId, decision, extra = {}) {
+ if (!decisions.includes(decision)) {
+ throw new Error(`Unsupported decision "${decision}"`);
+ }
+ const item = board.items.find((candidate) => candidate.id === itemId);
+ if (!item) {
+ throw new Error(`Item "${itemId}" not found on board "${board.id}"`);
+ }
+ board.decisions[itemId] = {
+ decision,
+ agent: normalizeText(extra.agent),
+ note: normalizeText(extra.note),
+ at: new Date().toISOString(),
+ };
+ board.updatedAt = new Date().toISOString();
+}
+
+function resetBoardDecisions(board) {
+ board.decisions = {};
+ board.updatedAt = new Date().toISOString();
+}
+
+function buildItemWorkStatus(board, item) {
+ const statuses = [];
+ const assignees = normalizeStringArray(item?.assignees);
+ if (assignees.length > 0) {
+ statuses.push({ label: `Assigned: ${assignees.join(", ")}` });
+ }
+ const decision = board.decisions?.[item.id];
+ const triageAgent = normalizeText(decision?.decision === "assign_agent" ? decision?.agent : "");
+ if (assignees.length === 0 && triageAgent) {
+ statuses.push({ label: `Assigned in triage: ${triageAgent}` });
+ }
+ const work = board.workStatus?.[item.id];
+ if (work?.sessionState === "active") {
+ const sessionName = normalizeText(work.sessionName);
+ statuses.push({ label: sessionName ? `Session active: ${sessionName}` : "Session active" });
+ } else if (work?.sessionState === "starting") {
+ statuses.push({ label: "Session starting" });
+ } else if (work?.sessionState === "requested") {
+ const sessionName = normalizeText(work.sessionName);
+ statuses.push({ label: sessionName ? `Session requested: ${sessionName}` : "Session requested" });
+ }
+ return statuses;
+}
+
+function buildBoardState(board) {
+ const allLabels = [...new Set(board.items.flatMap((item) => (Array.isArray(item.labels) ? item.labels : [])))].sort((a, b) =>
+ a.localeCompare(b),
+ );
+ const hasUnassigned = board.items.some((item) => !Array.isArray(item.assignees) || item.assignees.length === 0);
+ const allAssignees = [
+ ...new Set(board.items.flatMap((item) => (Array.isArray(item.assignees) ? item.assignees : []))),
+ ].sort((a, b) => a.localeCompare(b));
+ if (hasUnassigned) {
+ allAssignees.unshift("unassigned");
+ }
+ const pending = [];
+ const resolved = [];
+ for (const item of board.items) {
+ const itemWithStatus = { ...item, workStatus: buildItemWorkStatus(board, item) };
+ const result = board.decisions[item.id];
+ if (result) {
+ resolved.push({ ...itemWithStatus, result });
+ } else {
+ pending.push(itemWithStatus);
+ }
+ }
+ return {
+ boardId: board.id,
+ title: board.title,
+ repo: normalizeText(board.repo),
+ syncedAt: normalizeText(board.syncedAt),
+ filters: normalizeFilters(board.filters, defaultFilters),
+ availableLabels: allLabels,
+ availableAssignees: allAssignees,
+ pending,
+ resolved,
+ decisionCounts: resolved.reduce((counts, item) => {
+ const key = item.result.decision;
+ counts[key] = (counts[key] || 0) + 1;
+ return counts;
+ }, {}),
+ updatedAt: board.updatedAt,
+ };
+}
+
+function buildIssueDetails(issue) {
+ const parts = [];
+ const author = normalizeText(issue.author?.login);
+ if (author) {
+ parts.push(`Author: ${author}`);
+ }
+ if (normalizeText(issue.createdAt)) {
+ parts.push(`Created: ${normalizeText(issue.createdAt).slice(0, 10)}`);
+ }
+ if (normalizeText(issue.updatedAt)) {
+ parts.push(`Updated: ${normalizeText(issue.updatedAt).slice(0, 10)}`);
+ }
+ return parts.join(" | ");
+}
+
+function buildIssueDescription(issue) {
+ const body = normalizeText(issue.body);
+ if (!body) {
+ return "";
+ }
+ const normalized = body
+ .replace(/\r/g, "")
+ .replace(/!\[.*?\]\(.*?\)/g, "")
+ .replace(/\n{2,}/g, "\n\n")
+ .trim();
+ if (normalized.length <= 2200) {
+ return normalized;
+ }
+ return `${normalized.slice(0, 2197).trimEnd()}...`;
+}
+
+async function runGhJson(args, cwd) {
+ const result = await execFileAsync("gh", args, {
+ cwd,
+ windowsHide: true,
+ maxBuffer: 8 * 1024 * 1024,
+ });
+ return JSON.parse(result.stdout);
+}
+
+async function runGh(args, cwd) {
+ const result = await execFileAsync("gh", args, {
+ cwd,
+ windowsHide: true,
+ maxBuffer: 8 * 1024 * 1024,
+ });
+ return result.stdout;
+}
+
+async function closeGithubIssue(board, item, note) {
+ const issueNumber = normalizeText(item?.number);
+ const repo = normalizeText(board?.repo || item?.repo);
+ if (!issueNumber || !repo) {
+ throw new Error("Cannot close issue on GitHub because repo or issue number is missing.");
+ }
+ const args = ["issue", "close", issueNumber, "--repo", repo];
+ const comment = normalizeText(note);
+ if (comment) {
+ args.push("--comment", comment);
+ }
+ try {
+ await runGh(args, activeSession?.workspacePath || process.cwd());
+ } catch (error) {
+ const stderr = normalizeText(error?.stderr || "");
+ if (stderr.toLowerCase().includes("already closed")) {
+ return;
+ }
+ throw new Error(stderr || `Failed to close issue #${issueNumber} in ${repo}.`);
+ }
+}
+
+async function commentGithubIssue(board, item, note) {
+ const repo = normalizeText(board?.repo || item?.repo);
+ const issueNumber = extractIssueNumber(item);
+ const comment = normalizeText(note);
+ if (!repo || !issueNumber) {
+ throw new Error("Cannot comment on issue because repo or issue number is missing.");
+ }
+ if (!comment) {
+ return;
+ }
+ try {
+ await runGh(["issue", "comment", issueNumber, "--repo", repo, "--body", comment], activeSession?.workspacePath || process.cwd());
+ } catch (error) {
+ const stderr = normalizeText(error?.stderr || "");
+ throw new Error(stderr || `Failed to comment on issue #${issueNumber} in ${repo}.`);
+ }
+}
+
+function extractIssueNumber(item) {
+ const explicit = normalizeText(item?.number);
+ if (/^\d+$/.test(explicit)) {
+ return explicit;
+ }
+ const idMatch = normalizeText(item?.id).match(/^issue-(\d+)$/i);
+ if (idMatch) {
+ return idMatch[1];
+ }
+ const titleMatch = normalizeText(item?.title).match(/^#(\d+)\b/);
+ if (titleMatch) {
+ return titleMatch[1];
+ }
+ return "";
+}
+
+async function startImplementationSession(board, item, agent, note) {
+ if (!activeSession) {
+ throw new Error("Copilot session is unavailable for starting implementation sessions.");
+ }
+ const repo = normalizeText(board?.repo || item?.repo);
+ const issueNumber = extractIssueNumber(item);
+ if (!repo || !issueNumber) {
+ throw new Error("Cannot start implementation session because repo or issue number is missing.");
+ }
+ const rawTitle = normalizeText(item?.title);
+ const issueTitle = rawTitle.replace(new RegExp(`^#${issueNumber}\\s*`), "").trim() || rawTitle || `Issue #${issueNumber}`;
+ const summary = normalizeText(item?.description);
+ const kickoffLines = [
+ `Implement GitHub issue #${issueNumber}: ${issueTitle}`,
+ `Repository: ${repo}`,
+ ];
+ if (summary) {
+ kickoffLines.push(`Context: ${summary}`);
+ }
+ if (normalizeText(note)) {
+ kickoffLines.push(`Triage note: ${normalizeText(note)}`);
+ }
+ kickoffLines.push(
+ "Deliver a complete fix with code changes, run relevant validation, and open a PR-ready branch state with a concise summary.",
+ );
+ const kickoffPrompt = kickoffLines.join("\n");
+ const sessionRequest = [
+ `Create a new implementation project session for GitHub issue #${issueNumber} in ${repo}.`,
+ "Use the open_issue_session tool with these exact fields:",
+ `- repo_full_name: ${JSON.stringify(repo)}`,
+ `- issue_number: ${Number(issueNumber)}`,
+ `- issue_title: ${JSON.stringify(issueTitle)}`,
+ '- kickoff_mode: "autopilot"',
+ '- coordinate_with_creator: true',
+ '- notify_on_idle: "once"',
+ `- kickoff_prompt: ${JSON.stringify(kickoffPrompt)}`,
+ "",
+ "After the tool call succeeds, reply with a one-line confirmation including the new session name.",
+ ].join("\n");
+ await activeSession.send({
+ prompt: sessionRequest,
+ mode: "immediate",
+ displayPrompt: `Start implementation session for #${issueNumber}`,
+ });
+ return {
+ sessionState: "requested",
+ sessionName: `Issue #${issueNumber}`,
+ issueNumber,
+ agent: normalizeText(agent),
+ requestedAt: new Date().toISOString(),
+ };
+}
+
+function pruneDecisionsForCurrentItems(board) {
+ const currentIds = new Set(board.items.map((item) => item.id));
+ for (const itemId of Object.keys(board.decisions)) {
+ if (!currentIds.has(itemId)) {
+ delete board.decisions[itemId];
+ }
+ }
+ if (board.workStatus && typeof board.workStatus === "object") {
+ for (const itemId of Object.keys(board.workStatus)) {
+ if (!currentIds.has(itemId)) {
+ delete board.workStatus[itemId];
+ }
+ }
+ }
+}
+
+async function syncBoardFromRepo(board, filtersInput) {
+ const workspacePath = activeSession?.workspacePath;
+ let repo = normalizeText(board.repo);
+ if (!repo && workspacePath) {
+ const repoData = await runGhJson(["repo", "view", "--json", "nameWithOwner"], workspacePath);
+ repo = normalizeText(repoData?.nameWithOwner);
+ }
+ if (!repo) {
+ throw new Error("Repository is not configured. Open the canvas with a repo or call sync_from_repo with { repo: \"owner/name\" }.");
+ }
+ const filters = normalizeFilters(filtersInput, board.filters || defaultFilters);
+
+ const issues = await runGhJson(
+ [
+ "issue",
+ "list",
+ "--repo",
+ repo,
+ "--state",
+ "open",
+ "--limit",
+ String(MAX_SYNC_ISSUES),
+ "--json",
+ "number,title,url,labels,assignees,createdAt,updatedAt,author,body",
+ ],
+ workspacePath || process.cwd(),
+ );
+
+ const filteredIssues = Array.isArray(issues) ? sortIssues(issues.filter((issue) => issueMatchesFilters(issue, filters)), filters.sortBy) : [];
+ const items = filteredIssues.map((issue) => ({
+ id: `issue-${issue.number}`,
+ title: `#${issue.number} ${normalizeText(issue.title, "Untitled issue")}`,
+ description: buildIssueDescription(issue),
+ details: buildIssueDetails(issue),
+ repo,
+ number: String(issue.number),
+ url: normalizeText(issue.url),
+ labels: Array.isArray(issue.labels) ? issue.labels.map((label) => normalizeText(label?.name)).filter(Boolean) : [],
+ assignees: Array.isArray(issue.assignees) ? issue.assignees.map((assignee) => normalizeText(assignee?.login)).filter(Boolean) : [],
+ createdAt: normalizeText(issue.createdAt),
+ updatedAt: normalizeText(issue.updatedAt),
+ author: normalizeText(issue.author?.login),
+ }));
+
+ setBoardItems(board, items, true);
+ pruneDecisionsForCurrentItems(board);
+ board.source = "repo";
+ board.repo = repo;
+ board.filters = filters;
+ board.syncedAt = new Date().toISOString();
+}
+
+function renderHtml(instanceId, title) {
+ const safeTitle = escapeHtml(title || "Backlog Swipe Triage");
+ const safeInstanceId = escapeHtml(instanceId || "default");
+ return `
+
+
+
+
+ ${safeTitle}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ All labels
+
+
+
+
+
+
+ All assignees
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Applying action…
+
+
+
+
Swipe-up quick responses
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Swipe mappings: left=close, right=assign agent,
+ up=more options, down=ignore. Arrow keys work too.
+
+
+
+
+`;
+}
+
+function readJson(req, maxBytes = MAX_REQUEST_BODY_BYTES) {
+ return new Promise((resolve, reject) => {
+ const chunks = [];
+ let totalBytes = 0;
+ let settled = false;
+ req.on("data", (chunk) => {
+ if (settled) {
+ return;
+ }
+ totalBytes += chunk.length;
+ if (totalBytes > maxBytes) {
+ settled = true;
+ const error = new Error(`Request body exceeds ${maxBytes} bytes.`);
+ error.statusCode = 413;
+ req.destroy(error);
+ reject(error);
+ return;
+ }
+ chunks.push(chunk);
+ });
+ req.on("end", () => {
+ if (settled) {
+ return;
+ }
+ const raw = Buffer.concat(chunks).toString("utf8");
+ if (!raw) {
+ resolve({});
+ return;
+ }
+ try {
+ resolve(JSON.parse(raw));
+ } catch (error) {
+ error.statusCode = 400;
+ reject(error);
+ }
+ });
+ req.on("error", (error) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ reject(error);
+ });
+ });
+}
+
+async function handleServerRequest(instanceId, req, res) {
+ const entry = servers.get(instanceId);
+ if (!entry) {
+ res.statusCode = 404;
+ res.end("Instance not found");
+ return;
+ }
+
+ await ensureStorageLoaded();
+ const board = getOrCreateBoard(entry.boardId);
+ board.title = entry.title;
+
+ if (req.method === "GET" && req.url === "/") {
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ res.end(renderHtml(instanceId, board.title));
+ return;
+ }
+
+ if (req.method === "GET" && req.url === "/state") {
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
+ res.end(JSON.stringify(buildBoardState(board)));
+ return;
+ }
+
+ if (req.method === "POST" && req.url === "/sync") {
+ try {
+ const payload = await readJson(req);
+ const repo = normalizeText(payload?.repo);
+ if (repo) {
+ board.repo = repo;
+ }
+ if (payload?.resetDecisions === true) {
+ resetBoardDecisions(board);
+ }
+ await syncBoardFromRepo(board, payload?.filters);
+ await persistStorage();
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
+ res.end(JSON.stringify(buildBoardState(board)));
+ } catch (error) {
+ res.statusCode = error?.statusCode || 500;
+ res.end(error instanceof Error ? error.message : "Failed to sync from repo");
+ }
+ return;
+ }
+
+ if (req.method === "POST" && req.url === "/decision") {
+ let payload;
+ try {
+ payload = await readJson(req);
+ } catch (error) {
+ res.statusCode = error?.statusCode || 400;
+ res.end(
+ error?.statusCode === 413
+ ? "Request body too large"
+ : "Invalid JSON payload",
+ );
+ return;
+ }
+
+ const itemId = normalizeText(payload?.itemId);
+ const decision = normalizeText(payload?.decision);
+ const item = board.items.find((candidate) => candidate.id === itemId);
+ if (!itemId || !decision) {
+ res.statusCode = 400;
+ res.end("itemId and decision are required");
+ return;
+ }
+ if (!item) {
+ res.statusCode = 404;
+ res.end(`Item "${itemId}" not found`);
+ return;
+ }
+ if (decision === "close") {
+ await closeGithubIssue(board, item, payload?.note);
+ }
+ if (payload?.quickResponse === true && decision !== "close" && normalizeText(payload?.note)) {
+ await commentGithubIssue(board, item, payload?.note);
+ }
+ if (decision === "assign_agent") {
+ const sessionStatus = await startImplementationSession(board, item, payload?.agent, payload?.note);
+ board.workStatus[itemId] = {
+ ...sessionStatus,
+ agent: normalizeText(payload?.agent),
+ };
+ }
+
+ applyBoardDecision(board, itemId, decision, {
+ agent: payload?.agent,
+ note: payload?.note,
+ });
+ await persistStorage();
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
+ res.end(JSON.stringify(buildBoardState(board)));
+ return;
+ }
+
+ res.statusCode = 404;
+ res.end("Not found");
+}
+
+async function startServer(instanceId) {
+ const server = createServer((req, res) => {
+ handleServerRequest(instanceId, req, res).catch((error) => {
+ if (res.headersSent) {
+ res.end();
+ return;
+ }
+ res.statusCode = error?.statusCode || 500;
+ res.end(error instanceof Error ? error.message : "Internal server error");
+ });
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ const port = typeof address === "object" && address ? address.port : 0;
+ return { server, url: `http://127.0.0.1:${port}/` };
+}
+
+const session = await joinSession({
+ canvases: [
+ createCanvas({
+ id: "backlog-swipe-triage",
+ displayName: "Backlog Swipe Triage",
+ description: "Tinder-style backlog triage with swipe directions for assign, needs info, not now, close, and ignore.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ boardId: { type: "string", minLength: 1 },
+ title: { type: "string", minLength: 1 },
+ syncFromRepo: { type: "boolean" },
+ repo: { type: "string", minLength: 1 },
+ filters: filterSchema,
+ items: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ id: { type: "string" },
+ title: { type: "string" },
+ details: { type: "string" },
+ repo: { type: "string" },
+ number: { type: "string" },
+ url: { type: "string" },
+ },
+ required: ["title"],
+ additionalProperties: true,
+ },
+ },
+ },
+ additionalProperties: false,
+ },
+ actions: [
+ {
+ name: "sync_from_repo",
+ description: "Load open issues from the current repository into the triage board.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ boardId: { type: "string", minLength: 1 },
+ title: { type: "string" },
+ repo: { type: "string", minLength: 1 },
+ filters: filterSchema,
+ },
+ required: ["boardId"],
+ additionalProperties: false,
+ },
+ handler: async (ctx) => {
+ await ensureStorageLoaded();
+ const board = getOrCreateBoard(normalizeText(ctx.input?.boardId, "default"));
+ const title = normalizeText(ctx.input?.title);
+ if (title) {
+ board.title = title;
+ }
+ const repo = normalizeText(ctx.input?.repo);
+ if (repo) {
+ board.repo = repo;
+ }
+ await syncBoardFromRepo(board, ctx.input?.filters);
+ await persistStorage();
+ return buildBoardState(board);
+ },
+ },
+ {
+ name: "seed_backlog",
+ description: "Seed or update backlog items for a triage board.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ boardId: { type: "string", minLength: 1 },
+ title: { type: "string" },
+ replace: { type: "boolean" },
+ items: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ id: { type: "string" },
+ title: { type: "string" },
+ details: { type: "string" },
+ repo: { type: "string" },
+ number: { type: "string" },
+ url: { type: "string" },
+ },
+ required: ["title"],
+ additionalProperties: true,
+ },
+ },
+ },
+ required: ["boardId", "items"],
+ additionalProperties: false,
+ },
+ handler: async (ctx) => {
+ await ensureStorageLoaded();
+ const boardId = normalizeText(ctx.input?.boardId, "default");
+ const board = getOrCreateBoard(boardId);
+ const title = normalizeText(ctx.input?.title);
+ if (title) {
+ board.title = title;
+ }
+ setBoardItems(board, ctx.input?.items, ctx.input?.replace !== false);
+ await persistStorage();
+ return buildBoardState(board);
+ },
+ },
+ {
+ name: "apply_decision",
+ description: "Apply a triage decision to a backlog item.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ boardId: { type: "string", minLength: 1 },
+ itemId: { type: "string", minLength: 1 },
+ decision: { type: "string", enum: decisions },
+ agent: { type: "string" },
+ note: { type: "string" },
+ commentOnIssue: { type: "boolean" },
+ },
+ required: ["boardId", "itemId", "decision"],
+ additionalProperties: false,
+ },
+ handler: async (ctx) => {
+ await ensureStorageLoaded();
+ const board = getOrCreateBoard(normalizeText(ctx.input?.boardId, "default"));
+ const itemId = normalizeText(ctx.input?.itemId);
+ const item = board.items.find((candidate) => candidate.id === itemId);
+ const decision = normalizeText(ctx.input?.decision);
+ if (!item) {
+ throw new Error(`Item "${itemId}" not found`);
+ }
+ if (decision === "close") {
+ await closeGithubIssue(board, item, ctx.input?.note);
+ }
+ if (ctx.input?.commentOnIssue === true && decision !== "close" && normalizeText(ctx.input?.note)) {
+ await commentGithubIssue(board, item, ctx.input?.note);
+ }
+ if (decision === "assign_agent") {
+ const sessionStatus = await startImplementationSession(board, item, ctx.input?.agent, ctx.input?.note);
+ board.workStatus[itemId] = {
+ ...sessionStatus,
+ agent: normalizeText(ctx.input?.agent),
+ };
+ }
+ applyBoardDecision(board, itemId, decision, {
+ agent: ctx.input?.agent,
+ note: ctx.input?.note,
+ });
+ await persistStorage();
+ return buildBoardState(board);
+ },
+ },
+ {
+ name: "get_board",
+ description: "Get pending and triaged items for a triage board.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ boardId: { type: "string", minLength: 1 },
+ },
+ required: ["boardId"],
+ additionalProperties: false,
+ },
+ handler: async (ctx) => {
+ await ensureStorageLoaded();
+ const board = getOrCreateBoard(normalizeText(ctx.input?.boardId, "default"));
+ return buildBoardState(board);
+ },
+ },
+ ],
+ open: async (ctx) => {
+ await ensureStorageLoaded();
+ const boardId = normalizeText(ctx.input?.boardId, "default");
+ const board = getOrCreateBoard(boardId);
+ const title = normalizeText(ctx.input?.title, board.title || "Backlog Triage");
+ board.title = title;
+ const repo = normalizeText(ctx.input?.repo);
+ if (repo) {
+ board.repo = repo;
+ }
+ if (ctx.input?.filters && typeof ctx.input.filters === "object") {
+ board.filters = normalizeFilters(ctx.input.filters, board.filters || defaultFilters);
+ } else if (!board.filters) {
+ board.filters = { ...defaultFilters };
+ }
+ const syncFromRepo = ctx.input?.syncFromRepo !== false;
+ if (Array.isArray(ctx.input?.items) && ctx.input.items.length > 0) {
+ setBoardItems(board, ctx.input.items, true);
+ await persistStorage();
+ } else if (syncFromRepo) {
+ await syncBoardFromRepo(board, board.filters);
+ await persistStorage();
+ }
+
+ let entry = servers.get(ctx.instanceId);
+ if (!entry) {
+ entry = await startServer(ctx.instanceId);
+ servers.set(ctx.instanceId, entry);
+ }
+ entry.boardId = boardId;
+ entry.title = title;
+ return {
+ title,
+ status: "Swipe to triage backlog",
+ url: entry.url,
+ };
+ },
+ onClose: async (ctx) => {
+ const entry = servers.get(ctx.instanceId);
+ if (entry) {
+ servers.delete(ctx.instanceId);
+ await new Promise((resolve) => entry.server.close(() => resolve()));
+ }
+ },
+ }),
+ ],
+});
+activeSession = session;
diff --git a/extensions/backlog-swipe-triage/package-lock.json b/extensions/backlog-swipe-triage/package-lock.json
new file mode 100644
index 000000000..38325be11
--- /dev/null
+++ b/extensions/backlog-swipe-triage/package-lock.json
@@ -0,0 +1,218 @@
+{
+ "name": "backlog-swipe-triage",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "backlog-swipe-triage",
+ "version": "1.0.0",
+ "dependencies": {
+ "@github/copilot-sdk": "1.0.1"
+ }
+ },
+ "node_modules/@github/copilot": {
+ "version": "1.0.61",
+ "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.61.tgz",
+ "integrity": "sha512-E4f7YXTL2uUZY/ypnfsUruAeSgrHx3AGYEbm5N0DrpzPqoNAZqV6kHEWM4vu+W/nGvydIfPxmOTqaMEhM8r0Uw==",
+ "license": "SEE LICENSE IN LICENSE.md",
+ "dependencies": {
+ "detect-libc": "^2.1.2"
+ },
+ "bin": {
+ "copilot": "npm-loader.js"
+ },
+ "optionalDependencies": {
+ "@github/copilot-darwin-arm64": "1.0.61",
+ "@github/copilot-darwin-x64": "1.0.61",
+ "@github/copilot-linux-arm64": "1.0.61",
+ "@github/copilot-linux-x64": "1.0.61",
+ "@github/copilot-linuxmusl-arm64": "1.0.61",
+ "@github/copilot-linuxmusl-x64": "1.0.61",
+ "@github/copilot-win32-arm64": "1.0.61",
+ "@github/copilot-win32-x64": "1.0.61"
+ }
+ },
+ "node_modules/@github/copilot-darwin-arm64": {
+ "version": "1.0.61",
+ "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.61.tgz",
+ "integrity": "sha512-10prvjHRXB0SD28NsIpzdNDgLquQYUwaH5Ev9KVdIWdBPAvlQsHmQ4JSCyD/UILc/nrrr02CKUgum+mZRKUKIg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "bin": {
+ "copilot-darwin-arm64": "copilot"
+ }
+ },
+ "node_modules/@github/copilot-darwin-x64": {
+ "version": "1.0.61",
+ "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.61.tgz",
+ "integrity": "sha512-NXUjageJ3mxDfHtXGYu//XhJ+dhJFYObT4R3jeWgIHhd+4lX7FlC754nwlBP/ZuVhJ3ND22JK9sua9d2F3Cbwg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "bin": {
+ "copilot-darwin-x64": "copilot"
+ }
+ },
+ "node_modules/@github/copilot-linux-arm64": {
+ "version": "1.0.61",
+ "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.61.tgz",
+ "integrity": "sha512-dwB2+QSMr622JkePeK56M7YWXsTT/DQzKfpDq8Lk2kmGU052RZAarRmt8gcNm4anofN7pMSrqc3YHj1TM84MFw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "bin": {
+ "copilot-linux-arm64": "copilot"
+ }
+ },
+ "node_modules/@github/copilot-linux-x64": {
+ "version": "1.0.61",
+ "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.61.tgz",
+ "integrity": "sha512-q6n8R8oybvuCmmkP+43w809Wpud/wwRi/fFSZEYJagiNGmYJ00SDkrfJxHbZsAFMpaJC+oTswqzJHjRoZbO74w==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "bin": {
+ "copilot-linux-x64": "copilot"
+ }
+ },
+ "node_modules/@github/copilot-linuxmusl-arm64": {
+ "version": "1.0.61",
+ "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.61.tgz",
+ "integrity": "sha512-yWo7JXnZS11eJpm68E1RWKMR47EwzPKj3V7GX0EMTd8Fw0T2Aurk9wt9p3c9w0v02nTO1DqJhi68KVWJPdVqvA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "bin": {
+ "copilot-linuxmusl-arm64": "copilot"
+ }
+ },
+ "node_modules/@github/copilot-linuxmusl-x64": {
+ "version": "1.0.61",
+ "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.61.tgz",
+ "integrity": "sha512-nHzx27Ac4B0fpD9CcmvyrGOBEMJ01CPRgVRP0yAl4wpU4cM2I6+9TPyfYThlWDqZqiUKGXC1ZRQ+B8cJREVGmA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "bin": {
+ "copilot-linuxmusl-x64": "copilot"
+ }
+ },
+ "node_modules/@github/copilot-sdk": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.1.tgz",
+ "integrity": "sha512-w6AaS0WqqTE/3iyUrZznvgCLQhsUF7ZmEVCneacuHCfOzlH0r6ww9WUmyA0zgqmXO75V0IYrkIcnFke/qJkkDg==",
+ "license": "MIT",
+ "dependencies": {
+ "@github/copilot": "^1.0.61",
+ "vscode-jsonrpc": "^8.2.1",
+ "zod": "^4.3.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@github/copilot-win32-arm64": {
+ "version": "1.0.61",
+ "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.61.tgz",
+ "integrity": "sha512-k6knzI+K5HlZeJDS/yeJAfoYD4xcURWfuqunpTCyk1pDbIFxmrLSqR/TDi7KNlpsf883n5WqpnB06K5kysdHHQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "bin": {
+ "copilot-win32-arm64": "copilot.exe"
+ }
+ },
+ "node_modules/@github/copilot-win32-x64": {
+ "version": "1.0.61",
+ "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.61.tgz",
+ "integrity": "sha512-L6NZ6o73VZFHd7OoRaztV3Prh1PbW9HXqYsAx+XywNALQvE1u489WBUC1ggfYBW5MTBCf8mxSkYQdb3Am2omsw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "SEE LICENSE IN LICENSE.md",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "bin": {
+ "copilot-win32-x64": "copilot.exe"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/vscode-jsonrpc": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz",
+ "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/extensions/backlog-swipe-triage/package.json b/extensions/backlog-swipe-triage/package.json
new file mode 100644
index 000000000..15a0677a2
--- /dev/null
+++ b/extensions/backlog-swipe-triage/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "backlog-swipe-triage",
+ "version": "1.0.0",
+ "type": "module",
+ "main": "extension.mjs",
+ "description": "Swipe-driven backlog triage canvas for reviewing open issues and assigning implementation work.",
+ "dependencies": {
+ "@github/copilot-sdk": "1.0.1"
+ }
+}
diff --git a/website/src/pages/extensions.astro b/website/src/pages/extensions.astro
index 98e98a26b..78ae6f275 100644
--- a/website/src/pages/extensions.astro
+++ b/website/src/pages/extensions.astro
@@ -36,6 +36,17 @@ const initialItems = sortExtensions(extensionsData.items, 'title');
+
+
+
+
+
![]()
+
+
+
diff --git a/website/src/scripts/pages/extensions-render.ts b/website/src/scripts/pages/extensions-render.ts
index b872eef05..f2f310e33 100644
--- a/website/src/scripts/pages/extensions-render.ts
+++ b/website/src/scripts/pages/extensions-render.ts
@@ -6,6 +6,8 @@ export interface RenderableExtension {
path: string;
ref: string;
lastUpdated?: string | null;
+ imageUrl?: string | null;
+ assetPath?: string | null;
}
export type ExtensionSortOption = "title" | "lastUpdated";
@@ -40,14 +42,21 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
(item) => `
-
-
${escapeHtml(item.name)}
-
Canvas extension
-
- ${getLastUpdatedHtml(item.lastUpdated)}
-
-
-
+ ${
+ item.imageUrl
+ ? ``
+ : `Canvas
`
+ }
+
+
${escapeHtml(item.name)}
+
Canvas extension
+
+ ${getLastUpdatedHtml(item.lastUpdated)}
+
+
+